11'use client'
22
3+ import { useCallback , useEffect , useMemo , useState } from 'react'
4+ import { Check } from 'lucide-react'
35import {
46 Popover ,
57 PopoverAnchor ,
8+ PopoverBackButton ,
69 PopoverContent ,
710 PopoverDivider ,
11+ PopoverFolder ,
812 PopoverItem ,
913} from '@/components/emcn'
14+ import { cn } from '@/lib/core/utils/cn'
15+ import { WORKFLOW_COLORS } from '@/lib/workflows/colors'
16+
17+ /**
18+ * Validates a hex color string.
19+ * Accepts 3 or 6 character hex codes with or without #.
20+ */
21+ function isValidHex ( hex : string ) : boolean {
22+ const cleaned = hex . replace ( '#' , '' )
23+ return / ^ [ 0 - 9 A - F a - f ] { 3 } $ | ^ [ 0 - 9 A - F a - f ] { 6 } $ / . test ( cleaned )
24+ }
25+
26+ /**
27+ * Normalizes a hex color to lowercase 6-character format with #.
28+ */
29+ function normalizeHex ( hex : string ) : string {
30+ let cleaned = hex . replace ( '#' , '' ) . toLowerCase ( )
31+ if ( cleaned . length === 3 ) {
32+ cleaned = cleaned
33+ . split ( '' )
34+ . map ( ( c ) => c + c )
35+ . join ( '' )
36+ }
37+ return `#${ cleaned } `
38+ }
1039
1140interface ContextMenuProps {
1241 /**
@@ -53,6 +82,14 @@ interface ContextMenuProps {
5382 * Callback when delete is clicked
5483 */
5584 onDelete : ( ) => void
85+ /**
86+ * Callback when color is changed
87+ */
88+ onColorChange ?: ( color : string ) => void
89+ /**
90+ * Current workflow color (for showing selected state)
91+ */
92+ currentColor ?: string
5693 /**
5794 * Whether to show the open in new tab option (default: false)
5895 * Set to true for items that can be opened in a new tab
@@ -83,11 +120,21 @@ interface ContextMenuProps {
83120 * Set to true for items that can be exported (like workspaces)
84121 */
85122 showExport ?: boolean
123+ /**
124+ * Whether to show the change color option (default: false)
125+ * Set to true for workflows to allow color customization
126+ */
127+ showColorChange ?: boolean
86128 /**
87129 * Whether the export option is disabled (default: false)
88130 * Set to true when user lacks permissions
89131 */
90132 disableExport ?: boolean
133+ /**
134+ * Whether the change color option is disabled (default: false)
135+ * Set to true when user lacks permissions
136+ */
137+ disableColorChange ?: boolean
91138 /**
92139 * Whether the rename option is disabled (default: false)
93140 * Set to true when user lacks permissions
@@ -134,23 +181,74 @@ export function ContextMenu({
134181 onDuplicate,
135182 onExport,
136183 onDelete,
184+ onColorChange,
185+ currentColor,
137186 showOpenInNewTab = false ,
138187 showRename = true ,
139188 showCreate = false ,
140189 showCreateFolder = false ,
141190 showDuplicate = true ,
142191 showExport = false ,
192+ showColorChange = false ,
143193 disableExport = false ,
194+ disableColorChange = false ,
144195 disableRename = false ,
145196 disableDuplicate = false ,
146197 disableDelete = false ,
147198 disableCreate = false ,
148199 disableCreateFolder = false ,
149200} : ContextMenuProps ) {
150- // Section visibility for divider logic
201+ const [ hexInput , setHexInput ] = useState ( currentColor || '#ffffff' )
202+
203+ // Sync hexInput when currentColor changes (e.g., opening menu on different workflow)
204+ useEffect ( ( ) => {
205+ setHexInput ( currentColor || '#ffffff' )
206+ } , [ currentColor ] )
207+
208+ const canSubmitHex = useMemo ( ( ) => {
209+ if ( ! isValidHex ( hexInput ) ) return false
210+ const normalized = normalizeHex ( hexInput )
211+ if ( currentColor && normalized . toLowerCase ( ) === currentColor . toLowerCase ( ) ) return false
212+ return true
213+ } , [ hexInput , currentColor ] )
214+
215+ const handleHexSubmit = useCallback ( ( ) => {
216+ if ( ! canSubmitHex || ! onColorChange ) return
217+
218+ const normalized = normalizeHex ( hexInput )
219+ onColorChange ( normalized )
220+ setHexInput ( normalized )
221+ } , [ hexInput , canSubmitHex , onColorChange ] )
222+
223+ const handleHexKeyDown = useCallback (
224+ ( e : React . KeyboardEvent < HTMLInputElement > ) => {
225+ if ( e . key === 'Enter' ) {
226+ e . preventDefault ( )
227+ handleHexSubmit ( )
228+ }
229+ } ,
230+ [ handleHexSubmit ]
231+ )
232+
233+ const handleHexChange = useCallback ( ( e : React . ChangeEvent < HTMLInputElement > ) => {
234+ let value = e . target . value . trim ( )
235+ if ( value && ! value . startsWith ( '#' ) ) {
236+ value = `#${ value } `
237+ }
238+ value = value . slice ( 0 , 1 ) + value . slice ( 1 ) . replace ( / [ ^ 0 - 9 a - f A - F ] / g, '' )
239+ setHexInput ( value . slice ( 0 , 7 ) )
240+ } , [ ] )
241+
242+ const handleHexFocus = useCallback ( ( e : React . FocusEvent < HTMLInputElement > ) => {
243+ e . target . select ( )
244+ } , [ ] )
245+
151246 const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
152247 const hasEditSection =
153- ( showRename && onRename ) || ( showCreate && onCreate ) || ( showCreateFolder && onCreateFolder )
248+ ( showRename && onRename ) ||
249+ ( showCreate && onCreate ) ||
250+ ( showCreateFolder && onCreateFolder ) ||
251+ ( showColorChange && onColorChange )
154252 const hasCopySection = ( showDuplicate && onDuplicate ) || ( showExport && onExport )
155253
156254 return (
@@ -170,10 +268,21 @@ export function ContextMenu({
170268 height : '1px' ,
171269 } }
172270 />
173- < PopoverContent ref = { menuRef } align = 'start' side = 'bottom' sideOffset = { 4 } >
271+ < PopoverContent
272+ ref = { menuRef }
273+ align = 'start'
274+ side = 'bottom'
275+ sideOffset = { 4 }
276+ onPointerDownOutside = { ( e ) => e . preventDefault ( ) }
277+ onInteractOutside = { ( e ) => e . preventDefault ( ) }
278+ >
279+ { /* Back button - shown only when in a folder */ }
280+ < PopoverBackButton />
281+
174282 { /* Navigation actions */ }
175283 { showOpenInNewTab && onOpenInNewTab && (
176284 < PopoverItem
285+ rootOnly
177286 onClick = { ( ) => {
178287 onOpenInNewTab ( )
179288 onClose ( )
@@ -182,11 +291,12 @@ export function ContextMenu({
182291 Open in new tab
183292 </ PopoverItem >
184293 ) }
185- { hasNavigationSection && ( hasEditSection || hasCopySection ) && < PopoverDivider /> }
294+ { hasNavigationSection && ( hasEditSection || hasCopySection ) && < PopoverDivider rootOnly /> }
186295
187296 { /* Edit and create actions */ }
188297 { showRename && onRename && (
189298 < PopoverItem
299+ rootOnly
190300 disabled = { disableRename }
191301 onClick = { ( ) => {
192302 onRename ( )
@@ -198,6 +308,7 @@ export function ContextMenu({
198308 ) }
199309 { showCreate && onCreate && (
200310 < PopoverItem
311+ rootOnly
201312 disabled = { disableCreate }
202313 onClick = { ( ) => {
203314 onCreate ( )
@@ -209,6 +320,7 @@ export function ContextMenu({
209320 ) }
210321 { showCreateFolder && onCreateFolder && (
211322 < PopoverItem
323+ rootOnly
212324 disabled = { disableCreateFolder }
213325 onClick = { ( ) => {
214326 onCreateFolder ( )
@@ -218,11 +330,72 @@ export function ContextMenu({
218330 Create folder
219331 </ PopoverItem >
220332 ) }
333+ { showColorChange && onColorChange && (
334+ < PopoverFolder
335+ id = 'color-picker'
336+ title = 'Change color'
337+ expandOnHover
338+ className = { disableColorChange ? 'pointer-events-none opacity-50' : '' }
339+ >
340+ < div className = 'flex w-[140px] flex-col gap-[8px] p-[2px]' >
341+ { /* Preset colors */ }
342+ < div className = 'grid grid-cols-6 gap-[4px]' >
343+ { WORKFLOW_COLORS . map ( ( { color, name } ) => (
344+ < button
345+ key = { color }
346+ type = 'button'
347+ title = { name }
348+ onClick = { ( e ) => {
349+ e . stopPropagation ( )
350+ onColorChange ( color )
351+ } }
352+ className = { cn (
353+ 'h-[20px] w-[20px] rounded-[4px]' ,
354+ currentColor ?. toLowerCase ( ) === color . toLowerCase ( ) && 'ring-1 ring-white'
355+ ) }
356+ style = { { backgroundColor : color } }
357+ />
358+ ) ) }
359+ </ div >
360+
361+ { /* Hex input */ }
362+ < div className = 'flex items-center gap-[4px]' >
363+ < div
364+ className = 'h-[20px] w-[20px] flex-shrink-0 rounded-[4px]'
365+ style = { {
366+ backgroundColor : isValidHex ( hexInput ) ? normalizeHex ( hexInput ) : '#ffffff' ,
367+ } }
368+ />
369+ < input
370+ type = 'text'
371+ value = { hexInput }
372+ onChange = { handleHexChange }
373+ onKeyDown = { handleHexKeyDown }
374+ onFocus = { handleHexFocus }
375+ onClick = { ( e ) => e . stopPropagation ( ) }
376+ className = 'h-[20px] min-w-0 flex-1 rounded-[4px] bg-[#363636] px-[6px] text-[11px] text-white uppercase focus:outline-none'
377+ />
378+ < button
379+ type = 'button'
380+ disabled = { ! canSubmitHex }
381+ onClick = { ( e ) => {
382+ e . stopPropagation ( )
383+ handleHexSubmit ( )
384+ } }
385+ className = 'flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[var(--brand-tertiary-2)] text-white disabled:opacity-40'
386+ >
387+ < Check className = 'h-[12px] w-[12px]' />
388+ </ button >
389+ </ div >
390+ </ div >
391+ </ PopoverFolder >
392+ ) }
221393
222394 { /* Copy and export actions */ }
223- { hasEditSection && hasCopySection && < PopoverDivider /> }
395+ { hasEditSection && hasCopySection && < PopoverDivider rootOnly /> }
224396 { showDuplicate && onDuplicate && (
225397 < PopoverItem
398+ rootOnly
226399 disabled = { disableDuplicate }
227400 onClick = { ( ) => {
228401 onDuplicate ( )
@@ -234,6 +407,7 @@ export function ContextMenu({
234407 ) }
235408 { showExport && onExport && (
236409 < PopoverItem
410+ rootOnly
237411 disabled = { disableExport }
238412 onClick = { ( ) => {
239413 onExport ( )
@@ -245,8 +419,9 @@ export function ContextMenu({
245419 ) }
246420
247421 { /* Destructive action */ }
248- { ( hasNavigationSection || hasEditSection || hasCopySection ) && < PopoverDivider /> }
422+ { ( hasNavigationSection || hasEditSection || hasCopySection ) && < PopoverDivider rootOnly /> }
249423 < PopoverItem
424+ rootOnly
250425 disabled = { disableDelete }
251426 onClick = { ( ) => {
252427 onDelete ( )
0 commit comments