@mdxui/payload 6.0.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/.turbo/turbo-typecheck.log +5 -0
- package/CHANGELOG.md +136 -0
- package/package.json +58 -0
- package/src/app-preview/AppPreview.tsx +304 -0
- package/src/app-preview/index.ts +16 -0
- package/src/auth/AdminAuthWrapper.tsx +20 -0
- package/src/auth/AuthProvider.tsx +241 -0
- package/src/auth/AutoLogin.tsx +70 -0
- package/src/auth/index.ts +4 -0
- package/src/authkit/ApiKeys.tsx +77 -0
- package/src/authkit/UserProfile.tsx +77 -0
- package/src/authkit/UserSecurity.tsx +77 -0
- package/src/authkit/WorkOSProvider.tsx +70 -0
- package/src/authkit/index.ts +34 -0
- package/src/authkit/theme.ts +102 -0
- package/src/authkit/useWidgetToken.ts +67 -0
- package/src/dev-tools/Browser.tsx +364 -0
- package/src/dev-tools/DevTools.tsx +106 -0
- package/src/dev-tools/Terminal.tsx +216 -0
- package/src/dev-tools/index.ts +4 -0
- package/src/index.ts +39 -0
- package/src/mdx-preview/MDXPreview.tsx +183 -0
- package/src/mdx-preview/MDXProvider.tsx +62 -0
- package/src/mdx-preview/PayloadMDXField.tsx +120 -0
- package/src/mdx-preview/index.ts +28 -0
- package/src/mdx-preview/useMDXCompiler.ts +95 -0
- package/src/site-preview/PayloadSiteField.tsx +167 -0
- package/src/site-preview/SitePreview.tsx +194 -0
- package/src/site-preview/index.ts +31 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthKit Theme Configuration
|
|
3
|
+
*
|
|
4
|
+
* Configures @mdxui/auth widgets theme to match Payload CMS admin styling.
|
|
5
|
+
* Uses Radix Themes API for customization.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Theme configuration for WorkOS widgets
|
|
10
|
+
* Matches Payload CMS admin aesthetic
|
|
11
|
+
*/
|
|
12
|
+
export const payloadAuthKitTheme = {
|
|
13
|
+
/**
|
|
14
|
+
* Appearance mode - 'light', 'dark', or 'inherit'
|
|
15
|
+
* 'inherit' follows system/Payload dark mode setting
|
|
16
|
+
*/
|
|
17
|
+
appearance: 'inherit' as const,
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Accent color for interactive elements
|
|
21
|
+
* Payload uses a blue accent by default
|
|
22
|
+
*/
|
|
23
|
+
accentColor: 'blue' as const,
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Border radius for UI elements
|
|
27
|
+
* 'medium' matches Payload's rounded corners
|
|
28
|
+
*/
|
|
29
|
+
radius: 'medium' as const,
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Gray color palette
|
|
33
|
+
* 'slate' is close to Payload's neutral grays
|
|
34
|
+
*/
|
|
35
|
+
grayColor: 'slate' as const,
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Scaling factor for UI elements
|
|
39
|
+
* '100%' maintains standard sizing
|
|
40
|
+
*/
|
|
41
|
+
scaling: '100%' as const,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* CSS custom properties to inject for additional theming
|
|
46
|
+
* These override Radix Themes defaults to match Payload more closely
|
|
47
|
+
*/
|
|
48
|
+
export const customThemeStyles = `
|
|
49
|
+
/* Match Payload's font stack */
|
|
50
|
+
.workos-widgets {
|
|
51
|
+
font-family: var(--font-body, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Adjust card backgrounds for Payload dark mode */
|
|
55
|
+
[data-theme="dark"] .workos-widgets {
|
|
56
|
+
--color-background: var(--theme-elevation-100, #1a1a1a);
|
|
57
|
+
--color-surface: var(--theme-elevation-200, #242424);
|
|
58
|
+
--color-panel-solid: var(--theme-elevation-150, #1f1f1f);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* Match Payload's button styling */
|
|
62
|
+
.workos-widgets button[data-accent-color] {
|
|
63
|
+
font-weight: 500;
|
|
64
|
+
transition: all 150ms ease;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* Ensure proper contrast in light mode */
|
|
68
|
+
[data-theme="light"] .workos-widgets {
|
|
69
|
+
--color-background: var(--theme-elevation-0, #ffffff);
|
|
70
|
+
--color-surface: var(--theme-elevation-50, #f7f7f7);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* Match Payload's input field styling */
|
|
74
|
+
.workos-widgets input,
|
|
75
|
+
.workos-widgets select,
|
|
76
|
+
.workos-widgets textarea {
|
|
77
|
+
border-color: var(--theme-elevation-300, #e0e0e0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* Widget container max-width for better readability */
|
|
81
|
+
.workos-widget-container {
|
|
82
|
+
max-width: 600px;
|
|
83
|
+
margin: 0 auto;
|
|
84
|
+
padding: 2rem;
|
|
85
|
+
}
|
|
86
|
+
`
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Inject custom theme styles into document
|
|
90
|
+
* Call this in your provider component
|
|
91
|
+
*/
|
|
92
|
+
export function injectThemeStyles() {
|
|
93
|
+
if (typeof document === 'undefined') return
|
|
94
|
+
|
|
95
|
+
const styleId = 'workos-widgets-custom-theme'
|
|
96
|
+
if (document.getElementById(styleId)) return
|
|
97
|
+
|
|
98
|
+
const style = document.createElement('style')
|
|
99
|
+
style.id = styleId
|
|
100
|
+
style.textContent = customThemeStyles
|
|
101
|
+
document.head.appendChild(style)
|
|
102
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback } from 'react'
|
|
4
|
+
|
|
5
|
+
interface UseWidgetTokenResult {
|
|
6
|
+
token: string | null
|
|
7
|
+
loading: boolean
|
|
8
|
+
error: string | null
|
|
9
|
+
refetch: () => Promise<void>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hook to fetch authorization tokens for WorkOS widgets
|
|
14
|
+
*
|
|
15
|
+
* @param widget - The widget type (user-profile, user-security, etc.)
|
|
16
|
+
* @param organizationId - Optional organization context
|
|
17
|
+
* @returns Token state and refetch function
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```tsx
|
|
21
|
+
* const { token, loading, error } = useWidgetToken('user-profile')
|
|
22
|
+
* if (loading) return <Spinner />
|
|
23
|
+
* if (error) return <Error message={error} />
|
|
24
|
+
* return <WorkOSWidget authToken={token} />
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function useWidgetToken(
|
|
28
|
+
widget: string,
|
|
29
|
+
organizationId?: string
|
|
30
|
+
): UseWidgetTokenResult {
|
|
31
|
+
const [token, setToken] = useState<string | null>(null)
|
|
32
|
+
const [loading, setLoading] = useState(true)
|
|
33
|
+
const [error, setError] = useState<string | null>(null)
|
|
34
|
+
|
|
35
|
+
const fetchToken = useCallback(async () => {
|
|
36
|
+
try {
|
|
37
|
+
setLoading(true)
|
|
38
|
+
setError(null)
|
|
39
|
+
|
|
40
|
+
const params = new URLSearchParams({ widget })
|
|
41
|
+
if (organizationId) {
|
|
42
|
+
params.set('organizationId', organizationId)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const response = await fetch(`/api/workos/widget-token?${params}`)
|
|
46
|
+
const data = await response.json()
|
|
47
|
+
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
throw new Error(data.error || 'Failed to fetch token')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setToken(data.token)
|
|
53
|
+
} catch (err) {
|
|
54
|
+
setError(err instanceof Error ? err.message : 'Token fetch failed')
|
|
55
|
+
} finally {
|
|
56
|
+
setLoading(false)
|
|
57
|
+
}
|
|
58
|
+
}, [widget, organizationId])
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
fetchToken()
|
|
62
|
+
}, [fetchToken])
|
|
63
|
+
|
|
64
|
+
return { token, loading, error, refetch: fetchToken }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default useWidgetToken
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
interface BrowserProps {
|
|
6
|
+
/** Initial URL to load */
|
|
7
|
+
initialUrl?: string
|
|
8
|
+
/** API endpoint base URL */
|
|
9
|
+
apiUrl?: string
|
|
10
|
+
/** Height of the browser viewport */
|
|
11
|
+
height?: string | number
|
|
12
|
+
/** Width of the browser viewport */
|
|
13
|
+
width?: string | number
|
|
14
|
+
/** Show URL bar */
|
|
15
|
+
showUrlBar?: boolean
|
|
16
|
+
/** Show controls (back, forward, refresh) */
|
|
17
|
+
showControls?: boolean
|
|
18
|
+
/** Called when URL changes */
|
|
19
|
+
onNavigate?: (url: string) => void
|
|
20
|
+
/** Called when screenshot is taken */
|
|
21
|
+
onScreenshot?: (imageData: string) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type SessionStatus = 'IDLE' | 'CONNECTING' | 'RUNNING' | 'COMPLETED' | 'ERROR' | 'TIMED_OUT'
|
|
25
|
+
|
|
26
|
+
export const Browser: React.FC<BrowserProps> = ({
|
|
27
|
+
initialUrl = 'https://example.com.ai',
|
|
28
|
+
apiUrl = 'https://api.sb',
|
|
29
|
+
height = 600,
|
|
30
|
+
width = '100%',
|
|
31
|
+
showUrlBar = true,
|
|
32
|
+
showControls = true,
|
|
33
|
+
onNavigate,
|
|
34
|
+
onScreenshot,
|
|
35
|
+
}) => {
|
|
36
|
+
const [url, setUrl] = useState(initialUrl)
|
|
37
|
+
const [inputUrl, setInputUrl] = useState(initialUrl)
|
|
38
|
+
const [sessionId, setSessionId] = useState<string | null>(null)
|
|
39
|
+
const [status, setStatus] = useState<SessionStatus>('IDLE')
|
|
40
|
+
const [screenshot, setScreenshot] = useState<string | null>(null)
|
|
41
|
+
const [error, setError] = useState<string | null>(null)
|
|
42
|
+
const [connectUrl, setConnectUrl] = useState<string | null>(null)
|
|
43
|
+
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
44
|
+
|
|
45
|
+
// Create browser session
|
|
46
|
+
const createSession = useCallback(async () => {
|
|
47
|
+
setStatus('CONNECTING')
|
|
48
|
+
setError(null)
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetch(`${apiUrl}/rpc`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'Content-Type': 'application/json' },
|
|
54
|
+
body: JSON.stringify({
|
|
55
|
+
method: 'createBrowserSession',
|
|
56
|
+
params: {
|
|
57
|
+
browserSettings: {
|
|
58
|
+
viewport: { width: 1280, height: 720 },
|
|
59
|
+
},
|
|
60
|
+
keepAlive: true,
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const result = await response.json()
|
|
66
|
+
|
|
67
|
+
if (result.error) {
|
|
68
|
+
throw new Error(result.error.message || 'Failed to create session')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
setSessionId(result.result.id)
|
|
72
|
+
setStatus('RUNNING')
|
|
73
|
+
|
|
74
|
+
// Get connect URL for live view
|
|
75
|
+
const connectResponse = await fetch(`${apiUrl}/rpc`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'Content-Type': 'application/json' },
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
method: 'getBrowserConnectUrl',
|
|
80
|
+
params: { sessionId: result.result.id },
|
|
81
|
+
}),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const connectResult = await connectResponse.json()
|
|
85
|
+
if (connectResult.result?.url) {
|
|
86
|
+
setConnectUrl(connectResult.result.url)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return result.result.id
|
|
90
|
+
} catch (err) {
|
|
91
|
+
setError(err instanceof Error ? err.message : 'Unknown error')
|
|
92
|
+
setStatus('ERROR')
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
}, [apiUrl])
|
|
96
|
+
|
|
97
|
+
// Take screenshot
|
|
98
|
+
const takeScreenshot = useCallback(async () => {
|
|
99
|
+
if (!sessionId) return
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const response = await fetch(`${apiUrl}/rpc`, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: { 'Content-Type': 'application/json' },
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
method: 'runBrowserAction',
|
|
107
|
+
params: {
|
|
108
|
+
sessionId,
|
|
109
|
+
action: 'screenshot',
|
|
110
|
+
url,
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const result = await response.json()
|
|
116
|
+
|
|
117
|
+
if (result.result?.data) {
|
|
118
|
+
setScreenshot(result.result.data)
|
|
119
|
+
onScreenshot?.(result.result.data)
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
setError(err instanceof Error ? err.message : 'Screenshot failed')
|
|
123
|
+
}
|
|
124
|
+
}, [apiUrl, sessionId, url, onScreenshot])
|
|
125
|
+
|
|
126
|
+
// Navigate to URL
|
|
127
|
+
const navigate = useCallback(async (targetUrl: string) => {
|
|
128
|
+
if (!sessionId) {
|
|
129
|
+
const newSessionId = await createSession()
|
|
130
|
+
if (!newSessionId) return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
setUrl(targetUrl)
|
|
134
|
+
setInputUrl(targetUrl)
|
|
135
|
+
onNavigate?.(targetUrl)
|
|
136
|
+
|
|
137
|
+
// Take screenshot after navigation
|
|
138
|
+
setTimeout(() => takeScreenshot(), 2000)
|
|
139
|
+
}, [sessionId, createSession, onNavigate, takeScreenshot])
|
|
140
|
+
|
|
141
|
+
const handleUrlSubmit = useCallback((e: React.FormEvent) => {
|
|
142
|
+
e.preventDefault()
|
|
143
|
+
let targetUrl = inputUrl
|
|
144
|
+
if (!targetUrl.startsWith('http://') && !targetUrl.startsWith('https://')) {
|
|
145
|
+
targetUrl = 'https://' + targetUrl
|
|
146
|
+
}
|
|
147
|
+
navigate(targetUrl)
|
|
148
|
+
}, [inputUrl, navigate])
|
|
149
|
+
|
|
150
|
+
const handleRefresh = useCallback(() => {
|
|
151
|
+
takeScreenshot()
|
|
152
|
+
}, [takeScreenshot])
|
|
153
|
+
|
|
154
|
+
// Initialize session on mount
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (status === 'IDLE') {
|
|
157
|
+
createSession().then((id) => {
|
|
158
|
+
if (id) {
|
|
159
|
+
setTimeout(() => takeScreenshot(), 1000)
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div
|
|
167
|
+
style={{
|
|
168
|
+
backgroundColor: '#2d2d2d',
|
|
169
|
+
borderRadius: '8px',
|
|
170
|
+
overflow: 'hidden',
|
|
171
|
+
width: typeof width === 'number' ? `${width}px` : width,
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
{/* Browser Chrome */}
|
|
175
|
+
{(showUrlBar || showControls) && (
|
|
176
|
+
<div
|
|
177
|
+
style={{
|
|
178
|
+
backgroundColor: '#3c3c3c',
|
|
179
|
+
padding: '8px 12px',
|
|
180
|
+
display: 'flex',
|
|
181
|
+
alignItems: 'center',
|
|
182
|
+
gap: '8px',
|
|
183
|
+
borderBottom: '1px solid #1e1e1e',
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
{/* Traffic lights */}
|
|
187
|
+
<div style={{ display: 'flex', gap: '6px' }}>
|
|
188
|
+
<div style={{ width: 12, height: 12, borderRadius: '50%', backgroundColor: '#ff5f57' }} />
|
|
189
|
+
<div style={{ width: 12, height: 12, borderRadius: '50%', backgroundColor: '#febc2e' }} />
|
|
190
|
+
<div style={{ width: 12, height: 12, borderRadius: '50%', backgroundColor: '#28c840' }} />
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{/* Controls */}
|
|
194
|
+
{showControls && (
|
|
195
|
+
<div style={{ display: 'flex', gap: '4px', marginLeft: '8px' }}>
|
|
196
|
+
<button
|
|
197
|
+
onClick={handleRefresh}
|
|
198
|
+
disabled={status !== 'RUNNING'}
|
|
199
|
+
style={{
|
|
200
|
+
background: 'none',
|
|
201
|
+
border: 'none',
|
|
202
|
+
color: '#888',
|
|
203
|
+
cursor: 'pointer',
|
|
204
|
+
padding: '4px 8px',
|
|
205
|
+
borderRadius: '4px',
|
|
206
|
+
fontSize: '14px',
|
|
207
|
+
}}
|
|
208
|
+
title="Refresh"
|
|
209
|
+
>
|
|
210
|
+
↻
|
|
211
|
+
</button>
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
|
|
215
|
+
{/* URL Bar */}
|
|
216
|
+
{showUrlBar && (
|
|
217
|
+
<form onSubmit={handleUrlSubmit} style={{ flex: 1 }}>
|
|
218
|
+
<input
|
|
219
|
+
type="text"
|
|
220
|
+
value={inputUrl}
|
|
221
|
+
onChange={(e) => setInputUrl(e.target.value)}
|
|
222
|
+
placeholder="Enter URL..."
|
|
223
|
+
style={{
|
|
224
|
+
width: '100%',
|
|
225
|
+
backgroundColor: '#1e1e1e',
|
|
226
|
+
border: '1px solid #4a4a4a',
|
|
227
|
+
borderRadius: '6px',
|
|
228
|
+
color: '#d4d4d4',
|
|
229
|
+
padding: '6px 12px',
|
|
230
|
+
fontSize: '13px',
|
|
231
|
+
outline: 'none',
|
|
232
|
+
}}
|
|
233
|
+
/>
|
|
234
|
+
</form>
|
|
235
|
+
)}
|
|
236
|
+
|
|
237
|
+
{/* Status indicator */}
|
|
238
|
+
<div
|
|
239
|
+
style={{
|
|
240
|
+
width: 8,
|
|
241
|
+
height: 8,
|
|
242
|
+
borderRadius: '50%',
|
|
243
|
+
backgroundColor:
|
|
244
|
+
status === 'RUNNING' ? '#28c840' :
|
|
245
|
+
status === 'CONNECTING' ? '#febc2e' :
|
|
246
|
+
status === 'ERROR' ? '#ff5f57' :
|
|
247
|
+
'#888',
|
|
248
|
+
}}
|
|
249
|
+
title={status}
|
|
250
|
+
/>
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
|
|
254
|
+
{/* Browser Viewport */}
|
|
255
|
+
<div
|
|
256
|
+
style={{
|
|
257
|
+
height: typeof height === 'number' ? `${height}px` : height,
|
|
258
|
+
backgroundColor: '#fff',
|
|
259
|
+
position: 'relative',
|
|
260
|
+
overflow: 'hidden',
|
|
261
|
+
}}
|
|
262
|
+
>
|
|
263
|
+
{status === 'CONNECTING' && (
|
|
264
|
+
<div
|
|
265
|
+
style={{
|
|
266
|
+
position: 'absolute',
|
|
267
|
+
inset: 0,
|
|
268
|
+
display: 'flex',
|
|
269
|
+
alignItems: 'center',
|
|
270
|
+
justifyContent: 'center',
|
|
271
|
+
backgroundColor: '#1e1e1e',
|
|
272
|
+
color: '#d4d4d4',
|
|
273
|
+
}}
|
|
274
|
+
>
|
|
275
|
+
<div style={{ textAlign: 'center' }}>
|
|
276
|
+
<div style={{ fontSize: '24px', marginBottom: '8px' }}>🌐</div>
|
|
277
|
+
<div>Starting browser session...</div>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
)}
|
|
281
|
+
|
|
282
|
+
{error && (
|
|
283
|
+
<div
|
|
284
|
+
style={{
|
|
285
|
+
position: 'absolute',
|
|
286
|
+
inset: 0,
|
|
287
|
+
display: 'flex',
|
|
288
|
+
alignItems: 'center',
|
|
289
|
+
justifyContent: 'center',
|
|
290
|
+
backgroundColor: '#1e1e1e',
|
|
291
|
+
color: '#f14c4c',
|
|
292
|
+
}}
|
|
293
|
+
>
|
|
294
|
+
<div style={{ textAlign: 'center' }}>
|
|
295
|
+
<div style={{ fontSize: '24px', marginBottom: '8px' }}>⚠️</div>
|
|
296
|
+
<div>{error}</div>
|
|
297
|
+
<button
|
|
298
|
+
onClick={() => {
|
|
299
|
+
setError(null)
|
|
300
|
+
setStatus('IDLE')
|
|
301
|
+
createSession()
|
|
302
|
+
}}
|
|
303
|
+
style={{
|
|
304
|
+
marginTop: '12px',
|
|
305
|
+
padding: '8px 16px',
|
|
306
|
+
backgroundColor: '#4a4a4a',
|
|
307
|
+
border: 'none',
|
|
308
|
+
borderRadius: '4px',
|
|
309
|
+
color: '#d4d4d4',
|
|
310
|
+
cursor: 'pointer',
|
|
311
|
+
}}
|
|
312
|
+
>
|
|
313
|
+
Retry
|
|
314
|
+
</button>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
|
|
319
|
+
{screenshot && status === 'RUNNING' && (
|
|
320
|
+
<img
|
|
321
|
+
src={`data:image/png;base64,${screenshot}`}
|
|
322
|
+
alt="Browser screenshot"
|
|
323
|
+
style={{
|
|
324
|
+
width: '100%',
|
|
325
|
+
height: '100%',
|
|
326
|
+
objectFit: 'contain',
|
|
327
|
+
}}
|
|
328
|
+
/>
|
|
329
|
+
)}
|
|
330
|
+
|
|
331
|
+
{connectUrl && status === 'RUNNING' && !screenshot && (
|
|
332
|
+
<iframe
|
|
333
|
+
ref={iframeRef}
|
|
334
|
+
src={connectUrl}
|
|
335
|
+
style={{
|
|
336
|
+
width: '100%',
|
|
337
|
+
height: '100%',
|
|
338
|
+
border: 'none',
|
|
339
|
+
}}
|
|
340
|
+
title="Browser Session"
|
|
341
|
+
sandbox="allow-scripts allow-same-origin"
|
|
342
|
+
/>
|
|
343
|
+
)}
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
{/* Status bar */}
|
|
347
|
+
<div
|
|
348
|
+
style={{
|
|
349
|
+
backgroundColor: '#3c3c3c',
|
|
350
|
+
padding: '4px 12px',
|
|
351
|
+
fontSize: '11px',
|
|
352
|
+
color: '#888',
|
|
353
|
+
display: 'flex',
|
|
354
|
+
justifyContent: 'space-between',
|
|
355
|
+
}}
|
|
356
|
+
>
|
|
357
|
+
<span>{status === 'RUNNING' ? `Session: ${sessionId?.slice(0, 8)}...` : status}</span>
|
|
358
|
+
<span>{url}</span>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export default Browser
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react'
|
|
4
|
+
import { Terminal } from './Terminal'
|
|
5
|
+
import { Browser } from './Browser'
|
|
6
|
+
|
|
7
|
+
interface DevToolsProps {
|
|
8
|
+
/** API endpoint base URL */
|
|
9
|
+
apiUrl?: string
|
|
10
|
+
/** Initial tab to show */
|
|
11
|
+
defaultTab?: 'terminal' | 'browser' | 'split'
|
|
12
|
+
/** Height of the dev tools panel */
|
|
13
|
+
height?: string | number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Tab = 'terminal' | 'browser' | 'split'
|
|
17
|
+
|
|
18
|
+
export const DevTools: React.FC<DevToolsProps> = ({
|
|
19
|
+
apiUrl = 'https://api.sb',
|
|
20
|
+
defaultTab = 'split',
|
|
21
|
+
height = 500,
|
|
22
|
+
}) => {
|
|
23
|
+
const [activeTab, setActiveTab] = useState<Tab>(defaultTab)
|
|
24
|
+
|
|
25
|
+
const tabs: { id: Tab; label: string; icon: string }[] = [
|
|
26
|
+
{ id: 'terminal', label: 'Terminal', icon: '⌨️' },
|
|
27
|
+
{ id: 'browser', label: 'Browser', icon: '🌐' },
|
|
28
|
+
{ id: 'split', label: 'Split View', icon: '⬚' },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
style={{
|
|
34
|
+
backgroundColor: '#1e1e1e',
|
|
35
|
+
borderRadius: '8px',
|
|
36
|
+
overflow: 'hidden',
|
|
37
|
+
border: '1px solid #3c3c3c',
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
{/* Tab bar */}
|
|
41
|
+
<div
|
|
42
|
+
style={{
|
|
43
|
+
backgroundColor: '#2d2d2d',
|
|
44
|
+
display: 'flex',
|
|
45
|
+
borderBottom: '1px solid #3c3c3c',
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
{tabs.map((tab) => (
|
|
49
|
+
<button
|
|
50
|
+
key={tab.id}
|
|
51
|
+
onClick={() => setActiveTab(tab.id)}
|
|
52
|
+
style={{
|
|
53
|
+
padding: '8px 16px',
|
|
54
|
+
backgroundColor: activeTab === tab.id ? '#1e1e1e' : 'transparent',
|
|
55
|
+
border: 'none',
|
|
56
|
+
borderBottom: activeTab === tab.id ? '2px solid #569cd6' : '2px solid transparent',
|
|
57
|
+
color: activeTab === tab.id ? '#d4d4d4' : '#888',
|
|
58
|
+
cursor: 'pointer',
|
|
59
|
+
fontSize: '13px',
|
|
60
|
+
display: 'flex',
|
|
61
|
+
alignItems: 'center',
|
|
62
|
+
gap: '6px',
|
|
63
|
+
transition: 'all 0.2s',
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
<span>{tab.icon}</span>
|
|
67
|
+
<span>{tab.label}</span>
|
|
68
|
+
</button>
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
{/* Content */}
|
|
73
|
+
<div
|
|
74
|
+
style={{
|
|
75
|
+
height: typeof height === 'number' ? `${height}px` : height,
|
|
76
|
+
display: 'flex',
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
{activeTab === 'terminal' && (
|
|
80
|
+
<div style={{ flex: 1, padding: '8px' }}>
|
|
81
|
+
<Terminal apiUrl={apiUrl} height="100%" />
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{activeTab === 'browser' && (
|
|
86
|
+
<div style={{ flex: 1, padding: '8px' }}>
|
|
87
|
+
<Browser apiUrl={apiUrl} height="100%" />
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
{activeTab === 'split' && (
|
|
92
|
+
<>
|
|
93
|
+
<div style={{ flex: 1, padding: '8px', borderRight: '1px solid #3c3c3c' }}>
|
|
94
|
+
<Terminal apiUrl={apiUrl} height="100%" />
|
|
95
|
+
</div>
|
|
96
|
+
<div style={{ flex: 1, padding: '8px' }}>
|
|
97
|
+
<Browser apiUrl={apiUrl} height="100%" />
|
|
98
|
+
</div>
|
|
99
|
+
</>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default DevTools
|