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.
Files changed (63) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +22 -8
  3. package/dist/cli.js +9812 -4674
  4. package/dist/cli.js.map +1 -1
  5. package/dist/feature-installer.js +638 -0
  6. package/dist/feature-installer.js.map +1 -0
  7. package/dist/index.d.ts +107 -117
  8. package/dist/index.js +610 -779
  9. package/dist/index.js.map +1 -1
  10. package/dist/server.d.ts +403 -167
  11. package/dist/server.js +498 -367
  12. package/dist/server.js.map +1 -1
  13. package/dist/testing.d.ts +115 -0
  14. package/dist/testing.js +3 -3
  15. package/dist/testing.js.map +1 -1
  16. package/dist/worker.d.ts +397 -160
  17. package/dist/worker.js +506 -367
  18. package/dist/worker.js.map +1 -1
  19. package/features/ai-chat/feature.json +2 -0
  20. package/features/ai-chat/src/AiChatPage.tsx +2 -3
  21. package/features/ai-chat/src/ChatPanel.messages.tsx +344 -0
  22. package/features/ai-chat/src/ChatPanel.stream.ts +397 -0
  23. package/features/ai-chat/src/ChatPanel.tsx +158 -862
  24. package/features/ai-chat/src/__tests__/ChatPanel.stream.test.ts +163 -0
  25. package/features/canvas/src/CanvasListPage.tsx +1 -1
  26. package/features/docs/feature.json +4 -8
  27. package/features/docs/src/DocsEditorPage.tsx +33 -326
  28. package/features/docs/src/DocsListPage.tsx +33 -29
  29. package/features/docs/src/InviteDialog.tsx +11 -40
  30. package/features/docs/src/__tests__/docs-presence-access.test.ts +71 -0
  31. package/features/docs/src/docs-library-types.ts +28 -3
  32. package/features/docs/src/docs-ui.css +1 -4
  33. package/features/docs/src/use-docs-presence-access.ts +330 -0
  34. package/features/file-attachments/feature.json +2 -2
  35. package/features/file-attachments/src/AttachmentPreview.tsx +428 -0
  36. package/features/file-attachments/src/AttachmentsPage.tsx +100 -462
  37. package/features/file-attachments/src/useFilePreview.ts +54 -68
  38. package/features/file-manager/src/FileManagerPage.tsx +1 -1
  39. package/features/integration-test/src/integration.spec.ts +1 -1
  40. package/features/items/feature.json +1 -5
  41. package/features/items/src/ItemsPage.tsx +207 -135
  42. package/features/landing/feature.json +3 -0
  43. package/features/landing/src/LandingPage.tsx +4 -18
  44. package/features/leaderboard/feature.json +1 -5
  45. package/features/leaderboard/src/LeaderboardPage.tsx +333 -198
  46. package/features/messaging/feature.json +0 -4
  47. package/features/messaging/src/ChatMultiPage.tsx +1 -3
  48. package/features/messaging/src/ChatPage.tsx +1 -3
  49. package/features/messaging/src/components/chat/MessageActionSheet.tsx +40 -16
  50. package/features/search-bar/feature.json +8 -3
  51. package/features/search-bar/src/SearchOverlay.tsx +108 -252
  52. package/features/search-bar/src/__tests__/search-model.test.ts +40 -0
  53. package/features/search-bar/src/search-model.ts +223 -0
  54. package/features/tasks/src/TasksPage.tsx +3 -3
  55. package/features/topbar/src/Topbar.tsx +0 -17
  56. package/package.json +2 -3
  57. package/features/docs/src/docs-favorites.ts +0 -18
  58. package/features/docs/src/use-yjs-room-with-awareness.ts +0 -289
  59. package/features/file-attachments/src/attachments-constants.ts +0 -9
  60. package/features/items/src/items-constants.ts +0 -10
  61. package/features/leaderboard/src/leaderboard-constants.ts +0 -21
  62. package/features/messaging/src/components/chat/ReactionPicker.tsx +0 -2
  63. 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 with ordering
5
+ * - useQuery for fetching ordered records
6
6
  * - useMutations for CRUD operations
7
- * - useUser for current user
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 { useUser } from 'deepspace'
13
- import { useQuery } from 'deepspace'
14
- import { useMutations } from 'deepspace'
15
- import { Button, Modal, EmptyState, Badge } from '@/components/ui'
16
- import { ROLES, type Role } from 'deepspace'
17
- import { LEADERBOARD_CATEGORY, CATEGORY_CONFIG, type LeaderboardCategory } from '../components/leaderboard/leaderboard-constants'
18
-
19
- // ============================================================================
20
- // Types
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: string
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: string) => void
64
+ onSubmit: (playerName: string, score: number, category: LeaderboardCategory) => Promise<boolean>
42
65
  userName: string
43
66
  }
44
67
 
45
- function SubmitScoreModal({ isOpen, onClose, onSubmit, userName }: SubmitScoreModalProps) {
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<string>(LEADERBOARD_CATEGORY.GENERAL)
49
-
50
- const handleSubmit = () => {
51
- const parsed = Number(score)
52
- if (playerName.trim() && !isNaN(parsed)) {
53
- onSubmit(playerName.trim(), parsed, category)
54
- setPlayerName(userName)
55
- setScore('')
56
- setCategory(LEADERBOARD_CATEGORY.GENERAL)
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={isOpen} onClose={onClose} size="sm">
63
- <Modal.Header onClose={onClose}>
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
- <label className="block text-sm font-medium text-muted-foreground mb-2">Player Name</label>
70
- <input
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={(e) => setPlayerName(e.target.value)}
106
+ onChange={(event) => setPlayerName(event.target.value)}
74
107
  placeholder="Your name"
75
- 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"
108
+ disabled={submitting}
76
109
  autoFocus
77
110
  />
78
111
  </div>
79
112
  <div>
80
- <label className="block text-sm font-medium text-muted-foreground mb-2">Score</label>
81
- <input
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={(e) => setScore(e.target.value)}
120
+ onChange={(event) => setScore(event.target.value)}
85
121
  placeholder="Enter your score"
86
- 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"
122
+ disabled={submitting}
87
123
  />
88
124
  </div>
89
125
  <div>
90
- <label className="block text-sm font-medium text-muted-foreground mb-2">Category</label>
91
- <select
126
+ <Label htmlFor="leaderboard-category" className="mb-2 block">
127
+ Category
128
+ </Label>
129
+ <Select
92
130
  value={category}
93
- onChange={(e) => setCategory(e.target.value)}
94
- className="w-full px-3 py-2 bg-transparent border border-border rounded-lg text-foreground focus:outline-none focus:ring-ring"
131
+ onValueChange={(value) => setCategory(value as LeaderboardCategory)}
132
+ disabled={submitting}
95
133
  >
96
- {Object.values(LEADERBOARD_CATEGORY).map(cat => (
97
- <option key={cat} value={cat}>{CATEGORY_CONFIG[cat].title}</option>
98
- ))}
99
- </select>
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}>Cancel</Button>
105
- <Button onClick={handleSubmit} disabled={!playerName.trim() || !score}>Submit</Button>
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
- isOpen: boolean
161
+ entry: LeaderboardRecord
117
162
  onClose: () => void
118
- onSave: (score: number) => void
119
- entry: { recordId: string; data: LeaderboardEntry } | null
163
+ onSave: (entry: LeaderboardRecord, score: number) => Promise<boolean>
120
164
  }
121
165
 
122
- function EditScoreModal({ isOpen, onClose, onSave, entry }: EditScoreModalProps) {
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 handleOpen = () => {
126
- if (entry) setScore(String(entry.data.score))
127
- }
172
+ const handleSave = async () => {
173
+ if (!isValid || saving) return
128
174
 
129
- const handleSave = () => {
130
- const parsed = Number(score)
131
- if (!isNaN(parsed)) {
132
- onSave(parsed)
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={isOpen} onClose={onClose} size="sm">
142
- <Modal.Header onClose={onClose}>
143
- <Modal.Title>Edit Score - {entry?.data.playerName}</Modal.Title>
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
- <div>
147
- <label className="block text-sm font-medium text-muted-foreground mb-2">Score</label>
148
- <input
149
- type="number"
150
- value={score}
151
- onChange={(e) => setScore(e.target.value)}
152
- 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"
153
- autoFocus
154
- />
155
- </div>
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}>Cancel</Button>
159
- <Button onClick={handleSave} disabled={!score}>Save</Button>
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 === 1) {
171
- return <span className="text-lg" title="1st place">&#x1F947;</span>
172
- }
173
- if (rank === 2) {
174
- return <span className="text-lg" title="2nd place">&#x1F948;</span>
175
- }
176
- if (rank === 3) {
177
- return <span className="text-lg" title="3rd place">&#x1F949;</span>
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 || userRole === ROLES.ADMIN
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<{ recordId: string; data: LeaderboardEntry } | null>(null)
194
-
195
- // Query all leaderboard entries, sorted by score descending
196
- const { records: entries, status } = useQuery<LeaderboardEntry>('leaderboard', {
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
- // Mutations for leaderboard
202
- const { create, put, remove } = useMutations<LeaderboardEntry>('leaderboard')
203
-
204
- const handleSubmit = async (playerName: string, score: number, category: string) => {
205
- await create({
206
- playerName,
207
- score,
208
- category,
209
- playerId: user!.id,
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
- if (editingEntry) {
215
- await put(editingEntry.recordId, { ...editingEntry.data, score: newScore })
216
- setEditingEntry(null)
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 (recordId: string) => {
221
- if (confirm('Are you sure you want to delete this entry?')) {
222
- await remove(recordId)
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 bg-background overflow-y-auto ${className ?? ''}`}>
230
- {/* Header */}
231
- <div className="bg-card/60 backdrop-blur-md border-b border-border sticky top-0 z-10">
232
- <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
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 mt-1">
237
- {canSubmit
238
- ? 'Submit your score and compete for the top spot'
239
- : 'View the rankings below'
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
- <svg className="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
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
- {/* Content */}
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 className="flex items-center justify-center py-16">
260
- <div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
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={canSubmit
266
- ? "Be the first to submit a score!"
267
- : "No scores have been submitted yet"
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
- <svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
271
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M16 8v8m-4-5v5m-4-2v2m-2 4h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
272
- </svg>
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="bg-card/60 rounded-xl border border-border overflow-hidden">
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 text-muted-foreground uppercase tracking-wider w-16">Rank</th>
281
- <th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Player</th>
282
- <th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Category</th>
283
- <th className="px-4 py-3 text-right text-xs font-semibold text-muted-foreground uppercase tracking-wider">Score</th>
284
- {(canSubmit || isAdmin) && (
285
- <th className="px-4 py-3 text-right text-xs font-semibold text-muted-foreground uppercase tracking-wider w-24">Actions</th>
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 cat = entry.data.category as LeaderboardCategory
294
- const catConfig = CATEGORY_CONFIG[cat] ?? CATEGORY_CONFIG[LEADERBOARD_CATEGORY.GENERAL]
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 ${isOwn ? 'bg-primary/20/10' : 'hover:bg-muted/30'}`}
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 className={`font-medium ${isOwn ? 'text-primary' : 'text-foreground'}`}>
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={catConfig.color} size="sm">{catConfig.title}</Badge>
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
- {(canSubmit || isAdmin) && (
436
+ {canSubmit && (
319
437
  <td className="px-4 py-3 text-right">
320
- <div className="flex items-center justify-end gap-1">
321
- {(isOwn || isAdmin) && (
322
- <button
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
- className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted/60 rounded-lg transition-colors"
325
- title="Edit score"
445
+ aria-label={`Edit score for ${entry.data.playerName}`}
326
446
  >
327
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
328
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
329
- </svg>
330
- </button>
331
- )}
332
- {(isOwn || isAdmin) && (
333
- <button
334
- onClick={() => handleDelete(entry.recordId)}
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
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
339
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
340
- </svg>
341
- </button>
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
- {/* Submit Modal */}
356
- <SubmitScoreModal
357
- isOpen={showSubmitModal}
358
- onClose={() => setShowSubmitModal(false)}
359
- onSubmit={handleSubmit}
360
- userName={user?.name ?? ''}
361
- />
362
-
363
- {/* Edit Modal (admin or own) */}
364
- <EditScoreModal
365
- isOpen={!!editingEntry}
366
- onClose={() => setEditingEntry(null)}
367
- onSave={handleEditSave}
368
- entry={editingEntry}
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
  )