@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.
Files changed (158) hide show
  1. package/LICENSE +178 -0
  2. package/NOTICE +4 -0
  3. package/README.md +180 -0
  4. package/bin/paint.js +266 -0
  5. package/next-env.d.ts +6 -0
  6. package/next.config.ts +19 -0
  7. package/package.json +81 -0
  8. package/postcss.config.mjs +8 -0
  9. package/public/dev-editor-inspector.js +1872 -0
  10. package/src/app/api/claude/analyze/route.ts +319 -0
  11. package/src/app/api/claude/apply/route.ts +185 -0
  12. package/src/app/api/claude/pick-folder/route.ts +64 -0
  13. package/src/app/api/claude/scan/route.ts +221 -0
  14. package/src/app/api/claude/status/route.ts +55 -0
  15. package/src/app/api/project/scan/route.ts +634 -0
  16. package/src/app/api/project-scan/css-variables/route.ts +238 -0
  17. package/src/app/api/project-scan/route.ts +40 -0
  18. package/src/app/api/project-scan/tailwind-config/route.ts +172 -0
  19. package/src/app/api/proxy/[[...path]]/route.ts +2400 -0
  20. package/src/app/docs/DocsClient.tsx +322 -0
  21. package/src/app/docs/layout.tsx +7 -0
  22. package/src/app/docs/page.tsx +855 -0
  23. package/src/app/globals.css +176 -0
  24. package/src/app/layout.tsx +19 -0
  25. package/src/app/page.tsx +46 -0
  26. package/src/bridge/api-handlers.ts +885 -0
  27. package/src/bridge/proxy-handler.ts +329 -0
  28. package/src/bridge/server.ts +113 -0
  29. package/src/components/BreakpointTabs.tsx +72 -0
  30. package/src/components/ChangeSummaryModal.tsx +267 -0
  31. package/src/components/ConnectModal.tsx +994 -0
  32. package/src/components/Editor.tsx +90 -0
  33. package/src/components/PageSelector.tsx +208 -0
  34. package/src/components/PreviewFrame.tsx +299 -0
  35. package/src/components/ProjectFolderBanner.tsx +91 -0
  36. package/src/components/ResponsiveToolbar.tsx +222 -0
  37. package/src/components/TargetSelector.tsx +243 -0
  38. package/src/components/TopBar.tsx +315 -0
  39. package/src/components/common/CollapsibleSection.tsx +36 -0
  40. package/src/components/common/ColorPicker.tsx +920 -0
  41. package/src/components/common/EditablePre.tsx +136 -0
  42. package/src/components/common/ErrorBoundary.tsx +65 -0
  43. package/src/components/common/ResizablePanel.tsx +83 -0
  44. package/src/components/common/ScanAnimation.tsx +76 -0
  45. package/src/components/common/ToastContainer.tsx +97 -0
  46. package/src/components/common/UnitInput.tsx +77 -0
  47. package/src/components/common/VariableColorPicker.tsx +622 -0
  48. package/src/components/left-panel/AddElementPanel.tsx +237 -0
  49. package/src/components/left-panel/ComponentsPanel.tsx +609 -0
  50. package/src/components/left-panel/IconSidebar.tsx +99 -0
  51. package/src/components/left-panel/LayerNode.tsx +874 -0
  52. package/src/components/left-panel/LayerSearch.tsx +23 -0
  53. package/src/components/left-panel/LayersPanel.tsx +52 -0
  54. package/src/components/left-panel/LeftPanel.tsx +122 -0
  55. package/src/components/left-panel/PagesPanel.tsx +114 -0
  56. package/src/components/left-panel/icons.tsx +162 -0
  57. package/src/components/left-panel/terminal/ScanOverlay.tsx +66 -0
  58. package/src/components/left-panel/terminal/TerminalPanel.tsx +217 -0
  59. package/src/components/right-panel/ElementLogBox.tsx +248 -0
  60. package/src/components/right-panel/PanelTabs.tsx +83 -0
  61. package/src/components/right-panel/RightPanel.tsx +41 -0
  62. package/src/components/right-panel/changes/AiScanResultPanel.tsx +285 -0
  63. package/src/components/right-panel/changes/ChangeEntry.tsx +59 -0
  64. package/src/components/right-panel/changes/ChangelogActions.tsx +105 -0
  65. package/src/components/right-panel/changes/ChangesPanel.tsx +1474 -0
  66. package/src/components/right-panel/claude/ApplyConfirmModal.tsx +376 -0
  67. package/src/components/right-panel/claude/ClaudeErrorState.tsx +125 -0
  68. package/src/components/right-panel/claude/ClaudeIntegrationPanel.tsx +482 -0
  69. package/src/components/right-panel/claude/ClaudeProgressIndicator.tsx +76 -0
  70. package/src/components/right-panel/claude/DiffCard.tsx +130 -0
  71. package/src/components/right-panel/claude/DiffViewer.tsx +54 -0
  72. package/src/components/right-panel/claude/ProjectRootSelector.tsx +275 -0
  73. package/src/components/right-panel/claude/ResultsSummary.tsx +119 -0
  74. package/src/components/right-panel/claude/SetupFlow.tsx +315 -0
  75. package/src/components/right-panel/console/ConsolePanel.tsx +209 -0
  76. package/src/components/right-panel/design/AppearanceSection.tsx +703 -0
  77. package/src/components/right-panel/design/BackgroundSection.tsx +516 -0
  78. package/src/components/right-panel/design/BorderSection.tsx +161 -0
  79. package/src/components/right-panel/design/CSSRawView.tsx +412 -0
  80. package/src/components/right-panel/design/DesignCSSTabToggle.tsx +51 -0
  81. package/src/components/right-panel/design/DesignPanel.tsx +275 -0
  82. package/src/components/right-panel/design/ElementBreadcrumb.tsx +51 -0
  83. package/src/components/right-panel/design/GradientEditor.tsx +726 -0
  84. package/src/components/right-panel/design/LayoutSection.tsx +1948 -0
  85. package/src/components/right-panel/design/PositionSection.tsx +865 -0
  86. package/src/components/right-panel/design/PropertiesSection.tsx +86 -0
  87. package/src/components/right-panel/design/SVGSection.tsx +361 -0
  88. package/src/components/right-panel/design/ShadowBlurSection.tsx +227 -0
  89. package/src/components/right-panel/design/SizeSection.tsx +183 -0
  90. package/src/components/right-panel/design/TextSection.tsx +719 -0
  91. package/src/components/right-panel/design/icons.tsx +948 -0
  92. package/src/components/right-panel/design/inputs/BoxModelPreview.tsx +467 -0
  93. package/src/components/right-panel/design/inputs/ColorInput.tsx +43 -0
  94. package/src/components/right-panel/design/inputs/CompactInput.tsx +333 -0
  95. package/src/components/right-panel/design/inputs/DraggableLabel.tsx +118 -0
  96. package/src/components/right-panel/design/inputs/IconToggleGroup.tsx +54 -0
  97. package/src/components/right-panel/design/inputs/LinkedInputPair.tsx +174 -0
  98. package/src/components/right-panel/design/inputs/SectionHeader.tsx +79 -0
  99. package/src/components/right-panel/variables/VariablesPanel.tsx +388 -0
  100. package/src/hooks/useBridge.ts +95 -0
  101. package/src/hooks/useChangeTracker.ts +563 -0
  102. package/src/hooks/useClaudeAPI.ts +118 -0
  103. package/src/hooks/useDOMTree.ts +25 -0
  104. package/src/hooks/useKeyboardShortcuts.ts +76 -0
  105. package/src/hooks/usePostMessage.ts +589 -0
  106. package/src/hooks/useProjectScan.ts +204 -0
  107. package/src/hooks/useResizable.ts +20 -0
  108. package/src/hooks/useSelectedElement.ts +51 -0
  109. package/src/hooks/useTargetUrl.ts +81 -0
  110. package/src/inspector/DOMTraverser.ts +71 -0
  111. package/src/inspector/ElementSelector.ts +23 -0
  112. package/src/inspector/HoverHighlighter.ts +54 -0
  113. package/src/inspector/SelectionHighlighter.ts +27 -0
  114. package/src/inspector/StyleExtractor.ts +19 -0
  115. package/src/inspector/inspector.ts +17 -0
  116. package/src/inspector/messaging.ts +30 -0
  117. package/src/lib/apiBase.ts +15 -0
  118. package/src/lib/classifyElement.ts +430 -0
  119. package/src/lib/claude-bin.ts +197 -0
  120. package/src/lib/claude-stream.ts +158 -0
  121. package/src/lib/clientProjectScanner.ts +344 -0
  122. package/src/lib/componentMatcher.ts +156 -0
  123. package/src/lib/constants.ts +573 -0
  124. package/src/lib/cssVariableUtils.ts +409 -0
  125. package/src/lib/diffParser.ts +206 -0
  126. package/src/lib/folderPicker.ts +84 -0
  127. package/src/lib/gradientParser.ts +160 -0
  128. package/src/lib/projectScanner.ts +355 -0
  129. package/src/lib/promptBuilder.ts +402 -0
  130. package/src/lib/shadowParser.ts +124 -0
  131. package/src/lib/tailwindClassParser.ts +248 -0
  132. package/src/lib/textShadowUtils.ts +106 -0
  133. package/src/lib/utils.ts +299 -0
  134. package/src/lib/validatePath.ts +40 -0
  135. package/src/proxy.ts +92 -0
  136. package/src/server/terminal-server.ts +104 -0
  137. package/src/store/changeSlice.ts +288 -0
  138. package/src/store/claudeSlice.ts +222 -0
  139. package/src/store/componentSlice.ts +90 -0
  140. package/src/store/consoleSlice.ts +51 -0
  141. package/src/store/cssVariableSlice.ts +94 -0
  142. package/src/store/elementSlice.ts +78 -0
  143. package/src/store/index.ts +35 -0
  144. package/src/store/terminalSlice.ts +30 -0
  145. package/src/store/treeSlice.ts +69 -0
  146. package/src/store/uiSlice.ts +327 -0
  147. package/src/types/changelog.ts +49 -0
  148. package/src/types/claude.ts +131 -0
  149. package/src/types/component.ts +49 -0
  150. package/src/types/cssVariables.ts +18 -0
  151. package/src/types/element.ts +21 -0
  152. package/src/types/file-system-access.d.ts +27 -0
  153. package/src/types/gradient.ts +12 -0
  154. package/src/types/messages.ts +392 -0
  155. package/src/types/shadow.ts +8 -0
  156. package/src/types/tree.ts +9 -0
  157. package/tsconfig.json +42 -0
  158. package/tsconfig.server.json +12 -0
@@ -0,0 +1,136 @@
1
+ 'use client'
2
+
3
+ import { useState, useRef, useEffect, useCallback } from 'react'
4
+
5
+ /**
6
+ * A <pre> block that becomes an editable <textarea> on double-click.
7
+ * Returns the current (possibly edited) text via `onTextChange` so
8
+ * parent components can use the edited version for copy operations.
9
+ */
10
+ export function EditablePre({
11
+ text,
12
+ onTextChange,
13
+ className = '',
14
+ style,
15
+ }: {
16
+ text: string
17
+ onTextChange?: (edited: string) => void
18
+ className?: string
19
+ style?: React.CSSProperties
20
+ }) {
21
+ const [editing, setEditing] = useState(false)
22
+ const [localText, setLocalText] = useState(text)
23
+ const [modified, setModified] = useState(false)
24
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
25
+
26
+ // When the source text changes and user hasn't edited, sync
27
+ useEffect(() => {
28
+ if (!modified) {
29
+ setLocalText(text)
30
+ }
31
+ }, [text, modified])
32
+
33
+ // Auto-focus and select when entering edit mode
34
+ useEffect(() => {
35
+ if (editing && textareaRef.current) {
36
+ textareaRef.current.focus()
37
+ }
38
+ }, [editing])
39
+
40
+ const handleDoubleClick = useCallback(() => {
41
+ if (!editing) {
42
+ // If not yet modified, reset to latest computed text
43
+ if (!modified) setLocalText(text)
44
+ setEditing(true)
45
+ }
46
+ }, [editing, modified, text])
47
+
48
+ const handleBlur = useCallback(() => {
49
+ setEditing(false)
50
+ }, [])
51
+
52
+ const handleChange = useCallback(
53
+ (e: React.ChangeEvent<HTMLTextAreaElement>) => {
54
+ const val = e.target.value
55
+ setLocalText(val)
56
+ setModified(true)
57
+ onTextChange?.(val)
58
+ },
59
+ [onTextChange],
60
+ )
61
+
62
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
63
+ if (e.key === 'Escape') {
64
+ setEditing(false)
65
+ }
66
+ }, [])
67
+
68
+ const handleReset = useCallback(
69
+ (e: React.MouseEvent) => {
70
+ e.stopPropagation()
71
+ setLocalText(text)
72
+ setModified(false)
73
+ onTextChange?.(text)
74
+ },
75
+ [text, onTextChange],
76
+ )
77
+
78
+ if (editing) {
79
+ return (
80
+ <div className="relative">
81
+ <textarea
82
+ ref={textareaRef}
83
+ value={localText}
84
+ onChange={handleChange}
85
+ onBlur={handleBlur}
86
+ onKeyDown={handleKeyDown}
87
+ className={className}
88
+ style={{
89
+ ...style,
90
+ width: '100%',
91
+ minHeight: '120px',
92
+ resize: 'vertical',
93
+ background: 'var(--bg-tertiary)',
94
+ border: '1px solid var(--accent)',
95
+ borderRadius: '4px',
96
+ padding: '8px',
97
+ outline: 'none',
98
+ }}
99
+ spellCheck={false}
100
+ />
101
+ </div>
102
+ )
103
+ }
104
+
105
+ return (
106
+ <div className="relative group/editable">
107
+ <pre
108
+ onDoubleClick={handleDoubleClick}
109
+ className={className}
110
+ style={{
111
+ ...style,
112
+ cursor: 'text',
113
+ borderRadius: '4px',
114
+ transition: 'background 0.15s',
115
+ }}
116
+ title="Double-click to edit"
117
+ >
118
+ {localText}
119
+ </pre>
120
+ {modified && (
121
+ <button
122
+ onClick={handleReset}
123
+ className="absolute top-1 right-1 px-1.5 py-0.5 rounded text-[10px] transition-opacity opacity-0 group-hover/editable:opacity-100"
124
+ style={{
125
+ background: 'var(--bg-tertiary)',
126
+ color: 'var(--text-muted)',
127
+ border: '1px solid var(--border)',
128
+ }}
129
+ title="Reset to original"
130
+ >
131
+ Reset
132
+ </button>
133
+ )}
134
+ </div>
135
+ )
136
+ }
@@ -0,0 +1,65 @@
1
+ 'use client'
2
+
3
+ import { Component, type ReactNode } from 'react'
4
+
5
+ interface Props {
6
+ children: ReactNode
7
+ fallback?: ReactNode
8
+ panelName?: string
9
+ }
10
+
11
+ interface State {
12
+ hasError: boolean
13
+ error: Error | null
14
+ }
15
+
16
+ export class ErrorBoundary extends Component<Props, State> {
17
+ constructor(props: Props) {
18
+ super(props)
19
+ this.state = { hasError: false, error: null }
20
+ }
21
+
22
+ static getDerivedStateFromError(error: Error): State {
23
+ return { hasError: true, error }
24
+ }
25
+
26
+ render() {
27
+ if (this.state.hasError) {
28
+ if (this.props.fallback) {
29
+ return this.props.fallback
30
+ }
31
+
32
+ return (
33
+ <div
34
+ className="flex flex-col items-center justify-center h-full p-4 text-center"
35
+ style={{ background: 'var(--bg-primary)' }}
36
+ >
37
+ <div
38
+ className="text-sm font-medium mb-1"
39
+ style={{ color: 'var(--error)' }}
40
+ >
41
+ {this.props.panelName
42
+ ? `${this.props.panelName} error`
43
+ : 'Something went wrong'}
44
+ </div>
45
+ <div className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
46
+ {this.state.error?.message || 'An unexpected error occurred'}
47
+ </div>
48
+ <button
49
+ onClick={() => this.setState({ hasError: false, error: null })}
50
+ className="px-3 py-1 text-xs rounded"
51
+ style={{
52
+ background: 'var(--bg-hover)',
53
+ color: 'var(--text-secondary)',
54
+ border: '1px solid var(--border)',
55
+ }}
56
+ >
57
+ Try Again
58
+ </button>
59
+ </div>
60
+ )
61
+ }
62
+
63
+ return this.props.children
64
+ }
65
+ }
@@ -0,0 +1,83 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useRef } from 'react'
4
+
5
+ interface ResizablePanelProps {
6
+ width: number
7
+ minWidth: number
8
+ maxWidth: number
9
+ onResize: (width: number) => void
10
+ side: 'left' | 'right'
11
+ children: React.ReactNode
12
+ }
13
+
14
+ export function ResizablePanel({
15
+ width,
16
+ minWidth,
17
+ maxWidth,
18
+ onResize,
19
+ side,
20
+ children,
21
+ }: ResizablePanelProps) {
22
+ const panelRef = useRef<HTMLDivElement>(null)
23
+ const isDragging = useRef(false)
24
+
25
+ const handleMouseDown = useCallback(
26
+ (e: React.MouseEvent) => {
27
+ e.preventDefault()
28
+ isDragging.current = true
29
+ const startX = e.clientX
30
+ const startWidth = width
31
+
32
+ const handleMouseMove = (moveEvent: MouseEvent) => {
33
+ if (!isDragging.current) return
34
+ const delta =
35
+ side === 'left'
36
+ ? moveEvent.clientX - startX
37
+ : startX - moveEvent.clientX
38
+ const newWidth = Math.min(
39
+ Math.max(startWidth + delta, minWidth),
40
+ maxWidth,
41
+ )
42
+ onResize(newWidth)
43
+ }
44
+
45
+ const handleMouseUp = () => {
46
+ isDragging.current = false
47
+ document.removeEventListener('mousemove', handleMouseMove)
48
+ document.removeEventListener('mouseup', handleMouseUp)
49
+ document.body.style.cursor = ''
50
+ document.body.style.userSelect = ''
51
+ }
52
+
53
+ document.addEventListener('mousemove', handleMouseMove)
54
+ document.addEventListener('mouseup', handleMouseUp)
55
+ document.body.style.cursor = 'col-resize'
56
+ document.body.style.userSelect = 'none'
57
+ },
58
+ [width, minWidth, maxWidth, onResize, side],
59
+ )
60
+
61
+ return (
62
+ <div
63
+ ref={panelRef}
64
+ className="relative flex-shrink-0 overflow-hidden"
65
+ style={{
66
+ width,
67
+ background: 'var(--bg-secondary)',
68
+ borderLeft: side === 'right' ? '1px solid var(--border)' : 'none',
69
+ borderRight: side === 'left' ? '1px solid var(--border)' : 'none',
70
+ }}
71
+ >
72
+ {children}
73
+ <div
74
+ className="absolute top-0 bottom-0 w-1 cursor-col-resize z-10 hover:bg-[var(--accent)]"
75
+ style={{
76
+ [side === 'left' ? 'right' : 'left']: 0,
77
+ transition: 'background-color 0.15s',
78
+ }}
79
+ onMouseDown={handleMouseDown}
80
+ />
81
+ </div>
82
+ )
83
+ }
@@ -0,0 +1,76 @@
1
+ 'use client'
2
+
3
+ interface ScanAnimationProps {
4
+ active: boolean
5
+ label?: string
6
+ }
7
+
8
+ export function ScanAnimation({
9
+ active,
10
+ label = 'SCANNING',
11
+ }: ScanAnimationProps) {
12
+ if (!active) return null
13
+
14
+ return (
15
+ <div className="flex flex-col items-center gap-3">
16
+ {/* Pulsing rings */}
17
+ <div className="relative" style={{ width: 48, height: 48 }}>
18
+ {[0, 0.6, 1.2].map((delay, i) => (
19
+ <div
20
+ key={i}
21
+ style={{
22
+ position: 'absolute',
23
+ inset: 0,
24
+ borderRadius: '50%',
25
+ border: '1px solid var(--accent)',
26
+ animation: 'scan-ring 2.4s ease-out infinite',
27
+ animationDelay: `${delay}s`,
28
+ }}
29
+ />
30
+ ))}
31
+ {/* Center dot */}
32
+ <div
33
+ style={{
34
+ position: 'absolute',
35
+ top: '50%',
36
+ left: '50%',
37
+ transform: 'translate(-50%, -50%)',
38
+ width: 6,
39
+ height: 6,
40
+ borderRadius: '50%',
41
+ background: 'var(--accent)',
42
+ animation: 'scan-glow 1.6s ease-in-out infinite',
43
+ }}
44
+ />
45
+ </div>
46
+
47
+ {/* Status text */}
48
+ <div
49
+ className="text-[11px] font-medium tracking-wide"
50
+ style={{
51
+ color: 'var(--accent)',
52
+ animation: 'scan-pulse 2s ease-in-out infinite',
53
+ }}
54
+ >
55
+ {label}
56
+ </div>
57
+
58
+ {/* Animated dots */}
59
+ <div className="flex gap-1.5">
60
+ {[0, 0.2, 0.4, 0.6, 0.8].map((delay, i) => (
61
+ <div
62
+ key={i}
63
+ style={{
64
+ width: 4,
65
+ height: 4,
66
+ borderRadius: '50%',
67
+ background: 'var(--accent)',
68
+ animation: 'scan-dot 1.4s ease-in-out infinite',
69
+ animationDelay: `${delay}s`,
70
+ }}
71
+ />
72
+ ))}
73
+ </div>
74
+ </div>
75
+ )
76
+ }
@@ -0,0 +1,97 @@
1
+ 'use client'
2
+
3
+ import { useEditorStore } from '@/store'
4
+
5
+ const ICON_MAP = {
6
+ success: '\u2713',
7
+ error: '!',
8
+ info: 'i',
9
+ } as const
10
+
11
+ const COLOR_MAP = {
12
+ success: {
13
+ bg: 'rgba(74, 222, 128, 0.12)',
14
+ border: 'var(--success)',
15
+ text: 'var(--success)',
16
+ },
17
+ error: {
18
+ bg: 'rgba(248, 113, 113, 0.12)',
19
+ border: 'var(--error)',
20
+ text: 'var(--error)',
21
+ },
22
+ info: {
23
+ bg: 'rgba(74, 158, 255, 0.12)',
24
+ border: 'var(--accent)',
25
+ text: 'var(--accent)',
26
+ },
27
+ } as const
28
+
29
+ export function ToastContainer() {
30
+ const toasts = useEditorStore((s) => s.toasts)
31
+ const dismissToast = useEditorStore((s) => s.dismissToast)
32
+
33
+ if (toasts.length === 0) return null
34
+
35
+ return (
36
+ <div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none">
37
+ {toasts.map((toast) => {
38
+ const colors = COLOR_MAP[toast.type]
39
+ return (
40
+ <div
41
+ key={toast.id}
42
+ className="pointer-events-auto flex items-center gap-2.5 px-4 py-3 rounded-lg shadow-lg min-w-[280px] max-w-[400px] animate-[toast-in_0.25s_ease-out]"
43
+ style={{
44
+ background: 'var(--bg-primary)',
45
+ border: `1px solid ${colors.border}`,
46
+ boxShadow: `0 8px 24px rgba(0,0,0,0.4), 0 0 0 1px ${colors.border}`,
47
+ }}
48
+ >
49
+ {/* Icon */}
50
+ <div
51
+ className="flex items-center justify-center w-5 h-5 rounded-full flex-shrink-0 text-[11px] font-bold"
52
+ style={{ background: colors.bg, color: colors.text }}
53
+ >
54
+ {ICON_MAP[toast.type]}
55
+ </div>
56
+
57
+ {/* Message */}
58
+ <span
59
+ className="flex-1 text-xs leading-relaxed"
60
+ style={{ color: 'var(--text-primary)' }}
61
+ >
62
+ {toast.message}
63
+ </span>
64
+
65
+ {/* Dismiss */}
66
+ <button
67
+ onClick={() => dismissToast(toast.id)}
68
+ className="flex-shrink-0 p-0.5 rounded hover:bg-[var(--bg-hover)] transition-colors"
69
+ style={{ color: 'var(--text-muted)' }}
70
+ >
71
+ <svg
72
+ width="12"
73
+ height="12"
74
+ viewBox="0 0 24 24"
75
+ fill="none"
76
+ stroke="currentColor"
77
+ strokeWidth="2"
78
+ strokeLinecap="round"
79
+ strokeLinejoin="round"
80
+ >
81
+ <line x1="18" y1="6" x2="6" y2="18" />
82
+ <line x1="6" y1="6" x2="18" y2="18" />
83
+ </svg>
84
+ </button>
85
+ </div>
86
+ )
87
+ })}
88
+
89
+ <style>{`
90
+ @keyframes toast-in {
91
+ from { opacity: 0; transform: translateY(8px) scale(0.96); }
92
+ to { opacity: 1; transform: translateY(0) scale(1); }
93
+ }
94
+ `}</style>
95
+ </div>
96
+ )
97
+ }
@@ -0,0 +1,77 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback, useEffect } from 'react'
4
+ import { parseCSSValue, formatCSSValue } from '@/lib/utils'
5
+
6
+ interface UnitInputProps {
7
+ value: string
8
+ onChange: (value: string) => void
9
+ units?: string[]
10
+ placeholder?: string
11
+ }
12
+
13
+ export function UnitInput({
14
+ value,
15
+ onChange,
16
+ units = ['px', '%', 'em', 'rem', 'auto'],
17
+ placeholder = '0',
18
+ }: UnitInputProps) {
19
+ const parsed = parseCSSValue(value)
20
+ const [localValue, setLocalValue] = useState(String(parsed.number))
21
+ const [unit, setUnit] = useState(parsed.unit || 'px')
22
+
23
+ useEffect(() => {
24
+ const p = parseCSSValue(value)
25
+ setLocalValue(String(p.number))
26
+ setUnit(p.unit || 'px')
27
+ }, [value])
28
+
29
+ const commit = useCallback(
30
+ (num: string, u: string) => {
31
+ if (u === 'auto') {
32
+ onChange('auto')
33
+ } else {
34
+ const n = parseFloat(num)
35
+ if (!isNaN(n)) onChange(formatCSSValue(n, u))
36
+ }
37
+ },
38
+ [onChange],
39
+ )
40
+
41
+ return (
42
+ <div className="flex gap-1">
43
+ <input
44
+ type="number"
45
+ value={unit === 'auto' ? '' : localValue}
46
+ onChange={(e) => setLocalValue(e.target.value)}
47
+ onBlur={() => commit(localValue, unit)}
48
+ onKeyDown={(e) => {
49
+ if (e.key === 'Enter') commit(localValue, unit)
50
+ }}
51
+ disabled={unit === 'auto'}
52
+ placeholder={placeholder}
53
+ className="flex-1 min-w-0 text-xs py-1 px-2"
54
+ style={{ opacity: unit === 'auto' ? 0.5 : 1 }}
55
+ />
56
+ <select
57
+ value={unit}
58
+ onChange={(e) => {
59
+ setUnit(e.target.value)
60
+ commit(localValue, e.target.value)
61
+ }}
62
+ className="w-14 text-[11px] py-1 px-1 rounded"
63
+ style={{
64
+ background: 'var(--bg-tertiary)',
65
+ border: '1px solid var(--border)',
66
+ color: 'var(--text-secondary)',
67
+ }}
68
+ >
69
+ {units.map((u) => (
70
+ <option key={u} value={u}>
71
+ {u}
72
+ </option>
73
+ ))}
74
+ </select>
75
+ </div>
76
+ )
77
+ }