Extension: icon-state feedback + overlay picker instead of inline panels
- Injected buttons now show state via the tablet icon itself — spinner while sending, check on success, warning triangle on busy/offline/error — with detail in a hover tooltip, rather than a persistent inline text block - Replaced the in-page dropdown panel (block-inserted for row/banner buttons, fixed-position floating for menu items) with a single centred modal overlay used by all three injection shapes — simpler than maintaining per-variant positioning logic, and looks the same everywhere - Picker overlay now opens automatically when the default tablet is busy (409) or configured but not actually connected (delivered: false), not just when no default is set at all — mirrored in popup.js's own fallback Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
4aeca15281
commit
d7fbdce04d
2 changed files with 214 additions and 136 deletions
|
|
@ -95,7 +95,7 @@ function makeSendButton(bookingId, guestName, variant = 'block') {
|
|||
btn.id = `tcp-btn-${bookingId}-${variant}`;
|
||||
btn.type = 'button';
|
||||
btn.className = variant === 'block' ? 'button most_width' : 'button';
|
||||
btn.innerHTML = '<i class="far fa-tablet-alt fa-fw" aria-hidden="true"></i> <span>Tablet Push</span>';
|
||||
btn.innerHTML = '<i class="tcp-icon far fa-tablet-alt fa-fw" aria-hidden="true"></i> <span>Tablet Push</span>';
|
||||
btn.style.cssText = `
|
||||
${variant === 'block' ? 'margin-top: 6px;' : 'margin-right: 8px;'}
|
||||
background: #be123c; border-color: #be123c; color: #fff;
|
||||
|
|
@ -103,121 +103,193 @@ function makeSendButton(bookingId, guestName, variant = 'block') {
|
|||
btn.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSendClick(btn, bookingId, guestName, variant);
|
||||
handleSendClick(btn, bookingId, guestName);
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Visual feedback is split two ways: a per-button icon swap for the simple
|
||||
// pass/fail cases (no page real estate taken, self-explanatory at a glance),
|
||||
// and the picker overlay below for anything that actually needs a human
|
||||
// decision (no default configured, default busy, default offline). Detail
|
||||
// text lives in the title attribute (hover tooltip) rather than a persistent
|
||||
// inline block — keeps every variant (table-cell row, banner, nav-bar menu
|
||||
// item) visually identical instead of needing per-container layout math.
|
||||
const STATE_ICON = {
|
||||
idle: 'fa-tablet-alt',
|
||||
loading: 'fa-spinner fa-spin',
|
||||
success: 'fa-check',
|
||||
warning: 'fa-exclamation-triangle',
|
||||
error: 'fa-exclamation-triangle',
|
||||
};
|
||||
const STATE_BG = {
|
||||
idle: '#be123c',
|
||||
loading: '#be123c',
|
||||
success: '#166534',
|
||||
warning: '#b45309',
|
||||
error: '#dc2626',
|
||||
};
|
||||
const STATE_ICON_COLOR = {
|
||||
success: '#86efac',
|
||||
warning: '#fde68a',
|
||||
error: '#fecaca',
|
||||
};
|
||||
|
||||
// <button> variants (row/banner) get their whole background tinted; the
|
||||
// context-menu variant is an <a> with no background box of its own — tinting
|
||||
// that would look out of place in NewBook's own nav bar, so only its icon
|
||||
// recolours.
|
||||
function setButtonState(anchorBtn, state, title = '') {
|
||||
const icon = anchorBtn.querySelector('.tcp-icon');
|
||||
if (icon) {
|
||||
icon.className = `tcp-icon far ${STATE_ICON[state] || STATE_ICON.idle} fa-fw`;
|
||||
icon.style.color = STATE_ICON_COLOR[state] || '';
|
||||
}
|
||||
if (anchorBtn.tagName === 'BUTTON') {
|
||||
const bg = STATE_BG[state] || STATE_BG.idle;
|
||||
anchorBtn.style.background = bg;
|
||||
anchorBtn.style.borderColor = bg;
|
||||
}
|
||||
if (title) anchorBtn.title = title; else anchorBtn.removeAttribute('title');
|
||||
}
|
||||
|
||||
function scheduleRevert(anchorBtn) {
|
||||
setTimeout(() => {
|
||||
// Button may already be gone (NewBook rebuilt its container) — touching
|
||||
// a detached node is harmless, but skip the state reset if so, nothing
|
||||
// to show it on anymore.
|
||||
if (document.body.contains(anchorBtn)) setButtonState(anchorBtn, 'idle');
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// Injected buttons skip asking which tablet entirely when a default is
|
||||
// configured for this machine (see options.js) — push straight to it. Only
|
||||
// falls back to the full device-picker panel if that specific push comes
|
||||
// back busy (409), or if no default is configured at all — see
|
||||
// project_checkin memory's extension notes, this was an explicit UX request
|
||||
// ("injected buttons don't need to ask which tablet, just use/send to
|
||||
// default unless busy"). The popup is deliberately different — it always
|
||||
// shows the picker, just pre-selected to the default (see popup.js) — since
|
||||
// opening the popup is already a more deliberate action than a one-click
|
||||
// inline button.
|
||||
async function handleSendClick(anchorBtn, bookingId, guestName, variant) {
|
||||
const { defaultDeviceId } = await chrome.storage.local.get('defaultDeviceId');
|
||||
// opens the picker overlay if that specific push comes back busy (409), the
|
||||
// default is configured but not actually connected (delivered: false — see
|
||||
// device-stream.js), or no default is configured at all. The popup is
|
||||
// deliberately different — it always shows the picker, just pre-selected to
|
||||
// the default (see popup.js) — since opening the popup is already a more
|
||||
// deliberate action than a one-click inline button.
|
||||
async function handleSendClick(anchorBtn, bookingId, guestName) {
|
||||
if (anchorBtn.dataset.tcpBusy === '1') return; // already mid-send, ignore a double-click
|
||||
|
||||
const { defaultDeviceId } = await chrome.storage.local.get('defaultDeviceId');
|
||||
if (!defaultDeviceId) {
|
||||
showPickerOverlay(anchorBtn, bookingId, guestName);
|
||||
return;
|
||||
}
|
||||
|
||||
anchorBtn.dataset.tcpBusy = '1';
|
||||
setButtonState(anchorBtn, 'loading');
|
||||
|
||||
if (defaultDeviceId) {
|
||||
const status = showInlineStatus(anchorBtn, variant, 'Sending…');
|
||||
let result;
|
||||
try {
|
||||
result = await chrome.runtime.sendMessage({ action: 'tcpPush', bookingId, deviceId: defaultDeviceId });
|
||||
} catch {
|
||||
status.textContent = 'Extension was reloaded — refresh this NewBook page and try again.';
|
||||
delete anchorBtn.dataset.tcpBusy;
|
||||
setButtonState(anchorBtn, 'error', 'Extension was reloaded — refresh this NewBook page and try again.');
|
||||
scheduleRevert(anchorBtn);
|
||||
return;
|
||||
}
|
||||
delete anchorBtn.dataset.tcpBusy;
|
||||
|
||||
if (result?.ok && result.delivered !== false) {
|
||||
setButtonState(anchorBtn, 'success', 'Sent — the tablet should navigate now.');
|
||||
scheduleRevert(anchorBtn);
|
||||
return;
|
||||
}
|
||||
if (result?.ok) {
|
||||
if (result.delivered === false) {
|
||||
status.textContent = 'Sent, but that tablet isn’t connected right now — it’ll pick this up if it reconnects.';
|
||||
status.style.color = '#b45309';
|
||||
} else {
|
||||
status.textContent = 'Sent — the tablet should navigate now.';
|
||||
status.style.color = '#166534';
|
||||
}
|
||||
// delivered === false — session created server-side but nothing's
|
||||
// actually listening on that device right now.
|
||||
setButtonState(anchorBtn, 'warning', 'That tablet isn’t connected right now.');
|
||||
showPickerOverlay(anchorBtn, bookingId, guestName, 'Default tablet isn’t connected — choose another:');
|
||||
return;
|
||||
}
|
||||
if (result?.status === 409) {
|
||||
status.remove();
|
||||
// fall through to the picker below, with a note explaining why it's
|
||||
// asking despite a default being configured
|
||||
await togglePanel(anchorBtn, bookingId, guestName, variant, 'Default tablet is busy — choose another:');
|
||||
setButtonState(anchorBtn, 'warning', result.error || 'That tablet is busy.');
|
||||
showPickerOverlay(anchorBtn, bookingId, guestName, 'Default tablet is busy — choose another:');
|
||||
return;
|
||||
}
|
||||
status.textContent = `Failed: ${result?.error || 'unknown error'}`;
|
||||
status.style.color = '#dc2626';
|
||||
return;
|
||||
}
|
||||
|
||||
await togglePanel(anchorBtn, bookingId, guestName, variant);
|
||||
setButtonState(anchorBtn, 'error', `Failed: ${result?.error || 'unknown error'}`);
|
||||
scheduleRevert(anchorBtn);
|
||||
}
|
||||
|
||||
// The context-menu buttons (variant 'menu') live inside a horizontal nav
|
||||
// bar <li> — inserting a block-level status/panel 'afterend' there (like the
|
||||
// row/banner variants do) would stretch that menu item's row height instead
|
||||
// of floating over the page. Anchoring to the viewport via the button's own
|
||||
// bounding rect sidesteps needing the menu bar to be a positioned ancestor.
|
||||
function positionFloating(el, anchorBtn) {
|
||||
const rect = anchorBtn.getBoundingClientRect();
|
||||
el.style.position = 'fixed';
|
||||
el.style.top = `${rect.bottom + 4}px`;
|
||||
el.style.left = `${rect.left}px`;
|
||||
el.style.zIndex = '99999';
|
||||
// Centred modal overlay — replaces the old in-page dropdown panel entirely
|
||||
// (both its block-flow insertion for row/banner buttons and the fixed-
|
||||
// position floating variant the nav-bar menu items needed). A real overlay
|
||||
// doesn't care where its anchor lives on the page, so one implementation now
|
||||
// covers all three injection shapes.
|
||||
let overlayBackdrop = null;
|
||||
|
||||
function closeOverlay() {
|
||||
if (!overlayBackdrop) return;
|
||||
overlayBackdrop.remove();
|
||||
overlayBackdrop = null;
|
||||
document.removeEventListener('keydown', handleOverlayKeydown);
|
||||
}
|
||||
|
||||
// Small text-only status line for the default-tablet fast path — no
|
||||
// dropdown, nothing to pick, just feedback on what happened.
|
||||
function showInlineStatus(anchorBtn, variant, text) {
|
||||
if (openPanel) { openPanel.remove(); openPanel = null; }
|
||||
const el = document.createElement('div');
|
||||
const width = variant === 'block' ? 'width: 100%;' : '';
|
||||
const floating = variant === 'menu';
|
||||
el.style.cssText = `
|
||||
display: block; ${floating ? '' : 'margin-top: 6px;'} ${width} font-size: 12px; color: #64748b;
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
${floating ? 'background: #fff; padding: 6px 10px; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.15);' : ''}
|
||||
function handleOverlayKeydown(e) {
|
||||
if (e.key === 'Escape') closeOverlay();
|
||||
}
|
||||
|
||||
function buildOverlay() {
|
||||
closeOverlay();
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.style.cssText = `
|
||||
position: fixed; inset: 0; background: rgba(15, 23, 42, 0.45);
|
||||
z-index: 999998; display: flex; align-items: center; justify-content: center;
|
||||
`;
|
||||
el.textContent = text;
|
||||
if (floating) {
|
||||
document.body.appendChild(el);
|
||||
positionFloating(el, anchorBtn);
|
||||
} else {
|
||||
anchorBtn.insertAdjacentElement('afterend', el);
|
||||
}
|
||||
openPanel = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
let openPanel = null;
|
||||
|
||||
async function togglePanel(anchorBtn, bookingId, guestName, variant = 'block', note = '') {
|
||||
if (openPanel) { openPanel.remove(); openPanel = null; }
|
||||
backdrop.addEventListener('mousedown', e => { if (e.target === backdrop) closeOverlay(); });
|
||||
|
||||
const panel = document.createElement('div');
|
||||
// Always its own block below the anchor regardless of variant — an inline
|
||||
// anchor (the banner button) has no full-width block context to size
|
||||
// against naturally, so give it an explicit min-width instead of relying
|
||||
// on parent width like the row-cell variant can.
|
||||
const floating = variant === 'menu';
|
||||
const width = variant === 'block' ? 'width: 100%;' : 'min-width: 200px; max-width: 280px;';
|
||||
panel.style.cssText = `
|
||||
display: block; ${floating ? '' : 'margin-top: 6px;'} ${width} padding: 8px; background: #f4f5f7;
|
||||
border: 1px solid #e4e8ee; border-radius: 8px;
|
||||
${floating ? 'box-shadow: 0 2px 10px rgba(0,0,0,0.2);' : ''}
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 12px; color: #1e293b;
|
||||
position: relative; background: #fff; border-radius: 12px; padding: 20px;
|
||||
min-width: 260px; max-width: 320px; box-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif; font-size: 13px; color: #1e293b;
|
||||
`;
|
||||
panel.textContent = note ? `${note} loading…` : 'Loading tablets…';
|
||||
if (floating) {
|
||||
document.body.appendChild(panel);
|
||||
positionFloating(panel, anchorBtn);
|
||||
} else {
|
||||
anchorBtn.insertAdjacentElement('afterend', panel);
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.setAttribute('aria-label', 'Close');
|
||||
closeBtn.style.cssText = `
|
||||
position: absolute; top: 6px; right: 10px; border: none; background: none;
|
||||
font-size: 20px; line-height: 1; cursor: pointer; color: #94a3b8; padding: 4px;
|
||||
`;
|
||||
closeBtn.addEventListener('click', closeOverlay);
|
||||
panel.appendChild(closeBtn);
|
||||
|
||||
backdrop.appendChild(panel);
|
||||
document.body.appendChild(backdrop);
|
||||
overlayBackdrop = backdrop;
|
||||
document.addEventListener('keydown', handleOverlayKeydown);
|
||||
return panel;
|
||||
}
|
||||
|
||||
async function showPickerOverlay(anchorBtn, bookingId, guestName, note = '') {
|
||||
const panel = buildOverlay();
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.style.cssText = 'font-weight: 700; margin: 0 20px 10px 0; color: #1a1a2e;';
|
||||
title.textContent = guestName ? `Send ${guestName} to a tablet` : 'Send to a tablet';
|
||||
panel.appendChild(title);
|
||||
|
||||
if (note) {
|
||||
const noteEl = document.createElement('div');
|
||||
noteEl.textContent = note;
|
||||
noteEl.style.cssText = 'margin-bottom: 10px; color: #b45309; font-weight: 600;';
|
||||
panel.appendChild(noteEl);
|
||||
}
|
||||
openPanel = panel;
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.textContent = 'Loading tablets…';
|
||||
panel.appendChild(body);
|
||||
|
||||
// A stale content script (injected before the extension was last
|
||||
// reloaded) throws "Extension context invalidated" the moment it touches
|
||||
|
|
@ -230,82 +302,87 @@ async function togglePanel(anchorBtn, bookingId, guestName, variant = 'block', n
|
|||
try {
|
||||
res = await chrome.runtime.sendMessage({ action: 'tcpListDevices' });
|
||||
} catch {
|
||||
panel.textContent = 'Extension was reloaded — refresh this NewBook page and try again.';
|
||||
body.textContent = 'Extension was reloaded — refresh this NewBook page and try again.';
|
||||
return;
|
||||
}
|
||||
if (overlayBackdrop !== panel.parentElement) return; // closed while loading
|
||||
if (!res?.ok) {
|
||||
panel.textContent = `Could not load tablets: ${res?.error || 'not configured yet — see extension Settings'}`;
|
||||
body.textContent = `Could not load tablets: ${res?.error || 'not configured yet — see extension Settings'}`;
|
||||
return;
|
||||
}
|
||||
if (!res.devices.length) {
|
||||
panel.textContent = 'No tablets registered yet.';
|
||||
body.textContent = 'No tablets registered yet.';
|
||||
return;
|
||||
}
|
||||
|
||||
const select = document.createElement('select');
|
||||
select.style.cssText = 'width: 100%; margin-bottom: 6px; padding: 4px;';
|
||||
select.style.cssText = 'width: 100%; margin-bottom: 10px; padding: 6px; border-radius: 6px; border: 1px solid #e4e8ee;';
|
||||
select.innerHTML = res.devices.map(d =>
|
||||
`<option value="${d.device_id}" ${d.status === 'busy' ? 'disabled' : ''}>${escapeHtml(d.name)}${d.status === 'busy' ? ' (busy)' : ''}</option>`
|
||||
).join('');
|
||||
|
||||
const sendBtn = document.createElement('button');
|
||||
sendBtn.type = 'button';
|
||||
sendBtn.textContent = `Send${guestName ? ` — ${guestName}` : ''}`;
|
||||
sendBtn.style.cssText = `
|
||||
width: 100%; padding: 4px 8px; background: #c9a84c; color: #1a1a2e;
|
||||
border: none; border-radius: 6px; font-weight: 600; cursor: pointer;
|
||||
width: 100%; padding: 8px; background: #c9a84c; color: #1a1a2e;
|
||||
border: none; border-radius: 6px; font-weight: 700; cursor: pointer;
|
||||
`;
|
||||
|
||||
const statusEl = document.createElement('div');
|
||||
statusEl.style.cssText = 'margin-top: 8px; font-size: 12px;';
|
||||
|
||||
sendBtn.addEventListener('click', async () => {
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.textContent = 'Sending…';
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
action: 'tcpPush', bookingId, deviceId: select.value,
|
||||
});
|
||||
if (result?.ok) {
|
||||
panel.textContent = result.delivered === false
|
||||
? 'Sent, but that tablet isn’t connected right now — it’ll pick this up if it reconnects.'
|
||||
: 'Sent — the tablet should navigate now.';
|
||||
panel.style.color = result.delivered === false ? '#b45309' : '#1e293b';
|
||||
setButtonState(anchorBtn, 'loading');
|
||||
|
||||
const result = await chrome.runtime.sendMessage({ action: 'tcpPush', bookingId, deviceId: select.value });
|
||||
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = `Send${guestName ? ` — ${guestName}` : ''}`;
|
||||
|
||||
if (result?.ok && result.delivered !== false) {
|
||||
statusEl.textContent = 'Sent — the tablet should navigate now.';
|
||||
statusEl.style.color = '#166534';
|
||||
setButtonState(anchorBtn, 'success', 'Sent — the tablet should navigate now.');
|
||||
scheduleRevert(anchorBtn);
|
||||
setTimeout(closeOverlay, 1000);
|
||||
} else if (result?.ok) {
|
||||
statusEl.textContent = 'Sent, but that tablet isn’t connected right now.';
|
||||
statusEl.style.color = '#b45309';
|
||||
setButtonState(anchorBtn, 'warning', 'That tablet isn’t connected right now.');
|
||||
} else {
|
||||
panel.textContent = `Failed: ${result?.error || 'unknown error'}`;
|
||||
statusEl.textContent = `Failed: ${result?.error || 'unknown error'}`;
|
||||
statusEl.style.color = '#dc2626';
|
||||
setButtonState(anchorBtn, 'error', `Failed: ${result?.error || 'unknown error'}`);
|
||||
}
|
||||
});
|
||||
|
||||
panel.innerHTML = '';
|
||||
if (note) {
|
||||
const noteEl = document.createElement('div');
|
||||
noteEl.textContent = note;
|
||||
noteEl.style.cssText = 'margin-bottom: 6px; color: #b45309; font-weight: 600;';
|
||||
panel.appendChild(noteEl);
|
||||
}
|
||||
panel.appendChild(select);
|
||||
panel.appendChild(sendBtn);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
body.innerHTML = '';
|
||||
body.appendChild(select);
|
||||
body.appendChild(sendBtn);
|
||||
body.appendChild(statusEl);
|
||||
}
|
||||
|
||||
// Context-menu entries use a completely different markup shape than the
|
||||
// .button-based row/banner injections above — <li><a><div class="page_icon">
|
||||
// <i/></div><span/></a></li> — so this builds that shape directly rather
|
||||
// than reusing makeSendButton. Click handling (default-tablet fast path,
|
||||
// picker fallback) is identical, via the 'menu' variant plumbed through
|
||||
// handleSendClick/showInlineStatus/togglePanel above.
|
||||
// picker overlay fallback) is identical via the shared handleSendClick.
|
||||
function makeMenuLink(bookingId) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'context tcp-menu-item';
|
||||
const a = document.createElement('a');
|
||||
a.href = '#';
|
||||
a.innerHTML = `
|
||||
<div class="page_icon"><i class="far fa-tablet-alt fa-fw" alt="" style="margin-right: 0; margin-left: 0px; vertical-align: middle; font-size: 24px;" aria-hidden="true"></i></div>
|
||||
<div class="page_icon"><i class="tcp-icon far fa-tablet-alt fa-fw" alt="" style="margin-right: 0; margin-left: 0px; vertical-align: middle; font-size: 24px;" aria-hidden="true"></i></div>
|
||||
<span>Tablet Push</span>
|
||||
`;
|
||||
a.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSendClick(a, bookingId, '', 'menu');
|
||||
handleSendClick(a, bookingId, '');
|
||||
});
|
||||
li.appendChild(a);
|
||||
return li;
|
||||
|
|
|
|||
|
|
@ -126,12 +126,13 @@ function renderBooking(el, bookingId, devices, defaultDeviceId, baseUrl, apiKey)
|
|||
const msgEl = document.getElementById('msg');
|
||||
if (!msgEl) return; // "Send to a different tablet" was clicked before this resolved
|
||||
|
||||
if (result.ok) {
|
||||
if (result.delivered === false) {
|
||||
setMsg(msgEl, 'Sent, but that tablet isn’t connected right now — it’ll pick this up if it reconnects.', 'warn');
|
||||
} else {
|
||||
if (result.ok && result.delivered !== false) {
|
||||
setMsg(msgEl, 'Sent — the tablet should navigate now.', 'ok');
|
||||
}
|
||||
} else if (result.ok) {
|
||||
// Session created server-side but nothing's actually listening on
|
||||
// that device right now — same "needs a human decision" case as
|
||||
// busy, so offer the picker rather than a dead-end message.
|
||||
renderPicker(el, bookingId, devices, defaultDeviceId, baseUrl, apiKey, 'Default tablet isn’t connected — choose another:');
|
||||
} else if (result.status === 409) {
|
||||
// Default went busy in the moment between loading the popup and
|
||||
// sending — fall back to the manual picker rather than leaving the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue