From 3ffffd5b3234bd6dd3bf8746d2e966ee6d6d283a Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sat, 23 May 2026 16:33:57 -0400 Subject: [PATCH] feat(schedule): right-click menu + drag-to-resize on EPG event blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the + {/* Body click → edit, body drag → move. We hang the click on pointerup + so the threshold check above can demote a drag back to a click. */} +
startDrag(ev, 'body')}> + {event.name} + {_fmtTime(dispStart)}{drag && drag.moved ? ' → ' + _fmtTime(dispEnd) : ''} + {isLive && } + {isFailed && !} +
+ {canDrag && ( + <> + startDrag(ev, 'left')} + title="Drag to change start time" /> + startDrag(ev, 'right')} + title="Drag to change end time" /> + + )} + ); } -function _EpgRow({ recorder, schedules, dayStart, dayEnd, pph, now, projects, onEventClick, onEmptyClick }) { +function _EpgRow({ recorder, schedules, dayStart, dayEnd, pph, now, projects, onEventClick, onEmptyClick, onEventContextMenu, onEventResize }) { const dayEvents = (schedules || []).filter(s => s.recorder_id === recorder.id && _eventOverlapsDay(s, dayStart, dayEnd)); - const handleRowClick = (e) => { - // Translate clicked x to a Date in this row's day. Snap to 15-minute - // increments so the resulting modal pre-fill looks intentional. + const handleRowPointerUp = (e) => { + // Open the new-schedule modal only on a real click in the empty + // gutter. Clicks on event blocks stopPropagation themselves; we also + // guard against the tail of a block-drag bubbling up here. + if (e.target !== e.currentTarget) return; + if (e.button !== 0) return; const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left; - const minutes = Math.max(0, Math.min(24 * 60 - 30, Math.round((x / pph) * 60 / 15) * 15)); + const minutes = Math.max(0, Math.min(24 * 60 - 30, Math.round((x / pph) * 60 / _EPG_SNAP_MIN) * _EPG_SNAP_MIN)); const start = new Date(dayStart); start.setMinutes(minutes); onEmptyClick(recorder, start); }; return ( -
+
{dayEvents.map(s => ( <_EventBlock key={s.id} @@ -1077,12 +1180,49 @@ function _EpgRow({ recorder, schedules, dayStart, dayEnd, pph, now, projects, on pph={pph} now={now} projects={projects} - onClick={onEventClick} /> + onClick={onEventClick} + onContextMenu={onEventContextMenu} + onResize={onEventResize} /> ))}
); } +// ── Right-click menu for an EPG event block ───────────────────────────────── +// Same pattern as AssetContextMenu (screens-library.jsx): viewport-clamped, +// dismissed on outside click. Per-status action filtering mirrors the +// buttons rendered in the List view so the two surfaces stay consistent. +function _ScheduleContextMenu({ schedule, x, y, onClose, onEdit, onCancel, onDelete, onCopyId }) { + const ref = React.useRef(null); + const [pos, setPos] = React.useState({ left: x, top: y }); + React.useLayoutEffect(() => { + if (!ref.current) return; + const r = ref.current.getBoundingClientRect(); + const margin = 8; + let nx = x, ny = y; + if (x + r.width + margin > window.innerWidth) nx = window.innerWidth - r.width - margin; + if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin; + setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) }); + }, [x, y]); + + const canEdit = schedule.status === 'pending' || schedule.status === 'failed'; + const canCancel = schedule.status === 'pending' || schedule.status === 'running'; + const canDelete = schedule.status !== 'running'; + + return ( +
e.stopPropagation()} + onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }}> +
{schedule.name}
+ {canEdit && } + {canCancel && } + + {canDelete &&
} + {canDelete && } +
+ ); +} + function _NowLine({ now, dayStart, pph }) { if (!_sameDay(now, dayStart)) return null; const min = _minutesIntoDay(now, dayStart); @@ -1121,6 +1261,7 @@ function Schedule({ navigate }) { const [showNew, setShowNew] = React.useState(false); const [newDefaults, setNewDefaults] = React.useState(null); const [editing, setEditing] = React.useState(null); + const [ctxMenu, setCtxMenu] = React.useState(null); // { schedule, x, y } const [view, setView] = React.useState('today'); // 'today' | 'week' | 'list' const [day, setDay] = React.useState(() => _dayStart(new Date())); const [listFilter, setListFilter] = React.useState('upcoming'); @@ -1206,6 +1347,39 @@ function Schedule({ navigate }) { window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' }).then(load).catch(e => alert('Delete failed: ' + e.message)); }; + // Drag-resize commit: optimistically patch the in-memory schedule so the + // block stays put after the user lets go, then PUT the new times. The + // refetch reconciles in case the server adjusted anything (or rejected). + const handleResize = (s, newStart, newEnd) => { + setSchedules(prev => prev ? prev.map(x => x.id === s.id ? { ...x, start_at: newStart, end_at: newEnd } : x) : prev); + window.ZAMPP_API.fetch('/schedules/' + s.id, { + method: 'PUT', + body: JSON.stringify({ start_at: newStart, end_at: newEnd }), + }) + .then(load) + .catch(e => { alert('Resize failed: ' + e.message); load(); }); + }; + + const openCtx = (s, ev) => setCtxMenu({ schedule: s, x: ev.clientX, y: ev.clientY }); + // Dismiss the context menu on any outside click — capture phase so a + // click on a menu item still fires before the menu unmounts. + React.useEffect(() => { + if (!ctxMenu) return; + const close = () => setCtxMenu(null); + window.addEventListener('click', close); + window.addEventListener('contextmenu', close); + window.addEventListener('scroll', close, true); + return () => { + window.removeEventListener('click', close); + window.removeEventListener('contextmenu', close); + window.removeEventListener('scroll', close, true); + }; + }, [ctxMenu]); + + const copyId = (id) => { + if (navigator.clipboard) navigator.clipboard.writeText(id).catch(() => {}); + }; + // Days for Week view: the 7-day window starting at the Sunday of `day`. const weekDays = React.useMemo(() => { const sun = _addDays(dayStart, -dayStart.getDay()); @@ -1276,6 +1450,8 @@ function Schedule({ navigate }) { now={now} projects={projects} onEventClick={(s) => setEditing(s)} + onEventContextMenu={openCtx} + onEventResize={handleResize} onEmptyClick={openNewAt} /> ))}
@@ -1308,6 +1484,8 @@ function Schedule({ navigate }) { now={now} projects={projects} onEventClick={(s) => setEditing(s)} + onEventContextMenu={openCtx} + onEventResize={handleResize} onEmptyClick={openNewAt} /> ))}
@@ -1368,6 +1546,17 @@ function Schedule({ navigate }) { onClose={() => { setShowNew(false); setNewDefaults(null); }} onCreated={() => { setShowNew(false); setNewDefaults(null); load(); }} />} {editing && setEditing(null)} onSaved={() => { setEditing(null); load(); }} />} + {ctxMenu && ( + <_ScheduleContextMenu + schedule={ctxMenu.schedule} + x={ctxMenu.x} + y={ctxMenu.y} + onClose={() => setCtxMenu(null)} + onEdit={() => { const s = ctxMenu.schedule; setCtxMenu(null); setEditing(s); }} + onCancel={() => { const s = ctxMenu.schedule; setCtxMenu(null); cancel(s); }} + onDelete={() => { const s = ctxMenu.schedule; setCtxMenu(null); remove(s); }} + onCopyId={() => { const id = ctxMenu.schedule.id; setCtxMenu(null); copyId(id); }} /> + )} ); } diff --git a/services/web-ui/public/styles-rest.css b/services/web-ui/public/styles-rest.css index 55df268..12f36ee 100644 --- a/services/web-ui/public/styles-rest.css +++ b/services/web-ui/public/styles-rest.css @@ -833,17 +833,17 @@ position: absolute; top: 8px; height: calc(var(--epg-row-h) - 16px); - display: flex; align-items: center; gap: 8px; - padding: 0 10px 0 14px; + display: flex; align-items: stretch; background: var(--bg-2); border: 1px solid var(--border-strong); border-radius: 5px; font-size: 11.5px; text-align: left; color: var(--text-1); - cursor: pointer; overflow: hidden; - transition: background 80ms, border-color 80ms, transform 80ms; + transition: background 80ms, border-color 80ms, transform 80ms, box-shadow 80ms; + /* Don't paint text selection while the operator drags the block. */ + user-select: none; -webkit-user-select: none; } .epg-block:hover { background: var(--bg-3); @@ -851,12 +851,28 @@ transform: translateY(-1px); z-index: 1; } +.epg-block.dragging { + transform: translateY(-1px); + z-index: 10; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 0 1px var(--epg-block-color, var(--accent)); + transition: none; /* follow cursor 1:1, no easing during drag */ +} .epg-block-bar { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--epg-block-color, var(--accent)); + pointer-events: none; /* let the resize handle behind catch pointerdown */ } +.epg-block-body { + flex: 1; min-width: 0; + display: flex; align-items: center; gap: 8px; + padding: 0 10px 0 14px; + cursor: pointer; + overflow: hidden; +} +.epg-block.resizable .epg-block-body { cursor: grab; } +.epg-block.dragging .epg-block-body { cursor: grabbing; } .epg-block-name { flex: 1; min-width: 0; font-weight: 600; @@ -866,6 +882,31 @@ font-size: 10px; color: var(--text-3); flex-shrink: 0; } + +/* Resize handles. 8px-wide invisible-by-default hit zones at each end; + a 2px tinted bar fades in on hover so the operator sees where the + resize affordance is. Hides for non-pending blocks (no .resizable). */ +.epg-block-handle { + position: absolute; + top: 0; bottom: 0; + width: 8px; + cursor: ew-resize; + z-index: 3; +} +.epg-block-handle.left { left: 0; } +.epg-block-handle.right { right: 0; } +.epg-block-handle::after { + content: ''; + position: absolute; top: 6px; bottom: 6px; + width: 2px; + background: var(--epg-block-color, var(--accent)); + opacity: 0; + transition: opacity 80ms; +} +.epg-block-handle.left::after { left: 2px; } +.epg-block-handle.right::after { right: 2px; } +.epg-block-handle:hover::after, +.epg-block.dragging .epg-block-handle::after { opacity: 0.85; } .epg-block-glyph { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;