feat(schedule): right-click menu + drag-to-resize on EPG event blocks #25
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