Spaces:
Paused
Paused
| import { useState, useCallback, createContext, useContext } from 'react' | |
| import { motion, AnimatePresence } from 'framer-motion' | |
| import { | |
| Eye, Search, FileText, Dna, Copy, Shield, ArrowRight, | |
| Lock, Unlock, RefreshCw, Code, Scale, Brain, Hash, | |
| ExternalLink, Zap, | |
| } from 'lucide-react' | |
| const ContextMenuContext = createContext(null) | |
| export function useContextMenu() { | |
| return useContext(ContextMenuContext) | |
| } | |
| const GLYPH = { | |
| inspect: '◉', query: '⌕', dossier: '§', intel: '◇', copy: '⎘', | |
| revoke: '⊘', lock: '🔒', unlock: '🔓', reindex: '↻', | |
| api: '⌁', profile: '⚖', ml: '⟡', kpi: '#', link: '↗', | |
| password: '🗝', launch: '▶', | |
| } | |
| const SEPARATOR = { separator: true } | |
| function buildFileItems(file, actions) { | |
| if (!file) return [] | |
| return [ | |
| { glyph: GLYPH.inspect, icon: Eye, label: 'Inspect', action: () => actions.inspect?.(file) }, | |
| { glyph: GLYPH.query, icon: Search, label: 'Query File', action: () => actions.query?.(file) }, | |
| { glyph: GLYPH.dossier, icon: FileText, label: 'Open Dossier', action: () => actions.dossier?.(file) }, | |
| { glyph: GLYPH.intel, icon: Dna, label: 'File Intelligence', action: () => actions.intel?.(file) }, | |
| SEPARATOR, | |
| { glyph: GLYPH.kpi, icon: Hash, label: 'Extract KPIs', action: () => actions.kpi?.(file) }, | |
| { glyph: GLYPH.ml, icon: Brain, label: 'ML Insights', action: () => actions.ml?.(file) }, | |
| { glyph: GLYPH.profile, icon: Scale, label: 'Semantic Profile', action: () => actions.profile?.(file) }, | |
| SEPARATOR, | |
| { glyph: GLYPH.copy, icon: Copy, label: 'Copy File ID', action: () => navigator.clipboard?.writeText(file.file_id) }, | |
| { glyph: GLYPH.link, icon: ExternalLink, label: 'Copy Meta URL', action: () => actions.copyUrl?.(file) }, | |
| { glyph: GLYPH.reindex, icon: RefreshCw, label: 'Reindex', action: () => actions.reindex?.(file) }, | |
| SEPARATOR, | |
| { glyph: file.has_password ? GLYPH.unlock : GLYPH.lock, icon: file.has_password ? Unlock : Lock, label: file.has_password ? 'Password Set' : 'Set Password', action: () => actions.password?.(file) }, | |
| { glyph: GLYPH.revoke, icon: Shield, label: 'Revoke Access', action: () => actions.revoke?.(file), danger: true }, | |
| ] | |
| } | |
| function buildNavItems(item, actions) { | |
| return [ | |
| { glyph: GLYPH.launch, icon: ArrowRight, label: `Open ${item.label}`, action: () => actions.navigate?.(item.id) }, | |
| SEPARATOR, | |
| { glyph: GLYPH.copy, icon: Copy, label: 'Copy Panel Name', action: () => navigator.clipboard?.writeText(item.label) }, | |
| ] | |
| } | |
| function buildGenericItems(actions) { | |
| return [ | |
| { glyph: GLYPH.launch, icon: Zap, label: 'Command Palette', shortcut: '⌘K', action: () => actions.commandPalette?.() }, | |
| SEPARATOR, | |
| { glyph: GLYPH.api, icon: Code, label: 'API Reference', action: () => actions.navigate?.('api') }, | |
| { glyph: GLYPH.dossier, icon: FileText, label: 'Dashboard', action: () => actions.navigate?.('dashboard') }, | |
| ] | |
| } | |
| export function ContextMenuProvider({ children, actions }) { | |
| const [menu, setMenu] = useState(null) | |
| const open = useCallback((e, items) => { | |
| e.preventDefault() | |
| e.stopPropagation() | |
| const x = Math.min(e.clientX, window.innerWidth - 260) | |
| const y = Math.min(e.clientY, window.innerHeight - 420) | |
| setMenu({ x, y, items }) | |
| }, []) | |
| const close = useCallback(() => setMenu(null), []) | |
| const openFileMenu = useCallback((e, file) => { | |
| open(e, buildFileItems(file, actions)) | |
| }, [open, actions]) | |
| const openNavMenu = useCallback((e, item) => { | |
| open(e, buildNavItems(item, actions)) | |
| }, [open, actions]) | |
| const openGenericMenu = useCallback((e) => { | |
| open(e, buildGenericItems(actions)) | |
| }, [open, actions]) | |
| const value = { openFileMenu, openNavMenu, openGenericMenu, close } | |
| return ( | |
| <ContextMenuContext.Provider value={value}> | |
| {children} | |
| <AnimatePresence> | |
| {menu && ( | |
| <> | |
| <div | |
| className="fixed inset-0 z-[100]" | |
| onClick={close} | |
| onContextMenu={(e) => { e.preventDefault(); close() }} | |
| /> | |
| <motion.div | |
| initial={{ opacity: 0, scale: 0.92, y: -6 }} | |
| animate={{ opacity: 1, scale: 1, y: 0 }} | |
| exit={{ opacity: 0, scale: 0.92, y: -6 }} | |
| transition={{ duration: 0.12, ease: 'easeOut' }} | |
| className="fixed z-[101] min-w-[240px] py-1.5 rounded-xl glass-strong border border-white/10 shadow-2xl" | |
| style={{ left: menu.x, top: menu.y }} | |
| > | |
| {menu.items.map((item, i) => { | |
| if (item.separator) { | |
| return <div key={i} className="h-px bg-white/5 my-1.5" /> | |
| } | |
| const Icon = item.icon | |
| return ( | |
| <button | |
| key={i} | |
| disabled={item.disabled} | |
| onClick={() => { | |
| if (!item.disabled) { | |
| item.action() | |
| close() | |
| } | |
| }} | |
| className={`w-full flex items-center gap-3 px-3 py-2 text-xs transition-all disabled:opacity-30 disabled:cursor-not-allowed | |
| ${item.danger | |
| ? 'text-critical hover:bg-critical/10' | |
| : 'text-secondary hover:text-text hover:bg-white/5' | |
| }`} | |
| > | |
| <span className="text-primary text-sm font-mono w-4 text-center flex-shrink-0">{item.glyph}</span> | |
| <Icon className={`w-3.5 h-3.5 flex-shrink-0 ${item.danger ? 'text-critical' : 'text-primary'}`} /> | |
| <span className="flex-1 text-left font-mono">{item.label}</span> | |
| {item.shortcut && ( | |
| <span className="text-[9px] text-secondary/40 font-mono px-1 py-0.5 rounded bg-white/5">{item.shortcut}</span> | |
| )} | |
| </button> | |
| ) | |
| })} | |
| <div className="h-px bg-white/5 mt-1.5" /> | |
| <div className="px-3 py-1.5 text-[9px] font-mono text-secondary/30 flex items-center gap-2"> | |
| <span>◆ Jorki Context</span> | |
| <span>│</span> | |
| <span>right-click / ctrl+tap</span> | |
| </div> | |
| </motion.div> | |
| </> | |
| )} | |
| </AnimatePresence> | |
| </ContextMenuContext.Provider> | |
| ) | |
| } | |