deepspace 0.25.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +7 -6
  3. package/dist/{chunk-2GUGZLAX.js → chunk-YBXIUDZJ.js} +2 -2
  4. package/dist/{chunk-2GUGZLAX.js.map → chunk-YBXIUDZJ.js.map} +1 -1
  5. package/dist/cli.js +1036 -966
  6. package/dist/cli.js.map +1 -1
  7. package/dist/documentation-client-core.js +1 -1
  8. package/dist/documentation-default-renderer.cjs +1 -1
  9. package/dist/documentation-react.js +1 -1
  10. package/dist/documentation-runtime.js +1 -1
  11. package/dist/documentation-server-core.js +1 -1
  12. package/dist/documentation.js +1 -1
  13. package/dist/documentation.js.map +1 -1
  14. package/dist/index.d.ts +26 -434
  15. package/dist/index.js +99 -864
  16. package/dist/index.js.map +1 -1
  17. package/dist/schema.d.ts +4 -41
  18. package/dist/schema.js +2 -242
  19. package/dist/schema.js.map +1 -1
  20. package/dist/server.d.ts +6 -162
  21. package/dist/server.js +13 -281
  22. package/dist/server.js.map +1 -1
  23. package/dist/worker.d.ts +4 -160
  24. package/dist/worker.js +15 -281
  25. package/dist/worker.js.map +1 -1
  26. package/features/messaging/feature.json +3 -43
  27. package/features/messaging/src/ChatPage.tsx +12 -16
  28. package/features/messaging/src/components/chat/ChatHeader.tsx +5 -13
  29. package/features/messaging/src/components/chat/MessageItem.tsx +7 -9
  30. package/features/messaging/src/components/chat/MessageList.tsx +57 -40
  31. package/features/messaging/src/components/chat/ThreadPanel.tsx +25 -11
  32. package/features/messaging/src/components/chat/UserProfilePopover.tsx +10 -29
  33. package/features/messaging/src/hooks/useChatChannel.ts +18 -23
  34. package/features/messaging/src/messaging-schema.ts +0 -2
  35. package/features/search-bar/feature.json +9 -20
  36. package/features/search-bar/src/InlineSearch.tsx +101 -0
  37. package/features/search-bar/src/__tests__/search-model.test.tsx +38 -0
  38. package/features/search-bar/src/search-model.ts +2 -149
  39. package/package.json +1 -1
  40. package/features/messaging/src/ChatMultiPage.tsx +0 -298
  41. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +0 -188
  42. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +0 -163
  43. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +0 -147
  44. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +0 -344
  45. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +0 -460
  46. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +0 -190
  47. package/features/messaging/src/components/chat-multi/InvitationList.tsx +0 -149
  48. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +0 -159
  49. package/features/messaging/src/hooks/useMultiChannel.ts +0 -142
  50. package/features/search-bar/src/SearchOverlay.tsx +0 -566
  51. package/features/search-bar/src/__tests__/search-model.test.ts +0 -40
  52. package/features/sidebar/feature.json +0 -19
  53. 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
- }