deepspace 0.3.9 → 0.3.11

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 (120) hide show
  1. package/dist/cli.js +96 -18
  2. package/dist/cli.js.map +1 -1
  3. package/dist/index.d.ts +160 -1
  4. package/dist/index.js +283 -126
  5. package/dist/index.js.map +1 -1
  6. package/dist/server.d.ts +172 -2
  7. package/dist/server.js +581 -1
  8. package/dist/server.js.map +1 -1
  9. package/dist/worker.d.ts +252 -2
  10. package/dist/worker.js +588 -1
  11. package/dist/worker.js.map +1 -1
  12. package/features/admin-page/feature.json +29 -0
  13. package/features/admin-page/src/AdminPage.tsx +292 -0
  14. package/features/admin-page/src/admin-schema.ts +20 -0
  15. package/features/ai-chat/feature.json +41 -0
  16. package/features/ai-chat/src/AiChatPage.tsx +692 -0
  17. package/features/ai-chat/src/ChatPanel.tsx +1098 -0
  18. package/features/ai-chat/src/ai-chat-schema.ts +11 -0
  19. package/features/canvas/feature.json +45 -0
  20. package/features/canvas/src/CanvasEditorPage.tsx +87 -0
  21. package/features/canvas/src/CanvasListPage.tsx +202 -0
  22. package/features/canvas/src/canvas-schema.ts +33 -0
  23. package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
  24. package/features/canvas/src/components/CanvasView.tsx +369 -0
  25. package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
  26. package/features/cron/feature.json +33 -0
  27. package/features/cron/src/CronLogPage.tsx +100 -0
  28. package/features/cron/src/cron.ts +25 -0
  29. package/features/docs/feature.json +96 -0
  30. package/features/docs/src/DocsEditorPage.tsx +1012 -0
  31. package/features/docs/src/DocsListPage.tsx +951 -0
  32. package/features/docs/src/DocsOutlinePanel.tsx +68 -0
  33. package/features/docs/src/DocsPresence.tsx +138 -0
  34. package/features/docs/src/InviteDialog.tsx +336 -0
  35. package/features/docs/src/LibrarySidebar.tsx +464 -0
  36. package/features/docs/src/docs-favorites.ts +18 -0
  37. package/features/docs/src/docs-library-types.ts +15 -0
  38. package/features/docs/src/docs-schema.ts +80 -0
  39. package/features/docs/src/docs-ui.css +309 -0
  40. package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
  41. package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
  42. package/features/docs/src/editor/useDocEditor.ts +206 -0
  43. package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
  44. package/features/file-attachments/feature.json +42 -0
  45. package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
  46. package/features/file-attachments/src/attachments-constants.ts +9 -0
  47. package/features/file-attachments/src/attachments-schema.ts +45 -0
  48. package/features/file-attachments/src/useFilePreview.ts +471 -0
  49. package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
  50. package/features/file-manager/feature.json +24 -0
  51. package/features/file-manager/src/FileManagerPage.tsx +330 -0
  52. package/features/integration-test/feature.json +18 -0
  53. package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
  54. package/features/integration-test/src/integration.spec.ts +42 -0
  55. package/features/items/feature.json +33 -0
  56. package/features/items/src/ItemsPage.tsx +325 -0
  57. package/features/items/src/items-constants.ts +10 -0
  58. package/features/items/src/items-schema.ts +27 -0
  59. package/features/kanban/feature.json +18 -0
  60. package/features/kanban/src/Kanban.tsx +283 -0
  61. package/features/landing/feature.json +62 -0
  62. package/features/landing/src/LandingPage.tsx +627 -0
  63. package/features/landing/src/primitives.tsx +389 -0
  64. package/features/landing/src/sections/FAQSection.tsx +110 -0
  65. package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
  66. package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
  67. package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
  68. package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
  69. package/features/landing/src/sections/StatsSection.tsx +31 -0
  70. package/features/landing/src/sections/TeamSection.tsx +86 -0
  71. package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
  72. package/features/landing/src/sections/VideoSection.tsx +71 -0
  73. package/features/leaderboard/feature.json +33 -0
  74. package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
  75. package/features/leaderboard/src/leaderboard-constants.ts +21 -0
  76. package/features/leaderboard/src/leaderboard-schema.ts +36 -0
  77. package/features/messaging/feature.json +111 -0
  78. package/features/messaging/src/ChatMultiPage.tsx +300 -0
  79. package/features/messaging/src/ChatPage.tsx +75 -0
  80. package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
  81. package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
  82. package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
  83. package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
  84. package/features/messaging/src/components/chat/MessageList.tsx +248 -0
  85. package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
  86. package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
  87. package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
  88. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
  89. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
  90. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
  91. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
  92. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
  93. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
  94. package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
  95. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
  96. package/features/messaging/src/hooks/useChatChannel.ts +64 -0
  97. package/features/messaging/src/hooks/useLongPress.ts +74 -0
  98. package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
  99. package/features/messaging/src/messaging-schema.ts +25 -0
  100. package/features/presence-test/feature.json +21 -0
  101. package/features/presence-test/src/PresenceTestPage.tsx +120 -0
  102. package/features/sidebar/feature.json +19 -0
  103. package/features/sidebar/src/AppSidebar.tsx +558 -0
  104. package/features/tasks/feature.json +33 -0
  105. package/features/tasks/src/TasksPage.tsx +598 -0
  106. package/features/tasks/src/tasks-constants.ts +43 -0
  107. package/features/tasks/src/tasks-schema.ts +56 -0
  108. package/features/testing/feature.json +49 -0
  109. package/features/testing/src/TestPage.tsx +145 -0
  110. package/features/testing/src/cpu-burn-action.ts +51 -0
  111. package/features/testing/src/subrequest-burn-action.ts +53 -0
  112. package/features/testing/src/test-schema.ts +23 -0
  113. package/features/topbar/feature.json +18 -0
  114. package/features/topbar/src/Topbar.tsx +353 -0
  115. package/features/tree/feature.json +26 -0
  116. package/features/tree/src/TreeSidebar.tsx +607 -0
  117. package/features/tree/src/tree-constants.ts +68 -0
  118. package/features/tree/src/tree-hooks.ts +258 -0
  119. package/package.json +4 -2
  120. package/scripts/add-feature.cjs +654 -0
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Outline panel — renders a list of heading entries provided by the caller.
3
+ *
4
+ * In the Tiptap editor, headings are real ProseMirror nodes, so the page
5
+ * walks `editor.state.doc` once on every doc update and passes the result
6
+ * here. Clicking an entry jumps the caret to that node's position.
7
+ */
8
+
9
+ /** Matches docs2 outline width + toggle positioning beside the panel */
10
+ export const DOCUMENT_OUTLINE_WIDTH_PX = 220
11
+
12
+ export interface OutlineEntry {
13
+ level: number
14
+ title: string
15
+ /** ProseMirror position to focus when the entry is clicked. */
16
+ pos: number
17
+ }
18
+
19
+ export interface DocsOutlinePanelProps {
20
+ entries: OutlineEntry[]
21
+ onJumpTo: (pos: number) => void
22
+ }
23
+
24
+ export function DocsOutlinePanel({ entries, onJumpTo }: DocsOutlinePanelProps) {
25
+ return (
26
+ <aside
27
+ data-testid="document-outline-panel"
28
+ className="absolute bottom-0 left-0 top-0 z-[34] flex flex-col overflow-hidden border-r print:hidden"
29
+ style={{
30
+ width: DOCUMENT_OUTLINE_WIDTH_PX,
31
+ borderColor: 'var(--docs-el-line)',
32
+ backgroundColor: 'color-mix(in srgb, var(--docs-el-surface) 95%, transparent)',
33
+ }}
34
+ >
35
+ <div className="shrink-0 border-b px-3 py-3" style={{ borderColor: 'var(--docs-el-line)' }}>
36
+ <h2 className="truncate pl-1 text-left text-sm font-normal leading-5 text-[color:var(--docs-el-muted)]">
37
+ Document outline
38
+ </h2>
39
+ </div>
40
+ <nav className="flex-1 overflow-y-auto px-2 py-2">
41
+ {entries.length === 0 ? (
42
+ <p className="px-2 py-4 text-[12px] leading-snug text-[color:var(--docs-el-muted)]">
43
+ Headings you add to the document appear here. Use the H1 / H2 / H3 buttons in the
44
+ toolbar.
45
+ </p>
46
+ ) : (
47
+ <ul className="space-y-0.5">
48
+ {entries.map((e, i) => (
49
+ <li key={`${e.pos}-${i}`}>
50
+ <button
51
+ type="button"
52
+ onClick={() => onJumpTo(e.pos)}
53
+ className="w-full rounded-md px-2 py-1.5 text-left text-[12px] leading-snug transition-colors hover:bg-black/[0.05] dark:hover:bg-white/[0.06]"
54
+ style={{
55
+ paddingLeft: 8 + (e.level - 1) * 10,
56
+ color: 'var(--docs-el-text)',
57
+ }}
58
+ >
59
+ {e.title}
60
+ </button>
61
+ </li>
62
+ ))}
63
+ </ul>
64
+ )}
65
+ </nav>
66
+ </aside>
67
+ )
68
+ }
@@ -0,0 +1,138 @@
1
+ import { useMemo, useState } from 'react'
2
+
3
+ export interface DocsPresenceParticipant {
4
+ clientId: number
5
+ userId: string
6
+ name: string
7
+ email?: string
8
+ imageUrl?: string
9
+ mode: 'edit' | 'view'
10
+ typing?: boolean
11
+ lastTypedAt?: number
12
+ isSelf?: boolean
13
+ }
14
+
15
+ export interface DocsPresenceProps {
16
+ participants: DocsPresenceParticipant[]
17
+ typingNames: string[]
18
+ }
19
+
20
+ const MAX_VISIBLE_AVATARS = 3
21
+
22
+ /** Stable React keys (Yjs clientId is not used for PresenceRoom peers). */
23
+ function participantKey(p: DocsPresenceParticipant) {
24
+ return `${p.userId}:${p.isSelf ? 'self' : 'peer'}`
25
+ }
26
+
27
+ function initialsFor(name: string) {
28
+ const parts = name.trim().split(/\s+/).filter(Boolean)
29
+ if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase()
30
+ return (parts[0]?.slice(0, 2) || '?').toUpperCase()
31
+ }
32
+
33
+ function typingText(names: string[]) {
34
+ if (names.length === 0) return null
35
+ if (names.length === 1) return `${names[0]} is typing...`
36
+ if (names.length === 2) return `${names[0]} and ${names[1]} are typing...`
37
+ return `${names[0]}, ${names[1]}, and ${names.length - 2} more are typing...`
38
+ }
39
+
40
+ function ParticipantAvatar({ participant, index }: { participant: DocsPresenceParticipant; index: number }) {
41
+ const label = `${participant.name}${participant.isSelf ? ' (you)' : ''} - ${
42
+ participant.mode === 'edit' ? 'Editing' : 'Viewing'
43
+ }`
44
+
45
+ return (
46
+ <span
47
+ className="docs-presence-avatar"
48
+ style={{ zIndex: 20 - index }}
49
+ title={label}
50
+ aria-label={label}
51
+ >
52
+ {participant.imageUrl ? (
53
+ <img src={participant.imageUrl} alt="" className="h-full w-full object-cover" />
54
+ ) : (
55
+ <span aria-hidden>{initialsFor(participant.name)}</span>
56
+ )}
57
+ </span>
58
+ )
59
+ }
60
+
61
+ export function DocsPresence({ participants, typingNames }: DocsPresenceProps) {
62
+ const [open, setOpen] = useState(false)
63
+ const visibleParticipants = participants.slice(0, MAX_VISIBLE_AVATARS)
64
+ const hiddenCount = Math.max(0, participants.length - MAX_VISIBLE_AVATARS)
65
+ const message = typingText(typingNames)
66
+
67
+ const heading = useMemo(() => {
68
+ const count = participants.length
69
+ if (count === 0) return 'No active viewers'
70
+ if (count === 1) return '1 person here'
71
+ return `${count} people here`
72
+ }, [participants.length])
73
+
74
+ return (
75
+ <div className="relative flex shrink-0 items-center gap-2" data-testid="docs-presence">
76
+ {message ? (
77
+ <span
78
+ className="hidden max-w-[220px] truncate text-xs sm:inline"
79
+ style={{ color: 'var(--docs-el-muted)' }}
80
+ data-testid="docs-typing-indicator"
81
+ >
82
+ {message}
83
+ </span>
84
+ ) : null}
85
+
86
+ <div className="flex items-center -space-x-2" aria-label={heading}>
87
+ {visibleParticipants.map((participant, index) => (
88
+ <ParticipantAvatar key={participantKey(participant)} participant={participant} index={index} />
89
+ ))}
90
+ {hiddenCount > 0 ? (
91
+ <button
92
+ type="button"
93
+ className="docs-presence-more"
94
+ onClick={() => setOpen((value) => !value)}
95
+ aria-expanded={open}
96
+ aria-label={`View ${hiddenCount} more collaborator${hiddenCount === 1 ? '' : 's'}`}
97
+ data-testid="docs-presence-more"
98
+ >
99
+ +{hiddenCount}
100
+ </button>
101
+ ) : null}
102
+ </div>
103
+
104
+ {open ? (
105
+ <div
106
+ className="absolute right-0 top-9 z-50 w-64 rounded-xl border p-2 shadow-xl"
107
+ style={{
108
+ borderColor: 'var(--docs-el-line)',
109
+ backgroundColor: 'var(--docs-el-surface)',
110
+ color: 'var(--docs-el-text)',
111
+ }}
112
+ data-testid="docs-presence-popover"
113
+ >
114
+ <p className="px-2 pb-2 text-xs font-medium" style={{ color: 'var(--docs-el-muted)' }}>
115
+ {heading}
116
+ </p>
117
+ <ul className="max-h-72 overflow-y-auto">
118
+ {participants.map((participant) => (
119
+ <li key={participantKey(participant)} className="flex items-center gap-2 rounded-lg px-2 py-2">
120
+ <ParticipantAvatar participant={participant} index={0} />
121
+ <div className="min-w-0 flex-1">
122
+ <p className="truncate text-sm font-medium">
123
+ {participant.name}
124
+ {participant.isSelf ? ' (you)' : ''}
125
+ </p>
126
+ <p className="truncate text-xs" style={{ color: 'var(--docs-el-muted)' }}>
127
+ {participant.mode === 'edit' ? 'Editing' : 'Viewing'}
128
+ {participant.typing ? ' - typing' : ''}
129
+ </p>
130
+ </div>
131
+ </li>
132
+ ))}
133
+ </ul>
134
+ </div>
135
+ ) : null}
136
+ </div>
137
+ )
138
+ }
@@ -0,0 +1,336 @@
1
+ import { useMemo, useState } from 'react'
2
+ import type { RecordData } from 'deepspace'
3
+ import { useMutations, useQuery, useUser } from 'deepspace'
4
+ import { Mail, ShieldCheck, UserMinus, Users } from 'lucide-react'
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ useToast,
12
+ } from '../../components/ui'
13
+
14
+ export interface InviteDialogDocumentFields {
15
+ title: string
16
+ ownerId: string
17
+ collaborators?: string
18
+ editors?: string
19
+ folderId?: string
20
+ }
21
+
22
+ interface UserFields {
23
+ email?: string
24
+ name?: string
25
+ imageUrl?: string
26
+ }
27
+
28
+ type InviteRole = 'viewer' | 'editor'
29
+
30
+ /**
31
+ * Diff that resulted from an InviteDialog save. The editor page rebroadcasts
32
+ * this over the doc's presence channel so the affected peer gets the change
33
+ * even when the docs schema's `read: 'collaborator'` rule prevents the
34
+ * `documents` record update from reaching a now-removed user.
35
+ */
36
+ export interface InviteAclDiff {
37
+ /** Users dropped from `collaborators` entirely. */
38
+ removedUserIds: string[]
39
+ /** Users who lost the editor role but remain collaborators (now viewers). */
40
+ demotedUserIds: string[]
41
+ /** Existing collaborators who gained the editor role. */
42
+ promotedUserIds: string[]
43
+ }
44
+
45
+ interface InviteDialogProps {
46
+ open: boolean
47
+ onOpenChange: (open: boolean) => void
48
+ doc: RecordData<InviteDialogDocumentFields>
49
+ isOwner: boolean
50
+ /**
51
+ * Called after a save mutation that changed the ACL. The parent uses it to
52
+ * publish a one-shot permission-change signal over the doc's presence room
53
+ * so peers (including ones who just lost read access to the doc record)
54
+ * can react immediately instead of waiting for the next refresh.
55
+ */
56
+ onAclChange?: (diff: InviteAclDiff) => void
57
+ }
58
+
59
+ function parseIds(raw: string | undefined): string[] {
60
+ if (!raw) return []
61
+ try {
62
+ const parsed = JSON.parse(raw)
63
+ return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
64
+ } catch {
65
+ return []
66
+ }
67
+ }
68
+
69
+ function uniqueIds(ids: string[]): string[] {
70
+ return [...new Set(ids.filter(Boolean))]
71
+ }
72
+
73
+ function initialsFor(name: string): string {
74
+ const parts = name.trim().split(/\s+/).filter(Boolean)
75
+ if (parts.length === 0) return '?'
76
+ return parts
77
+ .slice(0, 2)
78
+ .map((p) => p[0]?.toUpperCase() ?? '')
79
+ .join('')
80
+ }
81
+
82
+ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }: InviteDialogProps) {
83
+ const { user } = useUser()
84
+ const { records: users } = useQuery<UserFields>('users')
85
+ const { put } = useMutations<InviteDialogDocumentFields>('documents')
86
+ const toast = useToast()
87
+ const [email, setEmail] = useState('')
88
+ const [role, setRole] = useState<InviteRole>('editor')
89
+ const [saving, setSaving] = useState(false)
90
+
91
+ const collaborators = useMemo(() => parseIds(doc.data.collaborators), [doc.data.collaborators])
92
+ const editors = useMemo(() => parseIds(doc.data.editors), [doc.data.editors])
93
+
94
+ const ownerRecord = useMemo(
95
+ () => users.find((u) => u.recordId === doc.data.ownerId),
96
+ [doc.data.ownerId, users],
97
+ )
98
+
99
+ const collaboratorRecords = useMemo(
100
+ () =>
101
+ collaborators
102
+ .map((id) => users.find((u) => u.recordId === id))
103
+ .filter((u): u is RecordData<UserFields> => Boolean(u)),
104
+ [collaborators, users],
105
+ )
106
+
107
+ if (!isOwner) return null
108
+
109
+ const saveAccess = async (nextCollaborators: string[], nextEditors: string[]) => {
110
+ const prevCollaborators = parseIds(doc.data.collaborators)
111
+ const prevEditors = parseIds(doc.data.editors)
112
+ const nextCollabList = uniqueIds(nextCollaborators)
113
+ const nextCollabSet = new Set(nextCollabList)
114
+ const nextEditorList = uniqueIds(nextEditors).filter((id) => nextCollabSet.has(id))
115
+ const nextEditorSet = new Set(nextEditorList)
116
+
117
+ setSaving(true)
118
+ try {
119
+ await put(doc.recordId, {
120
+ ...doc.data,
121
+ collaborators: JSON.stringify(nextCollabList),
122
+ editors: JSON.stringify(nextEditorList),
123
+ })
124
+
125
+ if (onAclChange) {
126
+ const removedUserIds = prevCollaborators.filter((id) => !nextCollabSet.has(id))
127
+ const demotedUserIds = prevEditors.filter(
128
+ (id) => nextCollabSet.has(id) && !nextEditorSet.has(id),
129
+ )
130
+ const promotedUserIds = nextEditorList.filter(
131
+ (id) => prevCollaborators.includes(id) && !prevEditors.includes(id),
132
+ )
133
+ if (removedUserIds.length || demotedUserIds.length || promotedUserIds.length) {
134
+ onAclChange({ removedUserIds, demotedUserIds, promotedUserIds })
135
+ }
136
+ }
137
+ } finally {
138
+ setSaving(false)
139
+ }
140
+ }
141
+
142
+ const addInvite = async () => {
143
+ const normalized = email.trim().toLowerCase()
144
+ if (!normalized) return
145
+
146
+ const target = users.find((u) => u.data.email?.trim().toLowerCase() === normalized)
147
+ if (!target) {
148
+ toast.error('User not found', 'No DeepSpace user with that email has used this app yet.')
149
+ return
150
+ }
151
+ if (target.recordId === doc.data.ownerId || target.recordId === user?.id) {
152
+ toast.info('Already has access', 'That user is the document owner.')
153
+ return
154
+ }
155
+ if (collaborators.includes(target.recordId)) {
156
+ toast.info('Already invited', `${target.data.email ?? normalized} already has access.`)
157
+ return
158
+ }
159
+
160
+ const nextCollaborators = [...collaborators, target.recordId]
161
+ const nextEditors = role === 'editor' ? [...editors, target.recordId] : editors
162
+ await saveAccess(nextCollaborators, nextEditors)
163
+ setEmail('')
164
+ toast.success('Invite added', `${target.data.email ?? normalized} now has ${role} access.`)
165
+ }
166
+
167
+ const setCollaboratorRole = async (userId: string, nextRole: InviteRole) => {
168
+ const nextEditors =
169
+ nextRole === 'editor'
170
+ ? uniqueIds([...editors, userId])
171
+ : editors.filter((id) => id !== userId)
172
+ await saveAccess(collaborators, nextEditors)
173
+ }
174
+
175
+ const removeCollaborator = async (userId: string) => {
176
+ await saveAccess(
177
+ collaborators.filter((id) => id !== userId),
178
+ editors.filter((id) => id !== userId),
179
+ )
180
+ }
181
+
182
+ const ownerName =
183
+ ownerRecord?.data.name?.trim() ||
184
+ ownerRecord?.data.email?.trim() ||
185
+ user?.name ||
186
+ user?.email ||
187
+ 'Owner'
188
+
189
+ return (
190
+ <Dialog open={open} onOpenChange={onOpenChange}>
191
+ <DialogContent className="docs-feature-scope max-w-xl">
192
+ <DialogHeader>
193
+ <DialogTitle>Share document</DialogTitle>
194
+ <DialogDescription>
195
+ Invite DeepSpace users by the email address on their account.
196
+ </DialogDescription>
197
+ </DialogHeader>
198
+
199
+ <div className="space-y-5">
200
+ <div className="rounded-xl border p-3" style={{ borderColor: 'var(--docs-el-line)' }}>
201
+ <label
202
+ className="mb-2 block text-xs font-semibold uppercase tracking-wide"
203
+ style={{ color: 'var(--docs-el-muted)' }}
204
+ >
205
+ Add people
206
+ </label>
207
+ <div className="flex flex-col gap-2 sm:flex-row">
208
+ <div className="relative min-w-0 flex-1">
209
+ <Mail
210
+ className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
211
+ style={{ color: 'var(--docs-el-muted)' }}
212
+ />
213
+ <input
214
+ value={email}
215
+ onChange={(e) => setEmail(e.target.value)}
216
+ onKeyDown={(e) => {
217
+ if (e.key === 'Enter') void addInvite()
218
+ }}
219
+ placeholder="person@gmail.com"
220
+ className="h-10 w-full rounded-lg border bg-transparent pl-9 pr-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
221
+ style={{ borderColor: 'var(--docs-el-line)', color: 'var(--docs-el-text)' }}
222
+ />
223
+ </div>
224
+ <select
225
+ value={role}
226
+ onChange={(e) => setRole(e.target.value as InviteRole)}
227
+ className="h-10 rounded-lg border bg-transparent px-3 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
228
+ style={{ borderColor: 'var(--docs-el-line)', color: 'var(--docs-el-text)' }}
229
+ >
230
+ <option value="editor">Editor</option>
231
+ <option value="viewer">Viewer</option>
232
+ </select>
233
+ <button
234
+ type="button"
235
+ onClick={() => void addInvite()}
236
+ disabled={saving || !email.trim()}
237
+ className="h-10 rounded-lg px-4 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
238
+ style={{ backgroundColor: 'var(--docs-el-accent)' }}
239
+ >
240
+ Add
241
+ </button>
242
+ </div>
243
+ </div>
244
+
245
+ <div>
246
+ <div className="mb-3 flex items-center gap-2">
247
+ <Users className="h-4 w-4" style={{ color: 'var(--docs-el-muted)' }} />
248
+ <h3 className="text-sm font-semibold">People with access</h3>
249
+ </div>
250
+
251
+ <div className="space-y-2">
252
+ <div
253
+ className="flex items-center gap-3 rounded-xl border p-3"
254
+ style={{ borderColor: 'var(--docs-el-line)' }}
255
+ >
256
+ <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-black/5 text-xs font-bold dark:bg-white/10">
257
+ {initialsFor(ownerName)}
258
+ </div>
259
+ <div className="min-w-0 flex-1">
260
+ <div className="truncate text-sm font-medium">{ownerName}</div>
261
+ <div className="truncate text-xs" style={{ color: 'var(--docs-el-muted)' }}>
262
+ {ownerRecord?.data.email ?? user?.email ?? 'Owner'}
263
+ </div>
264
+ </div>
265
+ <span className="inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium">
266
+ <ShieldCheck className="h-3.5 w-3.5" />
267
+ Owner
268
+ </span>
269
+ </div>
270
+
271
+ {collaboratorRecords.map((u) => {
272
+ const name = u.data.name?.trim() || u.data.email?.trim() || 'Collaborator'
273
+ const userRole: InviteRole = editors.includes(u.recordId) ? 'editor' : 'viewer'
274
+ return (
275
+ <div
276
+ key={u.recordId}
277
+ className="flex items-center gap-3 rounded-xl border p-3"
278
+ style={{ borderColor: 'var(--docs-el-line)' }}
279
+ >
280
+ {u.data.imageUrl ? (
281
+ <img
282
+ src={u.data.imageUrl}
283
+ alt=""
284
+ className="h-9 w-9 shrink-0 rounded-full object-cover"
285
+ />
286
+ ) : (
287
+ <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-black/5 text-xs font-bold dark:bg-white/10">
288
+ {initialsFor(name)}
289
+ </div>
290
+ )}
291
+ <div className="min-w-0 flex-1">
292
+ <div className="truncate text-sm font-medium">{name}</div>
293
+ <div className="truncate text-xs" style={{ color: 'var(--docs-el-muted)' }}>
294
+ {u.data.email ?? 'No email'}
295
+ </div>
296
+ </div>
297
+ <select
298
+ value={userRole}
299
+ onChange={(e) =>
300
+ void setCollaboratorRole(u.recordId, e.target.value as InviteRole)
301
+ }
302
+ disabled={saving}
303
+ className="h-8 rounded-lg border bg-transparent px-2 text-xs font-medium outline-none"
304
+ style={{ borderColor: 'var(--docs-el-line)', color: 'var(--docs-el-text)' }}
305
+ >
306
+ <option value="editor">Editor</option>
307
+ <option value="viewer">Viewer</option>
308
+ </select>
309
+ <button
310
+ type="button"
311
+ onClick={() => void removeCollaborator(u.recordId)}
312
+ disabled={saving}
313
+ className="rounded-lg p-1.5 text-red-600 transition-colors hover:bg-red-500/10 disabled:opacity-50"
314
+ title="Remove access"
315
+ >
316
+ <UserMinus className="h-4 w-4" />
317
+ </button>
318
+ </div>
319
+ )
320
+ })}
321
+
322
+ {collaboratorRecords.length === 0 ? (
323
+ <p
324
+ className="rounded-xl border p-3 text-sm"
325
+ style={{ color: 'var(--docs-el-muted)' }}
326
+ >
327
+ Only the owner can access this document.
328
+ </p>
329
+ ) : null}
330
+ </div>
331
+ </div>
332
+ </div>
333
+ </DialogContent>
334
+ </Dialog>
335
+ )
336
+ }