mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
fix(zones): frontend assignment-flow picker + missed devices.js zone_id projection
Follow-up to73f41c3(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 the73f41c3site 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:
parent
73f41c3288
commit
12fe0e43eb
|
|
@ -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})',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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>
|
||||
${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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue