llm-file-proxy / jorki /src /components /ContextMenu.jsx
josephrw's picture
Visitor tracking: first_name, visit_count, auto-message-all, attribution API, 5min interval
8075297 verified
Raw
History Blame Contribute Delete
6.42 kB
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>
)
}