deepspace 0.7.0 → 0.8.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.
- package/CHANGELOG.md +37 -0
- package/README.md +22 -8
- package/dist/cli.js +9812 -4674
- package/dist/cli.js.map +1 -1
- package/dist/feature-installer.js +638 -0
- package/dist/feature-installer.js.map +1 -0
- package/dist/index.d.ts +107 -117
- package/dist/index.js +610 -779
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +403 -167
- package/dist/server.js +498 -367
- package/dist/server.js.map +1 -1
- package/dist/testing.d.ts +115 -0
- package/dist/testing.js +3 -3
- package/dist/testing.js.map +1 -1
- package/dist/worker.d.ts +397 -160
- package/dist/worker.js +506 -367
- package/dist/worker.js.map +1 -1
- package/features/ai-chat/feature.json +2 -0
- package/features/ai-chat/src/AiChatPage.tsx +2 -3
- package/features/ai-chat/src/ChatPanel.messages.tsx +344 -0
- package/features/ai-chat/src/ChatPanel.stream.ts +397 -0
- package/features/ai-chat/src/ChatPanel.tsx +158 -862
- package/features/ai-chat/src/__tests__/ChatPanel.stream.test.ts +163 -0
- package/features/canvas/src/CanvasListPage.tsx +1 -1
- package/features/docs/feature.json +4 -8
- package/features/docs/src/DocsEditorPage.tsx +33 -326
- package/features/docs/src/DocsListPage.tsx +33 -29
- package/features/docs/src/InviteDialog.tsx +11 -40
- package/features/docs/src/__tests__/docs-presence-access.test.ts +71 -0
- package/features/docs/src/docs-library-types.ts +28 -3
- package/features/docs/src/docs-ui.css +1 -4
- package/features/docs/src/use-docs-presence-access.ts +330 -0
- package/features/file-attachments/feature.json +2 -2
- package/features/file-attachments/src/AttachmentPreview.tsx +428 -0
- package/features/file-attachments/src/AttachmentsPage.tsx +100 -462
- package/features/file-attachments/src/useFilePreview.ts +54 -68
- package/features/file-manager/src/FileManagerPage.tsx +1 -1
- package/features/integration-test/src/integration.spec.ts +1 -1
- package/features/items/feature.json +1 -5
- package/features/items/src/ItemsPage.tsx +207 -135
- package/features/landing/feature.json +3 -0
- package/features/landing/src/LandingPage.tsx +4 -18
- package/features/leaderboard/feature.json +1 -5
- package/features/leaderboard/src/LeaderboardPage.tsx +333 -198
- package/features/messaging/feature.json +0 -4
- package/features/messaging/src/ChatMultiPage.tsx +1 -3
- package/features/messaging/src/ChatPage.tsx +1 -3
- package/features/messaging/src/components/chat/MessageActionSheet.tsx +40 -16
- package/features/search-bar/feature.json +8 -3
- package/features/search-bar/src/SearchOverlay.tsx +108 -252
- package/features/search-bar/src/__tests__/search-model.test.ts +40 -0
- package/features/search-bar/src/search-model.ts +223 -0
- package/features/tasks/src/TasksPage.tsx +3 -3
- package/features/topbar/src/Topbar.tsx +0 -17
- package/package.json +2 -3
- package/features/docs/src/docs-favorites.ts +0 -18
- package/features/docs/src/use-yjs-room-with-awareness.ts +0 -289
- package/features/file-attachments/src/attachments-constants.ts +0 -9
- package/features/items/src/items-constants.ts +0 -10
- package/features/leaderboard/src/leaderboard-constants.ts +0 -21
- package/features/messaging/src/components/chat/ReactionPicker.tsx +0 -2
- package/scripts/add-feature.cjs +0 -807
|
@@ -2,250 +2,337 @@
|
|
|
2
2
|
* Leaderboard Page
|
|
3
3
|
*
|
|
4
4
|
* Demonstrates:
|
|
5
|
-
* - useQuery for fetching records
|
|
5
|
+
* - useQuery for fetching ordered records
|
|
6
6
|
* - useMutations for CRUD operations
|
|
7
|
-
* - useUser for
|
|
8
|
-
* - Role-based UI (admin can edit anyone's score)
|
|
7
|
+
* - useUser for role-aware actions
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
import { useState } from 'react'
|
|
12
|
-
import {
|
|
13
|
-
import { useQuery } from 'deepspace'
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
11
|
+
import { BarChart3, Medal, Pencil, Plus, Trash2, TriangleAlert } from 'lucide-react'
|
|
12
|
+
import { ROLES, useMutations, useQuery, useUser, type Role } from 'deepspace'
|
|
13
|
+
import {
|
|
14
|
+
Badge,
|
|
15
|
+
Button,
|
|
16
|
+
ConfirmModal,
|
|
17
|
+
EmptyState,
|
|
18
|
+
Input,
|
|
19
|
+
Label,
|
|
20
|
+
Modal,
|
|
21
|
+
Select,
|
|
22
|
+
SelectContent,
|
|
23
|
+
SelectItem,
|
|
24
|
+
SelectTrigger,
|
|
25
|
+
SelectValue,
|
|
26
|
+
useToast,
|
|
27
|
+
type BadgeProps,
|
|
28
|
+
} from '@/components/ui'
|
|
29
|
+
|
|
30
|
+
type BadgeVariant = BadgeProps['variant']
|
|
31
|
+
|
|
32
|
+
const LEADERBOARD_CATEGORY = {
|
|
33
|
+
GENERAL: 'general',
|
|
34
|
+
SPEED: 'speed',
|
|
35
|
+
ACCURACY: 'accuracy',
|
|
36
|
+
} as const
|
|
37
|
+
|
|
38
|
+
type LeaderboardCategory = (typeof LEADERBOARD_CATEGORY)[keyof typeof LEADERBOARD_CATEGORY]
|
|
39
|
+
|
|
40
|
+
const CATEGORY_CONFIG: Record<LeaderboardCategory, { title: string; color: BadgeVariant }> = {
|
|
41
|
+
[LEADERBOARD_CATEGORY.GENERAL]: { title: 'General', color: 'info' },
|
|
42
|
+
[LEADERBOARD_CATEGORY.SPEED]: { title: 'Speed', color: 'warning' },
|
|
43
|
+
[LEADERBOARD_CATEGORY.ACCURACY]: { title: 'Accuracy', color: 'success' },
|
|
44
|
+
}
|
|
22
45
|
|
|
23
46
|
interface LeaderboardEntry {
|
|
24
47
|
playerName: string
|
|
25
48
|
score: number
|
|
26
|
-
category:
|
|
49
|
+
category: LeaderboardCategory
|
|
27
50
|
playerId: string
|
|
28
51
|
}
|
|
29
52
|
|
|
53
|
+
type LeaderboardRecord = {
|
|
54
|
+
recordId: string
|
|
55
|
+
data: LeaderboardEntry
|
|
56
|
+
}
|
|
57
|
+
|
|
30
58
|
interface LeaderboardPageProps {
|
|
31
59
|
className?: string
|
|
32
60
|
}
|
|
33
61
|
|
|
34
|
-
// ============================================================================
|
|
35
|
-
// Submit Score Modal
|
|
36
|
-
// ============================================================================
|
|
37
|
-
|
|
38
62
|
interface SubmitScoreModalProps {
|
|
39
|
-
isOpen: boolean
|
|
40
63
|
onClose: () => void
|
|
41
|
-
onSubmit: (playerName: string, score: number, category:
|
|
64
|
+
onSubmit: (playerName: string, score: number, category: LeaderboardCategory) => Promise<boolean>
|
|
42
65
|
userName: string
|
|
43
66
|
}
|
|
44
67
|
|
|
45
|
-
function SubmitScoreModal({
|
|
68
|
+
function SubmitScoreModal({ onClose, onSubmit, userName }: SubmitScoreModalProps) {
|
|
46
69
|
const [playerName, setPlayerName] = useState(userName)
|
|
47
70
|
const [score, setScore] = useState('')
|
|
48
|
-
const [category, setCategory] = useState<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
71
|
+
const [category, setCategory] = useState<LeaderboardCategory>(LEADERBOARD_CATEGORY.GENERAL)
|
|
72
|
+
const [submitting, setSubmitting] = useState(false)
|
|
73
|
+
|
|
74
|
+
const parsedScore = Number(score)
|
|
75
|
+
const isValid =
|
|
76
|
+
playerName.trim().length > 0 && score.trim().length > 0 && Number.isFinite(parsedScore)
|
|
77
|
+
|
|
78
|
+
const handleSubmit = async () => {
|
|
79
|
+
if (!isValid || submitting) return
|
|
80
|
+
|
|
81
|
+
setSubmitting(true)
|
|
82
|
+
try {
|
|
83
|
+
const submitted = await onSubmit(playerName.trim(), parsedScore, category)
|
|
84
|
+
if (!submitted) return
|
|
57
85
|
onClose()
|
|
86
|
+
} finally {
|
|
87
|
+
setSubmitting(false)
|
|
58
88
|
}
|
|
59
89
|
}
|
|
60
90
|
|
|
61
91
|
return (
|
|
62
|
-
<Modal open
|
|
63
|
-
<Modal.Header
|
|
92
|
+
<Modal open onClose={submitting ? () => undefined : onClose} size="sm">
|
|
93
|
+
<Modal.Header>
|
|
64
94
|
<Modal.Title>Submit Score</Modal.Title>
|
|
65
95
|
</Modal.Header>
|
|
66
96
|
<Modal.Body>
|
|
67
97
|
<div className="space-y-4">
|
|
68
98
|
<div>
|
|
69
|
-
<
|
|
70
|
-
|
|
99
|
+
<Label htmlFor="leaderboard-player-name" className="mb-2 block">
|
|
100
|
+
Player name
|
|
101
|
+
</Label>
|
|
102
|
+
<Input
|
|
103
|
+
id="leaderboard-player-name"
|
|
71
104
|
type="text"
|
|
72
105
|
value={playerName}
|
|
73
|
-
onChange={(
|
|
106
|
+
onChange={(event) => setPlayerName(event.target.value)}
|
|
74
107
|
placeholder="Your name"
|
|
75
|
-
|
|
108
|
+
disabled={submitting}
|
|
76
109
|
autoFocus
|
|
77
110
|
/>
|
|
78
111
|
</div>
|
|
79
112
|
<div>
|
|
80
|
-
<
|
|
81
|
-
|
|
113
|
+
<Label htmlFor="leaderboard-score" className="mb-2 block">
|
|
114
|
+
Score
|
|
115
|
+
</Label>
|
|
116
|
+
<Input
|
|
117
|
+
id="leaderboard-score"
|
|
82
118
|
type="number"
|
|
83
119
|
value={score}
|
|
84
|
-
onChange={(
|
|
120
|
+
onChange={(event) => setScore(event.target.value)}
|
|
85
121
|
placeholder="Enter your score"
|
|
86
|
-
|
|
122
|
+
disabled={submitting}
|
|
87
123
|
/>
|
|
88
124
|
</div>
|
|
89
125
|
<div>
|
|
90
|
-
<
|
|
91
|
-
|
|
126
|
+
<Label htmlFor="leaderboard-category" className="mb-2 block">
|
|
127
|
+
Category
|
|
128
|
+
</Label>
|
|
129
|
+
<Select
|
|
92
130
|
value={category}
|
|
93
|
-
|
|
94
|
-
|
|
131
|
+
onValueChange={(value) => setCategory(value as LeaderboardCategory)}
|
|
132
|
+
disabled={submitting}
|
|
95
133
|
>
|
|
96
|
-
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
134
|
+
<SelectTrigger id="leaderboard-category">
|
|
135
|
+
<SelectValue placeholder="Choose a category" />
|
|
136
|
+
</SelectTrigger>
|
|
137
|
+
<SelectContent>
|
|
138
|
+
{Object.values(LEADERBOARD_CATEGORY).map((value) => (
|
|
139
|
+
<SelectItem key={value} value={value}>
|
|
140
|
+
{CATEGORY_CONFIG[value].title}
|
|
141
|
+
</SelectItem>
|
|
142
|
+
))}
|
|
143
|
+
</SelectContent>
|
|
144
|
+
</Select>
|
|
100
145
|
</div>
|
|
101
146
|
</div>
|
|
102
147
|
</Modal.Body>
|
|
103
148
|
<Modal.Footer>
|
|
104
|
-
<Button variant="secondary" onClick={onClose}>
|
|
105
|
-
|
|
149
|
+
<Button variant="secondary" onClick={onClose} disabled={submitting}>
|
|
150
|
+
Cancel
|
|
151
|
+
</Button>
|
|
152
|
+
<Button onClick={() => void handleSubmit()} disabled={!isValid} loading={submitting}>
|
|
153
|
+
Submit
|
|
154
|
+
</Button>
|
|
106
155
|
</Modal.Footer>
|
|
107
156
|
</Modal>
|
|
108
157
|
)
|
|
109
158
|
}
|
|
110
159
|
|
|
111
|
-
// ============================================================================
|
|
112
|
-
// Edit Score Modal (Admin)
|
|
113
|
-
// ============================================================================
|
|
114
|
-
|
|
115
160
|
interface EditScoreModalProps {
|
|
116
|
-
|
|
161
|
+
entry: LeaderboardRecord
|
|
117
162
|
onClose: () => void
|
|
118
|
-
onSave: (score: number) =>
|
|
119
|
-
entry: { recordId: string; data: LeaderboardEntry } | null
|
|
163
|
+
onSave: (entry: LeaderboardRecord, score: number) => Promise<boolean>
|
|
120
164
|
}
|
|
121
165
|
|
|
122
|
-
function EditScoreModal({
|
|
123
|
-
const [score, setScore] = useState(
|
|
166
|
+
function EditScoreModal({ entry, onClose, onSave }: EditScoreModalProps) {
|
|
167
|
+
const [score, setScore] = useState(String(entry.data.score))
|
|
168
|
+
const [saving, setSaving] = useState(false)
|
|
169
|
+
const parsedScore = Number(score)
|
|
170
|
+
const isValid = score.trim().length > 0 && Number.isFinite(parsedScore)
|
|
124
171
|
|
|
125
|
-
const
|
|
126
|
-
if (
|
|
127
|
-
}
|
|
172
|
+
const handleSave = async () => {
|
|
173
|
+
if (!isValid || saving) return
|
|
128
174
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
175
|
+
setSaving(true)
|
|
176
|
+
try {
|
|
177
|
+
const saved = await onSave(entry, parsedScore)
|
|
178
|
+
if (!saved) return
|
|
133
179
|
onClose()
|
|
180
|
+
} finally {
|
|
181
|
+
setSaving(false)
|
|
134
182
|
}
|
|
135
183
|
}
|
|
136
184
|
|
|
137
|
-
// Reset score when entry changes
|
|
138
|
-
if (isOpen && entry && score === '') handleOpen()
|
|
139
|
-
|
|
140
185
|
return (
|
|
141
|
-
<Modal open
|
|
142
|
-
<Modal.Header
|
|
143
|
-
<Modal.Title>Edit
|
|
186
|
+
<Modal open onClose={saving ? () => undefined : onClose} size="sm">
|
|
187
|
+
<Modal.Header>
|
|
188
|
+
<Modal.Title>Edit score for {entry.data.playerName}</Modal.Title>
|
|
144
189
|
</Modal.Header>
|
|
145
190
|
<Modal.Body>
|
|
146
|
-
<
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
191
|
+
<Label htmlFor="leaderboard-edit-score" className="mb-2 block">
|
|
192
|
+
Score
|
|
193
|
+
</Label>
|
|
194
|
+
<Input
|
|
195
|
+
id="leaderboard-edit-score"
|
|
196
|
+
type="number"
|
|
197
|
+
value={score}
|
|
198
|
+
onChange={(event) => setScore(event.target.value)}
|
|
199
|
+
disabled={saving}
|
|
200
|
+
autoFocus
|
|
201
|
+
/>
|
|
156
202
|
</Modal.Body>
|
|
157
203
|
<Modal.Footer>
|
|
158
|
-
<Button variant="secondary" onClick={onClose}>
|
|
159
|
-
|
|
204
|
+
<Button variant="secondary" onClick={onClose} disabled={saving}>
|
|
205
|
+
Cancel
|
|
206
|
+
</Button>
|
|
207
|
+
<Button onClick={() => void handleSave()} disabled={!isValid} loading={saving}>
|
|
208
|
+
Save
|
|
209
|
+
</Button>
|
|
160
210
|
</Modal.Footer>
|
|
161
211
|
</Modal>
|
|
162
212
|
)
|
|
163
213
|
}
|
|
164
214
|
|
|
165
|
-
// ============================================================================
|
|
166
|
-
// Rank Badge Component
|
|
167
|
-
// ============================================================================
|
|
168
|
-
|
|
169
215
|
function RankBadge({ rank }: { rank: number }) {
|
|
170
|
-
if (rank
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
216
|
+
if (rank <= 3) {
|
|
217
|
+
const label = rank === 1 ? '1st' : rank === 2 ? '2nd' : '3rd'
|
|
218
|
+
const variant: BadgeVariant = rank === 1 ? 'warning' : rank === 2 ? 'secondary' : 'outline'
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<Badge variant={variant} aria-label={`${label} place`}>
|
|
222
|
+
<Medal aria-hidden="true" className="mr-1 size-3" />
|
|
223
|
+
{label}
|
|
224
|
+
</Badge>
|
|
225
|
+
)
|
|
178
226
|
}
|
|
227
|
+
|
|
179
228
|
return <span className="text-sm font-medium text-muted-foreground">#{rank}</span>
|
|
180
229
|
}
|
|
181
230
|
|
|
182
|
-
// ============================================================================
|
|
183
|
-
// Main Page
|
|
184
|
-
// ============================================================================
|
|
185
|
-
|
|
186
231
|
export default function LeaderboardPage({ className }: LeaderboardPageProps) {
|
|
187
232
|
const { user } = useUser()
|
|
188
233
|
const userRole = (user?.role ?? ROLES.VIEWER) as Role
|
|
189
234
|
const isAdmin = userRole === ROLES.ADMIN
|
|
190
|
-
const canSubmit = userRole === ROLES.MEMBER ||
|
|
235
|
+
const canSubmit = userRole === ROLES.MEMBER || isAdmin
|
|
236
|
+
const toast = useToast()
|
|
191
237
|
|
|
192
238
|
const [showSubmitModal, setShowSubmitModal] = useState(false)
|
|
193
|
-
const [editingEntry, setEditingEntry] = useState<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
239
|
+
const [editingEntry, setEditingEntry] = useState<LeaderboardRecord | null>(null)
|
|
240
|
+
const [deleteTarget, setDeleteTarget] = useState<LeaderboardRecord | null>(null)
|
|
241
|
+
const [deleting, setDeleting] = useState(false)
|
|
242
|
+
|
|
243
|
+
const {
|
|
244
|
+
records: entries,
|
|
245
|
+
status,
|
|
246
|
+
error: queryError,
|
|
247
|
+
} = useQuery<LeaderboardEntry>('leaderboard', {
|
|
197
248
|
orderBy: 'score',
|
|
198
249
|
orderDir: 'desc',
|
|
199
250
|
})
|
|
251
|
+
const { createConfirmed, putConfirmed, removeConfirmed } =
|
|
252
|
+
useMutations<LeaderboardEntry>('leaderboard')
|
|
253
|
+
|
|
254
|
+
const handleSubmit = async (
|
|
255
|
+
playerName: string,
|
|
256
|
+
score: number,
|
|
257
|
+
category: LeaderboardCategory,
|
|
258
|
+
): Promise<boolean> => {
|
|
259
|
+
if (!user) {
|
|
260
|
+
toast.error('Could not submit score', 'Sign in and try again.')
|
|
261
|
+
return false
|
|
262
|
+
}
|
|
200
263
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
264
|
+
try {
|
|
265
|
+
await createConfirmed({ playerName, score, category, playerId: user.id })
|
|
266
|
+
toast.success(
|
|
267
|
+
'Score submitted',
|
|
268
|
+
`${score.toLocaleString()} points recorded for "${playerName}".`,
|
|
269
|
+
)
|
|
270
|
+
return true
|
|
271
|
+
} catch (error) {
|
|
272
|
+
toast.error(
|
|
273
|
+
'Could not submit score',
|
|
274
|
+
error instanceof Error ? error.message : 'Please try again.',
|
|
275
|
+
)
|
|
276
|
+
return false
|
|
277
|
+
}
|
|
211
278
|
}
|
|
212
279
|
|
|
213
|
-
const handleEditSave = async (newScore: number) => {
|
|
214
|
-
|
|
215
|
-
await
|
|
216
|
-
|
|
280
|
+
const handleEditSave = async (entry: LeaderboardRecord, newScore: number): Promise<boolean> => {
|
|
281
|
+
try {
|
|
282
|
+
await putConfirmed(entry.recordId, { score: newScore })
|
|
283
|
+
toast.success(
|
|
284
|
+
'Score updated',
|
|
285
|
+
`"${entry.data.playerName}" now has ${newScore.toLocaleString()} points.`,
|
|
286
|
+
)
|
|
287
|
+
return true
|
|
288
|
+
} catch (error) {
|
|
289
|
+
toast.error(
|
|
290
|
+
'Could not update score',
|
|
291
|
+
error instanceof Error ? error.message : 'Please try again.',
|
|
292
|
+
)
|
|
293
|
+
return false
|
|
217
294
|
}
|
|
218
295
|
}
|
|
219
296
|
|
|
220
|
-
const handleDelete = async (
|
|
221
|
-
if (
|
|
222
|
-
|
|
297
|
+
const handleDelete = async () => {
|
|
298
|
+
if (!deleteTarget || deleting) return
|
|
299
|
+
|
|
300
|
+
setDeleting(true)
|
|
301
|
+
try {
|
|
302
|
+
await removeConfirmed(deleteTarget.recordId)
|
|
303
|
+
toast.success('Score deleted', `The score for "${deleteTarget.data.playerName}" was deleted.`)
|
|
304
|
+
setDeleteTarget(null)
|
|
305
|
+
} catch (error) {
|
|
306
|
+
toast.error(
|
|
307
|
+
'Could not delete score',
|
|
308
|
+
error instanceof Error ? error.message : 'Please try again.',
|
|
309
|
+
)
|
|
310
|
+
} finally {
|
|
311
|
+
setDeleting(false)
|
|
223
312
|
}
|
|
224
313
|
}
|
|
225
314
|
|
|
226
315
|
const isLoading = status === 'loading'
|
|
227
316
|
|
|
228
317
|
return (
|
|
229
|
-
<div className={`h-full
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
<div className="flex items-center justify-between">
|
|
318
|
+
<div className={`h-full overflow-y-auto bg-background ${className ?? ''}`}>
|
|
319
|
+
<div className="sticky top-0 z-10 border-b border-border bg-card/60 backdrop-blur-md">
|
|
320
|
+
<div className="mx-auto max-w-4xl px-4 py-4 sm:px-6 lg:px-8">
|
|
321
|
+
<div className="flex items-center justify-between gap-4">
|
|
234
322
|
<div>
|
|
235
323
|
<h1 className="text-2xl font-bold text-foreground">Leaderboard</h1>
|
|
236
|
-
<p className="text-muted-foreground
|
|
237
|
-
{
|
|
238
|
-
? '
|
|
239
|
-
:
|
|
240
|
-
|
|
324
|
+
<p className="mt-1 text-muted-foreground">
|
|
325
|
+
{isLoading
|
|
326
|
+
? 'Loading current rankings...'
|
|
327
|
+
: canSubmit
|
|
328
|
+
? 'Submit a score and see where you rank.'
|
|
329
|
+
: 'Scores are ranked from highest to lowest.'}
|
|
241
330
|
</p>
|
|
242
331
|
</div>
|
|
243
332
|
|
|
244
333
|
{canSubmit && (
|
|
245
334
|
<Button onClick={() => setShowSubmitModal(true)}>
|
|
246
|
-
<
|
|
247
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
248
|
-
</svg>
|
|
335
|
+
<Plus aria-hidden="true" />
|
|
249
336
|
Submit Score
|
|
250
337
|
</Button>
|
|
251
338
|
)}
|
|
@@ -253,36 +340,61 @@ export default function LeaderboardPage({ className }: LeaderboardPageProps) {
|
|
|
253
340
|
</div>
|
|
254
341
|
</div>
|
|
255
342
|
|
|
256
|
-
|
|
257
|
-
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
343
|
+
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
|
258
344
|
{isLoading ? (
|
|
259
|
-
<div
|
|
260
|
-
|
|
345
|
+
<div
|
|
346
|
+
className="space-y-3 rounded-xl border border-border bg-card/60 p-4"
|
|
347
|
+
aria-label="Loading leaderboard"
|
|
348
|
+
aria-busy="true"
|
|
349
|
+
>
|
|
350
|
+
<div className="h-8 animate-pulse rounded-md bg-muted/60" />
|
|
351
|
+
<div className="h-12 animate-pulse rounded-md bg-muted/40" />
|
|
352
|
+
<div className="h-12 animate-pulse rounded-md bg-muted/40" />
|
|
353
|
+
<div className="h-12 animate-pulse rounded-md bg-muted/40" />
|
|
261
354
|
</div>
|
|
355
|
+
) : status === 'error' ? (
|
|
356
|
+
<EmptyState
|
|
357
|
+
title="Could not load leaderboard"
|
|
358
|
+
description={queryError ?? 'Refresh the page to try again.'}
|
|
359
|
+
icon={<TriangleAlert aria-hidden="true" />}
|
|
360
|
+
/>
|
|
262
361
|
) : entries.length === 0 ? (
|
|
263
362
|
<EmptyState
|
|
264
363
|
title="No scores yet"
|
|
265
|
-
description={
|
|
266
|
-
|
|
267
|
-
|
|
364
|
+
description={
|
|
365
|
+
canSubmit
|
|
366
|
+
? 'Submit the first score to start the leaderboard.'
|
|
367
|
+
: 'Scores will appear here after a member submits one.'
|
|
268
368
|
}
|
|
269
|
-
icon={
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
369
|
+
icon={<BarChart3 aria-hidden="true" />}
|
|
370
|
+
action={
|
|
371
|
+
canSubmit
|
|
372
|
+
? { label: 'Submit a score', onClick: () => setShowSubmitModal(true) }
|
|
373
|
+
: undefined
|
|
273
374
|
}
|
|
274
375
|
/>
|
|
275
376
|
) : (
|
|
276
|
-
<div className="
|
|
277
|
-
<table className="w-full">
|
|
377
|
+
<div className="overflow-x-auto rounded-xl border border-border bg-card/60">
|
|
378
|
+
<table className="w-full min-w-160">
|
|
379
|
+
<caption className="sr-only">Scores ranked from highest to lowest</caption>
|
|
278
380
|
<thead>
|
|
279
381
|
<tr className="border-b border-border">
|
|
280
|
-
<th className="px-4 py-3 text-left text-xs font-semibold
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
<th className="px-4 py-3 text-
|
|
284
|
-
|
|
285
|
-
|
|
382
|
+
<th className="w-20 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
383
|
+
Rank
|
|
384
|
+
</th>
|
|
385
|
+
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
386
|
+
Player
|
|
387
|
+
</th>
|
|
388
|
+
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
389
|
+
Category
|
|
390
|
+
</th>
|
|
391
|
+
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
392
|
+
Score
|
|
393
|
+
</th>
|
|
394
|
+
{canSubmit && (
|
|
395
|
+
<th className="w-24 px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
396
|
+
Actions
|
|
397
|
+
</th>
|
|
286
398
|
)}
|
|
287
399
|
</tr>
|
|
288
400
|
</thead>
|
|
@@ -290,57 +402,61 @@ export default function LeaderboardPage({ className }: LeaderboardPageProps) {
|
|
|
290
402
|
{entries.map((entry, index) => {
|
|
291
403
|
const rank = index + 1
|
|
292
404
|
const isOwn = entry.data.playerId === user?.id
|
|
293
|
-
const
|
|
294
|
-
|
|
405
|
+
const categoryConfig =
|
|
406
|
+
CATEGORY_CONFIG[entry.data.category] ??
|
|
407
|
+
CATEGORY_CONFIG[LEADERBOARD_CATEGORY.GENERAL]
|
|
408
|
+
const canManage = isOwn || isAdmin
|
|
295
409
|
|
|
296
410
|
return (
|
|
297
411
|
<tr
|
|
298
412
|
key={entry.recordId}
|
|
299
|
-
className={`transition-colors ${
|
|
413
|
+
className={`transition-colors ${
|
|
414
|
+
isOwn ? 'bg-primary/10' : 'hover:bg-muted/30'
|
|
415
|
+
}`}
|
|
300
416
|
>
|
|
301
417
|
<td className="px-4 py-3">
|
|
302
418
|
<RankBadge rank={rank} />
|
|
303
419
|
</td>
|
|
304
420
|
<td className="px-4 py-3">
|
|
305
|
-
<span
|
|
421
|
+
<span
|
|
422
|
+
className={`font-medium ${isOwn ? 'text-primary' : 'text-foreground'}`}
|
|
423
|
+
>
|
|
306
424
|
{entry.data.playerName}
|
|
307
425
|
</span>
|
|
308
|
-
{isOwn && (
|
|
309
|
-
<span className="ml-2 text-xs text-primary">(you)</span>
|
|
310
|
-
)}
|
|
426
|
+
{isOwn && <span className="ml-2 text-xs text-primary">(you)</span>}
|
|
311
427
|
</td>
|
|
312
428
|
<td className="px-4 py-3">
|
|
313
|
-
<Badge variant={
|
|
429
|
+
<Badge variant={categoryConfig.color} size="sm">
|
|
430
|
+
{categoryConfig.title}
|
|
431
|
+
</Badge>
|
|
314
432
|
</td>
|
|
315
433
|
<td className="px-4 py-3 text-right font-mono font-semibold text-foreground">
|
|
316
434
|
{entry.data.score.toLocaleString()}
|
|
317
435
|
</td>
|
|
318
|
-
{
|
|
436
|
+
{canSubmit && (
|
|
319
437
|
<td className="px-4 py-3 text-right">
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
<
|
|
438
|
+
{canManage && (
|
|
439
|
+
<div className="flex items-center justify-end gap-1">
|
|
440
|
+
<Button
|
|
441
|
+
variant="ghost"
|
|
442
|
+
size="icon"
|
|
443
|
+
className="size-8 text-muted-foreground"
|
|
323
444
|
onClick={() => setEditingEntry(entry)}
|
|
324
|
-
|
|
325
|
-
title="Edit score"
|
|
445
|
+
aria-label={`Edit score for ${entry.data.playerName}`}
|
|
326
446
|
>
|
|
327
|
-
<
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
className="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/20 rounded-lg transition-colors"
|
|
336
|
-
title="Delete"
|
|
447
|
+
<Pencil aria-hidden="true" />
|
|
448
|
+
</Button>
|
|
449
|
+
<Button
|
|
450
|
+
variant="ghost"
|
|
451
|
+
size="icon"
|
|
452
|
+
className="size-8 text-muted-foreground hover:bg-destructive/20 hover:text-destructive"
|
|
453
|
+
onClick={() => setDeleteTarget(entry)}
|
|
454
|
+
aria-label={`Delete score for ${entry.data.playerName}`}
|
|
337
455
|
>
|
|
338
|
-
<
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
)}
|
|
343
|
-
</div>
|
|
456
|
+
<Trash2 aria-hidden="true" />
|
|
457
|
+
</Button>
|
|
458
|
+
</div>
|
|
459
|
+
)}
|
|
344
460
|
</td>
|
|
345
461
|
)}
|
|
346
462
|
</tr>
|
|
@@ -352,20 +468,39 @@ export default function LeaderboardPage({ className }: LeaderboardPageProps) {
|
|
|
352
468
|
)}
|
|
353
469
|
</div>
|
|
354
470
|
|
|
355
|
-
{
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
{
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
471
|
+
{showSubmitModal && (
|
|
472
|
+
<SubmitScoreModal
|
|
473
|
+
onClose={() => setShowSubmitModal(false)}
|
|
474
|
+
onSubmit={handleSubmit}
|
|
475
|
+
userName={user?.name ?? ''}
|
|
476
|
+
/>
|
|
477
|
+
)}
|
|
478
|
+
|
|
479
|
+
{editingEntry && (
|
|
480
|
+
<EditScoreModal
|
|
481
|
+
key={editingEntry.recordId}
|
|
482
|
+
entry={editingEntry}
|
|
483
|
+
onClose={() => setEditingEntry(null)}
|
|
484
|
+
onSave={handleEditSave}
|
|
485
|
+
/>
|
|
486
|
+
)}
|
|
487
|
+
|
|
488
|
+
<ConfirmModal
|
|
489
|
+
open={deleteTarget !== null}
|
|
490
|
+
onClose={() => {
|
|
491
|
+
if (!deleting) setDeleteTarget(null)
|
|
492
|
+
}}
|
|
493
|
+
onConfirm={() => void handleDelete()}
|
|
494
|
+
title={
|
|
495
|
+
deleteTarget ? `Delete score for "${deleteTarget.data.playerName}"?` : 'Delete score?'
|
|
496
|
+
}
|
|
497
|
+
description={
|
|
498
|
+
deleteTarget
|
|
499
|
+
? `This permanently removes the ${deleteTarget.data.score.toLocaleString()}-point score.`
|
|
500
|
+
: 'This cannot be undone.'
|
|
501
|
+
}
|
|
502
|
+
confirmText="Delete"
|
|
503
|
+
loading={deleting}
|
|
369
504
|
/>
|
|
370
505
|
</div>
|
|
371
506
|
)
|