mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Driving the app in a real browser showed a context menu whose only item read
"schedule.ctx_new". t() returns the KEY when a string is missing — it never
returns undefined — so a missing key renders literally, and the common
`t('x') || 'A readable default'` guard is dead code: the key is truthy, the
default can never fire, and the pattern hides the problem instead of covering
it. Every occurrence of it in the app was doing exactly that.
Nineteen strings were affected, most of them predating this work: fifteen in
the self-hosted update panel and four in video walls, all of which have been
showing raw keys to users. The intended text was recovered from the dead
defaults, so the wording is the authors' own, and the defaults are removed
rather than left to imply a safety net that does not exist.
A test now walks the views for the keys they actually ask for and fails on any
that English does not define, and separately rejects the `|| default` pattern.
Neither problem is visible to a syntax check, a unit test, or review — only to
someone looking at the screen — so the guard is the only thing that keeps them
from coming back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
672 lines
35 KiB
JavaScript
672 lines
35 KiB
JavaScript
import { api } from '../api.js';
|
|
import { showToast } from '../components/toast.js';
|
|
import { t } from '../i18n.js';
|
|
import {
|
|
HOUR_PX, pxToMinutes, minutesToPx, rangeFromDrag, moveRange, resizeRange,
|
|
toLocalStamp, formatRange, canMoveAcrossDays, editsWholeSeries, isDrag,
|
|
dragArmMode, LONG_PRESS_MS, DEFAULT_NEW_MIN,
|
|
} from '../lib/schedule-grid.js';
|
|
|
|
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
|
|
|
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
|
|
|
function esc(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
|
|
|
|
export async function render(container) {
|
|
const [devices, content, groups, playlists, layoutsRaw] = await Promise.all([
|
|
api.getDevices(),
|
|
api.getContent(),
|
|
api.getGroups(),
|
|
api.getPlaylists(),
|
|
API('/layouts'),
|
|
]);
|
|
const layouts = (Array.isArray(layoutsRaw) ? layoutsRaw : []).filter(l => !l.is_template);
|
|
|
|
const today = new Date();
|
|
const weekStart = new Date(today);
|
|
weekStart.setDate(today.getDate() - today.getDay());
|
|
weekStart.setHours(0, 0, 0, 0);
|
|
|
|
const DAYS = [
|
|
t('schedule.day.sun'), t('schedule.day.mon'), t('schedule.day.tue'),
|
|
t('schedule.day.wed'), t('schedule.day.thu'), t('schedule.day.fri'),
|
|
t('schedule.day.sat'),
|
|
];
|
|
|
|
container.innerHTML = `
|
|
<div class="page-header">
|
|
<div><h1>${t('schedule.title')} <span class="help-tip" data-tip="${t('schedule.help_tip')}">?</span></h1><div class="subtitle">${t('schedule.subtitle')}</div></div>
|
|
</div>
|
|
<div class="schedule-controls" style="display:flex;gap:12px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
|
|
<select id="schedDevice" class="input" style="width:220px;max-width:100%;background:var(--bg-input)">
|
|
<option value="*">${t('schedule.all_screens')}</option>
|
|
${devices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('')}
|
|
</select>
|
|
<button class="btn btn-secondary btn-sm" id="prevWeek">${t('schedule.prev_week')}</button>
|
|
<span id="weekLabel" style="color:var(--text-secondary);font-size:13px"></span>
|
|
<button class="btn btn-secondary btn-sm" id="nextWeek">${t('schedule.next_week')}</button>
|
|
<button class="btn btn-primary btn-sm" id="addScheduleBtn">${t('schedule.add_schedule')}</button>
|
|
</div>
|
|
<!-- Legend: only meaningful in all-screens mode, where blocks from different targets
|
|
share one grid. Hidden for a single screen so that view stays uncluttered. -->
|
|
<div id="schedLegend" style="display:none;flex-wrap:wrap;gap:10px;margin:-6px 0 14px;font-size:12px"></div>
|
|
<div style="overflow-x:auto">
|
|
<div id="calendar" style="display:grid;grid-template-columns:60px repeat(7,1fr);min-width:800px;border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden"></div>
|
|
</div>
|
|
|
|
<div class="modal-overlay" id="scheduleModal" style="display:none">
|
|
<div class="modal" style="width:480px">
|
|
<div class="modal-header"><h3 id="schedModalTitle">${t('schedule.add_schedule')}</h3>
|
|
<button class="btn-icon" onclick="document.getElementById('scheduleModal').style.display='none'">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="form-group"><label>${t('schedule.apply_to')}</label>
|
|
<div style="display:flex;gap:16px;margin-bottom:8px">
|
|
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px">
|
|
<input type="radio" name="schedTarget" value="device" checked id="schedTargetDevice"> ${t('schedule.target_device')}
|
|
</label>
|
|
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px">
|
|
<input type="radio" name="schedTarget" value="group" id="schedTargetGroup"> ${t('schedule.target_group')}
|
|
</label>
|
|
</div>
|
|
<select id="schedDeviceSelect" class="input" style="background:var(--bg-input)">
|
|
${devices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('')}
|
|
</select>
|
|
<select id="schedGroupSelect" class="input" style="background:var(--bg-input);display:none">
|
|
${groups.map(g => `<option value="${esc(g.id)}">${esc(g.name)} (${t('schedule.group_devices_count', { n: g.device_count })})</option>`).join('')}
|
|
</select>
|
|
${groups.length === 0 ? `<div id="schedNoGroups" style="display:none;color:var(--text-muted);font-size:12px;margin-top:4px">${t('schedule.no_groups_msg')}</div>` : ''}
|
|
<div id="schedZoneNote" style="display:none;color:var(--text-muted);font-size:11px;margin-top:4px">${t('schedule.zone_note')}</div>
|
|
</div>
|
|
<div class="form-group"><label>${t('schedule.playlist_override')}</label>
|
|
<select id="schedPlaylist" class="input" style="background:var(--bg-input)">
|
|
<option value="">${t('schedule.no_playlist_override')}</option>
|
|
${playlists.map(p => `<option value="${esc(p.id)}">${esc(p.name)}${p.status === 'draft' ? ' ' + t('schedule.draft_suffix') : ''}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="form-group"><label>${t('schedule.layout_override')}</label>
|
|
<select id="schedLayout" class="input" style="background:var(--bg-input)">
|
|
<option value="">${t('schedule.no_layout_override')}</option>
|
|
${layouts.map(l => `<option value="${esc(l.id)}">${esc(l.name)}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="form-group"><label>${t('schedule.content_label')} <span style="color:var(--text-muted);font-weight:normal;font-size:11px">${t('schedule.content_hint')}</span></label>
|
|
<select id="schedContent" class="input" style="background:var(--bg-input)">
|
|
<option value="">${t('schedule.content_none')}</option>
|
|
${content.map(c => `<option value="${esc(c.id)}">${esc(c.filename)}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="form-group"><label>${t('schedule.title_label')}</label><input type="text" id="schedTitle" class="input" placeholder="${t('schedule.title_placeholder')}"></div>
|
|
<div style="display:flex;gap:12px">
|
|
<div class="form-group" style="flex:1"><label>${t('schedule.start_time')}</label><input type="time" id="schedStart" class="input" value="09:00"></div>
|
|
<div class="form-group" style="flex:1"><label>${t('schedule.end_time')}</label><input type="time" id="schedEnd" class="input" value="17:00"></div>
|
|
</div>
|
|
<!-- Which clock these hours are on. The server resolves the target's zone and the
|
|
player evaluates in it, but the user had no way to SEE that: hours typed as
|
|
"9 to 5" silently became UTC, so a schedule could sit closed while its owner
|
|
watched the screen. Stating the zone is the whole fix from the UI side. -->
|
|
<div id="schedTzNote" style="font-size:12px;color:var(--text-muted);margin:-4px 0 12px"></div>
|
|
<div class="form-group"><label>${t('schedule.repeat')}</label>
|
|
<select id="schedRepeat" class="input" style="background:var(--bg-input)">
|
|
<option value="">${t('schedule.repeat_none')}</option>
|
|
<option value="FREQ=DAILY">${t('schedule.repeat_daily')}</option>
|
|
<option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR">${t('schedule.repeat_weekdays')}</option>
|
|
<option value="FREQ=WEEKLY;BYDAY=SA,SU">${t('schedule.repeat_weekends')}</option>
|
|
<option value="FREQ=WEEKLY">${t('schedule.repeat_weekly')}</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group"><label>${t('schedule.priority')}</label><input type="number" id="schedPriority" class="input" value="0" min="0" max="100"></div>
|
|
<div class="form-group"><label>${t('schedule.color')}</label><input type="color" id="schedColor" value="#3B82F6" style="width:60px;height:32px;border:none;cursor:pointer"></div>
|
|
</div>
|
|
<div class="modal-footer" style="display:flex;justify-content:space-between;gap:8px">
|
|
<button class="btn btn-danger" id="deleteScheduleBtn" style="display:none">${t('common.delete')}</button>
|
|
<div style="display:flex;gap:8px;margin-left:auto">
|
|
<button class="btn btn-secondary" onclick="document.getElementById('scheduleModal').style.display='none'">${t('common.cancel')}</button>
|
|
<button class="btn btn-primary" id="saveScheduleBtn">${t('common.save')}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
let currentWeekStart = new Date(weekStart);
|
|
let editingId = null;
|
|
|
|
const deviceRadio = document.getElementById('schedTargetDevice');
|
|
const groupRadio = document.getElementById('schedTargetGroup');
|
|
const deviceSelect = document.getElementById('schedDeviceSelect');
|
|
const groupSelect = document.getElementById('schedGroupSelect');
|
|
const noGroupsMsg = document.getElementById('schedNoGroups');
|
|
const zoneNote = document.getElementById('schedZoneNote');
|
|
|
|
function updateTargetVisibility() {
|
|
const isGroup = groupRadio.checked;
|
|
deviceSelect.style.display = isGroup ? 'none' : '';
|
|
groupSelect.style.display = isGroup ? '' : 'none';
|
|
if (noGroupsMsg) noGroupsMsg.style.display = (isGroup && groups.length === 0) ? '' : 'none';
|
|
zoneNote.style.display = isGroup ? '' : 'none';
|
|
}
|
|
|
|
deviceRadio.addEventListener('change', updateTargetVisibility);
|
|
groupRadio.addEventListener('change', updateTargetVisibility);
|
|
|
|
// State which clock the hours above are on. The server stores a new schedule in the
|
|
// TARGET's zone (lib/device-timezone) and the player evaluates in that same zone — but
|
|
// the dialog never said so. A user typing "09:00" reasonably assumes their own clock;
|
|
// when the target sits in another zone the schedule is correct and still appears to do
|
|
// nothing, because it opens hours later. Naming the zone is the fix from the UI side.
|
|
const tzNote = document.getElementById('schedTzNote');
|
|
function updateTzNote() {
|
|
if (!tzNote) return;
|
|
const local = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
|
let zone = null;
|
|
if (!groupRadio.checked) {
|
|
const d = devices.find(x => x.id === deviceSelect.value);
|
|
zone = (d && d.timezone && d.timezone !== 'UTC' ? d.timezone : null) || (d && d.reported_timezone) || null;
|
|
}
|
|
if (!zone) { tzNote.textContent = t('schedule.tz_unknown'); return; }
|
|
tzNote.textContent = (zone === local)
|
|
? t('schedule.tz_same').replace('{zone}', zone)
|
|
: t('schedule.tz_device').replace('{zone}', zone).replace('{local}', local || '—');
|
|
}
|
|
deviceRadio.addEventListener('change', updateTzNote);
|
|
groupRadio.addEventListener('change', updateTzNote);
|
|
deviceSelect.addEventListener('change', updateTzNote);
|
|
updateTzNote();
|
|
|
|
function updateWeekLabel() {
|
|
const end = new Date(currentWeekStart);
|
|
end.setDate(end.getDate() + 6);
|
|
document.getElementById('weekLabel').textContent =
|
|
`${currentWeekStart.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}`;
|
|
}
|
|
|
|
// Stable colour per target, so the same screen is the same colour every week and
|
|
// across reloads. Hashing the id beats cycling a palette by index, which reshuffles
|
|
// whenever a device is added or removed.
|
|
const TARGET_COLORS = ['#3B82F6','#8B5CF6','#EC4899','#F59E0B','#10B981','#06B6D4','#EF4444','#84CC16','#A855F7','#14B8A6'];
|
|
function colorForTarget(key) {
|
|
let h = 0;
|
|
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0;
|
|
return TARGET_COLORS[h % TARGET_COLORS.length];
|
|
}
|
|
// What an event is aimed at. Group schedules name the group; device schedules name the
|
|
// device. In all-screens mode this is the thing the operator is actually scanning for.
|
|
function targetOf(ev) {
|
|
if (ev.group_id) return { key: 'g:' + ev.group_id, name: ev.group_name || t('schedule.target_group'), isGroup: true };
|
|
return { key: 'd:' + (ev.device_id || '?'), name: ev.device_name || t('schedule.target_device'), isGroup: false };
|
|
}
|
|
|
|
async function loadCalendar() {
|
|
const deviceId = document.getElementById('schedDevice').value;
|
|
if (!deviceId) return;
|
|
const allScreens = deviceId === '*';
|
|
updateWeekLabel();
|
|
|
|
// all=1 rather than a workspace id — the server scopes to the caller's own workspace.
|
|
const scope = allScreens ? 'all=1' : `device_id=${encodeURIComponent(deviceId)}`;
|
|
const events = await API(`/schedules/week?date=${currentWeekStart.toISOString()}&${scope}`);
|
|
|
|
const cal = document.getElementById('calendar');
|
|
let html = '<div style="background:var(--bg-secondary);border-bottom:1px solid var(--border)"></div>';
|
|
|
|
for (let d = 0; d < 7; d++) {
|
|
const date = new Date(currentWeekStart);
|
|
date.setDate(date.getDate() + d);
|
|
const isToday = date.toDateString() === new Date().toDateString();
|
|
html += `<div style="padding:8px;text-align:center;background:var(--bg-secondary);border-bottom:1px solid var(--border);border-left:1px solid var(--border);
|
|
${isToday ? 'color:var(--accent);font-weight:600' : 'color:var(--text-secondary)'};font-size:12px">
|
|
${DAYS[d]}<br>${date.getDate()}
|
|
</div>`;
|
|
}
|
|
|
|
for (const h of HOURS) {
|
|
html += `<div style="padding:4px 8px;font-size:10px;color:var(--text-muted);border-bottom:1px solid var(--border);text-align:right">${h === 0 ? t('schedule.hour_12am') : h < 12 ? h + t('schedule.hour_am') : h === 12 ? t('schedule.hour_12pm') : (h - 12) + t('schedule.hour_pm')}</div>`;
|
|
for (let d = 0; d < 7; d++) {
|
|
html += `<div style="position:relative;min-height:${HOUR_PX}px;height:${HOUR_PX}px;border-bottom:1px solid var(--border);border-left:1px solid var(--border);background:var(--bg-primary)" data-hour="${h}" data-day="${d}"></div>`;
|
|
}
|
|
}
|
|
|
|
cal.innerHTML = html;
|
|
|
|
const seenTargets = new Map();
|
|
events.forEach(ev => {
|
|
const start = new Date(ev.instance_start || ev.start_time);
|
|
const end = new Date(ev.instance_end || ev.end_time);
|
|
const dayIdx = start.getDay();
|
|
const startHour = start.getHours() + start.getMinutes() / 60;
|
|
const endHour = end.getHours() + end.getMinutes() / 60;
|
|
const duration = endHour - startHour;
|
|
|
|
const cell = cal.querySelector(`[data-hour="${Math.floor(startHour)}"][data-day="${dayIdx}"]`);
|
|
if (!cell) return;
|
|
|
|
const isGroupSchedule = !!ev.group_id;
|
|
const target = targetOf(ev);
|
|
seenTargets.set(target.key, target);
|
|
const block = document.createElement('div');
|
|
const topOffset = (startHour - Math.floor(startHour)) * HOUR_PX;
|
|
// In all-screens mode colour identifies WHO the block is for, so several targets share
|
|
// one grid and stay tellable apart. On a single screen the schedule's own colour is
|
|
// kept — there is only one target, so colour is free to mean something else.
|
|
const bg = allScreens ? colorForTarget(target.key) : (ev.color || '#3B82F6');
|
|
const tall = duration * HOUR_PX >= 34;
|
|
block.style.cssText = `position:absolute;top:${topOffset}px;left:2px;right:2px;height:${Math.max(18, duration * HOUR_PX)}px;
|
|
background:${bg};border-radius:3px;padding:2px 4px;font-size:10px;color:white;overflow:hidden;cursor:grab;z-index:1;opacity:0.92;
|
|
line-height:1.25;${isGroupSchedule ? 'border:1.5px dashed rgba(255,255,255,0.65);' : ''}`;
|
|
|
|
const label = ev.title || ev.playlist_name || ev.content_name || ev.widget_name || t('schedule.scheduled_label');
|
|
if (allScreens && tall) {
|
|
// Two lines when there is room: who it is for, then what plays. The target reads
|
|
// first because that is what the eye is scanning the grid for.
|
|
block.innerHTML = `<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(target.name)}</div>`
|
|
+ `<div style="opacity:.85;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(label)}</div>`;
|
|
} else {
|
|
block.textContent = (allScreens || isGroupSchedule) ? `${target.name} \u00b7 ${label}` : label;
|
|
}
|
|
|
|
const kind = isGroupSchedule ? t('schedule.target_group') : t('schedule.target_device');
|
|
block.title = `${kind}: ${target.name}\n${label}\n${start.toLocaleTimeString()} - ${end.toLocaleTimeString()}`
|
|
+ `\n${t('schedule.tooltip_priority', { n: ev.priority })}`
|
|
+ (ev.timezone ? `\n${t('schedule.tz_same').replace('{zone}', ev.timezone)}` : '');
|
|
block.dataset.schedId = ev.id;
|
|
block._ev = ev;
|
|
block.onclick = (e) => { if (dragState && dragState.moved) return; editSchedule(ev); };
|
|
// Bottom grip: the affordance that makes a block resizable rather than only movable.
|
|
// Hidden on very short blocks, where a grip would cover the whole thing.
|
|
if (duration * HOUR_PX >= 22) {
|
|
const grip = document.createElement('div');
|
|
grip.className = 'sched-resize-grip';
|
|
grip.style.cssText = 'position:absolute;left:0;right:0;bottom:0;height:10px;cursor:ns-resize;'
|
|
+ 'background:linear-gradient(to bottom,transparent,rgba(0,0,0,.28));';
|
|
block.appendChild(grip);
|
|
}
|
|
cell.appendChild(block);
|
|
});
|
|
|
|
attachGridInteractions(cal);
|
|
|
|
// Legend — only in all-screens mode, where the grid mixes targets. Sorted so the order
|
|
// is stable between reloads rather than following whatever the query happened to return.
|
|
const legend = document.getElementById('schedLegend');
|
|
if (legend) {
|
|
const targets = [...seenTargets.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
legend.style.display = (allScreens && targets.length) ? 'flex' : 'none';
|
|
legend.innerHTML = targets.map(tg => `
|
|
<span style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary)">
|
|
<span style="width:11px;height:11px;border-radius:3px;background:${colorForTarget(tg.key)};
|
|
${tg.isGroup ? 'border:1.5px dashed rgba(255,255,255,0.65);' : ''}"></span>${esc(tg.name)}
|
|
</span>`).join('');
|
|
}
|
|
}
|
|
|
|
// ==================== Direct manipulation (Outlook-style) ====================
|
|
// The calendar was read-only: the only way to create or move anything was the dialog. On a week
|
|
// grid that is the wrong instrument — the grid already shows exactly where a thing goes, so the
|
|
// grid should be where you put it. Three gestures, all sharing one pointer loop:
|
|
// drag empty space -> create (opens the dialog PREFILLED with the time you drew)
|
|
// drag a block -> move
|
|
// drag a block grip -> resize the end
|
|
//
|
|
// A drag is committed on pointerup, never mid-move, so an accidental nudge costs nothing. The
|
|
// click-to-edit handler is suppressed when a drag actually moved, or every drag would also open
|
|
// the dialog on release.
|
|
let dragState = null;
|
|
let ghostEl = null;
|
|
|
|
const dayColumnOf = (el) => { const c = el.closest('[data-day]'); return c ? Number(c.dataset.day) : null; };
|
|
|
|
function gridMinutesFromEvent(e, cal) {
|
|
// Absolute minutes-since-midnight from the pointer, using the hour cell under it as the datum
|
|
// rather than the grid top — the header row and any borders would otherwise skew every value.
|
|
const cell = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-hour]');
|
|
const ref = cell || (dragState && dragState.refCell);
|
|
if (!ref) return null;
|
|
const r = ref.getBoundingClientRect();
|
|
return Number(ref.dataset.hour) * 60 + pxToMinutes(e.clientY - r.top);
|
|
}
|
|
|
|
function showGhost(cal, dayIdx, startMin, endMin, label) {
|
|
const host = cal.querySelector(`[data-hour="${Math.floor(startMin / 60)}"][data-day="${dayIdx}"]`);
|
|
if (!host) return;
|
|
if (!ghostEl) {
|
|
ghostEl = document.createElement('div');
|
|
ghostEl.className = 'sched-ghost';
|
|
ghostEl.style.cssText = 'position:absolute;left:2px;right:2px;border-radius:3px;z-index:5;pointer-events:none;'
|
|
+ 'background:var(--accent,#3B82F6);opacity:.55;color:#fff;font-size:10px;padding:2px 4px;line-height:1.2;'
|
|
+ 'border:1px solid rgba(255,255,255,.8);overflow:hidden';
|
|
}
|
|
ghostEl.style.top = `${minutesToPx(startMin - Math.floor(startMin / 60) * 60)}px`;
|
|
ghostEl.style.height = `${Math.max(14, minutesToPx(endMin - startMin))}px`;
|
|
ghostEl.textContent = label;
|
|
host.appendChild(ghostEl);
|
|
}
|
|
function clearGhost() { if (ghostEl && ghostEl.parentNode) ghostEl.parentNode.removeChild(ghostEl); }
|
|
|
|
function attachGridInteractions(cal) {
|
|
cal.addEventListener('pointerdown', (e) => {
|
|
if (e.button !== 0) return; // left button only; right opens the menu
|
|
const block = e.target.closest('[data-sched-id]');
|
|
const cell = e.target.closest('[data-hour][data-day]');
|
|
if (!cell) return;
|
|
const startMin = gridMinutesFromEvent(e, cal);
|
|
if (startMin == null) return;
|
|
|
|
const origin = { x: e.clientX, y: e.clientY };
|
|
if (block && block._ev) {
|
|
const ev = block._ev;
|
|
const s = new Date(ev.instance_start || ev.start_time);
|
|
const en = new Date(ev.instance_end || ev.end_time);
|
|
const evStart = s.getHours() * 60 + s.getMinutes();
|
|
const evEnd = en.getHours() * 60 + en.getMinutes();
|
|
dragState = {
|
|
kind: e.target.classList.contains('sched-resize-grip') ? 'resize' : 'move',
|
|
ev, block, refCell: cell, moved: false, origin,
|
|
grabOffset: startMin - evStart,
|
|
evStart, evEnd, dayIdx: dayColumnOf(cell),
|
|
};
|
|
} else {
|
|
dragState = { kind: 'create', anchorMin: startMin, refCell: cell, moved: false, origin, dayIdx: dayColumnOf(cell) };
|
|
}
|
|
// A touchscreen cannot arm the way a mouse does. The browser decides at touch-START
|
|
// whether this gesture scrolls the page; by the time a finger has moved far enough to look
|
|
// like a drag, scrolling has already begun and the pointer stream is cancelled. So on touch
|
|
// we wait for a HOLD, and only then take the gesture over. Everything else still scrolls
|
|
// normally, which is what a phone user expects a calendar to do.
|
|
dragState.armMode = dragArmMode(e.pointerType);
|
|
if (dragState.armMode === 'longpress') {
|
|
dragState.armed = false;
|
|
dragState.longPressTimer = setTimeout(() => {
|
|
if (!dragState) return;
|
|
dragState.armed = true;
|
|
cal.style.touchAction = 'none'; // taken over — now the page must NOT scroll
|
|
if (dragState.block) dragState.block.style.opacity = '0.35';
|
|
if (navigator.vibrate) { try { navigator.vibrate(10); } catch (_) { /* optional */ } }
|
|
}, LONG_PRESS_MS);
|
|
} else {
|
|
dragState.armed = true;
|
|
}
|
|
cal.setPointerCapture?.(e.pointerId);
|
|
});
|
|
|
|
cal.addEventListener('pointermove', (e) => {
|
|
if (!dragState) return;
|
|
// Ignore the jitter of an ordinary click. Until the pointer has actually travelled, this
|
|
// is still a click and must stay one, or click-to-edit never fires.
|
|
const travelled = isDrag(e.clientX - dragState.origin.x, e.clientY - dragState.origin.y);
|
|
// Touch that moves BEFORE the hold completes is the user scrolling. Let go of it entirely
|
|
// rather than fighting the browser for the gesture.
|
|
if (dragState.armMode === 'longpress' && !dragState.armed) {
|
|
if (travelled) { clearTimeout(dragState.longPressTimer); dragState = null; clearGhost(); }
|
|
return;
|
|
}
|
|
if (!dragState.moved) {
|
|
if (!travelled) return;
|
|
dragState.moved = true;
|
|
cal.style.touchAction = 'none'; // stop a touch drag scrolling the page
|
|
cal.style.cursor = dragState.kind === 'resize' ? 'ns-resize' : 'grabbing';
|
|
if (dragState.block) dragState.block.style.opacity = '0.35'; // show what is being moved
|
|
}
|
|
const now = gridMinutesFromEvent(e, cal);
|
|
if (now == null) return;
|
|
let range, day = dragState.dayIdx;
|
|
if (dragState.kind === 'create') {
|
|
range = rangeFromDrag(dragState.anchorMin, now);
|
|
} else if (dragState.kind === 'resize') {
|
|
range = resizeRange(dragState.evStart, now);
|
|
} else {
|
|
range = moveRange(now - dragState.grabOffset, dragState.evEnd - dragState.evStart);
|
|
// Sideways only where the day is a real date. A recurring instance's day comes from its
|
|
// rule, so dragging it across columns would silently rewrite the recurrence.
|
|
const overDay = dayColumnOf(document.elementFromPoint(e.clientX, e.clientY) || dragState.refCell);
|
|
if (overDay != null && canMoveAcrossDays(dragState.ev)) day = overDay;
|
|
}
|
|
dragState.pending = { range, day };
|
|
clearGhost();
|
|
showGhost(cal, day, range.startMin, range.endMin, formatRange(range.startMin, range.endMin));
|
|
});
|
|
|
|
const resetDragChrome = (st) => {
|
|
cal.style.touchAction = '';
|
|
cal.style.cursor = '';
|
|
if (st && st.block) st.block.style.opacity = '';
|
|
};
|
|
const finish = async (e) => {
|
|
const st = dragState;
|
|
dragState = null;
|
|
clearGhost();
|
|
if (st) clearTimeout(st.longPressTimer);
|
|
resetDragChrome(st);
|
|
try { cal.releasePointerCapture?.(e.pointerId); } catch (_) { /* already released */ }
|
|
if (!st) return;
|
|
// A tap or click on empty space with no drag still means "put something here" — and on a
|
|
// phone it is the ONLY create gesture, since dragging out a range with a finger is awkward.
|
|
if (!st.moved && st.kind === 'create' && st.anchorMin != null) {
|
|
const d0 = new Date(currentWeekStart);
|
|
d0.setDate(d0.getDate() + (st.dayIdx || 0));
|
|
const start = Math.floor(st.anchorMin / 15) * 15;
|
|
openCreateAt(d0, start, Math.min(start + DEFAULT_NEW_MIN, 24 * 60));
|
|
return;
|
|
}
|
|
if (!st.pending || !st.moved) return; // a tap on a block: let the click handler edit it
|
|
const { range, day } = st.pending;
|
|
const dayDate = new Date(currentWeekStart);
|
|
dayDate.setDate(dayDate.getDate() + day);
|
|
|
|
if (st.kind === 'create') {
|
|
openCreateAt(dayDate, range.startMin, range.endMin);
|
|
return;
|
|
}
|
|
if (editsWholeSeries(st.ev)
|
|
&& !confirm(t('schedule.confirm_series'))) {
|
|
loadCalendar();
|
|
return;
|
|
}
|
|
try {
|
|
await API(`/schedules/${st.ev.id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
start_time: toLocalStamp(dayDate, range.startMin),
|
|
end_time: toLocalStamp(dayDate, range.endMin),
|
|
}),
|
|
});
|
|
showToast(t('schedule.toast.saved'), 'success');
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
loadCalendar();
|
|
};
|
|
cal.addEventListener('pointerup', finish);
|
|
cal.addEventListener('pointercancel', () => { const st = dragState; dragState = null; clearGhost(); resetDragChrome(st); });
|
|
|
|
// Right-click: act on what is under the pointer, like every calendar people already use.
|
|
cal.addEventListener('contextmenu', (e) => {
|
|
const cell = e.target.closest('[data-hour][data-day]');
|
|
if (!cell) return;
|
|
e.preventDefault();
|
|
const block = e.target.closest('[data-sched-id]');
|
|
const minutes = gridMinutesFromEvent(e, cal) ?? Number(cell.dataset.hour) * 60;
|
|
const dayDate = new Date(currentWeekStart);
|
|
dayDate.setDate(dayDate.getDate() + (dayColumnOf(cell) || 0));
|
|
showContextMenu(e.clientX, e.clientY, block && block._ev, dayDate, minutes);
|
|
});
|
|
}
|
|
|
|
function showContextMenu(x, y, ev, dayDate, minutes) {
|
|
document.querySelectorAll('.sched-ctx').forEach(n => n.remove());
|
|
const menu = document.createElement('div');
|
|
menu.className = 'sched-ctx';
|
|
menu.style.cssText = `position:fixed;left:${x}px;top:${y}px;z-index:2000;min-width:170px;background:var(--bg-secondary,#1f2530);`
|
|
+ 'border:1px solid var(--border,#333);border-radius:6px;padding:4px;box-shadow:0 6px 24px rgba(0,0,0,.4);font-size:13px';
|
|
const items = ev
|
|
? [[t('schedule.ctx_edit'), () => editSchedule(ev)],
|
|
[t('schedule.ctx_duplicate'), () => duplicateSchedule(ev)],
|
|
[t('schedule.ctx_delete'), () => deleteSchedule(ev)]]
|
|
: [[t('schedule.ctx_new'), () => openCreateAt(dayDate, Math.floor(minutes / 15) * 15, Math.floor(minutes / 15) * 15 + 60)]];
|
|
items.forEach(([label, fn]) => {
|
|
const b = document.createElement('div');
|
|
b.textContent = label;
|
|
b.style.cssText = 'padding:7px 10px;border-radius:4px;cursor:pointer;color:var(--text-primary,#e6edf7)';
|
|
b.onmouseenter = () => { b.style.background = 'var(--bg-primary,#151b2b)'; };
|
|
b.onmouseleave = () => { b.style.background = ''; };
|
|
b.onclick = () => { menu.remove(); fn(); };
|
|
menu.appendChild(b);
|
|
});
|
|
document.body.appendChild(menu);
|
|
const close = (evt) => { if (!menu.contains(evt.target)) { menu.remove(); document.removeEventListener('pointerdown', close, true); } };
|
|
setTimeout(() => document.addEventListener('pointerdown', close, true), 0);
|
|
}
|
|
|
|
async function duplicateSchedule(ev) {
|
|
try {
|
|
await API('/schedules', { method: 'POST', body: JSON.stringify({
|
|
device_id: ev.device_id || null, group_id: ev.group_id || null,
|
|
content_id: ev.content_id || null, playlist_id: ev.playlist_id || null, layout_id: ev.layout_id || null,
|
|
title: ev.title ? `${ev.title} (copy)` : null,
|
|
start_time: ev.start_time, end_time: ev.end_time,
|
|
recurrence: ev.recurrence || null, priority: ev.priority || 0, color: ev.color || '#3B82F6',
|
|
}) });
|
|
showToast(t('schedule.toast.saved'), 'success');
|
|
} catch (err) { showToast(err.message, 'error'); }
|
|
loadCalendar();
|
|
}
|
|
|
|
async function deleteSchedule(ev) {
|
|
if (!confirm(t('schedule.confirm_delete'))) return;
|
|
try {
|
|
await API(`/schedules/${ev.id}`, { method: 'DELETE' });
|
|
showToast(t('schedule.toast.deleted'), 'success');
|
|
} catch (err) { showToast(err.message, 'error'); }
|
|
loadCalendar();
|
|
}
|
|
|
|
// Open the dialog already filled in with the slot that was drawn, so the gesture supplies the
|
|
// times and the dialog only has to supply what it alone knows (which playlist, which target).
|
|
function openCreateAt(dayDate, startMin, endMin) {
|
|
document.getElementById('addScheduleBtn').onclick();
|
|
const hhmm = (m) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
|
|
document.getElementById('schedStart').value = hhmm(startMin);
|
|
document.getElementById('schedEnd').value = hhmm(endMin);
|
|
pendingCreateDate = dayDate;
|
|
}
|
|
let pendingCreateDate = null;
|
|
|
|
function editSchedule(ev) {
|
|
editingId = ev.id;
|
|
document.getElementById('schedModalTitle').textContent = t('schedule.edit_schedule');
|
|
document.getElementById('schedPlaylist').value = ev.playlist_id || '';
|
|
document.getElementById('schedLayout').value = ev.layout_id || '';
|
|
document.getElementById('schedContent').value = ev.content_id || '';
|
|
document.getElementById('schedTitle').value = ev.title || '';
|
|
const start = new Date(ev.start_time);
|
|
const end = new Date(ev.end_time);
|
|
document.getElementById('schedStart').value = `${String(start.getHours()).padStart(2,'0')}:${String(start.getMinutes()).padStart(2,'0')}`;
|
|
document.getElementById('schedEnd').value = `${String(end.getHours()).padStart(2,'0')}:${String(end.getMinutes()).padStart(2,'0')}`;
|
|
document.getElementById('schedRepeat').value = ev.recurrence || '';
|
|
document.getElementById('schedPriority').value = ev.priority || 0;
|
|
document.getElementById('schedColor').value = ev.color || '#3B82F6';
|
|
|
|
if (ev.group_id) {
|
|
groupRadio.checked = true;
|
|
groupSelect.value = ev.group_id;
|
|
} else {
|
|
deviceRadio.checked = true;
|
|
deviceSelect.value = ev.device_id || document.getElementById('schedDevice').value;
|
|
}
|
|
updateTargetVisibility();
|
|
|
|
document.getElementById('deleteScheduleBtn').style.display = '';
|
|
document.getElementById('scheduleModal').style.display = 'flex';
|
|
}
|
|
|
|
document.getElementById('addScheduleBtn').onclick = () => {
|
|
editingId = null;
|
|
document.getElementById('schedModalTitle').textContent = t('schedule.add_schedule');
|
|
document.getElementById('schedTitle').value = '';
|
|
document.getElementById('schedPlaylist').value = '';
|
|
document.getElementById('schedLayout').value = '';
|
|
document.getElementById('schedContent').value = '';
|
|
deviceRadio.checked = true;
|
|
deviceSelect.value = document.getElementById('schedDevice').value;
|
|
updateTargetVisibility();
|
|
document.getElementById('deleteScheduleBtn').style.display = 'none';
|
|
document.getElementById('scheduleModal').style.display = 'flex';
|
|
};
|
|
|
|
document.getElementById('deleteScheduleBtn').onclick = async () => {
|
|
if (!editingId) return;
|
|
if (!confirm(t('schedule.confirm_delete'))) return;
|
|
try {
|
|
await API(`/schedules/${editingId}`, { method: 'DELETE' });
|
|
document.getElementById('scheduleModal').style.display = 'none';
|
|
showToast(t('schedule.toast.deleted'), 'success');
|
|
loadCalendar();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
};
|
|
|
|
document.getElementById('saveScheduleBtn').onclick = async () => {
|
|
const isGroup = groupRadio.checked;
|
|
const contentId = document.getElementById('schedContent').value;
|
|
const startTime = document.getElementById('schedStart').value;
|
|
const endTime = document.getElementById('schedEnd').value;
|
|
|
|
if (isGroup && groups.length === 0) {
|
|
showToast(t('schedule.toast.no_groups'), 'error');
|
|
return;
|
|
}
|
|
|
|
const playlistId = document.getElementById('schedPlaylist').value;
|
|
const layoutId = document.getElementById('schedLayout').value;
|
|
|
|
// The date a new schedule is stamped with. A drag supplies the day it was drawn on;
|
|
// otherwise it is today. Built from LOCAL parts, not toISOString(), which is UTC and puts
|
|
// anyone west of Greenwich on the previous day for part of their evening.
|
|
const dref = pendingCreateDate || new Date();
|
|
const today = `${dref.getFullYear()}-${String(dref.getMonth() + 1).padStart(2, '0')}-${String(dref.getDate()).padStart(2, '0')}`;
|
|
pendingCreateDate = null;
|
|
const data = {
|
|
content_id: contentId || null,
|
|
playlist_id: playlistId || null,
|
|
layout_id: layoutId || null,
|
|
title: document.getElementById('schedTitle').value,
|
|
start_time: `${today}T${startTime}:00`,
|
|
end_time: `${today}T${endTime}:00`,
|
|
recurrence: document.getElementById('schedRepeat').value || null,
|
|
priority: parseInt(document.getElementById('schedPriority').value) || 0,
|
|
color: document.getElementById('schedColor').value,
|
|
};
|
|
|
|
if (isGroup) {
|
|
data.group_id = groupSelect.value;
|
|
} else {
|
|
data.device_id = deviceSelect.value;
|
|
}
|
|
|
|
try {
|
|
if (editingId) {
|
|
await API(`/schedules/${editingId}`, { method: 'PUT', body: JSON.stringify(data) });
|
|
} else {
|
|
await API('/schedules', { method: 'POST', body: JSON.stringify(data) });
|
|
}
|
|
document.getElementById('scheduleModal').style.display = 'none';
|
|
showToast(t('schedule.toast.saved'), 'success');
|
|
loadCalendar();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
};
|
|
|
|
document.getElementById('schedDevice').onchange = loadCalendar;
|
|
document.getElementById('prevWeek').onclick = () => { currentWeekStart.setDate(currentWeekStart.getDate() - 7); loadCalendar(); };
|
|
document.getElementById('nextWeek').onclick = () => { currentWeekStart.setDate(currentWeekStart.getDate() + 7); loadCalendar(); };
|
|
|
|
loadCalendar();
|
|
}
|
|
|
|
export function cleanup() {}
|