@antigenic-oss/paint 0.1.0
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/LICENSE +178 -0
- package/NOTICE +4 -0
- package/README.md +180 -0
- package/bin/paint.js +266 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +19 -0
- package/package.json +81 -0
- package/postcss.config.mjs +8 -0
- package/public/dev-editor-inspector.js +1872 -0
- package/src/app/api/claude/analyze/route.ts +319 -0
- package/src/app/api/claude/apply/route.ts +185 -0
- package/src/app/api/claude/pick-folder/route.ts +64 -0
- package/src/app/api/claude/scan/route.ts +221 -0
- package/src/app/api/claude/status/route.ts +55 -0
- package/src/app/api/project/scan/route.ts +634 -0
- package/src/app/api/project-scan/css-variables/route.ts +238 -0
- package/src/app/api/project-scan/route.ts +40 -0
- package/src/app/api/project-scan/tailwind-config/route.ts +172 -0
- package/src/app/api/proxy/[[...path]]/route.ts +2400 -0
- package/src/app/docs/DocsClient.tsx +322 -0
- package/src/app/docs/layout.tsx +7 -0
- package/src/app/docs/page.tsx +855 -0
- package/src/app/globals.css +176 -0
- package/src/app/layout.tsx +19 -0
- package/src/app/page.tsx +46 -0
- package/src/bridge/api-handlers.ts +885 -0
- package/src/bridge/proxy-handler.ts +329 -0
- package/src/bridge/server.ts +113 -0
- package/src/components/BreakpointTabs.tsx +72 -0
- package/src/components/ChangeSummaryModal.tsx +267 -0
- package/src/components/ConnectModal.tsx +994 -0
- package/src/components/Editor.tsx +90 -0
- package/src/components/PageSelector.tsx +208 -0
- package/src/components/PreviewFrame.tsx +299 -0
- package/src/components/ProjectFolderBanner.tsx +91 -0
- package/src/components/ResponsiveToolbar.tsx +222 -0
- package/src/components/TargetSelector.tsx +243 -0
- package/src/components/TopBar.tsx +315 -0
- package/src/components/common/CollapsibleSection.tsx +36 -0
- package/src/components/common/ColorPicker.tsx +920 -0
- package/src/components/common/EditablePre.tsx +136 -0
- package/src/components/common/ErrorBoundary.tsx +65 -0
- package/src/components/common/ResizablePanel.tsx +83 -0
- package/src/components/common/ScanAnimation.tsx +76 -0
- package/src/components/common/ToastContainer.tsx +97 -0
- package/src/components/common/UnitInput.tsx +77 -0
- package/src/components/common/VariableColorPicker.tsx +622 -0
- package/src/components/left-panel/AddElementPanel.tsx +237 -0
- package/src/components/left-panel/ComponentsPanel.tsx +609 -0
- package/src/components/left-panel/IconSidebar.tsx +99 -0
- package/src/components/left-panel/LayerNode.tsx +874 -0
- package/src/components/left-panel/LayerSearch.tsx +23 -0
- package/src/components/left-panel/LayersPanel.tsx +52 -0
- package/src/components/left-panel/LeftPanel.tsx +122 -0
- package/src/components/left-panel/PagesPanel.tsx +114 -0
- package/src/components/left-panel/icons.tsx +162 -0
- package/src/components/left-panel/terminal/ScanOverlay.tsx +66 -0
- package/src/components/left-panel/terminal/TerminalPanel.tsx +217 -0
- package/src/components/right-panel/ElementLogBox.tsx +248 -0
- package/src/components/right-panel/PanelTabs.tsx +83 -0
- package/src/components/right-panel/RightPanel.tsx +41 -0
- package/src/components/right-panel/changes/AiScanResultPanel.tsx +285 -0
- package/src/components/right-panel/changes/ChangeEntry.tsx +59 -0
- package/src/components/right-panel/changes/ChangelogActions.tsx +105 -0
- package/src/components/right-panel/changes/ChangesPanel.tsx +1474 -0
- package/src/components/right-panel/claude/ApplyConfirmModal.tsx +376 -0
- package/src/components/right-panel/claude/ClaudeErrorState.tsx +125 -0
- package/src/components/right-panel/claude/ClaudeIntegrationPanel.tsx +482 -0
- package/src/components/right-panel/claude/ClaudeProgressIndicator.tsx +76 -0
- package/src/components/right-panel/claude/DiffCard.tsx +130 -0
- package/src/components/right-panel/claude/DiffViewer.tsx +54 -0
- package/src/components/right-panel/claude/ProjectRootSelector.tsx +275 -0
- package/src/components/right-panel/claude/ResultsSummary.tsx +119 -0
- package/src/components/right-panel/claude/SetupFlow.tsx +315 -0
- package/src/components/right-panel/console/ConsolePanel.tsx +209 -0
- package/src/components/right-panel/design/AppearanceSection.tsx +703 -0
- package/src/components/right-panel/design/BackgroundSection.tsx +516 -0
- package/src/components/right-panel/design/BorderSection.tsx +161 -0
- package/src/components/right-panel/design/CSSRawView.tsx +412 -0
- package/src/components/right-panel/design/DesignCSSTabToggle.tsx +51 -0
- package/src/components/right-panel/design/DesignPanel.tsx +275 -0
- package/src/components/right-panel/design/ElementBreadcrumb.tsx +51 -0
- package/src/components/right-panel/design/GradientEditor.tsx +726 -0
- package/src/components/right-panel/design/LayoutSection.tsx +1948 -0
- package/src/components/right-panel/design/PositionSection.tsx +865 -0
- package/src/components/right-panel/design/PropertiesSection.tsx +86 -0
- package/src/components/right-panel/design/SVGSection.tsx +361 -0
- package/src/components/right-panel/design/ShadowBlurSection.tsx +227 -0
- package/src/components/right-panel/design/SizeSection.tsx +183 -0
- package/src/components/right-panel/design/TextSection.tsx +719 -0
- package/src/components/right-panel/design/icons.tsx +948 -0
- package/src/components/right-panel/design/inputs/BoxModelPreview.tsx +467 -0
- package/src/components/right-panel/design/inputs/ColorInput.tsx +43 -0
- package/src/components/right-panel/design/inputs/CompactInput.tsx +333 -0
- package/src/components/right-panel/design/inputs/DraggableLabel.tsx +118 -0
- package/src/components/right-panel/design/inputs/IconToggleGroup.tsx +54 -0
- package/src/components/right-panel/design/inputs/LinkedInputPair.tsx +174 -0
- package/src/components/right-panel/design/inputs/SectionHeader.tsx +79 -0
- package/src/components/right-panel/variables/VariablesPanel.tsx +388 -0
- package/src/hooks/useBridge.ts +95 -0
- package/src/hooks/useChangeTracker.ts +563 -0
- package/src/hooks/useClaudeAPI.ts +118 -0
- package/src/hooks/useDOMTree.ts +25 -0
- package/src/hooks/useKeyboardShortcuts.ts +76 -0
- package/src/hooks/usePostMessage.ts +589 -0
- package/src/hooks/useProjectScan.ts +204 -0
- package/src/hooks/useResizable.ts +20 -0
- package/src/hooks/useSelectedElement.ts +51 -0
- package/src/hooks/useTargetUrl.ts +81 -0
- package/src/inspector/DOMTraverser.ts +71 -0
- package/src/inspector/ElementSelector.ts +23 -0
- package/src/inspector/HoverHighlighter.ts +54 -0
- package/src/inspector/SelectionHighlighter.ts +27 -0
- package/src/inspector/StyleExtractor.ts +19 -0
- package/src/inspector/inspector.ts +17 -0
- package/src/inspector/messaging.ts +30 -0
- package/src/lib/apiBase.ts +15 -0
- package/src/lib/classifyElement.ts +430 -0
- package/src/lib/claude-bin.ts +197 -0
- package/src/lib/claude-stream.ts +158 -0
- package/src/lib/clientProjectScanner.ts +344 -0
- package/src/lib/componentMatcher.ts +156 -0
- package/src/lib/constants.ts +573 -0
- package/src/lib/cssVariableUtils.ts +409 -0
- package/src/lib/diffParser.ts +206 -0
- package/src/lib/folderPicker.ts +84 -0
- package/src/lib/gradientParser.ts +160 -0
- package/src/lib/projectScanner.ts +355 -0
- package/src/lib/promptBuilder.ts +402 -0
- package/src/lib/shadowParser.ts +124 -0
- package/src/lib/tailwindClassParser.ts +248 -0
- package/src/lib/textShadowUtils.ts +106 -0
- package/src/lib/utils.ts +299 -0
- package/src/lib/validatePath.ts +40 -0
- package/src/proxy.ts +92 -0
- package/src/server/terminal-server.ts +104 -0
- package/src/store/changeSlice.ts +288 -0
- package/src/store/claudeSlice.ts +222 -0
- package/src/store/componentSlice.ts +90 -0
- package/src/store/consoleSlice.ts +51 -0
- package/src/store/cssVariableSlice.ts +94 -0
- package/src/store/elementSlice.ts +78 -0
- package/src/store/index.ts +35 -0
- package/src/store/terminalSlice.ts +30 -0
- package/src/store/treeSlice.ts +69 -0
- package/src/store/uiSlice.ts +327 -0
- package/src/types/changelog.ts +49 -0
- package/src/types/claude.ts +131 -0
- package/src/types/component.ts +49 -0
- package/src/types/cssVariables.ts +18 -0
- package/src/types/element.ts +21 -0
- package/src/types/file-system-access.d.ts +27 -0
- package/src/types/gradient.ts +12 -0
- package/src/types/messages.ts +392 -0
- package/src/types/shadow.ts +8 -0
- package/src/types/tree.ts +9 -0
- package/tsconfig.json +42 -0
- package/tsconfig.server.json +12 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Suspense, useCallback } from 'react'
|
|
4
|
+
import { useEditorStore } from '@/store'
|
|
5
|
+
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
|
|
6
|
+
import { usePostMessage } from '@/hooks/usePostMessage'
|
|
7
|
+
import { useBridge } from '@/hooks/useBridge'
|
|
8
|
+
import { TopBar } from './TopBar'
|
|
9
|
+
import { LeftPanel } from './left-panel/LeftPanel'
|
|
10
|
+
import { RightPanel } from './right-panel/RightPanel'
|
|
11
|
+
import { PreviewFrame } from './PreviewFrame'
|
|
12
|
+
import { ConnectModal } from './ConnectModal'
|
|
13
|
+
import { ErrorBoundary } from './common/ErrorBoundary'
|
|
14
|
+
import { ToastContainer } from './common/ToastContainer'
|
|
15
|
+
|
|
16
|
+
function PanelLoading() {
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
className="flex items-center justify-center h-full"
|
|
20
|
+
style={{ background: 'var(--bg-secondary)' }}
|
|
21
|
+
>
|
|
22
|
+
<div className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
23
|
+
Loading...
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Editor() {
|
|
30
|
+
const targetUrl = useEditorStore((s) => s.targetUrl)
|
|
31
|
+
const connectionStatus = useEditorStore((s) => s.connectionStatus)
|
|
32
|
+
const rightPanelOpen = useEditorStore((s) => s.rightPanelOpen)
|
|
33
|
+
const leftPanelWidth = useEditorStore((s) => s.leftPanelWidth)
|
|
34
|
+
const rightPanelWidth = useEditorStore((s) => s.rightPanelWidth)
|
|
35
|
+
const { sendToInspector } = usePostMessage()
|
|
36
|
+
|
|
37
|
+
useKeyboardShortcuts()
|
|
38
|
+
useBridge()
|
|
39
|
+
|
|
40
|
+
// Hide iframe hover overlay when interacting with any panel outside the canvas
|
|
41
|
+
const hideHover = useCallback(() => {
|
|
42
|
+
sendToInspector({ type: 'HIDE_HOVER' })
|
|
43
|
+
}, [sendToInspector])
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
className="flex flex-col h-screen"
|
|
48
|
+
style={{ background: 'var(--bg-primary)' }}
|
|
49
|
+
>
|
|
50
|
+
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
|
51
|
+
<div onMouseDown={hideHover} onMouseEnter={hideHover}>
|
|
52
|
+
<TopBar />
|
|
53
|
+
</div>
|
|
54
|
+
<div className="flex flex-1 overflow-hidden">
|
|
55
|
+
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
|
56
|
+
<div onMouseDown={hideHover} onMouseEnter={hideHover} className="flex">
|
|
57
|
+
<ErrorBoundary panelName="Layers panel">
|
|
58
|
+
<Suspense fallback={<PanelLoading />}>
|
|
59
|
+
<LeftPanel width={leftPanelWidth} />
|
|
60
|
+
</Suspense>
|
|
61
|
+
</ErrorBoundary>
|
|
62
|
+
</div>
|
|
63
|
+
<div className="flex-1 min-w-0 relative">
|
|
64
|
+
<ErrorBoundary panelName="Preview">
|
|
65
|
+
<PreviewFrame />
|
|
66
|
+
</ErrorBoundary>
|
|
67
|
+
</div>
|
|
68
|
+
{rightPanelOpen && (
|
|
69
|
+
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
|
70
|
+
<div
|
|
71
|
+
onMouseDown={hideHover}
|
|
72
|
+
onMouseEnter={hideHover}
|
|
73
|
+
className="flex"
|
|
74
|
+
>
|
|
75
|
+
<ErrorBoundary panelName="Design panel">
|
|
76
|
+
<Suspense fallback={<PanelLoading />}>
|
|
77
|
+
<RightPanel width={rightPanelWidth} />
|
|
78
|
+
</Suspense>
|
|
79
|
+
</ErrorBoundary>
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
<ToastContainer />
|
|
84
|
+
{(!targetUrl ||
|
|
85
|
+
['confirming', 'scanning', 'connecting'].includes(
|
|
86
|
+
connectionStatus,
|
|
87
|
+
)) && <ConnectModal />}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
4
|
+
import { useEditorStore } from '@/store'
|
|
5
|
+
import { sendViaIframe } from '@/hooks/usePostMessage'
|
|
6
|
+
|
|
7
|
+
export function PageSelector() {
|
|
8
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
9
|
+
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
10
|
+
const pageLinks = useEditorStore((s) => s.pageLinks)
|
|
11
|
+
const currentPagePath = useEditorStore((s) => s.currentPagePath)
|
|
12
|
+
const setCurrentPagePath = useEditorStore((s) => s.setCurrentPagePath)
|
|
13
|
+
const targetUrl = useEditorStore((s) => s.targetUrl)
|
|
14
|
+
const setConnectionStatus = useEditorStore((s) => s.setConnectionStatus)
|
|
15
|
+
|
|
16
|
+
// Close dropdown on outside click
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!isOpen) return
|
|
19
|
+
const handleClick = (e: MouseEvent) => {
|
|
20
|
+
if (
|
|
21
|
+
dropdownRef.current &&
|
|
22
|
+
!dropdownRef.current.contains(e.target as Node)
|
|
23
|
+
) {
|
|
24
|
+
setIsOpen(false)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
document.addEventListener('mousedown', handleClick)
|
|
28
|
+
return () => document.removeEventListener('mousedown', handleClick)
|
|
29
|
+
}, [isOpen])
|
|
30
|
+
|
|
31
|
+
const handleNavigate = useCallback(
|
|
32
|
+
(path: string) => {
|
|
33
|
+
if (!targetUrl) return
|
|
34
|
+
setCurrentPagePath(path)
|
|
35
|
+
sendViaIframe({ type: 'NAVIGATE_TO', payload: { path } })
|
|
36
|
+
setIsOpen(false)
|
|
37
|
+
},
|
|
38
|
+
[targetUrl, setCurrentPagePath],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const handleRefresh = useCallback(() => {
|
|
42
|
+
setConnectionStatus('connecting')
|
|
43
|
+
}, [setConnectionStatus])
|
|
44
|
+
|
|
45
|
+
// Build page list: always include "/" plus discovered links
|
|
46
|
+
const allPages = useRef<Array<{ href: string; text: string }>>([])
|
|
47
|
+
const seen = new Set<string>()
|
|
48
|
+
const pages: Array<{ href: string; text: string }> = []
|
|
49
|
+
|
|
50
|
+
// Always add root
|
|
51
|
+
pages.push({ href: '/', text: 'Home' })
|
|
52
|
+
seen.add('/')
|
|
53
|
+
|
|
54
|
+
// Add discovered links (deduplicated)
|
|
55
|
+
for (const link of pageLinks) {
|
|
56
|
+
if (!seen.has(link.href)) {
|
|
57
|
+
seen.add(link.href)
|
|
58
|
+
pages.push(link)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
allPages.current = pages
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div ref={dropdownRef} className="relative flex items-center gap-1">
|
|
65
|
+
{/* Page dropdown */}
|
|
66
|
+
<button
|
|
67
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
68
|
+
className="flex items-center gap-1.5 px-2 py-1 text-[11px] rounded transition-colors"
|
|
69
|
+
style={{
|
|
70
|
+
color: 'var(--text-secondary)',
|
|
71
|
+
background: isOpen ? 'var(--bg-hover)' : 'transparent',
|
|
72
|
+
}}
|
|
73
|
+
title="Navigate to another page"
|
|
74
|
+
>
|
|
75
|
+
<span className="truncate max-w-[140px] font-medium">
|
|
76
|
+
{currentPagePath}
|
|
77
|
+
</span>
|
|
78
|
+
<svg
|
|
79
|
+
width="8"
|
|
80
|
+
height="8"
|
|
81
|
+
viewBox="0 0 8 8"
|
|
82
|
+
fill="currentColor"
|
|
83
|
+
style={{
|
|
84
|
+
transform: isOpen ? 'rotate(180deg)' : 'none',
|
|
85
|
+
transition: 'transform 0.15s',
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
<path
|
|
89
|
+
d="M1 2.5L4 5.5L7 2.5"
|
|
90
|
+
stroke="currentColor"
|
|
91
|
+
strokeWidth="1.5"
|
|
92
|
+
fill="none"
|
|
93
|
+
strokeLinecap="round"
|
|
94
|
+
strokeLinejoin="round"
|
|
95
|
+
/>
|
|
96
|
+
</svg>
|
|
97
|
+
</button>
|
|
98
|
+
|
|
99
|
+
{/* Refresh button */}
|
|
100
|
+
<button
|
|
101
|
+
onClick={handleRefresh}
|
|
102
|
+
className="flex items-center justify-center w-6 h-6 rounded transition-colors hover:bg-[var(--bg-hover)]"
|
|
103
|
+
style={{ color: 'var(--text-muted)' }}
|
|
104
|
+
title="Refresh page"
|
|
105
|
+
>
|
|
106
|
+
<svg
|
|
107
|
+
width="12"
|
|
108
|
+
height="12"
|
|
109
|
+
viewBox="0 0 16 16"
|
|
110
|
+
fill="none"
|
|
111
|
+
stroke="currentColor"
|
|
112
|
+
strokeWidth="1.5"
|
|
113
|
+
strokeLinecap="round"
|
|
114
|
+
strokeLinejoin="round"
|
|
115
|
+
>
|
|
116
|
+
<path d="M1 1v5h5" />
|
|
117
|
+
<path d="M3.51 10a6 6 0 1 0 .49-5.5L1 6" />
|
|
118
|
+
</svg>
|
|
119
|
+
</button>
|
|
120
|
+
|
|
121
|
+
{/* Dropdown */}
|
|
122
|
+
{isOpen && (
|
|
123
|
+
<div
|
|
124
|
+
className="absolute top-full left-0 mt-1 w-60 max-h-72 overflow-y-auto rounded shadow-lg z-50"
|
|
125
|
+
style={{
|
|
126
|
+
background: 'var(--bg-secondary)',
|
|
127
|
+
border: '1px solid var(--border)',
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{/* Header */}
|
|
131
|
+
<div
|
|
132
|
+
className="px-3 py-1.5 text-[10px] font-medium uppercase tracking-wider"
|
|
133
|
+
style={{
|
|
134
|
+
color: 'var(--text-muted)',
|
|
135
|
+
borderBottom: '1px solid var(--border)',
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
Pages ({pages.length})
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{pages.map((page, i) => {
|
|
142
|
+
const isActive = page.href === currentPagePath
|
|
143
|
+
return (
|
|
144
|
+
<button
|
|
145
|
+
key={page.href}
|
|
146
|
+
onClick={() => handleNavigate(page.href)}
|
|
147
|
+
className="w-full text-left px-3 py-1.5 text-xs transition-colors flex items-center gap-2"
|
|
148
|
+
style={{
|
|
149
|
+
color: isActive ? 'var(--accent)' : 'var(--text-primary)',
|
|
150
|
+
background: isActive
|
|
151
|
+
? 'rgba(74, 158, 255, 0.08)'
|
|
152
|
+
: 'transparent',
|
|
153
|
+
borderBottom:
|
|
154
|
+
i < pages.length - 1 ? '1px solid var(--border)' : 'none',
|
|
155
|
+
}}
|
|
156
|
+
title={page.href}
|
|
157
|
+
>
|
|
158
|
+
{/* Page icon */}
|
|
159
|
+
<svg
|
|
160
|
+
width="12"
|
|
161
|
+
height="12"
|
|
162
|
+
viewBox="0 0 16 16"
|
|
163
|
+
fill="none"
|
|
164
|
+
stroke={isActive ? 'var(--accent)' : 'var(--text-muted)'}
|
|
165
|
+
strokeWidth="1.5"
|
|
166
|
+
strokeLinecap="round"
|
|
167
|
+
strokeLinejoin="round"
|
|
168
|
+
className="flex-shrink-0"
|
|
169
|
+
>
|
|
170
|
+
<rect x="2" y="2" width="12" height="12" rx="1.5" />
|
|
171
|
+
<path d="M2 5.5h12" />
|
|
172
|
+
</svg>
|
|
173
|
+
<div className="truncate flex-1">
|
|
174
|
+
<div className="truncate font-medium">
|
|
175
|
+
{page.text || page.href}
|
|
176
|
+
</div>
|
|
177
|
+
{page.text && page.href !== page.text && (
|
|
178
|
+
<div
|
|
179
|
+
className="truncate"
|
|
180
|
+
style={{ color: 'var(--text-muted)', fontSize: '10px' }}
|
|
181
|
+
>
|
|
182
|
+
{page.href}
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
{isActive && (
|
|
187
|
+
<div
|
|
188
|
+
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
|
189
|
+
style={{ background: 'var(--accent)' }}
|
|
190
|
+
/>
|
|
191
|
+
)}
|
|
192
|
+
</button>
|
|
193
|
+
)
|
|
194
|
+
})}
|
|
195
|
+
|
|
196
|
+
{pages.length <= 1 && (
|
|
197
|
+
<div
|
|
198
|
+
className="px-3 py-2 text-[10px] text-center"
|
|
199
|
+
style={{ color: 'var(--text-muted)' }}
|
|
200
|
+
>
|
|
201
|
+
No additional pages found
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useCallback, useState } from 'react'
|
|
4
|
+
import { useEditorStore } from '@/store'
|
|
5
|
+
import { usePostMessage, isEditorOnLocalhost } from '@/hooks/usePostMessage'
|
|
6
|
+
import {
|
|
7
|
+
PREVIEW_WIDTH_MIN,
|
|
8
|
+
PREVIEW_WIDTH_MAX,
|
|
9
|
+
PROXY_HEADER,
|
|
10
|
+
} from '@/lib/constants'
|
|
11
|
+
import { ResponsiveToolbar } from './ResponsiveToolbar'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build the proxy URL for the iframe. Routes through /api/proxy/
|
|
15
|
+
* so the proxy can inject the inspector script and strip security
|
|
16
|
+
* headers (COEP, CSP, X-Frame-Options) that would block the editor.
|
|
17
|
+
* Used only when the editor runs on localhost.
|
|
18
|
+
*/
|
|
19
|
+
function buildProxyUrl(targetUrl: string, pagePath: string): string {
|
|
20
|
+
const path = pagePath === '/' ? '' : pagePath
|
|
21
|
+
const encoded = encodeURIComponent(targetUrl)
|
|
22
|
+
return `/api/proxy${path}?${PROXY_HEADER}=${encoded}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build the bridge proxy URL for the iframe. Routes through the bridge server
|
|
27
|
+
* running on the user's machine. The bridge injects the inspector script
|
|
28
|
+
* and strips security headers, just like the local proxy.
|
|
29
|
+
* Used when the editor is deployed remotely and a bridge is connected.
|
|
30
|
+
*/
|
|
31
|
+
function buildBridgeUrl(
|
|
32
|
+
bridgeUrl: string,
|
|
33
|
+
targetUrl: string,
|
|
34
|
+
pagePath: string,
|
|
35
|
+
): string {
|
|
36
|
+
const path = pagePath === '/' ? '' : pagePath
|
|
37
|
+
const encoded = encodeURIComponent(targetUrl)
|
|
38
|
+
return `${bridgeUrl}${path}?${PROXY_HEADER}=${encoded}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the direct URL for the iframe. Loads the target page directly
|
|
43
|
+
* without the proxy. Used when the editor is deployed remotely (e.g. Vercel)
|
|
44
|
+
* and can't proxy to the user's localhost.
|
|
45
|
+
* Requires the user to manually add the inspector script tag to their project.
|
|
46
|
+
*/
|
|
47
|
+
function buildDirectUrl(targetUrl: string, pagePath: string): string {
|
|
48
|
+
const path = pagePath === '/' ? '' : pagePath
|
|
49
|
+
return `${targetUrl}${path}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the appropriate iframe URL based on whether the editor is local,
|
|
54
|
+
* has a bridge connection, or is remote without bridge.
|
|
55
|
+
*/
|
|
56
|
+
function buildIframeUrl(targetUrl: string, pagePath: string): string {
|
|
57
|
+
if (isEditorOnLocalhost()) {
|
|
58
|
+
return buildProxyUrl(targetUrl, pagePath)
|
|
59
|
+
}
|
|
60
|
+
// Check for bridge connection
|
|
61
|
+
const bridgeUrl = useEditorStore.getState().bridgeUrl
|
|
62
|
+
if (bridgeUrl) {
|
|
63
|
+
return buildBridgeUrl(bridgeUrl, targetUrl, pagePath)
|
|
64
|
+
}
|
|
65
|
+
return buildDirectUrl(targetUrl, pagePath)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function PreviewFrame() {
|
|
69
|
+
const targetUrl = useEditorStore((s) => s.targetUrl)
|
|
70
|
+
const connectionStatus = useEditorStore((s) => s.connectionStatus)
|
|
71
|
+
const previewWidth = useEditorStore((s) => s.previewWidth)
|
|
72
|
+
const setPreviewWidth = useEditorStore((s) => s.setPreviewWidth)
|
|
73
|
+
const currentPagePath = useEditorStore((s) => s.currentPagePath)
|
|
74
|
+
const setConnectionStatus = useEditorStore((s) => s.setConnectionStatus)
|
|
75
|
+
const viewMode = useEditorStore((s) => s.viewMode)
|
|
76
|
+
const { iframeRef, sendToInspector } = usePostMessage()
|
|
77
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
78
|
+
const lastSrcRef = useRef<string | null>(null)
|
|
79
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
80
|
+
|
|
81
|
+
// Handle initial connection — load through proxy
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!targetUrl || connectionStatus !== 'connecting') return
|
|
84
|
+
|
|
85
|
+
const iframe = iframeRef.current
|
|
86
|
+
if (!iframe) return
|
|
87
|
+
|
|
88
|
+
const newSrc = buildIframeUrl(targetUrl, currentPagePath)
|
|
89
|
+
|
|
90
|
+
if (lastSrcRef.current !== newSrc) {
|
|
91
|
+
lastSrcRef.current = newSrc
|
|
92
|
+
iframe.src = newSrc
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const handleError = () => {
|
|
96
|
+
setConnectionStatus('disconnected')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
iframe.addEventListener('error', handleError)
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
iframe.removeEventListener('error', handleError)
|
|
103
|
+
}
|
|
104
|
+
}, [
|
|
105
|
+
targetUrl,
|
|
106
|
+
connectionStatus,
|
|
107
|
+
currentPagePath,
|
|
108
|
+
iframeRef,
|
|
109
|
+
setConnectionStatus,
|
|
110
|
+
])
|
|
111
|
+
|
|
112
|
+
// Handle page navigation when already connected
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!targetUrl || connectionStatus !== 'connected') return
|
|
115
|
+
|
|
116
|
+
const iframe = iframeRef.current
|
|
117
|
+
if (!iframe) return
|
|
118
|
+
|
|
119
|
+
const newSrc = buildIframeUrl(targetUrl, currentPagePath)
|
|
120
|
+
|
|
121
|
+
if (lastSrcRef.current !== newSrc) {
|
|
122
|
+
lastSrcRef.current = newSrc
|
|
123
|
+
iframe.src = newSrc
|
|
124
|
+
}
|
|
125
|
+
}, [targetUrl, connectionStatus, currentPagePath, iframeRef])
|
|
126
|
+
|
|
127
|
+
// Preview mode — stay on proxy URL but disable inspector overlays.
|
|
128
|
+
// Previously this switched to the direct URL for full JS interactivity,
|
|
129
|
+
// but that breaks when the target page embeds the external inspector script
|
|
130
|
+
// (e.g. from Vercel): the external inspector starts with selection ON and
|
|
131
|
+
// can't receive SET_SELECTION_MODE:false due to cross-origin postMessage
|
|
132
|
+
// restrictions. Keeping the proxy URL ensures the only inspector running
|
|
133
|
+
// is the proxy-injected one (same-origin, fully controllable).
|
|
134
|
+
// The proxy already preserves enough scripts for most apps (Expo/RN Web
|
|
135
|
+
// bundles load fine through the proxy).
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!targetUrl || connectionStatus !== 'connected') return
|
|
138
|
+
|
|
139
|
+
// Selection mode is managed by TopBar via sendToInspector.
|
|
140
|
+
// When exiting preview, TopBar re-enables selection and the proxy
|
|
141
|
+
// iframe is still loaded — no reload needed.
|
|
142
|
+
}, [viewMode, targetUrl, connectionStatus, currentPagePath, iframeRef])
|
|
143
|
+
|
|
144
|
+
// Drag resize logic — symmetric from center
|
|
145
|
+
const dragStateRef = useRef<{
|
|
146
|
+
startX: number
|
|
147
|
+
startWidth: number
|
|
148
|
+
side: 'left' | 'right'
|
|
149
|
+
} | null>(null)
|
|
150
|
+
|
|
151
|
+
const handleDragStart = useCallback(
|
|
152
|
+
(e: React.MouseEvent, side: 'left' | 'right') => {
|
|
153
|
+
e.preventDefault()
|
|
154
|
+
dragStateRef.current = {
|
|
155
|
+
startX: e.clientX,
|
|
156
|
+
startWidth: previewWidth,
|
|
157
|
+
side,
|
|
158
|
+
}
|
|
159
|
+
setIsDragging(true)
|
|
160
|
+
},
|
|
161
|
+
[previewWidth],
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (!isDragging) return
|
|
166
|
+
|
|
167
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
168
|
+
const state = dragStateRef.current
|
|
169
|
+
if (!state) return
|
|
170
|
+
const delta = e.clientX - state.startX
|
|
171
|
+
// Symmetric: dragging right handle right = wider, left handle left = wider
|
|
172
|
+
const direction = state.side === 'right' ? 1 : -1
|
|
173
|
+
const newWidth = Math.round(state.startWidth + delta * direction * 2)
|
|
174
|
+
const clamped = Math.min(
|
|
175
|
+
Math.max(newWidth, PREVIEW_WIDTH_MIN),
|
|
176
|
+
PREVIEW_WIDTH_MAX,
|
|
177
|
+
)
|
|
178
|
+
setPreviewWidth(clamped)
|
|
179
|
+
sendToInspector({ type: 'SET_BREAKPOINT', payload: { width: clamped } })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const handleMouseUp = () => {
|
|
183
|
+
dragStateRef.current = null
|
|
184
|
+
setIsDragging(false)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
document.addEventListener('mousemove', handleMouseMove)
|
|
188
|
+
document.addEventListener('mouseup', handleMouseUp)
|
|
189
|
+
return () => {
|
|
190
|
+
document.removeEventListener('mousemove', handleMouseMove)
|
|
191
|
+
document.removeEventListener('mouseup', handleMouseUp)
|
|
192
|
+
}
|
|
193
|
+
}, [isDragging, setPreviewWidth, sendToInspector])
|
|
194
|
+
|
|
195
|
+
// Check if preview fills container (no handles needed)
|
|
196
|
+
const containerWidth = containerRef.current?.clientWidth ?? 0
|
|
197
|
+
const isFullWidth = previewWidth >= containerWidth && containerWidth > 0
|
|
198
|
+
|
|
199
|
+
if (!targetUrl) {
|
|
200
|
+
return (
|
|
201
|
+
<div
|
|
202
|
+
className="flex items-center justify-center h-full"
|
|
203
|
+
style={{ background: 'var(--bg-primary)' }}
|
|
204
|
+
>
|
|
205
|
+
<div className="text-center">
|
|
206
|
+
<div
|
|
207
|
+
className="text-lg font-medium mb-2"
|
|
208
|
+
style={{ color: 'var(--text-secondary)' }}
|
|
209
|
+
>
|
|
210
|
+
No project connected
|
|
211
|
+
</div>
|
|
212
|
+
<div className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
|
213
|
+
Enter a localhost URL above to get started
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const showHandles = !isFullWidth && connectionStatus === 'connected'
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div
|
|
224
|
+
className="flex flex-col h-full"
|
|
225
|
+
style={{ background: 'var(--bg-primary)' }}
|
|
226
|
+
>
|
|
227
|
+
{connectionStatus === 'connected' && <ResponsiveToolbar />}
|
|
228
|
+
|
|
229
|
+
<div
|
|
230
|
+
ref={containerRef}
|
|
231
|
+
className="flex items-start justify-center flex-1 overflow-auto relative"
|
|
232
|
+
style={{ padding: '0' }}
|
|
233
|
+
>
|
|
234
|
+
{/* Left drag handle */}
|
|
235
|
+
{showHandles && (
|
|
236
|
+
<div
|
|
237
|
+
onMouseDown={(e) => handleDragStart(e, 'left')}
|
|
238
|
+
className="absolute top-0 bottom-0 z-10 flex items-center justify-center"
|
|
239
|
+
style={{
|
|
240
|
+
width: 6,
|
|
241
|
+
left: `calc(50% - ${previewWidth / 2}px - 6px)`,
|
|
242
|
+
cursor: 'col-resize',
|
|
243
|
+
}}
|
|
244
|
+
>
|
|
245
|
+
<div
|
|
246
|
+
className="w-1 rounded-full transition-colors"
|
|
247
|
+
style={{
|
|
248
|
+
height: 40,
|
|
249
|
+
background: isDragging ? 'var(--accent)' : 'var(--border)',
|
|
250
|
+
}}
|
|
251
|
+
/>
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
<div
|
|
256
|
+
className="h-full mx-auto"
|
|
257
|
+
style={{
|
|
258
|
+
width: isFullWidth ? '100%' : previewWidth,
|
|
259
|
+
maxWidth: '100%',
|
|
260
|
+
transition: isDragging ? 'none' : 'width 0.2s ease',
|
|
261
|
+
}}
|
|
262
|
+
>
|
|
263
|
+
<iframe
|
|
264
|
+
ref={iframeRef}
|
|
265
|
+
className="w-full h-full border-0"
|
|
266
|
+
style={{
|
|
267
|
+
background: '#fff',
|
|
268
|
+
pointerEvents: isDragging ? 'none' : 'auto',
|
|
269
|
+
borderLeft: !isFullWidth ? '1px solid var(--border)' : 'none',
|
|
270
|
+
borderRight: !isFullWidth ? '1px solid var(--border)' : 'none',
|
|
271
|
+
}}
|
|
272
|
+
title="Preview"
|
|
273
|
+
/>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
{/* Right drag handle */}
|
|
277
|
+
{showHandles && (
|
|
278
|
+
<div
|
|
279
|
+
onMouseDown={(e) => handleDragStart(e, 'right')}
|
|
280
|
+
className="absolute top-0 bottom-0 z-10 flex items-center justify-center"
|
|
281
|
+
style={{
|
|
282
|
+
width: 6,
|
|
283
|
+
right: `calc(50% - ${previewWidth / 2}px - 6px)`,
|
|
284
|
+
cursor: 'col-resize',
|
|
285
|
+
}}
|
|
286
|
+
>
|
|
287
|
+
<div
|
|
288
|
+
className="w-1 rounded-full transition-colors"
|
|
289
|
+
style={{
|
|
290
|
+
height: 40,
|
|
291
|
+
background: isDragging ? 'var(--accent)' : 'var(--border)',
|
|
292
|
+
}}
|
|
293
|
+
/>
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
)
|
|
299
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { useEditorStore } from '@/store'
|
|
5
|
+
|
|
6
|
+
export function ProjectFolderBanner() {
|
|
7
|
+
const connectionStatus = useEditorStore((s) => s.connectionStatus)
|
|
8
|
+
const targetUrl = useEditorStore((s) => s.targetUrl)
|
|
9
|
+
const portRoots = useEditorStore((s) => s.portRoots)
|
|
10
|
+
const scanStatus = useEditorStore((s) => s.scanStatus)
|
|
11
|
+
const scanError = useEditorStore((s) => s.scanError)
|
|
12
|
+
const scannedProjectName = useEditorStore((s) => s.scannedProjectName)
|
|
13
|
+
const componentFileMap = useEditorStore((s) => s.componentFileMap)
|
|
14
|
+
|
|
15
|
+
const [dismissed, setDismissed] = useState(false)
|
|
16
|
+
|
|
17
|
+
const hasProjectRoot = targetUrl ? !!portRoots[targetUrl] : false
|
|
18
|
+
|
|
19
|
+
// Don't show if not connected or dismissed
|
|
20
|
+
if (connectionStatus !== 'connected' || dismissed) return null
|
|
21
|
+
|
|
22
|
+
// Show scan results feedback if project root is set
|
|
23
|
+
if (hasProjectRoot && scanStatus === 'complete' && componentFileMap) {
|
|
24
|
+
const count = Object.keys(componentFileMap).length
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className="flex items-center justify-between px-3 py-1.5 text-xs"
|
|
28
|
+
style={{
|
|
29
|
+
background:
|
|
30
|
+
count > 0 ? 'rgba(74, 222, 128, 0.08)' : 'rgba(251, 191, 36, 0.08)',
|
|
31
|
+
borderBottom: '1px solid var(--border)',
|
|
32
|
+
color: count > 0 ? 'var(--success)' : 'var(--warning)',
|
|
33
|
+
}}
|
|
34
|
+
>
|
|
35
|
+
<span>
|
|
36
|
+
{count > 0
|
|
37
|
+
? `Found ${count} components in ${scannedProjectName || 'project'}`
|
|
38
|
+
: `No component files found — check project folder`}
|
|
39
|
+
</span>
|
|
40
|
+
<button
|
|
41
|
+
onClick={() => setDismissed(true)}
|
|
42
|
+
className="ml-2 px-1.5 py-0.5 rounded text-[10px] hover:bg-[var(--bg-hover)] transition-colors"
|
|
43
|
+
style={{ color: 'var(--text-muted)' }}
|
|
44
|
+
>
|
|
45
|
+
Dismiss
|
|
46
|
+
</button>
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Show scan error
|
|
52
|
+
if (hasProjectRoot && scanStatus === 'error') {
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
className="flex items-center justify-between px-3 py-1.5 text-xs"
|
|
56
|
+
style={{
|
|
57
|
+
background: 'rgba(248, 113, 113, 0.08)',
|
|
58
|
+
borderBottom: '1px solid var(--border)',
|
|
59
|
+
color: 'var(--error)',
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
<span>{scanError || 'Scan failed'}</span>
|
|
63
|
+
<button
|
|
64
|
+
onClick={() => setDismissed(true)}
|
|
65
|
+
className="ml-2 px-1.5 py-0.5 rounded text-[10px] hover:bg-[var(--bg-hover)] transition-colors"
|
|
66
|
+
style={{ color: 'var(--text-muted)' }}
|
|
67
|
+
>
|
|
68
|
+
Dismiss
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Show scanning indicator
|
|
75
|
+
if (hasProjectRoot && scanStatus === 'scanning') {
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
className="flex items-center px-3 py-1.5 text-xs"
|
|
79
|
+
style={{
|
|
80
|
+
background: 'rgba(74, 158, 255, 0.06)',
|
|
81
|
+
borderBottom: '1px solid var(--border)',
|
|
82
|
+
color: 'var(--text-secondary)',
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
Scanning project for components...
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return null
|
|
91
|
+
}
|