deepspace 0.3.10 → 0.4.0
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.
- package/dist/cli.js +331 -115
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +199 -16
- package/dist/index.js +354 -166
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +242 -73
- package/dist/server.js +985 -374
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +322 -73
- package/dist/worker.js +961 -343
- package/dist/worker.js.map +1 -1
- package/features/admin-page/feature.json +29 -0
- package/features/admin-page/src/AdminPage.tsx +292 -0
- package/features/admin-page/src/admin-schema.ts +20 -0
- package/features/ai-chat/feature.json +41 -0
- package/features/ai-chat/src/AiChatPage.tsx +692 -0
- package/features/ai-chat/src/ChatPanel.tsx +1098 -0
- package/features/ai-chat/src/ai-chat-schema.ts +11 -0
- package/features/canvas/feature.json +45 -0
- package/features/canvas/src/CanvasEditorPage.tsx +87 -0
- package/features/canvas/src/CanvasListPage.tsx +202 -0
- package/features/canvas/src/canvas-schema.ts +33 -0
- package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
- package/features/canvas/src/components/CanvasView.tsx +369 -0
- package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
- package/features/cron/feature.json +33 -0
- package/features/cron/src/CronLogPage.tsx +100 -0
- package/features/cron/src/cron.ts +25 -0
- package/features/docs/feature.json +96 -0
- package/features/docs/src/DocsEditorPage.tsx +1012 -0
- package/features/docs/src/DocsListPage.tsx +951 -0
- package/features/docs/src/DocsOutlinePanel.tsx +68 -0
- package/features/docs/src/DocsPresence.tsx +138 -0
- package/features/docs/src/InviteDialog.tsx +336 -0
- package/features/docs/src/LibrarySidebar.tsx +464 -0
- package/features/docs/src/docs-favorites.ts +18 -0
- package/features/docs/src/docs-library-types.ts +15 -0
- package/features/docs/src/docs-schema.ts +80 -0
- package/features/docs/src/docs-ui.css +309 -0
- package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
- package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
- package/features/docs/src/editor/useDocEditor.ts +206 -0
- package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
- package/features/file-attachments/feature.json +42 -0
- package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
- package/features/file-attachments/src/attachments-constants.ts +9 -0
- package/features/file-attachments/src/attachments-schema.ts +45 -0
- package/features/file-attachments/src/useFilePreview.ts +471 -0
- package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
- package/features/file-manager/feature.json +24 -0
- package/features/file-manager/src/FileManagerPage.tsx +330 -0
- package/features/integration-test/feature.json +18 -0
- package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
- package/features/integration-test/src/integration.spec.ts +42 -0
- package/features/items/feature.json +33 -0
- package/features/items/src/ItemsPage.tsx +325 -0
- package/features/items/src/items-constants.ts +10 -0
- package/features/items/src/items-schema.ts +27 -0
- package/features/kanban/feature.json +18 -0
- package/features/kanban/src/Kanban.tsx +283 -0
- package/features/landing/feature.json +62 -0
- package/features/landing/src/LandingPage.tsx +627 -0
- package/features/landing/src/primitives.tsx +389 -0
- package/features/landing/src/sections/FAQSection.tsx +110 -0
- package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
- package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
- package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
- package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
- package/features/landing/src/sections/StatsSection.tsx +31 -0
- package/features/landing/src/sections/TeamSection.tsx +86 -0
- package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
- package/features/landing/src/sections/VideoSection.tsx +71 -0
- package/features/leaderboard/feature.json +33 -0
- package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
- package/features/leaderboard/src/leaderboard-constants.ts +21 -0
- package/features/leaderboard/src/leaderboard-schema.ts +36 -0
- package/features/messaging/feature.json +111 -0
- package/features/messaging/src/ChatMultiPage.tsx +300 -0
- package/features/messaging/src/ChatPage.tsx +75 -0
- package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
- package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
- package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
- package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
- package/features/messaging/src/components/chat/MessageList.tsx +248 -0
- package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
- package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
- package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
- package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
- package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
- package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
- package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
- package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
- package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
- package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
- package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
- package/features/messaging/src/hooks/useChatChannel.ts +64 -0
- package/features/messaging/src/hooks/useLongPress.ts +74 -0
- package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
- package/features/messaging/src/messaging-schema.ts +25 -0
- package/features/presence-test/feature.json +21 -0
- package/features/presence-test/src/PresenceTestPage.tsx +120 -0
- package/features/sidebar/feature.json +19 -0
- package/features/sidebar/src/AppSidebar.tsx +558 -0
- package/features/tasks/feature.json +33 -0
- package/features/tasks/src/TasksPage.tsx +598 -0
- package/features/tasks/src/tasks-constants.ts +43 -0
- package/features/tasks/src/tasks-schema.ts +56 -0
- package/features/testing/feature.json +49 -0
- package/features/testing/src/TestPage.tsx +145 -0
- package/features/testing/src/cpu-burn-action.ts +51 -0
- package/features/testing/src/subrequest-burn-action.ts +53 -0
- package/features/testing/src/test-schema.ts +23 -0
- package/features/topbar/feature.json +18 -0
- package/features/topbar/src/Topbar.tsx +353 -0
- package/features/tree/feature.json +26 -0
- package/features/tree/src/TreeSidebar.tsx +607 -0
- package/features/tree/src/tree-constants.ts +68 -0
- package/features/tree/src/tree-hooks.ts +258 -0
- package/package.json +4 -2
- 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
|
+
}
|