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.
- package/dist/cli.js +96 -18
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +160 -1
- package/dist/index.js +283 -126
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +172 -2
- package/dist/server.js +581 -1
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +252 -2
- package/dist/worker.js +588 -1
- package/dist/worker.js.map +1 -1
- package/features/admin-page/feature.json +29 -0
- package/features/admin-page/src/AdminPage.tsx +292 -0
- package/features/admin-page/src/admin-schema.ts +20 -0
- package/features/ai-chat/feature.json +41 -0
- package/features/ai-chat/src/AiChatPage.tsx +692 -0
- package/features/ai-chat/src/ChatPanel.tsx +1098 -0
- package/features/ai-chat/src/ai-chat-schema.ts +11 -0
- package/features/canvas/feature.json +45 -0
- package/features/canvas/src/CanvasEditorPage.tsx +87 -0
- package/features/canvas/src/CanvasListPage.tsx +202 -0
- package/features/canvas/src/canvas-schema.ts +33 -0
- package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
- package/features/canvas/src/components/CanvasView.tsx +369 -0
- package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
- package/features/cron/feature.json +33 -0
- package/features/cron/src/CronLogPage.tsx +100 -0
- package/features/cron/src/cron.ts +25 -0
- package/features/docs/feature.json +96 -0
- package/features/docs/src/DocsEditorPage.tsx +1012 -0
- package/features/docs/src/DocsListPage.tsx +951 -0
- package/features/docs/src/DocsOutlinePanel.tsx +68 -0
- package/features/docs/src/DocsPresence.tsx +138 -0
- package/features/docs/src/InviteDialog.tsx +336 -0
- package/features/docs/src/LibrarySidebar.tsx +464 -0
- package/features/docs/src/docs-favorites.ts +18 -0
- package/features/docs/src/docs-library-types.ts +15 -0
- package/features/docs/src/docs-schema.ts +80 -0
- package/features/docs/src/docs-ui.css +309 -0
- package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
- package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
- package/features/docs/src/editor/useDocEditor.ts +206 -0
- package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
- package/features/file-attachments/feature.json +42 -0
- package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
- package/features/file-attachments/src/attachments-constants.ts +9 -0
- package/features/file-attachments/src/attachments-schema.ts +45 -0
- package/features/file-attachments/src/useFilePreview.ts +471 -0
- package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
- package/features/file-manager/feature.json +24 -0
- package/features/file-manager/src/FileManagerPage.tsx +330 -0
- package/features/integration-test/feature.json +18 -0
- package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
- package/features/integration-test/src/integration.spec.ts +42 -0
- package/features/items/feature.json +33 -0
- package/features/items/src/ItemsPage.tsx +325 -0
- package/features/items/src/items-constants.ts +10 -0
- package/features/items/src/items-schema.ts +27 -0
- package/features/kanban/feature.json +18 -0
- package/features/kanban/src/Kanban.tsx +283 -0
- package/features/landing/feature.json +62 -0
- package/features/landing/src/LandingPage.tsx +627 -0
- package/features/landing/src/primitives.tsx +389 -0
- package/features/landing/src/sections/FAQSection.tsx +110 -0
- package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
- package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
- package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
- package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
- package/features/landing/src/sections/StatsSection.tsx +31 -0
- package/features/landing/src/sections/TeamSection.tsx +86 -0
- package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
- package/features/landing/src/sections/VideoSection.tsx +71 -0
- package/features/leaderboard/feature.json +33 -0
- package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
- package/features/leaderboard/src/leaderboard-constants.ts +21 -0
- package/features/leaderboard/src/leaderboard-schema.ts +36 -0
- package/features/messaging/feature.json +111 -0
- package/features/messaging/src/ChatMultiPage.tsx +300 -0
- package/features/messaging/src/ChatPage.tsx +75 -0
- package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
- package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
- package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
- package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
- package/features/messaging/src/components/chat/MessageList.tsx +248 -0
- package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
- package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
- package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
- package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
- package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
- package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
- package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
- package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
- package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
- package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
- package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
- package/features/messaging/src/hooks/useChatChannel.ts +64 -0
- package/features/messaging/src/hooks/useLongPress.ts +74 -0
- package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
- package/features/messaging/src/messaging-schema.ts +25 -0
- package/features/presence-test/feature.json +21 -0
- package/features/presence-test/src/PresenceTestPage.tsx +120 -0
- package/features/sidebar/feature.json +19 -0
- package/features/sidebar/src/AppSidebar.tsx +558 -0
- package/features/tasks/feature.json +33 -0
- package/features/tasks/src/TasksPage.tsx +598 -0
- package/features/tasks/src/tasks-constants.ts +43 -0
- package/features/tasks/src/tasks-schema.ts +56 -0
- package/features/testing/feature.json +49 -0
- package/features/testing/src/TestPage.tsx +145 -0
- package/features/testing/src/cpu-burn-action.ts +51 -0
- package/features/testing/src/subrequest-burn-action.ts +53 -0
- package/features/testing/src/test-schema.ts +23 -0
- package/features/topbar/feature.json +18 -0
- package/features/topbar/src/Topbar.tsx +353 -0
- package/features/tree/feature.json +26 -0
- package/features/tree/src/TreeSidebar.tsx +607 -0
- package/features/tree/src/tree-constants.ts +68 -0
- package/features/tree/src/tree-hooks.ts +258 -0
- package/package.json +4 -2
- 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
|
+
}
|