feat(schedule): right-click menu + drag-to-resize on EPG event blocks
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 <button> block with a <div> hosting three
zones (left handle / body / right handle). Pointer events with
setPointerCapture so drags survive losing the cursor over the block,
and pointerup demotes back to click if travel was below threshold.
Optimistic local update on resize, PUT /schedules/:id with just the
two changed time fields, refetch to reconcile.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
97f08b32de
commit
3ffffd5b32
2 changed files with 261 additions and 31 deletions
|
|
@ -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 (
|
||||
<button
|
||||
<div
|
||||
ref={blockRef}
|
||||
className={classes.join(' ')}
|
||||
style={{ left, width, '--epg-block-color': color || 'var(--text-3)' }}
|
||||
onClick={(ev) => { ev.stopPropagation(); onClick(event); }}
|
||||
title={event.name + ' · ' + _fmtTime(s) + ' → ' + _fmtTime(e) + (event.error_message ? ' · ' + event.error_message : '')}>
|
||||
onContextMenu={(ev) => { ev.preventDefault(); ev.stopPropagation(); onContextMenu(event, ev); }}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
title={event.name + ' · ' + _fmtTime(dispStart) + ' → ' + _fmtTime(dispEnd) + (event.error_message ? ' · ' + event.error_message : '')}>
|
||||
<span className="epg-block-bar" />
|
||||
<span className="epg-block-name">{event.name}</span>
|
||||
<span className="epg-block-time mono">{_fmtTime(s)}</span>
|
||||
{isLive && <span className="epg-block-glyph live" title="on air">●</span>}
|
||||
{isFailed && <span className="epg-block-glyph failed" title={event.error_message || 'failed'}>!</span>}
|
||||
</button>
|
||||
{/* 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. */}
|
||||
<div className="epg-block-body" onPointerDown={(ev) => startDrag(ev, 'body')}>
|
||||
<span className="epg-block-name">{event.name}</span>
|
||||
<span className="epg-block-time mono">{_fmtTime(dispStart)}{drag && drag.moved ? ' → ' + _fmtTime(dispEnd) : ''}</span>
|
||||
{isLive && <span className="epg-block-glyph live" title="on air">●</span>}
|
||||
{isFailed && <span className="epg-block-glyph failed" title={event.error_message || 'failed'}>!</span>}
|
||||
</div>
|
||||
{canDrag && (
|
||||
<>
|
||||
<span className="epg-block-handle left"
|
||||
onPointerDown={(ev) => startDrag(ev, 'left')}
|
||||
title="Drag to change start time" />
|
||||
<span className="epg-block-handle right"
|
||||
onPointerDown={(ev) => startDrag(ev, 'right')}
|
||||
title="Drag to change end time" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="epg-row" style={{ width: 24 * pph }} onClick={handleRowClick}>
|
||||
<div className="epg-row" style={{ width: 24 * pph }} onPointerUp={handleRowPointerUp}>
|
||||
{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} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div ref={ref} className="ctx-menu" style={{ left: pos.left, top: pos.top }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||
<div className="ctx-header">{schedule.name}</div>
|
||||
{canEdit && <button onClick={onEdit}><Icon name="edit" size={11} />Edit…</button>}
|
||||
{canCancel && <button onClick={onCancel}><Icon name="x" size={11} />Cancel run</button>}
|
||||
<button onClick={onCopyId}><Icon name="library" size={11} />Copy schedule ID</button>
|
||||
{canDelete && <div className="ctx-divider" />}
|
||||
{canDelete && <button className="danger" onClick={onDelete}><Icon name="trash" size={11} />Delete schedule</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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} />
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1308,6 +1484,8 @@ function Schedule({ navigate }) {
|
|||
now={now}
|
||||
projects={projects}
|
||||
onEventClick={(s) => setEditing(s)}
|
||||
onEventContextMenu={openCtx}
|
||||
onEventResize={handleResize}
|
||||
onEmptyClick={openNewAt} />
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1368,6 +1546,17 @@ function Schedule({ navigate }) {
|
|||
onClose={() => { setShowNew(false); setNewDefaults(null); }}
|
||||
onCreated={() => { setShowNew(false); setNewDefaults(null); load(); }} />}
|
||||
{editing && <EditScheduleModal schedule={editing} onClose={() => 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); }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue