diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index c58d912..e951fef 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -1018,55 +1018,158 @@ function _EpgRuler({ pph }) { ); } -function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, onClick }) { - const s = new Date(event.start_at); - const e = new Date(event.end_at); - const startMin = _minutesIntoDay(s, dayStart); - const endMin = _minutesIntoDay(e, dayStart); +// Minimum schedule length the UI permits while dragging. Anything shorter +// rarely reflects a real plan and would let the operator accidentally +// dismiss a block to zero width. +const _EPG_MIN_MS = 5 * 60 * 1000; +// Drag snap quantum. Mirrors the new-schedule click snap so a resized +// block lines up to the same grid an operator just placed a new one on. +const _EPG_SNAP_MIN = 15; +// Pointer travel (in px) before we treat the gesture as a drag rather +// than a click. Below this, pointerup fires the click handler. +const _EPG_DRAG_THRESHOLD = 4; + +function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, onClick, onContextMenu, onResize }) { + // Drag state: null when idle, otherwise the in-flight resize/move + // describing the original times and the current (snapped) preview times. + // We render from this preview while dragging so the block follows the + // cursor without round-tripping through props. + const [drag, setDrag] = React.useState(null); + const blockRef = React.useRef(null); + + const eventStartMs = new Date(event.start_at).getTime(); + const eventEndMs = new Date(event.end_at).getTime(); + const isLive = event.status === 'running' || (event.status === 'pending' && eventStartMs <= now.getTime() && now.getTime() < eventEndMs); + const isFailed = event.status === 'failed'; + const isPast = (event.status === 'completed' || event.status === 'cancelled') || eventEndMs < now.getTime(); + const color = _projectColor(recorder?.project_id, projects); + + // Only pending schedules can be resized. The API rejects PUTs against + // running schedules outright; cancelling them is what an operator + // actually wants there. Terminal statuses are read-only. + const canDrag = event.status === 'pending'; + + const startDrag = (e, type) => { + if (!canDrag) return; + if (e.button !== 0) return; // ignore right-click + e.stopPropagation(); + try { blockRef.current.setPointerCapture(e.pointerId); } catch (_) {} + setDrag({ + type, pointerId: e.pointerId, + startX: e.clientX, + origStart: eventStartMs, origEnd: eventEndMs, + currStart: eventStartMs, currEnd: eventEndMs, + moved: false, + }); + }; + + const onPointerMove = (ev) => { + if (!drag) return; + const dx = ev.clientX - drag.startX; + if (!drag.moved && Math.abs(dx) < _EPG_DRAG_THRESHOLD) return; + const snapMs = _EPG_SNAP_MIN * 60 * 1000; + const dMs = Math.round((dx / pph) * 3600 * 1000 / snapMs) * snapMs; + const dayStartMs = dayStart.getTime(); + const dayEndMs = dayEnd.getTime(); + let cs = drag.origStart, ce = drag.origEnd; + if (drag.type === 'left') { + cs = Math.max(dayStartMs, Math.min(drag.origEnd - _EPG_MIN_MS, drag.origStart + dMs)); + } else if (drag.type === 'right') { + ce = Math.min(dayEndMs, Math.max(drag.origStart + _EPG_MIN_MS, drag.origEnd + dMs)); + } else if (drag.type === 'body') { + cs = drag.origStart + dMs; + ce = drag.origEnd + dMs; + // Clamp to the day; preserve duration when bumping against an edge. + if (cs < dayStartMs) { ce += (dayStartMs - cs); cs = dayStartMs; } + if (ce > dayEndMs) { cs -= (ce - dayEndMs); ce = dayEndMs; } + } + setDrag({ ...drag, currStart: cs, currEnd: ce, moved: true }); + }; + + const endDrag = (ev) => { + if (!drag) return; + try { blockRef.current.releasePointerCapture(drag.pointerId); } catch (_) {} + const d = drag; + setDrag(null); + if (!d.moved) { + // Treat as a click — open the edit modal. + onClick(event); + return; + } + if (d.currStart === d.origStart && d.currEnd === d.origEnd) return; + onResize(event, new Date(d.currStart).toISOString(), new Date(d.currEnd).toISOString()); + }; + + // Render from drag preview while a gesture is in flight so the block + // tracks the pointer; otherwise from the canonical event prop. + const dispStartMs = drag ? drag.currStart : eventStartMs; + const dispEndMs = drag ? drag.currEnd : eventEndMs; + const dispStart = new Date(dispStartMs); + const dispEnd = new Date(dispEndMs); + const startMin = _minutesIntoDay(dispStart, dayStart); + const endMin = _minutesIntoDay(dispEnd, dayStart); const left = (startMin / 60) * pph; const width = Math.max(40, ((endMin - startMin) / 60) * pph); - const isLive = event.status === 'running' || (event.status === 'pending' && s <= now && now < e); - const isFailed = event.status === 'failed'; - const isPast = (event.status === 'completed' || event.status === 'cancelled') || e < now; - const color = _projectColor(recorder?.project_id, projects); - const classes = ['epg-block']; - if (isLive) classes.push('live'); - if (isFailed) classes.push('failed'); + if (isLive) classes.push('live'); + if (isFailed) classes.push('failed'); else if (isPast) classes.push('past'); + if (drag && drag.moved) classes.push('dragging'); + if (canDrag) classes.push('resizable'); return ( - + {/* 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;