deepspace 0.26.0 → 0.26.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 +35 -0
- package/dist/{chunk-LPXWXF6R.js → chunk-YBXIUDZJ.js} +2 -2
- package/dist/{chunk-LPXWXF6R.js.map → chunk-YBXIUDZJ.js.map} +1 -1
- package/dist/cli.js +80 -45
- package/dist/cli.js.map +1 -1
- package/dist/documentation-client-core.js +1 -1
- package/dist/documentation-default-renderer.cjs +1 -1
- package/dist/documentation-react.js +1 -1
- package/dist/documentation-runtime.js +1 -1
- package/dist/documentation-server-core.js +1 -1
- package/dist/documentation.js +1 -1
- package/dist/documentation.js.map +1 -1
- package/dist/index.d.ts +26 -434
- package/dist/index.js +99 -864
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +4 -41
- package/dist/schema.js +2 -242
- package/dist/schema.js.map +1 -1
- package/dist/server.d.ts +6 -162
- package/dist/server.js +13 -281
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +4 -160
- package/dist/worker.js +15 -281
- package/dist/worker.js.map +1 -1
- package/features/messaging/feature.json +3 -43
- package/features/messaging/src/ChatPage.tsx +12 -16
- package/features/messaging/src/components/chat/ChatHeader.tsx +5 -13
- package/features/messaging/src/components/chat/MessageItem.tsx +7 -9
- package/features/messaging/src/components/chat/MessageList.tsx +57 -40
- package/features/messaging/src/components/chat/ThreadPanel.tsx +25 -11
- package/features/messaging/src/components/chat/UserProfilePopover.tsx +10 -29
- package/features/messaging/src/hooks/useChatChannel.ts +18 -23
- package/features/messaging/src/messaging-schema.ts +0 -2
- package/features/search-bar/feature.json +9 -20
- package/features/search-bar/src/InlineSearch.tsx +101 -0
- package/features/search-bar/src/__tests__/search-model.test.tsx +38 -0
- package/features/search-bar/src/search-model.ts +2 -149
- package/package.json +1 -1
- package/features/messaging/src/ChatMultiPage.tsx +0 -298
- package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +0 -188
- package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +0 -163
- package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +0 -147
- package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +0 -344
- package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +0 -460
- package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +0 -190
- package/features/messaging/src/components/chat-multi/InvitationList.tsx +0 -149
- package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +0 -159
- package/features/messaging/src/hooks/useMultiChannel.ts +0 -142
- package/features/search-bar/src/SearchOverlay.tsx +0 -566
- package/features/search-bar/src/__tests__/search-model.test.ts +0 -40
- package/features/sidebar/feature.json +0 -19
- package/features/sidebar/src/AppSidebar.tsx +0 -558
|
@@ -1,460 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ChannelSidebar -- Multi-channel navigation panel.
|
|
3
|
-
*
|
|
4
|
-
* Design language:
|
|
5
|
-
* - Collapsible sections with smooth expand/collapse
|
|
6
|
-
* - Gradient-accented group icons, presence-aware DM avatars
|
|
7
|
-
* - Quick-filter search that narrows both groups and DMs
|
|
8
|
-
* - Glassmorphic header with user profile in footer
|
|
9
|
-
* - Subtle section dividers with label badges
|
|
10
|
-
*
|
|
11
|
-
* On mobile, this is a full-screen view; on desktop, a fixed-width left panel.
|
|
12
|
-
* Sidebar scrollbar CSS is injected via a <style> tag.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { useState, useMemo, useCallback, useEffect } from 'react'
|
|
16
|
-
import { useUser } from 'deepspace'
|
|
17
|
-
import { usePresence } from 'deepspace'
|
|
18
|
-
import type { Channel, ChannelInvitation } from 'deepspace'
|
|
19
|
-
import type { RecordData } from 'deepspace'
|
|
20
|
-
import { ChannelListItem } from './ChannelListItem'
|
|
21
|
-
import { CreateGroupModal } from './CreateGroupModal'
|
|
22
|
-
import { NewMessageModal } from './NewMessageModal'
|
|
23
|
-
import { BrowseGroupsModal } from './BrowseGroupsModal'
|
|
24
|
-
import { InvitationList } from './InvitationList'
|
|
25
|
-
|
|
26
|
-
const SIDEBAR_STYLES_ID = 'deepspace-sidebar-styles'
|
|
27
|
-
const SIDEBAR_CSS = `/* Channel sidebar smooth transitions */
|
|
28
|
-
[data-testid="channel-sidebar"] {
|
|
29
|
-
transition: transform 200ms ease-out;
|
|
30
|
-
}
|
|
31
|
-
/* Scrollbar styling for sidebar */
|
|
32
|
-
.channel-sidebar-scroll::-webkit-scrollbar {
|
|
33
|
-
width: 5px;
|
|
34
|
-
}
|
|
35
|
-
.channel-sidebar-scroll::-webkit-scrollbar-track {
|
|
36
|
-
background: transparent;
|
|
37
|
-
}
|
|
38
|
-
.channel-sidebar-scroll::-webkit-scrollbar-thumb {
|
|
39
|
-
background: transparent;
|
|
40
|
-
border-radius: 3px;
|
|
41
|
-
transition: background 200ms;
|
|
42
|
-
}
|
|
43
|
-
.channel-sidebar-scroll:hover::-webkit-scrollbar-thumb {
|
|
44
|
-
background: var(--border);
|
|
45
|
-
}
|
|
46
|
-
.channel-sidebar-scroll:hover::-webkit-scrollbar-thumb:hover {
|
|
47
|
-
background: var(--muted-foreground);
|
|
48
|
-
}
|
|
49
|
-
/* Firefox scrollbar */
|
|
50
|
-
.channel-sidebar-scroll {
|
|
51
|
-
scrollbar-width: thin;
|
|
52
|
-
scrollbar-color: transparent transparent;
|
|
53
|
-
}
|
|
54
|
-
.channel-sidebar-scroll:hover {
|
|
55
|
-
scrollbar-color: var(--border) transparent;
|
|
56
|
-
}
|
|
57
|
-
/* Respect reduced motion */
|
|
58
|
-
@media (prefers-reduced-motion: reduce) {
|
|
59
|
-
[data-testid="channel-sidebar"],
|
|
60
|
-
[data-testid="channel-sidebar"] * {
|
|
61
|
-
animation-duration: 0.01ms !important;
|
|
62
|
-
transition-duration: 0.01ms !important;
|
|
63
|
-
}
|
|
64
|
-
}`
|
|
65
|
-
|
|
66
|
-
interface PendingInvitation {
|
|
67
|
-
invitation: RecordData<ChannelInvitation>
|
|
68
|
-
channel: RecordData<Channel> | undefined
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
interface ChannelSidebarProps {
|
|
72
|
-
groups: RecordData<Channel>[]
|
|
73
|
-
dms: RecordData<Channel>[]
|
|
74
|
-
pendingInvitations: PendingInvitation[]
|
|
75
|
-
activeChannelId: string | null
|
|
76
|
-
onSelectChannel: (id: string) => void
|
|
77
|
-
hasUnread: (id: string) => boolean
|
|
78
|
-
getDMDisplayName: (channel: RecordData<Channel>) => string
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function ChannelSidebar({
|
|
82
|
-
groups,
|
|
83
|
-
dms,
|
|
84
|
-
pendingInvitations,
|
|
85
|
-
activeChannelId,
|
|
86
|
-
onSelectChannel,
|
|
87
|
-
hasUnread,
|
|
88
|
-
getDMDisplayName,
|
|
89
|
-
}: ChannelSidebarProps) {
|
|
90
|
-
const { user } = useUser()
|
|
91
|
-
const { isOnline, users } = usePresence()
|
|
92
|
-
const [showCreateGroup, setShowCreateGroup] = useState(false)
|
|
93
|
-
const [showNewMessage, setShowNewMessage] = useState(false)
|
|
94
|
-
const [showBrowse, setShowBrowse] = useState(false)
|
|
95
|
-
const [filterQuery, setFilterQuery] = useState('')
|
|
96
|
-
const [groupsCollapsed, setGroupsCollapsed] = useState(false)
|
|
97
|
-
const [dmsCollapsed, setDmsCollapsed] = useState(false)
|
|
98
|
-
|
|
99
|
-
// Inject sidebar CSS once
|
|
100
|
-
useEffect(() => {
|
|
101
|
-
if (document.getElementById(SIDEBAR_STYLES_ID)) return
|
|
102
|
-
const style = document.createElement('style')
|
|
103
|
-
style.id = SIDEBAR_STYLES_ID
|
|
104
|
-
style.textContent = SIDEBAR_CSS
|
|
105
|
-
document.head.appendChild(style)
|
|
106
|
-
}, [])
|
|
107
|
-
|
|
108
|
-
const normalizedQuery = filterQuery.toLowerCase().trim()
|
|
109
|
-
|
|
110
|
-
const sortedGroups = useMemo(
|
|
111
|
-
() =>
|
|
112
|
-
[...groups]
|
|
113
|
-
.filter((g) => !normalizedQuery || g.data.name.toLowerCase().includes(normalizedQuery))
|
|
114
|
-
.sort((a, b) => a.data.name.localeCompare(b.data.name)),
|
|
115
|
-
[groups, normalizedQuery]
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
const sortedDMs = useMemo(
|
|
119
|
-
() =>
|
|
120
|
-
[...dms]
|
|
121
|
-
.filter((d) => {
|
|
122
|
-
if (!normalizedQuery) return true
|
|
123
|
-
return getDMDisplayName(d).toLowerCase().includes(normalizedQuery)
|
|
124
|
-
})
|
|
125
|
-
.sort((a, b) => getDMDisplayName(a).localeCompare(getDMDisplayName(b))),
|
|
126
|
-
[dms, getDMDisplayName, normalizedQuery]
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
const getDMOtherUserId = useCallback(
|
|
130
|
-
(channel: RecordData<Channel>): string | undefined => {
|
|
131
|
-
if (!user) return undefined
|
|
132
|
-
const parts = channel.data.name.replace('dm-', '').split('-')
|
|
133
|
-
return parts.find((id) => id !== user.id)
|
|
134
|
-
},
|
|
135
|
-
[user]
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
const getDMAvatarInfo = useCallback(
|
|
139
|
-
(channel: RecordData<Channel>) => {
|
|
140
|
-
const otherId = getDMOtherUserId(channel)
|
|
141
|
-
if (!otherId) return { url: undefined, fallback: '?', online: false }
|
|
142
|
-
const otherUser = users.find((u) => u.id === otherId)
|
|
143
|
-
return {
|
|
144
|
-
url: otherUser?.imageUrl,
|
|
145
|
-
fallback: otherUser?.name ?? '?',
|
|
146
|
-
online: isOnline(otherId),
|
|
147
|
-
}
|
|
148
|
-
},
|
|
149
|
-
[getDMOtherUserId, users, isOnline]
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
const totalUnreadGroups = groups.filter((g) => hasUnread(g.recordId)).length
|
|
153
|
-
const totalUnreadDMs = dms.filter((d) => hasUnread(d.recordId)).length
|
|
154
|
-
|
|
155
|
-
return (
|
|
156
|
-
<>
|
|
157
|
-
<aside
|
|
158
|
-
data-testid="channel-sidebar"
|
|
159
|
-
className="flex flex-col h-full bg-card/95 backdrop-blur-sm"
|
|
160
|
-
>
|
|
161
|
-
{/* Header */}
|
|
162
|
-
<div className="shrink-0 px-4 pt-4 pb-3">
|
|
163
|
-
<div className="flex items-center justify-between mb-3">
|
|
164
|
-
<h2 className="text-lg font-bold text-foreground tracking-tight">Messages</h2>
|
|
165
|
-
<button
|
|
166
|
-
data-testid="new-message-btn"
|
|
167
|
-
onClick={() => setShowNewMessage(true)}
|
|
168
|
-
className="p-2 rounded-xl bg-primary/10 text-primary hover:bg-primary/20 active:bg-primary/25 transition-colors duration-200 cursor-pointer"
|
|
169
|
-
title="New message"
|
|
170
|
-
>
|
|
171
|
-
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
172
|
-
<path strokeLinecap="round" strokeLinejoin="round" 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" />
|
|
173
|
-
</svg>
|
|
174
|
-
</button>
|
|
175
|
-
</div>
|
|
176
|
-
|
|
177
|
-
{/* Search / filter */}
|
|
178
|
-
<div className="relative">
|
|
179
|
-
<svg
|
|
180
|
-
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground/60 pointer-events-none"
|
|
181
|
-
fill="none"
|
|
182
|
-
viewBox="0 0 24 24"
|
|
183
|
-
stroke="currentColor"
|
|
184
|
-
strokeWidth={2}
|
|
185
|
-
>
|
|
186
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
187
|
-
</svg>
|
|
188
|
-
<input
|
|
189
|
-
data-testid="sidebar-search"
|
|
190
|
-
type="text"
|
|
191
|
-
value={filterQuery}
|
|
192
|
-
onChange={(e) => setFilterQuery(e.target.value)}
|
|
193
|
-
placeholder="Search conversations..."
|
|
194
|
-
className="w-full pl-9 pr-3 py-2 text-sm bg-muted/40 border border-border/50 rounded-xl text-foreground placeholder:text-muted-foreground/50 outline-none focus:border-primary/40 focus:bg-muted/60 focus:ring-1 focus:ring-primary/10 transition-all duration-200"
|
|
195
|
-
/>
|
|
196
|
-
{filterQuery && (
|
|
197
|
-
<button
|
|
198
|
-
onClick={() => setFilterQuery('')}
|
|
199
|
-
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded-md text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
|
200
|
-
>
|
|
201
|
-
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
202
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
203
|
-
</svg>
|
|
204
|
-
</button>
|
|
205
|
-
)}
|
|
206
|
-
</div>
|
|
207
|
-
</div>
|
|
208
|
-
|
|
209
|
-
{/* Scrollable channel lists */}
|
|
210
|
-
<div className="flex-1 overflow-y-auto px-2.5 pb-2 channel-sidebar-scroll">
|
|
211
|
-
{/* Pending invitations */}
|
|
212
|
-
<InvitationList
|
|
213
|
-
invitations={pendingInvitations}
|
|
214
|
-
onAccepted={(channelId) => onSelectChannel(channelId)}
|
|
215
|
-
/>
|
|
216
|
-
|
|
217
|
-
{/* Groups section */}
|
|
218
|
-
<SidebarSection
|
|
219
|
-
label="Groups"
|
|
220
|
-
count={sortedGroups.length}
|
|
221
|
-
unreadCount={totalUnreadGroups}
|
|
222
|
-
collapsed={groupsCollapsed}
|
|
223
|
-
onToggle={() => setGroupsCollapsed((c) => !c)}
|
|
224
|
-
onAction={() => setShowCreateGroup(true)}
|
|
225
|
-
actionTitle="Create group"
|
|
226
|
-
actionTestId="create-group-btn"
|
|
227
|
-
>
|
|
228
|
-
{sortedGroups.length === 0 ? (
|
|
229
|
-
<EmptyState
|
|
230
|
-
message={normalizedQuery ? 'No groups match your search' : 'No groups yet'}
|
|
231
|
-
actionLabel={normalizedQuery ? undefined : 'Create one'}
|
|
232
|
-
onAction={normalizedQuery ? undefined : () => setShowCreateGroup(true)}
|
|
233
|
-
/>
|
|
234
|
-
) : (
|
|
235
|
-
<div className="space-y-0.5">
|
|
236
|
-
{sortedGroups.map((ch: RecordData<Channel>) => (
|
|
237
|
-
<ChannelListItem
|
|
238
|
-
key={ch.recordId}
|
|
239
|
-
name={ch.data.name}
|
|
240
|
-
isActive={activeChannelId === ch.recordId}
|
|
241
|
-
hasUnread={hasUnread(ch.recordId)}
|
|
242
|
-
isPrivate={ch.data.type === 'private'}
|
|
243
|
-
onClick={() => onSelectChannel(ch.recordId)}
|
|
244
|
-
/>
|
|
245
|
-
))}
|
|
246
|
-
</div>
|
|
247
|
-
)}
|
|
248
|
-
|
|
249
|
-
{/* Browse public groups */}
|
|
250
|
-
{!normalizedQuery && (
|
|
251
|
-
<button
|
|
252
|
-
data-testid="browse-groups-btn"
|
|
253
|
-
onClick={() => setShowBrowse(true)}
|
|
254
|
-
className="flex items-center gap-2 w-full px-3 py-2 mt-1 text-[13px] text-muted-foreground/70 hover:text-foreground hover:bg-muted/40 rounded-xl transition-all duration-200 cursor-pointer"
|
|
255
|
-
>
|
|
256
|
-
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
257
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
|
258
|
-
</svg>
|
|
259
|
-
<span>Browse public groups</span>
|
|
260
|
-
</button>
|
|
261
|
-
)}
|
|
262
|
-
</SidebarSection>
|
|
263
|
-
|
|
264
|
-
{/* DMs section */}
|
|
265
|
-
<SidebarSection
|
|
266
|
-
label="Direct Messages"
|
|
267
|
-
count={sortedDMs.length}
|
|
268
|
-
unreadCount={totalUnreadDMs}
|
|
269
|
-
collapsed={dmsCollapsed}
|
|
270
|
-
onToggle={() => setDmsCollapsed((c) => !c)}
|
|
271
|
-
onAction={() => setShowNewMessage(true)}
|
|
272
|
-
actionTitle="New message"
|
|
273
|
-
>
|
|
274
|
-
{sortedDMs.length === 0 ? (
|
|
275
|
-
<EmptyState
|
|
276
|
-
message={normalizedQuery ? 'No messages match your search' : 'No messages yet'}
|
|
277
|
-
actionLabel={normalizedQuery ? undefined : 'Start a conversation'}
|
|
278
|
-
onAction={normalizedQuery ? undefined : () => setShowNewMessage(true)}
|
|
279
|
-
/>
|
|
280
|
-
) : (
|
|
281
|
-
<div className="space-y-0.5">
|
|
282
|
-
{sortedDMs.map((ch: RecordData<Channel>) => {
|
|
283
|
-
const avatar = getDMAvatarInfo(ch)
|
|
284
|
-
return (
|
|
285
|
-
<ChannelListItem
|
|
286
|
-
key={ch.recordId}
|
|
287
|
-
name={getDMDisplayName(ch)}
|
|
288
|
-
isActive={activeChannelId === ch.recordId}
|
|
289
|
-
hasUnread={hasUnread(ch.recordId)}
|
|
290
|
-
avatarUrl={avatar.url}
|
|
291
|
-
avatarFallback={avatar.fallback}
|
|
292
|
-
isOnline={avatar.online}
|
|
293
|
-
onClick={() => onSelectChannel(ch.recordId)}
|
|
294
|
-
/>
|
|
295
|
-
)
|
|
296
|
-
})}
|
|
297
|
-
</div>
|
|
298
|
-
)}
|
|
299
|
-
</SidebarSection>
|
|
300
|
-
</div>
|
|
301
|
-
|
|
302
|
-
{/* Current user footer */}
|
|
303
|
-
{user && (
|
|
304
|
-
<div className="shrink-0 px-3 py-3 border-t border-border/50 bg-card/80 backdrop-blur-sm">
|
|
305
|
-
<div className="flex items-center gap-3 px-1">
|
|
306
|
-
<div className="relative shrink-0">
|
|
307
|
-
{user.imageUrl ? (
|
|
308
|
-
<img
|
|
309
|
-
src={user.imageUrl}
|
|
310
|
-
alt=""
|
|
311
|
-
className="w-9 h-9 rounded-full object-cover ring-2 ring-success/20"
|
|
312
|
-
/>
|
|
313
|
-
) : (
|
|
314
|
-
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center text-sm font-semibold text-primary ring-2 ring-primary/10">
|
|
315
|
-
{user.name?.[0]?.toUpperCase() ?? '?'}
|
|
316
|
-
</div>
|
|
317
|
-
)}
|
|
318
|
-
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-success border-[2.5px] border-card" />
|
|
319
|
-
</div>
|
|
320
|
-
<div className="flex-1 min-w-0">
|
|
321
|
-
<p className="text-sm font-semibold text-foreground truncate">{user.name}</p>
|
|
322
|
-
<p className="text-[11px] text-success font-medium">Online</p>
|
|
323
|
-
</div>
|
|
324
|
-
</div>
|
|
325
|
-
</div>
|
|
326
|
-
)}
|
|
327
|
-
</aside>
|
|
328
|
-
|
|
329
|
-
{showCreateGroup && (
|
|
330
|
-
<CreateGroupModal
|
|
331
|
-
onClose={() => setShowCreateGroup(false)}
|
|
332
|
-
onCreated={(id) => onSelectChannel(id)}
|
|
333
|
-
/>
|
|
334
|
-
)}
|
|
335
|
-
{showNewMessage && (
|
|
336
|
-
<NewMessageModal
|
|
337
|
-
onClose={() => setShowNewMessage(false)}
|
|
338
|
-
onSelected={(id) => onSelectChannel(id)}
|
|
339
|
-
/>
|
|
340
|
-
)}
|
|
341
|
-
{showBrowse && (
|
|
342
|
-
<BrowseGroupsModal
|
|
343
|
-
onClose={() => setShowBrowse(false)}
|
|
344
|
-
onSelected={(id) => {
|
|
345
|
-
onSelectChannel(id)
|
|
346
|
-
setShowBrowse(false)
|
|
347
|
-
}}
|
|
348
|
-
/>
|
|
349
|
-
)}
|
|
350
|
-
</>
|
|
351
|
-
)
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Collapsible section container with label, count badge, and action button.
|
|
356
|
-
*/
|
|
357
|
-
function SidebarSection({
|
|
358
|
-
label,
|
|
359
|
-
count,
|
|
360
|
-
unreadCount,
|
|
361
|
-
collapsed,
|
|
362
|
-
onToggle,
|
|
363
|
-
onAction,
|
|
364
|
-
actionTitle,
|
|
365
|
-
actionTestId,
|
|
366
|
-
children,
|
|
367
|
-
}: {
|
|
368
|
-
label: string
|
|
369
|
-
count: number
|
|
370
|
-
unreadCount: number
|
|
371
|
-
collapsed: boolean
|
|
372
|
-
onToggle: () => void
|
|
373
|
-
onAction: () => void
|
|
374
|
-
actionTitle: string
|
|
375
|
-
actionTestId?: string
|
|
376
|
-
children: React.ReactNode
|
|
377
|
-
}) {
|
|
378
|
-
return (
|
|
379
|
-
<div className="mb-1">
|
|
380
|
-
{/* Section header */}
|
|
381
|
-
<div className="flex items-center gap-1 px-1 py-2">
|
|
382
|
-
<button
|
|
383
|
-
onClick={onToggle}
|
|
384
|
-
className="flex items-center gap-1.5 flex-1 min-w-0 cursor-pointer group/section"
|
|
385
|
-
>
|
|
386
|
-
<svg
|
|
387
|
-
className={`w-3 h-3 text-muted-foreground/50 transition-transform duration-200 ${
|
|
388
|
-
collapsed ? '-rotate-90' : 'rotate-0'
|
|
389
|
-
}`}
|
|
390
|
-
fill="none"
|
|
391
|
-
viewBox="0 0 24 24"
|
|
392
|
-
stroke="currentColor"
|
|
393
|
-
strokeWidth={2.5}
|
|
394
|
-
>
|
|
395
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
396
|
-
</svg>
|
|
397
|
-
<span className="text-[11px] font-bold text-muted-foreground/70 uppercase tracking-widest group-hover/section:text-muted-foreground transition-colors duration-150">
|
|
398
|
-
{label}
|
|
399
|
-
</span>
|
|
400
|
-
{count > 0 && (
|
|
401
|
-
<span className="text-[10px] font-medium text-muted-foreground/40 tabular-nums">
|
|
402
|
-
{count}
|
|
403
|
-
</span>
|
|
404
|
-
)}
|
|
405
|
-
{unreadCount > 0 && (
|
|
406
|
-
<span className="ml-auto inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-primary/15 text-primary text-[10px] font-bold tabular-nums">
|
|
407
|
-
{unreadCount}
|
|
408
|
-
</span>
|
|
409
|
-
)}
|
|
410
|
-
</button>
|
|
411
|
-
<button
|
|
412
|
-
data-testid={actionTestId}
|
|
413
|
-
onClick={onAction}
|
|
414
|
-
className="p-1 rounded-lg text-muted-foreground/40 hover:text-foreground hover:bg-muted/50 transition-all duration-200 cursor-pointer"
|
|
415
|
-
title={actionTitle}
|
|
416
|
-
>
|
|
417
|
-
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
418
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
|
419
|
-
</svg>
|
|
420
|
-
</button>
|
|
421
|
-
</div>
|
|
422
|
-
|
|
423
|
-
{/* Collapsible body */}
|
|
424
|
-
<div
|
|
425
|
-
className={`overflow-hidden transition-all duration-200 ease-out ${
|
|
426
|
-
collapsed ? 'max-h-0 opacity-0' : 'max-h-[2000px] opacity-100'
|
|
427
|
-
}`}
|
|
428
|
-
>
|
|
429
|
-
{children}
|
|
430
|
-
</div>
|
|
431
|
-
</div>
|
|
432
|
-
)
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* Friendly empty state with optional CTA.
|
|
437
|
-
*/
|
|
438
|
-
function EmptyState({
|
|
439
|
-
message,
|
|
440
|
-
actionLabel,
|
|
441
|
-
onAction,
|
|
442
|
-
}: {
|
|
443
|
-
message: string
|
|
444
|
-
actionLabel?: string
|
|
445
|
-
onAction?: () => void
|
|
446
|
-
}) {
|
|
447
|
-
return (
|
|
448
|
-
<div className="px-3 py-4 text-center">
|
|
449
|
-
<p className="text-[12px] text-muted-foreground/60">{message}</p>
|
|
450
|
-
{actionLabel && onAction && (
|
|
451
|
-
<button
|
|
452
|
-
onClick={onAction}
|
|
453
|
-
className="mt-1.5 text-[12px] font-medium text-primary hover:text-primary/80 transition-colors cursor-pointer"
|
|
454
|
-
>
|
|
455
|
-
{actionLabel}
|
|
456
|
-
</button>
|
|
457
|
-
)}
|
|
458
|
-
</div>
|
|
459
|
-
)
|
|
460
|
-
}
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CreateGroupModal -- Create a new public or private group.
|
|
3
|
-
* Name is normalized to lowercase-kebab. Description is optional.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { useState, useCallback, useEffect } from 'react'
|
|
7
|
-
import { useChannels } from 'deepspace'
|
|
8
|
-
import { useMutations } from 'deepspace'
|
|
9
|
-
import { useUser } from 'deepspace'
|
|
10
|
-
import type { ChannelMember } from 'deepspace'
|
|
11
|
-
|
|
12
|
-
interface CreateGroupModalProps {
|
|
13
|
-
onClose: () => void
|
|
14
|
-
onCreated: (channelId: string) => void
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps) {
|
|
18
|
-
const [name, setName] = useState('')
|
|
19
|
-
const [description, setDescription] = useState('')
|
|
20
|
-
const [type, setType] = useState<'public' | 'private'>('public')
|
|
21
|
-
const [isCreating, setIsCreating] = useState(false)
|
|
22
|
-
const { create } = useChannels()
|
|
23
|
-
const { create: createMembership } = useMutations<ChannelMember>('channel-members')
|
|
24
|
-
const { user } = useUser()
|
|
25
|
-
|
|
26
|
-
useEffect(() => {
|
|
27
|
-
function handleEscape(e: KeyboardEvent) {
|
|
28
|
-
if (e.key === 'Escape') onClose()
|
|
29
|
-
}
|
|
30
|
-
document.addEventListener('keydown', handleEscape)
|
|
31
|
-
return () => document.removeEventListener('keydown', handleEscape)
|
|
32
|
-
}, [onClose])
|
|
33
|
-
|
|
34
|
-
const handleSubmit = useCallback(
|
|
35
|
-
async (e: React.FormEvent) => {
|
|
36
|
-
e.preventDefault()
|
|
37
|
-
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-')
|
|
38
|
-
if (!trimmedName || isCreating) return
|
|
39
|
-
|
|
40
|
-
setIsCreating(true)
|
|
41
|
-
try {
|
|
42
|
-
const channelId = await create({
|
|
43
|
-
name: trimmedName,
|
|
44
|
-
type,
|
|
45
|
-
description: description.trim(),
|
|
46
|
-
})
|
|
47
|
-
if (channelId && user) {
|
|
48
|
-
await createMembership({
|
|
49
|
-
channelId,
|
|
50
|
-
userId: user.id,
|
|
51
|
-
joinedAt: new Date().toISOString(),
|
|
52
|
-
} as unknown as ChannelMember)
|
|
53
|
-
onCreated(channelId)
|
|
54
|
-
}
|
|
55
|
-
onClose()
|
|
56
|
-
} finally {
|
|
57
|
-
setIsCreating(false)
|
|
58
|
-
}
|
|
59
|
-
},
|
|
60
|
-
[name, description, type, isCreating, create, createMembership, user, onCreated, onClose]
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
return (
|
|
64
|
-
<>
|
|
65
|
-
<div className="fixed inset-0 bg-black/40 z-50 animate-in fade-in duration-150" onClick={onClose} />
|
|
66
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
|
67
|
-
<div
|
|
68
|
-
data-testid="create-group-modal"
|
|
69
|
-
className="bg-card border border-border rounded-2xl shadow-2xl w-full max-w-md animate-in fade-in zoom-in-95 duration-200"
|
|
70
|
-
onClick={(e) => e.stopPropagation()}
|
|
71
|
-
>
|
|
72
|
-
<div className="px-6 pt-6 pb-0">
|
|
73
|
-
<h2 className="text-lg font-semibold text-foreground">Create a group</h2>
|
|
74
|
-
<p className="text-sm text-muted-foreground mt-1">
|
|
75
|
-
Groups are where conversations happen around a topic.
|
|
76
|
-
</p>
|
|
77
|
-
</div>
|
|
78
|
-
|
|
79
|
-
<form onSubmit={handleSubmit} className="px-6 pt-5 pb-6">
|
|
80
|
-
<div className="space-y-4">
|
|
81
|
-
<div>
|
|
82
|
-
<label htmlFor="group-name" className="block text-sm font-medium text-foreground mb-1.5">
|
|
83
|
-
Name
|
|
84
|
-
</label>
|
|
85
|
-
<input
|
|
86
|
-
id="group-name"
|
|
87
|
-
data-testid="group-name-input"
|
|
88
|
-
type="text"
|
|
89
|
-
value={name}
|
|
90
|
-
onChange={(e) => setName(e.target.value)}
|
|
91
|
-
placeholder="e.g. design-team"
|
|
92
|
-
className="w-full bg-background border border-border rounded-lg px-3.5 py-2.5 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20 transition-shadow"
|
|
93
|
-
autoFocus
|
|
94
|
-
maxLength={50}
|
|
95
|
-
/>
|
|
96
|
-
</div>
|
|
97
|
-
|
|
98
|
-
<div>
|
|
99
|
-
<label htmlFor="group-desc" className="block text-sm font-medium text-foreground mb-1.5">
|
|
100
|
-
Description
|
|
101
|
-
<span className="text-muted-foreground font-normal ml-1">(optional)</span>
|
|
102
|
-
</label>
|
|
103
|
-
<input
|
|
104
|
-
id="group-desc"
|
|
105
|
-
data-testid="group-description-input"
|
|
106
|
-
type="text"
|
|
107
|
-
value={description}
|
|
108
|
-
onChange={(e) => setDescription(e.target.value)}
|
|
109
|
-
placeholder="What's this group about?"
|
|
110
|
-
className="w-full bg-background border border-border rounded-lg px-3.5 py-2.5 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20 transition-shadow"
|
|
111
|
-
maxLength={120}
|
|
112
|
-
/>
|
|
113
|
-
</div>
|
|
114
|
-
|
|
115
|
-
<fieldset>
|
|
116
|
-
<legend className="block text-sm font-medium text-foreground mb-2">Visibility</legend>
|
|
117
|
-
<div className="grid grid-cols-2 gap-2">
|
|
118
|
-
<label
|
|
119
|
-
className={`flex items-center gap-2.5 px-3.5 py-3 rounded-lg border cursor-pointer transition-all ${
|
|
120
|
-
type === 'public'
|
|
121
|
-
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
|
122
|
-
: 'border-border hover:border-muted-foreground/30'
|
|
123
|
-
}`}
|
|
124
|
-
>
|
|
125
|
-
<input
|
|
126
|
-
type="radio"
|
|
127
|
-
name="group-type"
|
|
128
|
-
value="public"
|
|
129
|
-
checked={type === 'public'}
|
|
130
|
-
onChange={() => setType('public')}
|
|
131
|
-
className="sr-only"
|
|
132
|
-
/>
|
|
133
|
-
<svg className={`w-4 h-4 shrink-0 ${type === 'public' ? 'text-primary' : 'text-muted-foreground'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
134
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
135
|
-
</svg>
|
|
136
|
-
<div>
|
|
137
|
-
<span className="text-sm font-medium text-foreground block">Public</span>
|
|
138
|
-
<span className="text-xs text-muted-foreground">Anyone can join</span>
|
|
139
|
-
</div>
|
|
140
|
-
</label>
|
|
141
|
-
<label
|
|
142
|
-
className={`flex items-center gap-2.5 px-3.5 py-3 rounded-lg border cursor-pointer transition-all ${
|
|
143
|
-
type === 'private'
|
|
144
|
-
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
|
145
|
-
: 'border-border hover:border-muted-foreground/30'
|
|
146
|
-
}`}
|
|
147
|
-
>
|
|
148
|
-
<input
|
|
149
|
-
type="radio"
|
|
150
|
-
name="group-type"
|
|
151
|
-
value="private"
|
|
152
|
-
checked={type === 'private'}
|
|
153
|
-
onChange={() => setType('private')}
|
|
154
|
-
className="sr-only"
|
|
155
|
-
/>
|
|
156
|
-
<svg className={`w-4 h-4 shrink-0 ${type === 'private' ? 'text-primary' : 'text-muted-foreground'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
157
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
158
|
-
</svg>
|
|
159
|
-
<div>
|
|
160
|
-
<span className="text-sm font-medium text-foreground block">Private</span>
|
|
161
|
-
<span className="text-xs text-muted-foreground">Invite only</span>
|
|
162
|
-
</div>
|
|
163
|
-
</label>
|
|
164
|
-
</div>
|
|
165
|
-
</fieldset>
|
|
166
|
-
</div>
|
|
167
|
-
|
|
168
|
-
<div className="flex justify-end gap-2 mt-6">
|
|
169
|
-
<button
|
|
170
|
-
type="button"
|
|
171
|
-
onClick={onClose}
|
|
172
|
-
className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground rounded-lg border border-border hover:bg-muted transition-colors"
|
|
173
|
-
>
|
|
174
|
-
Cancel
|
|
175
|
-
</button>
|
|
176
|
-
<button
|
|
177
|
-
data-testid="create-group-submit"
|
|
178
|
-
type="submit"
|
|
179
|
-
disabled={!name.trim() || isCreating}
|
|
180
|
-
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
181
|
-
>
|
|
182
|
-
{isCreating ? 'Creating...' : 'Create Group'}
|
|
183
|
-
</button>
|
|
184
|
-
</div>
|
|
185
|
-
</form>
|
|
186
|
-
</div>
|
|
187
|
-
</div>
|
|
188
|
-
</>
|
|
189
|
-
)
|
|
190
|
-
}
|