fix(zones): frontend assignment-flow picker + missed devices.js zone_id projection

Follow-up to 73f41c3 (server-side zone_id wiring). With this commit
the zone feature is verified working end-to-end: dashboard zone
picker renders correctly, zone_id saves and persists, the per-row
zone dropdown reflects the saved zone after reload, and a live
player run with computed-style inspection confirmed zone divs and
video elements size correctly within their geometry.

Frontend (device-detail.js, en.js):
- Add-content modal: zone picker slot now renders in all four states
  (has_zones / no_layout / fetch_failed / empty_layout) instead of
  silently vanishing when zones.length === 0. Informational rows
  match form-group styling and tell the user which control to use
  next. Closes the gate-4 symptom where 38-of-42 devices (no layout
  assigned) silently dropped zone_id on every assignment.
- Both /api/layouts/:id fetches (add modal, edit-path) now have
  !res.ok throw guards and surface failures via console.warn instead
  of swallowing them. The add modal additionally exposes the failure
  state to the user via the fetch_failed info row.
- Edit-path zone dropdown: replaced brittle DOM-scraping (reading
  the i18n label text and matching z.id.slice(0,8) against rendered
  meta HTML) with a data-current-zone-id attribute stashed at row
  render from a.zone_id. Removes the i18n-format coupling and gives
  exact UUID match.
- 3 new i18n keys in en.js (other locales fall back).

Server (devices.js):
- The GET /api/devices/:id assignments query had its own ad-hoc
  SELECT projection that was missed during the 73f41c3 site survey.
  Without pi.zone_id in this projection, loadDevice() got assignments
  without zone_id and the edit-path dropdown displayed "No zone"
  after every save+reload even though the DB had the correct value.
  One-line fix: add pi.zone_id, mirroring the ITEM_SELECT change in
  routes/assignments.js. Listed as the 8th site that 73f41c3's
  original survey missed; this commit closes it.

Verification:
- JS parse + en.js ESM load + server module load all clean.
- Live SQL probe: GET /api/devices/:id projection now returns zone_id
  for the test rows (id=31 zone_id=z-sh-1, id=54 zone_id=z-sh-2).
- Browser test by hand: zone picker renders per state, zone_id
  persists, reload shows saved zone, computed styles on rendered
  .zone divs match expected geometry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-05-14 21:26:58 -05:00
parent 73f41c3288
commit 12fe0e43eb
3 changed files with 50 additions and 23 deletions

View file

@ -334,6 +334,9 @@ export default {
'device.assign.modal_title': 'Add to Playlist',
'device.assign.zone_label': 'Zone',
'device.assign.zone_default': 'Default (fullscreen)',
'device.assign.zone_no_layout': 'This device has no layout assigned. Content will play fullscreen. Pick a layout from the Layout dropdown on this device to use zones.',
'device.assign.zone_load_failed': 'Layout zones could not be loaded. Try refreshing the page.',
'device.assign.zone_empty_layout': 'This layout has no zones defined.',
'device.assign.duration_label': 'Display Duration (seconds, for images/widgets)',
'device.assign.tab.media': 'Media ({n})',
'device.assign.tab.widgets': 'Widgets ({n})',

View file

@ -504,7 +504,7 @@ function renderPlaylist(assignments) {
</div>
</div>
<div class="playlist-item-actions" style="display:flex;align-items:center;gap:4px">
<select class="input zone-select" data-assignment-id="${a.id}" style="width:100px;font-size:11px;padding:2px 4px;background:var(--bg-input);display:none">
<select class="input zone-select" data-assignment-id="${a.id}" data-current-zone-id="${a.zone_id || ''}" style="width:100px;font-size:11px;padding:2px 4px;background:var(--bg-input);display:none">
<option value="">${t('device.pl_item.no_zone')}</option>
</select>
<button class="btn-icon mute-toggle" data-mute-assignment="${a.id}" data-muted="${a.muted ? '1' : '0'}" title="${a.muted ? t('device.pl_item.unmute') : t('device.pl_item.mute')}" style="color:${a.muted ? 'var(--danger)' : 'var(--text-muted)'}">
@ -884,13 +884,25 @@ async function setupPlaylistActions(device) {
fetch('/api/kiosk', { headers }).then(r => r.json()),
]);
// Get layout zones if device has a layout assigned
// Get layout zones if device has a layout assigned. We track
// zonesFetchFailed separately so the modal can distinguish "fetch
// broke" from "fetch succeeded, layout genuinely has no zones" -
// both end with zones=[] but the user message differs.
// The !res.ok throw is required because fetch only rejects on network
// errors; an HTTP 403/404 would otherwise json-parse into {error: ...}
// and zones would silently be [].
let zones = [];
let zonesFetchFailed = false;
if (device.layout_id) {
try {
const layout = await fetch(`/api/layouts/${device.layout_id}`, { headers }).then(r => r.json());
const res = await fetch(`/api/layouts/${device.layout_id}`, { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const layout = await res.json();
zones = layout.zones || [];
} catch {}
} catch (e) {
console.warn('Failed to load layout for zone picker:', e.message);
zonesFetchFailed = true;
}
}
if (!content.length && !widgets.length && !kioskPages.length) {
@ -911,15 +923,21 @@ async function setupPlaylistActions(device) {
</button>
</div>
<div class="modal-body">
${zones.length > 0 ? `
<div class="form-group">
<label>${t('device.assign.zone_label')}</label>
${zones.length > 0 ? `
<select id="assignZone" class="input" style="background:var(--bg-input)">
<option value="">${t('device.assign.zone_default')}</option>
${zones.map(z => `<option value="${z.id}">${z.name} (${Math.round(z.width_percent)}% x ${Math.round(z.height_percent)}%)</option>`).join('')}
</select>
` : !device.layout_id ? `
<div style="font-size:12px;color:var(--text-muted);padding:6px 0;line-height:1.5">${t('device.assign.zone_no_layout')}</div>
` : zonesFetchFailed ? `
<div style="font-size:12px;color:var(--danger);padding:6px 0;line-height:1.5">${t('device.assign.zone_load_failed')}</div>
` : `
<div style="font-size:12px;color:var(--text-muted);padding:6px 0;line-height:1.5">${t('device.assign.zone_empty_layout')}</div>
`}
</div>
` : ''}
<div class="form-group">
<label>${t('device.assign.duration_label')}</label>
<input type="number" id="assignDuration" class="input" value="10" min="1" max="3600">
@ -1042,31 +1060,32 @@ async function setupPlaylistActions(device) {
}
function attachRemoveHandlers(device) {
// Populate zone selectors if device has a layout
// Populate zone selectors if device has a layout. The current zone_id for
// each assignment is read from data-current-zone-id on the .zone-select
// element (stashed at render time from a.zone_id); no DOM-scraping.
// Fetch errors are logged - the dropdowns simply stay hidden (display:none
// is the default from the render), same end-state as before but no longer
// silent.
if (device.layout_id) {
const token = localStorage.getItem('token');
fetch(`/api/layouts/${device.layout_id}`, { headers: { Authorization: `Bearer ${token}` }})
.then(r => r.json())
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(layout => {
const zones = layout.zones || [];
document.querySelectorAll('.zone-select').forEach(select => {
select.style.display = '';
const assignmentId = select.dataset.assignmentId;
// Find current zone_id from the playlist item's data
const zoneText = select.closest('.playlist-item')?.querySelector('[style*="color:var(--accent)"]')?.textContent || '';
const currentZoneId = select.dataset.currentZoneId || '';
zones.forEach(z => {
const opt = document.createElement('option');
opt.value = z.id;
opt.textContent = z.name;
select.appendChild(opt);
});
// Set current value by matching zone_id from the meta text
const currentAssignment = document.querySelector(`.playlist-item[data-assignment-id="${assignmentId}"]`);
if (currentAssignment) {
const meta = currentAssignment.querySelector('.playlist-item-meta')?.innerHTML || '';
const zoneMatch = zones.find(z => meta.includes(z.id.slice(0, 8)));
if (zoneMatch) select.value = zoneMatch.id;
}
if (currentZoneId) select.value = currentZoneId;
select.onchange = async () => {
try {
await api.updateAssignment(assignmentId, { zone_id: select.value || null });
@ -1075,7 +1094,12 @@ function attachRemoveHandlers(device) {
} catch (err) { showToast(err.message, 'error'); }
};
});
}).catch(() => {});
})
.catch(e => {
// No toast - fires once per device-detail load, would be annoying for
// a layout misconfig that's already surfaced via the modal info row.
console.warn('Failed to load layout for edit-zone dropdowns:', e.message);
});
}
// Mute toggle buttons

View file

@ -84,7 +84,7 @@ router.get('/:id', (req, res) => {
let playlist_has_published = false;
if (device.playlist_id) {
assignments = db.prepare(`
SELECT pi.id, pi.content_id, pi.widget_id, pi.sort_order, pi.duration_sec,
SELECT pi.id, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec,
pi.created_at, pi.updated_at,
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path,
c.duration_sec as content_duration, c.remote_url,