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
|
@@ -8,17 +8,25 @@
|
|
|
8
8
|
* - Role-based UI elements
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { useQuery } from 'deepspace'
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
11
|
+
import { useMemo, useState } from 'react'
|
|
12
|
+
import { Archive, ArchiveRestore, Inbox, Plus, Trash2 } from 'lucide-react'
|
|
13
|
+
import { ROLES, useMutations, useQuery, useUser, type Role } from 'deepspace'
|
|
14
|
+
import {
|
|
15
|
+
Badge,
|
|
16
|
+
Button,
|
|
17
|
+
ConfirmModal,
|
|
18
|
+
EmptyState,
|
|
19
|
+
Input,
|
|
20
|
+
Label,
|
|
21
|
+
Modal,
|
|
22
|
+
Textarea,
|
|
23
|
+
useToast,
|
|
24
|
+
} from '@/components/ui'
|
|
25
|
+
|
|
26
|
+
const ITEM_STATUS = {
|
|
27
|
+
ACTIVE: 'active',
|
|
28
|
+
ARCHIVED: 'archived',
|
|
29
|
+
} as const
|
|
22
30
|
|
|
23
31
|
interface Item {
|
|
24
32
|
title: string
|
|
@@ -27,134 +35,157 @@ interface Item {
|
|
|
27
35
|
ownerId: string
|
|
28
36
|
}
|
|
29
37
|
|
|
38
|
+
type ItemRecord = {
|
|
39
|
+
recordId: string
|
|
40
|
+
data: Item
|
|
41
|
+
createdAt: string
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
interface ItemsPageProps {
|
|
31
45
|
className?: string
|
|
32
46
|
}
|
|
33
47
|
|
|
34
|
-
// ============================================================================
|
|
35
|
-
// Create Item Modal
|
|
36
|
-
// ============================================================================
|
|
37
|
-
|
|
38
48
|
interface CreateItemModalProps {
|
|
39
49
|
isOpen: boolean
|
|
40
50
|
onClose: () => void
|
|
41
|
-
onCreate: (title: string, description: string) =>
|
|
51
|
+
onCreate: (title: string, description: string) => Promise<boolean>
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
function CreateItemModal({ isOpen, onClose, onCreate }: CreateItemModalProps) {
|
|
45
55
|
const [title, setTitle] = useState('')
|
|
46
56
|
const [description, setDescription] = useState('')
|
|
57
|
+
const [creating, setCreating] = useState(false)
|
|
47
58
|
|
|
48
|
-
const handleSubmit = () => {
|
|
49
|
-
|
|
50
|
-
|
|
59
|
+
const handleSubmit = async () => {
|
|
60
|
+
const nextTitle = title.trim()
|
|
61
|
+
if (!nextTitle || creating) return
|
|
62
|
+
|
|
63
|
+
setCreating(true)
|
|
64
|
+
try {
|
|
65
|
+
const created = await onCreate(nextTitle, description.trim())
|
|
66
|
+
if (!created) return
|
|
51
67
|
setTitle('')
|
|
52
68
|
setDescription('')
|
|
53
69
|
onClose()
|
|
70
|
+
} finally {
|
|
71
|
+
setCreating(false)
|
|
54
72
|
}
|
|
55
73
|
}
|
|
56
74
|
|
|
57
75
|
return (
|
|
58
|
-
<Modal open={isOpen} onClose={onClose} size="sm">
|
|
59
|
-
<Modal.Header
|
|
76
|
+
<Modal open={isOpen} onClose={creating ? () => undefined : onClose} size="sm">
|
|
77
|
+
<Modal.Header>
|
|
60
78
|
<Modal.Title>Create Item</Modal.Title>
|
|
61
79
|
</Modal.Header>
|
|
62
80
|
<Modal.Body>
|
|
63
81
|
<div className="space-y-4">
|
|
64
82
|
<div>
|
|
65
|
-
<
|
|
66
|
-
|
|
83
|
+
<Label htmlFor="item-title" className="mb-2 block">
|
|
84
|
+
Title
|
|
85
|
+
</Label>
|
|
86
|
+
<Input
|
|
87
|
+
id="item-title"
|
|
67
88
|
type="text"
|
|
68
89
|
value={title}
|
|
69
|
-
onChange={(
|
|
90
|
+
onChange={(event) => setTitle(event.target.value)}
|
|
70
91
|
placeholder="Enter item title"
|
|
71
|
-
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"
|
|
72
92
|
autoFocus
|
|
73
93
|
/>
|
|
74
94
|
</div>
|
|
75
95
|
<div>
|
|
76
|
-
<
|
|
77
|
-
|
|
96
|
+
<Label htmlFor="item-description" className="mb-2 block">
|
|
97
|
+
Description
|
|
98
|
+
</Label>
|
|
99
|
+
<Textarea
|
|
100
|
+
id="item-description"
|
|
78
101
|
value={description}
|
|
79
|
-
onChange={(
|
|
102
|
+
onChange={(event) => setDescription(event.target.value)}
|
|
80
103
|
placeholder="Enter description (optional)"
|
|
81
104
|
rows={3}
|
|
82
|
-
className="
|
|
105
|
+
className="resize-none"
|
|
83
106
|
/>
|
|
84
107
|
</div>
|
|
85
108
|
</div>
|
|
86
109
|
</Modal.Body>
|
|
87
110
|
<Modal.Footer>
|
|
88
|
-
<Button variant="secondary" onClick={onClose}>
|
|
89
|
-
|
|
111
|
+
<Button variant="secondary" onClick={onClose} disabled={creating}>
|
|
112
|
+
Cancel
|
|
113
|
+
</Button>
|
|
114
|
+
<Button onClick={() => void handleSubmit()} disabled={!title.trim()} loading={creating}>
|
|
115
|
+
Create
|
|
116
|
+
</Button>
|
|
90
117
|
</Modal.Footer>
|
|
91
118
|
</Modal>
|
|
92
119
|
)
|
|
93
120
|
}
|
|
94
121
|
|
|
95
|
-
// ============================================================================
|
|
96
|
-
// Item Card Component
|
|
97
|
-
// ============================================================================
|
|
98
|
-
|
|
99
122
|
interface ItemCardProps {
|
|
100
|
-
item:
|
|
123
|
+
item: ItemRecord
|
|
101
124
|
isOwner: boolean
|
|
125
|
+
isToggling?: boolean
|
|
102
126
|
onToggleStatus: () => void
|
|
103
127
|
onDelete: () => void
|
|
104
128
|
}
|
|
105
129
|
|
|
106
|
-
function ItemCard({ item, isOwner, onToggleStatus, onDelete }: ItemCardProps) {
|
|
130
|
+
function ItemCard({ item, isOwner, isToggling = false, onToggleStatus, onDelete }: ItemCardProps) {
|
|
107
131
|
const isArchived = item.data.status === ITEM_STATUS.ARCHIVED
|
|
132
|
+
const statusAction = isArchived ? 'Restore' : 'Archive'
|
|
108
133
|
|
|
109
134
|
return (
|
|
110
|
-
<div
|
|
135
|
+
<div
|
|
136
|
+
className={`rounded-xl border bg-card/60 p-4 ${
|
|
137
|
+
isArchived ? 'border-border/30 opacity-60' : 'border-border'
|
|
138
|
+
}`}
|
|
139
|
+
>
|
|
111
140
|
<div className="flex items-start justify-between gap-3">
|
|
112
|
-
<div className="
|
|
113
|
-
<div className="flex items-center gap-2
|
|
114
|
-
<h3
|
|
141
|
+
<div className="min-w-0 flex-1">
|
|
142
|
+
<div className="mb-1 flex items-center gap-2">
|
|
143
|
+
<h3
|
|
144
|
+
className={`font-medium ${
|
|
145
|
+
isArchived ? 'text-muted-foreground line-through' : 'text-foreground'
|
|
146
|
+
}`}
|
|
147
|
+
>
|
|
115
148
|
{item.data.title}
|
|
116
149
|
</h3>
|
|
117
|
-
<Badge
|
|
118
|
-
variant={isArchived ? 'secondary' : 'success'}
|
|
119
|
-
size="sm"
|
|
120
|
-
>
|
|
150
|
+
<Badge variant={isArchived ? 'secondary' : 'success'} size="sm">
|
|
121
151
|
{isArchived ? 'Archived' : 'Active'}
|
|
122
152
|
</Badge>
|
|
123
153
|
</div>
|
|
124
154
|
{item.data.description && (
|
|
125
|
-
<p className="text-sm text-muted-foreground
|
|
155
|
+
<p className="line-clamp-2 text-sm text-muted-foreground">{item.data.description}</p>
|
|
126
156
|
)}
|
|
127
|
-
<p className="text-xs text-muted-foreground
|
|
157
|
+
<p className="mt-2 text-xs text-muted-foreground">
|
|
128
158
|
{new Date(item.createdAt).toLocaleDateString()}
|
|
129
159
|
</p>
|
|
130
160
|
</div>
|
|
131
161
|
|
|
132
162
|
{isOwner && (
|
|
133
163
|
<div className="flex items-center gap-1">
|
|
134
|
-
<
|
|
164
|
+
<Button
|
|
165
|
+
variant="ghost"
|
|
166
|
+
size="icon"
|
|
167
|
+
className="size-8 text-muted-foreground"
|
|
135
168
|
onClick={onToggleStatus}
|
|
136
|
-
|
|
137
|
-
|
|
169
|
+
aria-label={`${statusAction} ${item.data.title}`}
|
|
170
|
+
loading={isToggling}
|
|
138
171
|
>
|
|
139
|
-
{
|
|
140
|
-
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
172
|
+
{!isToggling &&
|
|
173
|
+
(isArchived ? (
|
|
174
|
+
<ArchiveRestore aria-hidden="true" />
|
|
175
|
+
) : (
|
|
176
|
+
<Archive aria-hidden="true" />
|
|
177
|
+
))}
|
|
178
|
+
</Button>
|
|
179
|
+
<Button
|
|
180
|
+
variant="ghost"
|
|
181
|
+
size="icon"
|
|
182
|
+
className="size-8 text-muted-foreground hover:bg-destructive/20 hover:text-destructive"
|
|
150
183
|
onClick={onDelete}
|
|
151
|
-
|
|
152
|
-
|
|
184
|
+
disabled={isToggling}
|
|
185
|
+
aria-label={`Delete ${item.data.title}`}
|
|
153
186
|
>
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
</svg>
|
|
157
|
-
</button>
|
|
187
|
+
<Trash2 aria-hidden="true" />
|
|
188
|
+
</Button>
|
|
158
189
|
</div>
|
|
159
190
|
)}
|
|
160
191
|
</div>
|
|
@@ -162,91 +193,120 @@ function ItemCard({ item, isOwner, onToggleStatus, onDelete }: ItemCardProps) {
|
|
|
162
193
|
)
|
|
163
194
|
}
|
|
164
195
|
|
|
165
|
-
// ============================================================================
|
|
166
|
-
// Main Page
|
|
167
|
-
// ============================================================================
|
|
168
|
-
|
|
169
196
|
export default function ItemsPage({ className }: ItemsPageProps) {
|
|
170
197
|
const { user } = useUser()
|
|
171
198
|
const userRole = (user?.role ?? ROLES.VIEWER) as Role
|
|
172
199
|
const canCreate = userRole === ROLES.MEMBER || userRole === ROLES.ADMIN
|
|
200
|
+
const toast = useToast()
|
|
173
201
|
|
|
174
202
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
|
203
|
+
const [togglingId, setTogglingId] = useState<string | null>(null)
|
|
204
|
+
const [deleteTarget, setDeleteTarget] = useState<ItemRecord | null>(null)
|
|
205
|
+
const [deleting, setDeleting] = useState(false)
|
|
175
206
|
|
|
176
|
-
// Query all items
|
|
177
207
|
const { records: items, status } = useQuery<Item>('items', {
|
|
178
208
|
orderBy: 'createdAt',
|
|
179
209
|
orderDir: 'desc',
|
|
180
210
|
})
|
|
211
|
+
const { createConfirmed, putConfirmed, removeConfirmed } = useMutations<Item>('items')
|
|
181
212
|
|
|
182
|
-
// Mutations for items
|
|
183
|
-
const { create, put, remove } = useMutations<Item>('items')
|
|
184
|
-
|
|
185
|
-
// Separate own items and others
|
|
186
213
|
const { myItems, otherItems } = useMemo(() => {
|
|
187
214
|
const my: typeof items = []
|
|
188
215
|
const other: typeof items = []
|
|
189
216
|
|
|
190
|
-
items.forEach(item => {
|
|
191
|
-
if (item.data.ownerId === user?.id)
|
|
192
|
-
|
|
193
|
-
} else {
|
|
194
|
-
other.push(item)
|
|
195
|
-
}
|
|
217
|
+
items.forEach((item) => {
|
|
218
|
+
if (item.data.ownerId === user?.id) my.push(item)
|
|
219
|
+
else other.push(item)
|
|
196
220
|
})
|
|
197
221
|
|
|
198
222
|
return { myItems: my, otherItems: other }
|
|
199
223
|
}, [items, user?.id])
|
|
200
224
|
|
|
201
|
-
const handleCreate = async (title: string, description: string) => {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
225
|
+
const handleCreate = async (title: string, description: string): Promise<boolean> => {
|
|
226
|
+
if (!user) {
|
|
227
|
+
toast.error('Could not create item', 'Sign in and try again.')
|
|
228
|
+
return false
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
await createConfirmed({
|
|
233
|
+
title,
|
|
234
|
+
description,
|
|
235
|
+
status: ITEM_STATUS.ACTIVE,
|
|
236
|
+
ownerId: user.id,
|
|
237
|
+
})
|
|
238
|
+
toast.success('Item created', `"${title}" is ready.`)
|
|
239
|
+
return true
|
|
240
|
+
} catch (error) {
|
|
241
|
+
toast.error(
|
|
242
|
+
'Could not create item',
|
|
243
|
+
error instanceof Error ? error.message : 'Please try again.',
|
|
244
|
+
)
|
|
245
|
+
return false
|
|
246
|
+
}
|
|
208
247
|
}
|
|
209
248
|
|
|
210
249
|
const handleToggleStatus = async (itemId: string, currentStatus: string) => {
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
250
|
+
const item = items.find((candidate) => candidate.recordId === itemId)
|
|
251
|
+
if (!item || togglingId) return
|
|
252
|
+
|
|
253
|
+
const newStatus =
|
|
254
|
+
currentStatus === ITEM_STATUS.ACTIVE ? ITEM_STATUS.ARCHIVED : ITEM_STATUS.ACTIVE
|
|
255
|
+
setTogglingId(itemId)
|
|
256
|
+
try {
|
|
257
|
+
await putConfirmed(itemId, { status: newStatus })
|
|
258
|
+
if (newStatus === ITEM_STATUS.ARCHIVED) {
|
|
259
|
+
toast.success('Item archived', `"${item.data.title}" moved to the archive.`)
|
|
260
|
+
} else {
|
|
261
|
+
toast.success('Item restored', `"${item.data.title}" is active again.`)
|
|
262
|
+
}
|
|
263
|
+
} catch (error) {
|
|
264
|
+
toast.error(
|
|
265
|
+
'Could not update item',
|
|
266
|
+
error instanceof Error ? error.message : 'Please try again.',
|
|
267
|
+
)
|
|
268
|
+
} finally {
|
|
269
|
+
setTogglingId(null)
|
|
218
270
|
}
|
|
219
271
|
}
|
|
220
272
|
|
|
221
|
-
const handleDelete = async (
|
|
222
|
-
if (
|
|
223
|
-
|
|
273
|
+
const handleDelete = async () => {
|
|
274
|
+
if (!deleteTarget || deleting) return
|
|
275
|
+
|
|
276
|
+
setDeleting(true)
|
|
277
|
+
try {
|
|
278
|
+
await removeConfirmed(deleteTarget.recordId)
|
|
279
|
+
toast.success('Item deleted', `"${deleteTarget.data.title}" was deleted.`)
|
|
280
|
+
setDeleteTarget(null)
|
|
281
|
+
} catch (error) {
|
|
282
|
+
toast.error(
|
|
283
|
+
'Could not delete item',
|
|
284
|
+
error instanceof Error ? error.message : 'Please try again.',
|
|
285
|
+
)
|
|
286
|
+
} finally {
|
|
287
|
+
setDeleting(false)
|
|
224
288
|
}
|
|
225
289
|
}
|
|
226
290
|
|
|
227
291
|
const isLoading = status === 'loading'
|
|
228
292
|
|
|
229
293
|
return (
|
|
230
|
-
<div className={`h-full
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
294
|
+
<div className={`h-full overflow-y-auto bg-background ${className ?? ''}`}>
|
|
295
|
+
<div className="sticky top-0 z-10 border-b border-border bg-card/60 backdrop-blur-md">
|
|
296
|
+
<div className="mx-auto max-w-4xl px-4 py-4 sm:px-6 lg:px-8">
|
|
234
297
|
<div className="flex items-center justify-between">
|
|
235
298
|
<div>
|
|
236
|
-
<h1 className="text-2xl font-bold text-foreground">
|
|
237
|
-
<p className="text-muted-foreground
|
|
238
|
-
{
|
|
239
|
-
? '
|
|
240
|
-
:
|
|
241
|
-
}
|
|
299
|
+
<h1 className="text-2xl font-bold text-foreground">Items</h1>
|
|
300
|
+
<p className="mt-1 text-muted-foreground">
|
|
301
|
+
{isLoading
|
|
302
|
+
? 'Loading items...'
|
|
303
|
+
: `${items.length} ${items.length === 1 ? 'item' : 'items'} in this workspace`}
|
|
242
304
|
</p>
|
|
243
305
|
</div>
|
|
244
306
|
|
|
245
307
|
{canCreate && (
|
|
246
308
|
<Button onClick={() => setShowCreateModal(true)}>
|
|
247
|
-
<
|
|
248
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
249
|
-
</svg>
|
|
309
|
+
<Plus aria-hidden="true" />
|
|
250
310
|
New Item
|
|
251
311
|
</Button>
|
|
252
312
|
)}
|
|
@@ -254,57 +314,58 @@ export default function ItemsPage({ className }: ItemsPageProps) {
|
|
|
254
314
|
</div>
|
|
255
315
|
</div>
|
|
256
316
|
|
|
257
|
-
|
|
258
|
-
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
317
|
+
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
|
259
318
|
{isLoading ? (
|
|
260
|
-
<div className="
|
|
261
|
-
<div className="
|
|
319
|
+
<div className="grid gap-4 sm:grid-cols-2" aria-label="Loading items" aria-busy="true">
|
|
320
|
+
<div className="h-32 animate-pulse rounded-xl border border-border bg-muted/40" />
|
|
321
|
+
<div className="h-32 animate-pulse rounded-xl border border-border bg-muted/40" />
|
|
262
322
|
</div>
|
|
263
323
|
) : items.length === 0 ? (
|
|
264
324
|
<EmptyState
|
|
265
325
|
title="No items yet"
|
|
266
|
-
description={
|
|
267
|
-
|
|
268
|
-
|
|
326
|
+
description={
|
|
327
|
+
canCreate
|
|
328
|
+
? 'Create your first item to get started.'
|
|
329
|
+
: 'No items have been created yet.'
|
|
269
330
|
}
|
|
270
|
-
icon={
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
</svg>
|
|
331
|
+
icon={<Inbox aria-hidden="true" />}
|
|
332
|
+
action={
|
|
333
|
+
canCreate ? { label: 'New item', onClick: () => setShowCreateModal(true) } : undefined
|
|
274
334
|
}
|
|
275
335
|
/>
|
|
276
336
|
) : (
|
|
277
337
|
<div className="space-y-8">
|
|
278
|
-
{/* My Items */}
|
|
279
338
|
{myItems.length > 0 && (
|
|
280
339
|
<section>
|
|
281
|
-
<h2 className="text-lg font-semibold text-foreground
|
|
340
|
+
<h2 className="mb-4 text-lg font-semibold text-foreground">My Items</h2>
|
|
282
341
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
283
|
-
{myItems.map(item => (
|
|
342
|
+
{myItems.map((item) => (
|
|
284
343
|
<ItemCard
|
|
285
344
|
key={item.recordId}
|
|
286
345
|
item={item}
|
|
287
|
-
isOwner
|
|
288
|
-
|
|
289
|
-
|
|
346
|
+
isOwner
|
|
347
|
+
isToggling={togglingId === item.recordId}
|
|
348
|
+
onToggleStatus={() =>
|
|
349
|
+
void handleToggleStatus(item.recordId, item.data.status)
|
|
350
|
+
}
|
|
351
|
+
onDelete={() => setDeleteTarget(item)}
|
|
290
352
|
/>
|
|
291
353
|
))}
|
|
292
354
|
</div>
|
|
293
355
|
</section>
|
|
294
356
|
)}
|
|
295
357
|
|
|
296
|
-
{/* Other Items */}
|
|
297
358
|
{otherItems.length > 0 && (
|
|
298
359
|
<section>
|
|
299
|
-
<h2 className="text-lg font-semibold text-foreground
|
|
360
|
+
<h2 className="mb-4 text-lg font-semibold text-foreground">All Items</h2>
|
|
300
361
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
301
|
-
{otherItems.map(item => (
|
|
362
|
+
{otherItems.map((item) => (
|
|
302
363
|
<ItemCard
|
|
303
364
|
key={item.recordId}
|
|
304
365
|
item={item}
|
|
305
366
|
isOwner={false}
|
|
306
|
-
onToggleStatus={() =>
|
|
307
|
-
onDelete={() =>
|
|
367
|
+
onToggleStatus={() => undefined}
|
|
368
|
+
onDelete={() => undefined}
|
|
308
369
|
/>
|
|
309
370
|
))}
|
|
310
371
|
</div>
|
|
@@ -314,12 +375,23 @@ export default function ItemsPage({ className }: ItemsPageProps) {
|
|
|
314
375
|
)}
|
|
315
376
|
</div>
|
|
316
377
|
|
|
317
|
-
{/* Create Modal */}
|
|
318
378
|
<CreateItemModal
|
|
319
379
|
isOpen={showCreateModal}
|
|
320
380
|
onClose={() => setShowCreateModal(false)}
|
|
321
381
|
onCreate={handleCreate}
|
|
322
382
|
/>
|
|
383
|
+
|
|
384
|
+
<ConfirmModal
|
|
385
|
+
open={deleteTarget !== null}
|
|
386
|
+
onClose={() => {
|
|
387
|
+
if (!deleting) setDeleteTarget(null)
|
|
388
|
+
}}
|
|
389
|
+
onConfirm={() => void handleDelete()}
|
|
390
|
+
title={deleteTarget ? `Delete "${deleteTarget.data.title}"?` : 'Delete item?'}
|
|
391
|
+
description="This cannot be undone."
|
|
392
|
+
confirmText="Delete"
|
|
393
|
+
loading={deleting}
|
|
394
|
+
/>
|
|
323
395
|
</div>
|
|
324
396
|
)
|
|
325
397
|
}
|
|
@@ -110,20 +110,6 @@ function useLandingCSS() {
|
|
|
110
110
|
}, [])
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
// ============================================================================
|
|
114
|
-
// Landing page "seen" flag
|
|
115
|
-
// ============================================================================
|
|
116
|
-
|
|
117
|
-
const LANDING_SEEN_KEY = 'app-landing-seen'
|
|
118
|
-
|
|
119
|
-
export function hasSeenLanding(): boolean {
|
|
120
|
-
try { return localStorage.getItem(LANDING_SEEN_KEY) === 'true' } catch { return false }
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export function markLandingSeen(): void {
|
|
124
|
-
try { localStorage.setItem(LANDING_SEEN_KEY, 'true') } catch { /* storage unavailable (private mode/quota): non-critical */ }
|
|
125
|
-
}
|
|
126
|
-
|
|
127
113
|
// ============================================================================
|
|
128
114
|
// Configuration
|
|
129
115
|
// ============================================================================
|
|
@@ -265,7 +251,7 @@ function LandingNav({ isScrolled, scrollRoot }: {
|
|
|
265
251
|
})}
|
|
266
252
|
<div className="h-px bg-foreground/[0.08] my-1" />
|
|
267
253
|
<button
|
|
268
|
-
onClick={() =>
|
|
254
|
+
onClick={() => navigate('/home')}
|
|
269
255
|
className="px-4 py-2.5 rounded-xl text-sm font-medium text-left text-primary hover:bg-foreground/[0.08] transition-colors"
|
|
270
256
|
>
|
|
271
257
|
Get Started
|
|
@@ -298,7 +284,7 @@ function LandingNav({ isScrolled, scrollRoot }: {
|
|
|
298
284
|
)
|
|
299
285
|
})}
|
|
300
286
|
</div>
|
|
301
|
-
<Button size="sm" onClick={() =>
|
|
287
|
+
<Button size="sm" onClick={() => navigate('/home')} className="hidden md:inline-flex">
|
|
302
288
|
Get Started
|
|
303
289
|
</Button>
|
|
304
290
|
<button className="md:hidden transition-colors text-muted-foreground hover:text-foreground" onClick={() => setMobileOpen(prev => !prev)} aria-label="Toggle menu">
|
|
@@ -377,7 +363,7 @@ function HeroSection() {
|
|
|
377
363
|
</motion.p>
|
|
378
364
|
|
|
379
365
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={typewriterDone ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }} transition={{ duration: 0.7, delay: 0.15, ease: [0.25, 0.4, 0.25, 1] }} className="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
|
|
380
|
-
<Button size="lg" onClick={() =>
|
|
366
|
+
<Button size="lg" onClick={() => navigate('/home')} className="min-w-[180px] group">
|
|
381
367
|
Get Started
|
|
382
368
|
<ArrowRight className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1" />
|
|
383
369
|
</Button>
|
|
@@ -509,7 +495,7 @@ function CTASection() {
|
|
|
509
495
|
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold text-foreground leading-tight tracking-[-0.02em]">Ready to get started?</h2>
|
|
510
496
|
<p className="mt-5 text-muted-foreground text-lg max-w-md mx-auto leading-relaxed">Jump in and start exploring. No setup required.</p>
|
|
511
497
|
<div className="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
|
|
512
|
-
<Button size="lg" onClick={() =>
|
|
498
|
+
<Button size="lg" onClick={() => navigate('/home')} className="min-w-[200px] group">
|
|
513
499
|
Launch App
|
|
514
500
|
<ArrowRight className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1" />
|
|
515
501
|
</Button>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "Leaderboard",
|
|
4
4
|
"category": "data",
|
|
5
5
|
"description": "Score-based leaderboard with categories, rankings, and admin editing",
|
|
6
|
-
"details": "Ranked leaderboard table with score submission, category
|
|
6
|
+
"details": "Ranked leaderboard table with score submission, category labels, medal badges for the top 3, and admin score editing. Uses useQuery with ordering for real-time rankings and confirmed mutations for persisted CRUD feedback.",
|
|
7
7
|
"files": [
|
|
8
8
|
{
|
|
9
9
|
"src": "src/LeaderboardPage.tsx",
|
|
@@ -12,10 +12,6 @@
|
|
|
12
12
|
{
|
|
13
13
|
"src": "src/leaderboard-schema.ts",
|
|
14
14
|
"dest": "src/schemas/leaderboard-schema.ts"
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
"src": "src/leaderboard-constants.ts",
|
|
18
|
-
"dest": "src/components/leaderboard/leaderboard-constants.ts"
|
|
19
15
|
}
|
|
20
16
|
],
|
|
21
17
|
"schema": {
|