deepspace 0.3.9 → 0.3.11

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 (120) hide show
  1. package/dist/cli.js +96 -18
  2. package/dist/cli.js.map +1 -1
  3. package/dist/index.d.ts +160 -1
  4. package/dist/index.js +283 -126
  5. package/dist/index.js.map +1 -1
  6. package/dist/server.d.ts +172 -2
  7. package/dist/server.js +581 -1
  8. package/dist/server.js.map +1 -1
  9. package/dist/worker.d.ts +252 -2
  10. package/dist/worker.js +588 -1
  11. package/dist/worker.js.map +1 -1
  12. package/features/admin-page/feature.json +29 -0
  13. package/features/admin-page/src/AdminPage.tsx +292 -0
  14. package/features/admin-page/src/admin-schema.ts +20 -0
  15. package/features/ai-chat/feature.json +41 -0
  16. package/features/ai-chat/src/AiChatPage.tsx +692 -0
  17. package/features/ai-chat/src/ChatPanel.tsx +1098 -0
  18. package/features/ai-chat/src/ai-chat-schema.ts +11 -0
  19. package/features/canvas/feature.json +45 -0
  20. package/features/canvas/src/CanvasEditorPage.tsx +87 -0
  21. package/features/canvas/src/CanvasListPage.tsx +202 -0
  22. package/features/canvas/src/canvas-schema.ts +33 -0
  23. package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
  24. package/features/canvas/src/components/CanvasView.tsx +369 -0
  25. package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
  26. package/features/cron/feature.json +33 -0
  27. package/features/cron/src/CronLogPage.tsx +100 -0
  28. package/features/cron/src/cron.ts +25 -0
  29. package/features/docs/feature.json +96 -0
  30. package/features/docs/src/DocsEditorPage.tsx +1012 -0
  31. package/features/docs/src/DocsListPage.tsx +951 -0
  32. package/features/docs/src/DocsOutlinePanel.tsx +68 -0
  33. package/features/docs/src/DocsPresence.tsx +138 -0
  34. package/features/docs/src/InviteDialog.tsx +336 -0
  35. package/features/docs/src/LibrarySidebar.tsx +464 -0
  36. package/features/docs/src/docs-favorites.ts +18 -0
  37. package/features/docs/src/docs-library-types.ts +15 -0
  38. package/features/docs/src/docs-schema.ts +80 -0
  39. package/features/docs/src/docs-ui.css +309 -0
  40. package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
  41. package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
  42. package/features/docs/src/editor/useDocEditor.ts +206 -0
  43. package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
  44. package/features/file-attachments/feature.json +42 -0
  45. package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
  46. package/features/file-attachments/src/attachments-constants.ts +9 -0
  47. package/features/file-attachments/src/attachments-schema.ts +45 -0
  48. package/features/file-attachments/src/useFilePreview.ts +471 -0
  49. package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
  50. package/features/file-manager/feature.json +24 -0
  51. package/features/file-manager/src/FileManagerPage.tsx +330 -0
  52. package/features/integration-test/feature.json +18 -0
  53. package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
  54. package/features/integration-test/src/integration.spec.ts +42 -0
  55. package/features/items/feature.json +33 -0
  56. package/features/items/src/ItemsPage.tsx +325 -0
  57. package/features/items/src/items-constants.ts +10 -0
  58. package/features/items/src/items-schema.ts +27 -0
  59. package/features/kanban/feature.json +18 -0
  60. package/features/kanban/src/Kanban.tsx +283 -0
  61. package/features/landing/feature.json +62 -0
  62. package/features/landing/src/LandingPage.tsx +627 -0
  63. package/features/landing/src/primitives.tsx +389 -0
  64. package/features/landing/src/sections/FAQSection.tsx +110 -0
  65. package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
  66. package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
  67. package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
  68. package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
  69. package/features/landing/src/sections/StatsSection.tsx +31 -0
  70. package/features/landing/src/sections/TeamSection.tsx +86 -0
  71. package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
  72. package/features/landing/src/sections/VideoSection.tsx +71 -0
  73. package/features/leaderboard/feature.json +33 -0
  74. package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
  75. package/features/leaderboard/src/leaderboard-constants.ts +21 -0
  76. package/features/leaderboard/src/leaderboard-schema.ts +36 -0
  77. package/features/messaging/feature.json +111 -0
  78. package/features/messaging/src/ChatMultiPage.tsx +300 -0
  79. package/features/messaging/src/ChatPage.tsx +75 -0
  80. package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
  81. package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
  82. package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
  83. package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
  84. package/features/messaging/src/components/chat/MessageList.tsx +248 -0
  85. package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
  86. package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
  87. package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
  88. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
  89. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
  90. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
  91. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
  92. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
  93. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
  94. package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
  95. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
  96. package/features/messaging/src/hooks/useChatChannel.ts +64 -0
  97. package/features/messaging/src/hooks/useLongPress.ts +74 -0
  98. package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
  99. package/features/messaging/src/messaging-schema.ts +25 -0
  100. package/features/presence-test/feature.json +21 -0
  101. package/features/presence-test/src/PresenceTestPage.tsx +120 -0
  102. package/features/sidebar/feature.json +19 -0
  103. package/features/sidebar/src/AppSidebar.tsx +558 -0
  104. package/features/tasks/feature.json +33 -0
  105. package/features/tasks/src/TasksPage.tsx +598 -0
  106. package/features/tasks/src/tasks-constants.ts +43 -0
  107. package/features/tasks/src/tasks-schema.ts +56 -0
  108. package/features/testing/feature.json +49 -0
  109. package/features/testing/src/TestPage.tsx +145 -0
  110. package/features/testing/src/cpu-burn-action.ts +51 -0
  111. package/features/testing/src/subrequest-burn-action.ts +53 -0
  112. package/features/testing/src/test-schema.ts +23 -0
  113. package/features/topbar/feature.json +18 -0
  114. package/features/topbar/src/Topbar.tsx +353 -0
  115. package/features/tree/feature.json +26 -0
  116. package/features/tree/src/TreeSidebar.tsx +607 -0
  117. package/features/tree/src/tree-constants.ts +68 -0
  118. package/features/tree/src/tree-hooks.ts +258 -0
  119. package/package.json +4 -2
  120. package/scripts/add-feature.cjs +654 -0
@@ -0,0 +1,33 @@
1
+ {
2
+ "id": "tasks",
3
+ "name": "Tasks",
4
+ "category": "data",
5
+ "description": "Claimable task system with submissions, grading, and difficulty levels",
6
+ "details": "Task/challenge system demonstrating advanced schema features: unclaimed-or-own permissions for claiming, writableFields for restricting member updates, timestampTrigger for automatic timestamps, and admin grading workflow with pass/fail/excellent grades.",
7
+ "files": [
8
+ {
9
+ "src": "src/TasksPage.tsx",
10
+ "dest": "src/pages/tasks.tsx"
11
+ },
12
+ {
13
+ "src": "src/tasks-schema.ts",
14
+ "dest": "src/schemas/tasks-schema.ts"
15
+ },
16
+ {
17
+ "src": "src/tasks-constants.ts",
18
+ "dest": "src/components/tasks/tasks-constants.ts"
19
+ }
20
+ ],
21
+ "schema": {
22
+ "exportName": "challengesSchema",
23
+ "importPath": "./schemas/tasks-schema",
24
+ "spreadOperator": false
25
+ },
26
+ "route": {
27
+ "path": "/tasks",
28
+ "protected": true
29
+ },
30
+ "requires": [],
31
+ "instructions": [],
32
+ "patterns": []
33
+ }
@@ -0,0 +1,598 @@
1
+ /**
2
+ * Tasks Page (Challenges)
3
+ *
4
+ * Demonstrates:
5
+ * - 'unclaimed-or-own' permission for claimable tasks
6
+ * - writableFields for restricting which fields can be updated
7
+ * - timestampTrigger for automatic timestamps
8
+ * - Admin grading workflow
9
+ */
10
+
11
+ import { useState, useMemo } from 'react'
12
+ import { useUser } from 'deepspace'
13
+ import { useUsers } from 'deepspace'
14
+ import { useQuery } from 'deepspace'
15
+ import { useMutations } from 'deepspace'
16
+ import { Button, Modal, Badge, EmptyState, type BadgeProps } from 'deepspace'
17
+ import { ROLES, type Role } from 'deepspace'
18
+ import { DIFFICULTY, DIFFICULTY_CONFIG, GRADE, GRADE_CONFIG, type Difficulty, type Grade, type BadgeVariant } from '../components/tasks/tasks-constants'
19
+
20
+ // Tailwind color classes for buttons (semantic colors)
21
+ const buttonColorClasses: Record<NonNullable<BadgeVariant>, string> = {
22
+ default: 'text-primary',
23
+ success: 'text-success',
24
+ warning: 'text-warning',
25
+ destructive: 'text-destructive',
26
+ info: 'text-info',
27
+ secondary: 'text-muted-foreground',
28
+ outline: 'text-foreground',
29
+ }
30
+
31
+ // ============================================================================
32
+ // Types
33
+ // ============================================================================
34
+
35
+ interface Challenge {
36
+ title: string
37
+ description: string
38
+ difficulty: string
39
+ points: number
40
+ claimedById?: string
41
+ claimedAt?: string
42
+ submitted: boolean
43
+ submissionUrl?: string
44
+ submissionNotes?: string
45
+ submittedAt?: string
46
+ grade?: string
47
+ feedback?: string
48
+ gradedById?: string
49
+ gradedAt?: string
50
+ createdById: string
51
+ }
52
+
53
+ interface TasksPageProps {
54
+ className?: string
55
+ }
56
+
57
+ // ============================================================================
58
+ // Create Challenge Modal
59
+ // ============================================================================
60
+
61
+ interface CreateChallengeModalProps {
62
+ isOpen: boolean
63
+ onClose: () => void
64
+ onCreate: (title: string, description: string, difficulty: Difficulty) => void
65
+ }
66
+
67
+ function CreateChallengeModal({ isOpen, onClose, onCreate }: CreateChallengeModalProps) {
68
+ const [title, setTitle] = useState('')
69
+ const [description, setDescription] = useState('')
70
+ const [difficulty, setDifficulty] = useState<Difficulty>(DIFFICULTY.MEDIUM)
71
+
72
+ const handleSubmit = () => {
73
+ if (title.trim() && description.trim()) {
74
+ onCreate(title.trim(), description.trim(), difficulty)
75
+ setTitle('')
76
+ setDescription('')
77
+ setDifficulty(DIFFICULTY.MEDIUM)
78
+ onClose()
79
+ }
80
+ }
81
+
82
+ return (
83
+ <Modal open={isOpen} onClose={onClose} size="md">
84
+ <Modal.Header onClose={onClose}>
85
+ <Modal.Title>Create Challenge</Modal.Title>
86
+ </Modal.Header>
87
+ <Modal.Body>
88
+ <div className="space-y-4">
89
+ <div>
90
+ <label className="block text-sm font-medium text-muted-foreground mb-2">Title</label>
91
+ <input
92
+ type="text"
93
+ value={title}
94
+ onChange={(e) => setTitle(e.target.value)}
95
+ placeholder="Challenge title"
96
+ className="w-full px-3 py-2 bg-transparent border border-border rounded-lg text-foreground placeholder-muted-foreground focus:outline-none focus:ring-ring"
97
+ autoFocus
98
+ />
99
+ </div>
100
+ <div>
101
+ <label className="block text-sm font-medium text-muted-foreground mb-2">Description</label>
102
+ <textarea
103
+ value={description}
104
+ onChange={(e) => setDescription(e.target.value)}
105
+ placeholder="Describe the challenge..."
106
+ rows={3}
107
+ className="w-full px-3 py-2 bg-transparent border border-border rounded-lg text-foreground placeholder-muted-foreground focus:outline-none focus:ring-ring resize-none"
108
+ />
109
+ </div>
110
+ <div>
111
+ <label className="block text-sm font-medium text-muted-foreground mb-2">Difficulty</label>
112
+ <div className="flex gap-2">
113
+ {Object.entries(DIFFICULTY_CONFIG).map(([key, config]) => (
114
+ <button
115
+ key={key}
116
+ onClick={() => setDifficulty(key as Difficulty)}
117
+ className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-all border ${buttonColorClasses[config.color ?? 'default']} ${
118
+ difficulty === key
119
+ ? 'border-primary/30 bg-primary/20'
120
+ : 'border-border bg-muted/40 hover:bg-muted/60'
121
+ }`}
122
+ >
123
+ {config.title} ({config.points} pts)
124
+ </button>
125
+ ))}
126
+ </div>
127
+ </div>
128
+ </div>
129
+ </Modal.Body>
130
+ <Modal.Footer>
131
+ <Button variant="secondary" onClick={onClose}>Cancel</Button>
132
+ <Button onClick={handleSubmit} disabled={!title.trim() || !description.trim()}>
133
+ Create Challenge
134
+ </Button>
135
+ </Modal.Footer>
136
+ </Modal>
137
+ )
138
+ }
139
+
140
+ // ============================================================================
141
+ // Submit Modal
142
+ // ============================================================================
143
+
144
+ interface SubmitModalProps {
145
+ isOpen: boolean
146
+ onClose: () => void
147
+ onSubmit: (url: string, notes: string) => void
148
+ challenge: { recordId: string; data: Challenge } | null
149
+ }
150
+
151
+ function SubmitModal({ isOpen, onClose, onSubmit, challenge }: SubmitModalProps) {
152
+ const [url, setUrl] = useState('')
153
+ const [notes, setNotes] = useState('')
154
+
155
+ const handleSubmit = () => {
156
+ onSubmit(url.trim(), notes.trim())
157
+ setUrl('')
158
+ setNotes('')
159
+ onClose()
160
+ }
161
+
162
+ if (!challenge) return null
163
+
164
+ return (
165
+ <Modal open={isOpen} onClose={onClose} size="md">
166
+ <Modal.Header onClose={onClose}>
167
+ <Modal.Title>Submit Challenge</Modal.Title>
168
+ </Modal.Header>
169
+ <Modal.Body>
170
+ <div className="space-y-4">
171
+ <div className="p-3 bg-muted/40 rounded-lg border border-border/30">
172
+ <h4 className="font-medium text-foreground">{challenge.data.title}</h4>
173
+ <p className="text-sm text-muted-foreground mt-1">{challenge.data.description}</p>
174
+ </div>
175
+ <div>
176
+ <label className="block text-sm font-medium text-muted-foreground mb-2">Submission URL (optional)</label>
177
+ <input
178
+ type="url"
179
+ value={url}
180
+ onChange={(e) => setUrl(e.target.value)}
181
+ placeholder="https://..."
182
+ className="w-full px-3 py-2 bg-transparent border border-border rounded-lg text-foreground placeholder-muted-foreground focus:outline-none focus:ring-ring"
183
+ />
184
+ </div>
185
+ <div>
186
+ <label className="block text-sm font-medium text-muted-foreground mb-2">Notes</label>
187
+ <textarea
188
+ value={notes}
189
+ onChange={(e) => setNotes(e.target.value)}
190
+ placeholder="Describe your solution..."
191
+ rows={3}
192
+ className="w-full px-3 py-2 bg-transparent border border-border rounded-lg text-foreground placeholder-muted-foreground focus:outline-none focus:ring-ring resize-none"
193
+ />
194
+ </div>
195
+ </div>
196
+ </Modal.Body>
197
+ <Modal.Footer>
198
+ <Button variant="secondary" onClick={onClose}>Cancel</Button>
199
+ <Button onClick={handleSubmit}>Submit</Button>
200
+ </Modal.Footer>
201
+ </Modal>
202
+ )
203
+ }
204
+
205
+ // ============================================================================
206
+ // Grade Modal (Admin only)
207
+ // ============================================================================
208
+
209
+ interface GradeModalProps {
210
+ isOpen: boolean
211
+ onClose: () => void
212
+ onGrade: (grade: Grade, feedback: string) => void
213
+ challenge: { recordId: string; data: Challenge } | null
214
+ }
215
+
216
+ function GradeModal({ isOpen, onClose, onGrade, challenge }: GradeModalProps) {
217
+ const [grade, setGrade] = useState<Grade>(GRADE.PASS)
218
+ const [feedback, setFeedback] = useState('')
219
+
220
+ const handleSubmit = () => {
221
+ onGrade(grade, feedback.trim())
222
+ setGrade(GRADE.PASS)
223
+ setFeedback('')
224
+ onClose()
225
+ }
226
+
227
+ if (!challenge) return null
228
+
229
+ return (
230
+ <Modal open={isOpen} onClose={onClose} size="md">
231
+ <Modal.Header onClose={onClose}>
232
+ <Modal.Title>Grade Submission</Modal.Title>
233
+ </Modal.Header>
234
+ <Modal.Body>
235
+ <div className="space-y-4">
236
+ <div className="p-3 bg-muted/40 rounded-lg border border-border/30">
237
+ <h4 className="font-medium text-foreground">{challenge.data.title}</h4>
238
+ {challenge.data.submissionUrl && (
239
+ <a
240
+ href={challenge.data.submissionUrl}
241
+ target="_blank"
242
+ rel="noopener noreferrer"
243
+ className="text-sm text-primary hover:underline mt-1 block"
244
+ >
245
+ {challenge.data.submissionUrl}
246
+ </a>
247
+ )}
248
+ {challenge.data.submissionNotes && (
249
+ <p className="text-sm text-muted-foreground mt-2">{challenge.data.submissionNotes}</p>
250
+ )}
251
+ </div>
252
+ <div>
253
+ <label className="block text-sm font-medium text-muted-foreground mb-2">Grade</label>
254
+ <div className="flex gap-2">
255
+ {Object.entries(GRADE_CONFIG).map(([key, config]) => (
256
+ <button
257
+ key={key}
258
+ onClick={() => setGrade(key as Grade)}
259
+ className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-all border ${buttonColorClasses[config.color ?? 'default']} ${
260
+ grade === key
261
+ ? 'border-primary/30 bg-primary/20'
262
+ : 'border-border bg-muted/40 hover:bg-muted/60'
263
+ }`}
264
+ >
265
+ {config.title}
266
+ </button>
267
+ ))}
268
+ </div>
269
+ </div>
270
+ <div>
271
+ <label className="block text-sm font-medium text-muted-foreground mb-2">Feedback</label>
272
+ <textarea
273
+ value={feedback}
274
+ onChange={(e) => setFeedback(e.target.value)}
275
+ placeholder="Provide feedback..."
276
+ rows={3}
277
+ className="w-full px-3 py-2 bg-transparent border border-border rounded-lg text-foreground placeholder-muted-foreground focus:outline-none focus:ring-ring resize-none"
278
+ />
279
+ </div>
280
+ </div>
281
+ </Modal.Body>
282
+ <Modal.Footer>
283
+ <Button variant="secondary" onClick={onClose}>Cancel</Button>
284
+ <Button onClick={handleSubmit}>Save Grade</Button>
285
+ </Modal.Footer>
286
+ </Modal>
287
+ )
288
+ }
289
+
290
+ // ============================================================================
291
+ // Challenge Card
292
+ // ============================================================================
293
+
294
+ interface ChallengeCardProps {
295
+ challenge: { recordId: string; data: Challenge; createdAt: string }
296
+ currentUserId?: string
297
+ isAdmin: boolean
298
+ getUserName: (id: string) => string
299
+ onClaim: () => void
300
+ onUnclaim: () => void
301
+ onSubmit: () => void
302
+ onGrade: () => void
303
+ }
304
+
305
+ function ChallengeCard({
306
+ challenge,
307
+ currentUserId,
308
+ isAdmin,
309
+ getUserName,
310
+ onClaim,
311
+ onUnclaim,
312
+ onSubmit,
313
+ onGrade,
314
+ }: ChallengeCardProps) {
315
+ const { data } = challenge
316
+ const difficultyConfig = DIFFICULTY_CONFIG[data.difficulty as Difficulty] ?? DIFFICULTY_CONFIG[DIFFICULTY.MEDIUM]
317
+ const gradeConfig = data.grade ? GRADE_CONFIG[data.grade as Grade] : null
318
+
319
+ const isClaimed = !!data.claimedById
320
+ const isClaimedByMe = data.claimedById === currentUserId
321
+ const isSubmitted = data.submitted
322
+ const isGraded = !!data.grade
323
+
324
+ // Determine status
325
+ let status: 'available' | 'claimed' | 'submitted' | 'graded' = 'available'
326
+ if (isGraded) status = 'graded'
327
+ else if (isSubmitted) status = 'submitted'
328
+ else if (isClaimed) status = 'claimed'
329
+
330
+ const statusConfig: Record<typeof status, { label: string; color: BadgeVariant }> = {
331
+ available: { label: 'Available', color: 'success' },
332
+ claimed: { label: isClaimedByMe ? 'Claimed by you' : 'Claimed', color: 'warning' },
333
+ submitted: { label: 'Submitted', color: 'info' },
334
+ graded: { label: gradeConfig?.title ?? 'Graded', color: gradeConfig?.color ?? 'default' },
335
+ }
336
+
337
+ return (
338
+ <div className="p-4 bg-card/60 rounded-xl border border-border">
339
+ <div className="flex items-start justify-between gap-3 mb-3">
340
+ <div className="flex-1 min-w-0">
341
+ <div className="flex items-center gap-2 mb-1">
342
+ <h3 className="font-medium text-foreground truncate">{data.title}</h3>
343
+ <Badge variant={difficultyConfig.color} size="sm">
344
+ {difficultyConfig.title}
345
+ </Badge>
346
+ </div>
347
+ <p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p>
348
+ </div>
349
+ <div className="text-right shrink-0">
350
+ <div className="text-lg font-bold text-primary">{data.points}</div>
351
+ <div className="text-xs text-muted-foreground">points</div>
352
+ </div>
353
+ </div>
354
+
355
+ {/* Status */}
356
+ <div className="flex items-center justify-between mb-3">
357
+ <Badge variant={statusConfig[status].color} size="sm">{statusConfig[status].label}</Badge>
358
+ {isClaimed && !isClaimedByMe && (
359
+ <span className="text-xs text-muted-foreground">
360
+ by {getUserName(data.claimedById!)}
361
+ </span>
362
+ )}
363
+ </div>
364
+
365
+ {/* Feedback (if graded) */}
366
+ {isGraded && data.feedback && (
367
+ <div className="p-2 bg-muted/40 rounded-lg mb-3 border border-border/30">
368
+ <p className="text-xs text-muted-foreground mb-1">Feedback</p>
369
+ <p className="text-sm text-muted-foreground">{data.feedback}</p>
370
+ </div>
371
+ )}
372
+
373
+ {/* Actions */}
374
+ <div className="flex gap-2">
375
+ {/* Claim/Unclaim */}
376
+ {!isClaimed && !isAdmin && (
377
+ <Button size="sm" onClick={onClaim}>Claim</Button>
378
+ )}
379
+ {isClaimedByMe && !isSubmitted && (
380
+ <>
381
+ <Button size="sm" onClick={onSubmit}>Submit</Button>
382
+ <Button size="sm" variant="secondary" onClick={onUnclaim}>Unclaim</Button>
383
+ </>
384
+ )}
385
+
386
+ {/* Admin: Grade */}
387
+ {isAdmin && isSubmitted && !isGraded && (
388
+ <Button size="sm" onClick={onGrade}>Grade</Button>
389
+ )}
390
+ </div>
391
+ </div>
392
+ )
393
+ }
394
+
395
+ // ============================================================================
396
+ // Main Page
397
+ // ============================================================================
398
+
399
+ export default function TasksPage({ className }: TasksPageProps) {
400
+ const { user } = useUser()
401
+ const { users } = useUsers()
402
+ const userRole = (user?.role ?? ROLES.VIEWER) as Role
403
+ const isAdmin = user?.role === 'admin'
404
+ const canCreate = userRole === ROLES.MEMBER || isAdmin
405
+
406
+ const [showCreateModal, setShowCreateModal] = useState(false)
407
+ const [submitChallenge, setSubmitChallenge] = useState<{ recordId: string; data: Challenge } | null>(null)
408
+ const [gradeChallenge, setGradeChallenge] = useState<{ recordId: string; data: Challenge } | null>(null)
409
+ const [filter, setFilter] = useState<'all' | 'available' | 'mine' | 'pending'>('all')
410
+
411
+ // Query all challenges
412
+ const { records: challenges, status } = useQuery<Challenge>('challenges', {
413
+ orderBy: 'createdAt',
414
+ orderDir: 'desc',
415
+ })
416
+
417
+ const { create, put } = useMutations<Challenge>('challenges')
418
+
419
+ // Get user name helper
420
+ const getUserName = (userId: string) => {
421
+ const u = users.find(usr => usr.id === userId)
422
+ return u?.name ?? 'Unknown'
423
+ }
424
+
425
+ // Filter challenges
426
+ const filteredChallenges = useMemo(() => {
427
+ return challenges.filter(c => {
428
+ switch (filter) {
429
+ case 'available':
430
+ return !c.data.claimedById
431
+ case 'mine':
432
+ return c.data.claimedById === user?.id
433
+ case 'pending':
434
+ return c.data.submitted && !c.data.grade
435
+ default:
436
+ return true
437
+ }
438
+ })
439
+ }, [challenges, filter, user?.id])
440
+
441
+ // Handlers
442
+ const handleCreate = async (title: string, description: string, difficulty: Difficulty) => {
443
+ await create({
444
+ title,
445
+ description,
446
+ difficulty,
447
+ points: DIFFICULTY_CONFIG[difficulty].points,
448
+ submitted: false,
449
+ createdById: user!.id,
450
+ })
451
+ }
452
+
453
+ const handleClaim = async (challenge: { recordId: string; data: Challenge }) => {
454
+ await put(challenge.recordId, {
455
+ ...challenge.data,
456
+ claimedById: user!.id,
457
+ })
458
+ }
459
+
460
+ const handleUnclaim = async (challenge: { recordId: string; data: Challenge }) => {
461
+ await put(challenge.recordId, {
462
+ ...challenge.data,
463
+ claimedById: undefined,
464
+ })
465
+ }
466
+
467
+ const handleSubmit = async (url: string, notes: string) => {
468
+ if (!submitChallenge) return
469
+ await put(submitChallenge.recordId, {
470
+ ...submitChallenge.data,
471
+ submitted: true,
472
+ submissionUrl: url || undefined,
473
+ submissionNotes: notes || undefined,
474
+ })
475
+ setSubmitChallenge(null)
476
+ }
477
+
478
+ const handleGrade = async (grade: Grade, feedback: string) => {
479
+ if (!gradeChallenge) return
480
+ await put(gradeChallenge.recordId, {
481
+ ...gradeChallenge.data,
482
+ grade,
483
+ feedback: feedback || undefined,
484
+ gradedById: user!.id,
485
+ })
486
+ setGradeChallenge(null)
487
+ }
488
+
489
+ const isLoading = status === 'loading'
490
+
491
+ return (
492
+ <div className={`h-full bg-background overflow-y-auto ${className ?? ''}`}>
493
+ {/* Header */}
494
+ <div className="bg-card/60 backdrop-blur-md border-b border-border sticky top-0 z-10">
495
+ <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
496
+ <div className="flex items-center justify-between">
497
+ <div>
498
+ <h1 className="text-2xl font-bold text-foreground">Challenges</h1>
499
+ <p className="text-muted-foreground mt-1">
500
+ Claim tasks, submit solutions, earn points
501
+ </p>
502
+ </div>
503
+
504
+ {canCreate && (
505
+ <Button onClick={() => setShowCreateModal(true)}>
506
+ <svg className="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
507
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
508
+ </svg>
509
+ New Challenge
510
+ </Button>
511
+ )}
512
+ </div>
513
+
514
+ {/* Filters */}
515
+ <div className="flex gap-1 mt-4">
516
+ {(['all', 'available', 'mine', ...(isAdmin ? ['pending'] as const : [])] as const).map(f => (
517
+ <button
518
+ key={f}
519
+ onClick={() => setFilter(f as 'all' | 'available' | 'mine' | 'pending')}
520
+ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors border ${
521
+ filter === f
522
+ ? 'bg-primary/20 text-primary border-primary/30'
523
+ : 'text-muted-foreground hover:text-foreground hover:bg-muted/60 border-transparent'
524
+ }`}
525
+ >
526
+ {f === 'all' ? 'All' : f === 'available' ? 'Available' : f === 'mine' ? 'My Claims' : 'Pending Review'}
527
+ </button>
528
+ ))}
529
+ </div>
530
+ </div>
531
+ </div>
532
+
533
+ {/* Content */}
534
+ <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
535
+ {isLoading ? (
536
+ <div className="flex items-center justify-center py-16">
537
+ <div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
538
+ </div>
539
+ ) : filteredChallenges.length === 0 ? (
540
+ <EmptyState
541
+ title={filter === 'all' ? 'No challenges yet' : `No ${filter} challenges`}
542
+ description={
543
+ filter === 'available'
544
+ ? 'All challenges have been claimed'
545
+ : filter === 'mine'
546
+ ? "You haven't claimed any challenges yet"
547
+ : canCreate
548
+ ? 'Create the first challenge to get started'
549
+ : 'No challenges have been created yet'
550
+ }
551
+ icon={
552
+ <svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
553
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
554
+ </svg>
555
+ }
556
+ />
557
+ ) : (
558
+ <div className="grid gap-4 sm:grid-cols-2">
559
+ {filteredChallenges.map(challenge => (
560
+ <ChallengeCard
561
+ key={challenge.recordId}
562
+ challenge={challenge}
563
+ currentUserId={user?.id}
564
+ isAdmin={isAdmin}
565
+ getUserName={getUserName}
566
+ onClaim={() => handleClaim(challenge)}
567
+ onUnclaim={() => handleUnclaim(challenge)}
568
+ onSubmit={() => setSubmitChallenge(challenge)}
569
+ onGrade={() => setGradeChallenge(challenge)}
570
+ />
571
+ ))}
572
+ </div>
573
+ )}
574
+ </div>
575
+
576
+ {/* Modals */}
577
+ <CreateChallengeModal
578
+ isOpen={showCreateModal}
579
+ onClose={() => setShowCreateModal(false)}
580
+ onCreate={handleCreate}
581
+ />
582
+
583
+ <SubmitModal
584
+ isOpen={!!submitChallenge}
585
+ onClose={() => setSubmitChallenge(null)}
586
+ onSubmit={handleSubmit}
587
+ challenge={submitChallenge}
588
+ />
589
+
590
+ <GradeModal
591
+ isOpen={!!gradeChallenge}
592
+ onClose={() => setGradeChallenge(null)}
593
+ onGrade={handleGrade}
594
+ challenge={gradeChallenge}
595
+ />
596
+ </div>
597
+ )
598
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Tasks/Challenges Feature - Constants
3
+ */
4
+
5
+ import type { BadgeProps } from 'deepspace'
6
+
7
+ export type BadgeVariant = BadgeProps['variant']
8
+
9
+ // ============================================================================
10
+ // Difficulty
11
+ // ============================================================================
12
+
13
+ export const DIFFICULTY = {
14
+ EASY: 'easy',
15
+ MEDIUM: 'medium',
16
+ HARD: 'hard',
17
+ } as const
18
+
19
+ export type Difficulty = typeof DIFFICULTY[keyof typeof DIFFICULTY]
20
+
21
+ export const DIFFICULTY_CONFIG: Record<Difficulty, { title: string; color: BadgeVariant; points: number }> = {
22
+ [DIFFICULTY.EASY]: { title: 'Easy', color: 'success', points: 5 },
23
+ [DIFFICULTY.MEDIUM]: { title: 'Medium', color: 'warning', points: 10 },
24
+ [DIFFICULTY.HARD]: { title: 'Hard', color: 'destructive', points: 20 },
25
+ }
26
+
27
+ // ============================================================================
28
+ // Grade
29
+ // ============================================================================
30
+
31
+ export const GRADE = {
32
+ PASS: 'pass',
33
+ FAIL: 'fail',
34
+ EXCELLENT: 'excellent',
35
+ } as const
36
+
37
+ export type Grade = typeof GRADE[keyof typeof GRADE]
38
+
39
+ export const GRADE_CONFIG: Record<Grade, { title: string; color: BadgeVariant; multiplier: number }> = {
40
+ [GRADE.FAIL]: { title: 'Fail', color: 'destructive', multiplier: 0 },
41
+ [GRADE.PASS]: { title: 'Pass', color: 'success', multiplier: 1 },
42
+ [GRADE.EXCELLENT]: { title: 'Excellent', color: 'default', multiplier: 1.5 },
43
+ }