@@ -5,14 +5,17 @@ import timeGridPlugin from '@fullcalendar/timegrid'
|
|||||||
import interactionPlugin from '@fullcalendar/interaction'
|
import interactionPlugin from '@fullcalendar/interaction'
|
||||||
import useProjectStore from '../../store/useProjectStore'
|
import useProjectStore from '../../store/useProjectStore'
|
||||||
import useFocusStore from '../../store/useFocusStore'
|
import useFocusStore from '../../store/useFocusStore'
|
||||||
import { updateDeliverable } from '../../api/deliverables'
|
import { updateDeliverable, deleteDeliverable } from '../../api/deliverables'
|
||||||
import DeliverableModal from '../Deliverables/DeliverableModal'
|
import DeliverableModal from '../Deliverables/DeliverableModal'
|
||||||
|
import ContextMenu from '../UI/ContextMenu'
|
||||||
|
|
||||||
export default function MainCalendar() {
|
export default function MainCalendar() {
|
||||||
const calRef = useRef(null)
|
const calRef = useRef(null)
|
||||||
const { projects, updateDeliverable: storeUpdate } = useProjectStore()
|
const { projects, updateDeliverable: storeUpdate, removeDeliverable } = useProjectStore()
|
||||||
const openFocus = useFocusStore(s => s.openFocus)
|
const openFocus = useFocusStore(s => s.openFocus)
|
||||||
const [modal, setModal] = useState({ open: false, deliverable: null, defaultDate: '' })
|
|
||||||
|
const [modal, setModal] = useState({ open: false, deliverable: null, defaultDate: '' })
|
||||||
|
const [contextMenu, setContextMenu] = useState(null)
|
||||||
|
|
||||||
const events = projects.flatMap(p =>
|
const events = projects.flatMap(p =>
|
||||||
(p.deliverables || []).map(d => ({
|
(p.deliverables || []).map(d => ({
|
||||||
@@ -26,22 +29,78 @@ export default function MainCalendar() {
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleEventDrop = useCallback(async ({ event }) => {
|
const getDeliverable = (projectId, deliverableId) => {
|
||||||
const { deliverableId } = event.extendedProps
|
const p = projects.find(p => p.id === projectId)
|
||||||
storeUpdate(await updateDeliverable(deliverableId, { due_date: event.startStr.substring(0,10) }))
|
return { project: p, deliverable: p?.deliverables.find(d => d.id === deliverableId) }
|
||||||
}, [storeUpdate])
|
}
|
||||||
|
|
||||||
|
// Single click → Focus View
|
||||||
const handleEventClick = useCallback(({ event }) => {
|
const handleEventClick = useCallback(({ event }) => {
|
||||||
const { deliverableId, projectId } = event.extendedProps
|
const { deliverableId, projectId } = event.extendedProps
|
||||||
openFocus(projectId, deliverableId)
|
openFocus(projectId, deliverableId)
|
||||||
}, [openFocus])
|
}, [openFocus])
|
||||||
|
|
||||||
const handleDateClick = useCallback(({ dateStr }) => {
|
// Drag-and-drop → patch date
|
||||||
setModal({ open: true, deliverable: null, defaultDate: dateStr.substring(0,10) })
|
const handleEventDrop = useCallback(async ({ event }) => {
|
||||||
|
const { deliverableId } = event.extendedProps
|
||||||
|
storeUpdate(await updateDeliverable(deliverableId, { due_date: event.startStr.substring(0, 10) }))
|
||||||
|
}, [storeUpdate])
|
||||||
|
|
||||||
|
// Click empty date → add deliverable
|
||||||
|
const handleDateClick = useCallback(({ dateStr }) => {
|
||||||
|
setModal({ open: true, deliverable: null, defaultDate: dateStr.substring(0, 10) })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Attach dblclick + contextmenu to each event element after mount
|
||||||
|
const handleEventDidMount = useCallback(({ event, el }) => {
|
||||||
|
const { deliverableId, projectId } = event.extendedProps
|
||||||
|
|
||||||
|
// Double-click → open edit modal directly
|
||||||
|
el.addEventListener('dblclick', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const { deliverable } = getDeliverable(projectId, deliverableId)
|
||||||
|
if (deliverable) setModal({ open: true, deliverable, defaultDate: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Right-click → context menu
|
||||||
|
el.addEventListener('contextmenu', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const { project, deliverable } = getDeliverable(projectId, deliverableId)
|
||||||
|
if (!deliverable) return
|
||||||
|
setContextMenu({
|
||||||
|
x: e.clientX, y: e.clientY,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
icon: '✎', label: 'Edit Deliverable',
|
||||||
|
action: () => setModal({ open: true, deliverable, defaultDate: '' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '◎', label: 'Open Focus View',
|
||||||
|
action: () => openFocus(projectId, deliverableId),
|
||||||
|
},
|
||||||
|
...(project?.drive_url ? [{
|
||||||
|
icon: '⬡', label: 'Open Drive Folder',
|
||||||
|
action: () => window.open(project.drive_url, '_blank'),
|
||||||
|
}] : []),
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
icon: '✕', label: 'Delete Deliverable', danger: true,
|
||||||
|
action: async () => {
|
||||||
|
if (window.confirm(`Delete "${deliverable.title}"?`)) {
|
||||||
|
await deleteDeliverable(deliverableId)
|
||||||
|
removeDeliverable(deliverableId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [projects, openFocus])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-surface p-4">
|
<div className="h-full flex flex-col bg-surface p-4" onContextMenu={e => e.preventDefault()}>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<FullCalendar
|
<FullCalendar
|
||||||
ref={calRef}
|
ref={calRef}
|
||||||
@@ -53,18 +112,29 @@ export default function MainCalendar() {
|
|||||||
eventDrop={handleEventDrop}
|
eventDrop={handleEventDrop}
|
||||||
eventClick={handleEventClick}
|
eventClick={handleEventClick}
|
||||||
dateClick={handleDateClick}
|
dateClick={handleDateClick}
|
||||||
|
eventDidMount={handleEventDidMount}
|
||||||
height="100%"
|
height="100%"
|
||||||
dayMaxEvents={4}
|
dayMaxEvents={4}
|
||||||
eventDisplay="block"
|
eventDisplay="block"
|
||||||
displayEventTime={false}
|
displayEventTime={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DeliverableModal
|
<DeliverableModal
|
||||||
isOpen={modal.open}
|
isOpen={modal.open}
|
||||||
onClose={() => setModal({ open: false, deliverable: null, defaultDate: '' })}
|
onClose={() => setModal({ open: false, deliverable: null, defaultDate: '' })}
|
||||||
deliverable={modal.deliverable}
|
deliverable={modal.deliverable}
|
||||||
defaultDate={modal.defaultDate}
|
defaultDate={modal.defaultDate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{contextMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={contextMenu.x}
|
||||||
|
y={contextMenu.y}
|
||||||
|
items={contextMenu.items}
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,89 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import Badge from '../UI/Badge'
|
import Badge from '../UI/Badge'
|
||||||
import { formatDate } from '../../utils/dateHelpers'
|
import { formatDate } from '../../utils/dateHelpers'
|
||||||
|
import ContextMenu from '../UI/ContextMenu'
|
||||||
|
import { updateDeliverable, deleteDeliverable } from '../../api/deliverables'
|
||||||
|
import useProjectStore from '../../store/useProjectStore'
|
||||||
|
import { STATUS_OPTIONS } from '../../utils/statusHelpers'
|
||||||
|
|
||||||
export default function DeliverableCard({ deliverable, isActive, index, projectColor, onSelect, onEdit }) {
|
export default function DeliverableCard({ deliverable, isActive, index, projectColor, onSelect, onEdit }) {
|
||||||
|
const { updateDeliverable: storeUpdate, removeDeliverable } = useProjectStore()
|
||||||
|
const [ctxMenu, setCtxMenu] = useState(null)
|
||||||
|
|
||||||
|
const handleContextMenu = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setCtxMenu({
|
||||||
|
x: e.clientX, y: e.clientY,
|
||||||
|
items: [
|
||||||
|
{ icon: '✎', label: 'Edit Deliverable', highlight: true, action: () => onEdit(deliverable) },
|
||||||
|
{ separator: true },
|
||||||
|
...STATUS_OPTIONS.map(s => ({
|
||||||
|
icon: s.value === deliverable.status ? '●' : '○',
|
||||||
|
label: `Mark ${s.label}`,
|
||||||
|
action: async () => {
|
||||||
|
storeUpdate(await updateDeliverable(deliverable.id, { status: s.value }))
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
icon: '✕', label: 'Delete Deliverable', danger: true,
|
||||||
|
action: async () => {
|
||||||
|
if (window.confirm(`Delete "${deliverable.title}"?`)) {
|
||||||
|
await deleteDeliverable(deliverable.id)
|
||||||
|
removeDeliverable(deliverable.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
onClick={() => onSelect(deliverable.id)}
|
<div
|
||||||
className={`relative flex flex-col gap-2 rounded-xl border p-4 min-w-[190px] max-w-[230px] flex-shrink-0 cursor-pointer
|
onClick={() => onSelect(deliverable.id)}
|
||||||
transition-all duration-200 select-none mt-4
|
onDoubleClick={(e) => { e.stopPropagation(); onEdit(deliverable) }}
|
||||||
${isActive
|
onContextMenu={handleContextMenu}
|
||||||
? 'border-gold bg-surface-elevated shadow-gold scale-105 ring-2 ring-gold/30'
|
title="Click: Select · Double-click: Edit · Right-click: Menu"
|
||||||
: 'border-surface-border bg-surface hover:border-gold/40 hover:bg-surface-elevated/60'
|
className={`relative flex flex-col gap-2 rounded-xl border p-4 min-w-[190px] max-w-[230px] flex-shrink-0 cursor-pointer
|
||||||
}`}
|
transition-all duration-200 select-none mt-4
|
||||||
>
|
${isActive
|
||||||
{isActive && (
|
? 'border-gold bg-surface-elevated shadow-gold scale-105 ring-2 ring-gold/30'
|
||||||
<div className="absolute -top-5 left-1/2 -translate-x-1/2 bg-gold text-surface text-[9px] font-black px-2.5 py-0.5 rounded-full tracking-widest uppercase whitespace-nowrap">
|
: 'border-surface-border bg-surface hover:border-gold/40 hover:bg-surface-elevated/60'
|
||||||
Selected
|
}`}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<div className="absolute -top-5 left-1/2 -translate-x-1/2 bg-gold text-surface text-[9px] font-black px-2.5 py-0.5 rounded-full tracking-widest uppercase whitespace-nowrap">
|
||||||
|
Selected
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: projectColor }} />
|
||||||
|
<span className={`text-[10px] font-semibold uppercase tracking-widest ${isActive ? 'text-gold' : 'text-text-muted/60'}`}>
|
||||||
|
Deliverable {index + 1}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p className={`text-sm font-semibold leading-snug ${isActive ? 'text-text-primary' : 'text-text-muted'}`}>
|
||||||
<div className="flex items-center gap-1.5">
|
{deliverable.title}
|
||||||
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: projectColor }} />
|
</p>
|
||||||
<span className={`text-[10px] font-semibold uppercase tracking-widest ${isActive ? 'text-gold' : 'text-text-muted/60'}`}>
|
<p className={`text-xs font-mono ${isActive ? 'text-gold' : 'text-text-muted/50'}`}>
|
||||||
Deliverable {index + 1}
|
{formatDate(deliverable.due_date)}
|
||||||
</span>
|
</p>
|
||||||
|
<Badge status={deliverable.status} />
|
||||||
|
{isActive && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onEdit(deliverable) }}
|
||||||
|
className="mt-1 text-[10px] text-gold/70 hover:text-gold border border-gold/20 hover:border-gold/50 rounded px-2 py-0.5 transition-all text-center"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-sm font-semibold leading-snug ${isActive ? 'text-text-primary' : 'text-text-muted'}`}>
|
|
||||||
{deliverable.title}
|
{ctxMenu && (
|
||||||
</p>
|
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={() => setCtxMenu(null)} />
|
||||||
<p className={`text-xs font-mono ${isActive ? 'text-gold' : 'text-text-muted/50'}`}>
|
|
||||||
{formatDate(deliverable.due_date)}
|
|
||||||
</p>
|
|
||||||
<Badge status={deliverable.status} />
|
|
||||||
{isActive && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onEdit(deliverable) }}
|
|
||||||
className="mt-1 text-[10px] text-gold/70 hover:text-gold border border-gold/20 hover:border-gold/50 rounded px-2 py-0.5 transition-all text-center"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import Badge from '../UI/Badge'
|
import Badge from '../UI/Badge'
|
||||||
import { formatDate } from '../../utils/dateHelpers'
|
import { formatDate } from '../../utils/dateHelpers'
|
||||||
import useFocusStore from '../../store/useFocusStore'
|
import useFocusStore from '../../store/useFocusStore'
|
||||||
|
import useProjectStore from '../../store/useProjectStore'
|
||||||
|
import { deleteDeliverable } from '../../api/deliverables'
|
||||||
|
import DeliverableModal from '../Deliverables/DeliverableModal'
|
||||||
|
import ContextMenu from '../UI/ContextMenu'
|
||||||
|
|
||||||
function DriveIcon() {
|
function DriveIcon() {
|
||||||
return (
|
return (
|
||||||
@@ -16,35 +21,74 @@ function DriveIcon() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectCard({ project, onEdit, onDelete }) {
|
export default function ProjectCard({ project, onEdit, onDelete }) {
|
||||||
const openFocus = useFocusStore(s => s.openFocus)
|
const openFocus = useFocusStore(s => s.openFocus)
|
||||||
|
const { removeDeliverable } = useProjectStore()
|
||||||
|
const [delModal, setDelModal] = useState({ open: false, deliverable: null })
|
||||||
|
const [ctxMenu, setCtxMenu] = useState(null)
|
||||||
|
|
||||||
|
const openDelEdit = (d) => setDelModal({ open: true, deliverable: d })
|
||||||
|
|
||||||
|
const handleRowCtx = (e, d) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setCtxMenu({
|
||||||
|
x: e.clientX, y: e.clientY,
|
||||||
|
items: [
|
||||||
|
{ icon: '✎', label: 'Edit Deliverable', action: () => openDelEdit(d) },
|
||||||
|
{ icon: '◎', label: 'Open Focus View', action: () => openFocus(project.id, d.id) },
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
icon: '✕', label: 'Delete Deliverable', danger: true,
|
||||||
|
action: async () => {
|
||||||
|
if (window.confirm(`Delete "${d.title}"?`)) {
|
||||||
|
await deleteDeliverable(d.id)
|
||||||
|
removeDeliverable(d.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHeaderCtx = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setCtxMenu({
|
||||||
|
x: e.clientX, y: e.clientY,
|
||||||
|
items: [
|
||||||
|
{ icon: '✎', label: 'Edit Project', action: () => onEdit(project) },
|
||||||
|
...(project.drive_url ? [{ icon: '⬡', label: 'Open Drive', action: () => window.open(project.drive_url, '_blank') }] : []),
|
||||||
|
{ separator: true },
|
||||||
|
{ icon: '✕', label: 'Delete Project', danger: true, action: () => onDelete(project) },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-elevated border border-surface-border rounded-lg overflow-hidden transition-all hover:border-gold/20">
|
<div className="bg-surface-elevated border border-surface-border rounded-lg overflow-hidden transition-all hover:border-gold/20">
|
||||||
<div className="h-1 w-full" style={{ backgroundColor: project.color }} />
|
<div className="h-1 w-full" style={{ backgroundColor: project.color }} />
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
|
|
||||||
{/* Header row */}
|
{/* Header — double-click to edit, right-click for menu */}
|
||||||
<div className="flex items-start justify-between mb-1.5">
|
<div
|
||||||
|
className="flex items-start justify-between mb-1.5 cursor-default"
|
||||||
|
onDoubleClick={() => onEdit(project)}
|
||||||
|
onContextMenu={handleHeaderCtx}
|
||||||
|
title="Double-click to edit project"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: project.color }} />
|
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: project.color }} />
|
||||||
<span className="text-sm font-semibold text-text-primary truncate">{project.name}</span>
|
<span className="text-sm font-semibold text-text-primary truncate">{project.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-0.5 flex-shrink-0 ml-1">
|
<div className="flex items-center gap-0.5 flex-shrink-0 ml-1">
|
||||||
{project.drive_url && (
|
{project.drive_url && (
|
||||||
<a
|
<a href={project.drive_url} target="_blank" rel="noopener noreferrer"
|
||||||
href={project.drive_url}
|
title="Open Google Drive folder" onClick={e => e.stopPropagation()}
|
||||||
target="_blank"
|
className="flex items-center gap-1 text-[10px] text-text-muted hover:text-text-primary bg-surface hover:bg-surface-border/40 border border-surface-border hover:border-gold/30 rounded px-1.5 py-1 transition-all mr-1">
|
||||||
rel="noopener noreferrer"
|
<DriveIcon /><span>Drive</span>
|
||||||
title="Open Google Drive folder"
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
className="flex items-center gap-1 text-[10px] text-text-muted hover:text-text-primary bg-surface hover:bg-surface-border/40 border border-surface-border hover:border-gold/30 rounded px-1.5 py-1 transition-all mr-1"
|
|
||||||
>
|
|
||||||
<DriveIcon />
|
|
||||||
<span>Drive</span>
|
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => onEdit(project)} className="text-text-muted hover:text-gold p-1 transition-colors text-sm">✎</button>
|
<button onClick={() => onEdit(project)} className="text-text-muted hover:text-gold p-1 transition-colors text-sm" title="Edit project">✎</button>
|
||||||
<button onClick={() => onDelete(project)} className="text-text-muted hover:text-red-400 p-1 transition-colors text-sm">✕</button>
|
<button onClick={() => onDelete(project)} className="text-text-muted hover:text-red-400 p-1 transition-colors text-sm" title="Delete project">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -55,8 +99,14 @@ export default function ProjectCard({ project, onEdit, onDelete }) {
|
|||||||
{/* Deliverable rows */}
|
{/* Deliverable rows */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{(project.deliverables || []).map(d => (
|
{(project.deliverables || []).map(d => (
|
||||||
<button key={d.id} onClick={() => openFocus(project.id, d.id)}
|
<button
|
||||||
className="w-full flex items-center justify-between text-xs bg-surface rounded px-2 py-1.5 border border-transparent hover:border-gold/20 hover:bg-surface-border/20 transition-all text-left group">
|
key={d.id}
|
||||||
|
onClick={() => openFocus(project.id, d.id)}
|
||||||
|
onDoubleClick={(e) => { e.stopPropagation(); openDelEdit(d) }}
|
||||||
|
onContextMenu={(e) => handleRowCtx(e, d)}
|
||||||
|
title="Click: Focus View · Double-click: Edit · Right-click: Menu"
|
||||||
|
className="w-full flex items-center justify-between text-xs bg-surface rounded px-2 py-1.5 border border-transparent hover:border-gold/20 hover:bg-surface-border/20 transition-all text-left group"
|
||||||
|
>
|
||||||
<span className="text-text-muted group-hover:text-text-primary truncate flex-1 pr-2">{d.title}</span>
|
<span className="text-text-muted group-hover:text-text-primary truncate flex-1 pr-2">{d.title}</span>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
<span className="text-text-muted/60 font-mono text-[9px]">{formatDate(d.due_date)}</span>
|
<span className="text-text-muted/60 font-mono text-[9px]">{formatDate(d.due_date)}</span>
|
||||||
@@ -70,6 +120,18 @@ export default function ProjectCard({ project, onEdit, onDelete }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Local deliverable edit modal */}
|
||||||
|
<DeliverableModal
|
||||||
|
isOpen={delModal.open}
|
||||||
|
onClose={() => setDelModal({ open: false, deliverable: null })}
|
||||||
|
deliverable={delModal.deliverable}
|
||||||
|
projectId={project.id}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ctxMenu && (
|
||||||
|
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={() => setCtxMenu(null)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
55
frontend/src/components/UI/ContextMenu.jsx
Normal file
55
frontend/src/components/UI/ContextMenu.jsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
export default function ContextMenu({ x, y, items, onClose }) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMouseDown = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() }
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') onClose() }
|
||||||
|
document.addEventListener('mousedown', onMouseDown)
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onMouseDown)
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
// Keep menu inside viewport
|
||||||
|
const W = 192
|
||||||
|
const H = items.length * 34
|
||||||
|
const adjX = Math.min(x, window.innerWidth - W - 8)
|
||||||
|
const adjY = Math.min(y, window.innerHeight - H - 8)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
style={{ left: adjX, top: adjY }}
|
||||||
|
className="fixed z-[200] min-w-[192px] bg-surface-elevated border border-surface-border rounded-xl shadow-2xl py-1.5 overflow-hidden"
|
||||||
|
>
|
||||||
|
{items.map((item, i) =>
|
||||||
|
item.separator ? (
|
||||||
|
<div key={i} className="h-px bg-surface-border my-1 mx-2" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
disabled={item.disabled}
|
||||||
|
onClick={() => { item.action(); onClose() }}
|
||||||
|
className={`w-full flex items-center gap-2.5 px-3 py-2 text-xs text-left transition-colors disabled:opacity-40 disabled:cursor-not-allowed
|
||||||
|
${item.danger
|
||||||
|
? 'text-red-400 hover:bg-red-500/10'
|
||||||
|
: item.highlight
|
||||||
|
? 'text-gold hover:bg-gold/10'
|
||||||
|
: 'text-text-primary hover:bg-surface-border/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm w-4 text-center leading-none flex-shrink-0">{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{item.shortcut && (
|
||||||
|
<span className="ml-auto text-text-muted/50 text-[10px]">{item.shortcut}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user