mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
853 lines
39 KiB
JavaScript
853 lines
39 KiB
JavaScript
import { api } from '../api.js';
|
||
import { on, off, requestScreenshot } from '../socket.js';
|
||
import { showToast } from '../components/toast.js';
|
||
import { esc } from '../utils.js';
|
||
import { t, tn } from '../i18n.js';
|
||
|
||
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
|
||
// Command types only — labels resolved through t('dashboard.cmd.<type>')
|
||
const GROUP_COMMANDS = [
|
||
{ type: 'screen_on' },
|
||
{ type: 'screen_off' },
|
||
{ type: 'launch' },
|
||
{ type: 'update' },
|
||
{ type: 'reboot', destructive: true },
|
||
{ type: 'shutdown', destructive: true },
|
||
];
|
||
const CMD_LABEL_KEY = {
|
||
screen_on: 'dashboard.cmd.screen_on',
|
||
screen_off: 'dashboard.cmd.screen_off',
|
||
launch: 'dashboard.cmd.restart_app',
|
||
update: 'dashboard.cmd.check_update',
|
||
reboot: 'dashboard.cmd.reboot',
|
||
shutdown: 'dashboard.cmd.shutdown',
|
||
};
|
||
|
||
let statusHandler = null;
|
||
let screenshotHandler = null;
|
||
let refreshInterval = null;
|
||
let playbackHandler = null;
|
||
let progressTickInterval = null;
|
||
let wallChangedHandler = null;
|
||
// device_id -> { content_name, duration_sec, started_at }
|
||
const playbackByDevice = new Map();
|
||
// Multi-select state for the "Create Video Wall" gesture. Holds device_ids
|
||
// the user has ticked via checkboxes on the dashboard cards.
|
||
const selectedDeviceIds = new Set();
|
||
|
||
function formatTimeAgo(timestamp) {
|
||
if (!timestamp) return t('common.never');
|
||
const seconds = Math.floor(Date.now() / 1000 - timestamp);
|
||
if (seconds < 60) return t('common.just_now');
|
||
if (seconds < 3600) return t('common.minutes_ago', { n: Math.floor(seconds / 60) });
|
||
if (seconds < 86400) return t('common.hours_ago', { n: Math.floor(seconds / 3600) });
|
||
return t('common.days_ago', { n: Math.floor(seconds / 86400) });
|
||
}
|
||
|
||
function formatBytes(mb) {
|
||
if (mb === null || mb === undefined) return '--';
|
||
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
|
||
return `${mb} MB`;
|
||
}
|
||
|
||
function renderProgressFor(deviceId) {
|
||
const state = playbackByDevice.get(deviceId);
|
||
document.querySelectorAll(`#progress-${CSS.escape(deviceId)}`).forEach(el => {
|
||
if (!state) { el.style.display = 'none'; return; }
|
||
const elapsed = Math.max(0, (Date.now() - state.started_at) / 1000);
|
||
const name = state.content_name || '';
|
||
const fill = el.querySelector('.device-card-progress-fill');
|
||
const nameEl = el.querySelector('.dcp-name');
|
||
const timeEl = el.querySelector('.dcp-time');
|
||
if (state.duration_sec && state.duration_sec > 0) {
|
||
const remaining = Math.max(0, Math.ceil(state.duration_sec - elapsed));
|
||
const pct = Math.min(100, (elapsed / state.duration_sec) * 100);
|
||
fill.style.width = pct + '%';
|
||
if (nameEl) nameEl.textContent = name;
|
||
if (timeEl) timeEl.textContent = remaining + 's';
|
||
} else {
|
||
// Unknown duration (e.g. video plays to end) — show indeterminate state
|
||
fill.style.width = '100%';
|
||
fill.classList.add('indeterminate');
|
||
if (nameEl) nameEl.textContent = name;
|
||
if (timeEl) timeEl.textContent = '';
|
||
}
|
||
el.style.display = 'block';
|
||
});
|
||
}
|
||
|
||
function renderDeviceCard(device) {
|
||
const token = localStorage.getItem('token');
|
||
const screenshotUrl = device.screenshot_path
|
||
? `/api/devices/${device.id}/screenshot?t=${device.screenshot_at || ''}&token=${token}`
|
||
: null;
|
||
|
||
const checked = selectedDeviceIds.has(device.id);
|
||
return `
|
||
<div class="device-card${checked ? ' selected' : ''}" draggable="true" data-device-id="${device.id}" data-device-name="${esc(device.name)}" onclick="window.location.hash='/device/${device.id}'">
|
||
<label class="device-card-select" title="Select for wall" onclick="event.stopPropagation()">
|
||
<input type="checkbox" class="device-select-cb" data-device-id="${device.id}"${checked ? ' checked' : ''}>
|
||
</label>
|
||
<div class="device-card-preview" id="preview-${device.id}">
|
||
${screenshotUrl
|
||
? `<img src="${screenshotUrl}" alt="Screenshot" loading="lazy">`
|
||
: `<div class="no-preview">
|
||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||
</svg>
|
||
<span>${t('dashboard.no_preview')}</span>
|
||
</div>`
|
||
}
|
||
<div class="device-card-status">
|
||
<span class="status-dot ${device.status}"></span>
|
||
<span>${device.status === 'provisioning' ? t('dashboard.awaiting_pairing') : device.status}</span>
|
||
</div>
|
||
${device.status === 'provisioning' && device.pairing_code ? `
|
||
<div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#f59e0b;padding:4px 12px;border-radius:6px;font-size:13px;font-weight:600;letter-spacing:2px;font-family:monospace">
|
||
${device.pairing_code}
|
||
</div>` : ''}
|
||
<div class="device-card-progress" id="progress-${device.id}" style="display:none">
|
||
<div class="device-card-progress-label"><span class="dcp-name"></span><span class="dcp-time"></span></div>
|
||
<div class="device-card-progress-track"><div class="device-card-progress-fill"></div></div>
|
||
</div>
|
||
</div>
|
||
<div class="device-card-body">
|
||
<div class="device-card-name">${esc(device.name)}</div>
|
||
${device.owner_name || device.owner_email ? `<div style="font-size:11px;color:var(--text-muted);margin-bottom:4px">
|
||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px">
|
||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
|
||
</svg>
|
||
${esc(device.owner_name || device.owner_email)}
|
||
</div>` : ''}
|
||
<div class="device-card-meta">
|
||
<div class="meta-item">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||
</svg>
|
||
${formatTimeAgo(device.last_heartbeat)}
|
||
</div>
|
||
${device.battery_level !== null && device.battery_level !== undefined ? `
|
||
<div class="meta-item">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="1" y="6" width="18" height="12" rx="2" ry="2"/><line x1="23" y1="13" x2="23" y2="11"/>
|
||
</svg>
|
||
${device.battery_level}%
|
||
</div>` : ''}
|
||
${device.wifi_rssi ? `
|
||
<div class="meta-item">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/>
|
||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/>
|
||
</svg>
|
||
${device.wifi_rssi} dBm
|
||
</div>` : ''}
|
||
${device.storage_free_mb ? `
|
||
<div class="meta-item">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
|
||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
||
</svg>
|
||
${formatBytes(device.storage_free_mb)} free
|
||
</div>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderWallCard(wall) {
|
||
// Compose a tiny grid preview using the wall's actual cols×rows. Each cell
|
||
// is filled (assigned) or hollow (empty slot).
|
||
const cells = [];
|
||
for (let r = 0; r < wall.grid_rows; r++) {
|
||
for (let c = 0; c < wall.grid_cols; c++) {
|
||
const dev = (wall.devices || []).find(d => d.grid_col === c && d.grid_row === r);
|
||
cells.push(`<div class="wall-card-cell${dev ? ' filled' : ''}" title="${dev ? esc(dev.device_name) : '[' + c + ',' + r + ']'}"></div>`);
|
||
}
|
||
}
|
||
const onlineCount = (wall.devices || []).filter(d => d.device_status === 'online').length;
|
||
return `
|
||
<div class="device-card wall-card" data-wall-id="${wall.id}" onclick="window.location.hash='#/wall/${wall.id}'">
|
||
<div class="device-card-preview wall-card-preview">
|
||
<div class="wall-card-grid" style="grid-template-columns:repeat(${wall.grid_cols},1fr);grid-template-rows:repeat(${wall.grid_rows},1fr)">${cells.join('')}</div>
|
||
<div class="device-card-status">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="12" y1="3" x2="12" y2="21"/></svg>
|
||
<span>${wall.grid_cols}×${wall.grid_rows} wall</span>
|
||
</div>
|
||
</div>
|
||
<div class="device-card-body">
|
||
<div class="device-card-name">${esc(wall.name)}</div>
|
||
<div class="device-card-meta">
|
||
<div class="meta-item">${(wall.devices || []).length} ${(wall.devices || []).length === 1 ? 'tile' : 'tiles'}</div>
|
||
<div class="meta-item" style="color:${onlineCount === (wall.devices || []).length ? 'var(--success)' : 'var(--text-muted)'}">${onlineCount} online</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function getGroupPlaylistLabel(devices, playlists) {
|
||
const playlistMap = new Map((playlists || []).map(p => [p.id, p]));
|
||
const assigned = devices.filter(d => d.playlist_id).map(d => d.playlist_id);
|
||
if (assigned.length === 0) return '';
|
||
const unique = [...new Set(assigned)];
|
||
if (unique.length === 1) {
|
||
const pl = playlistMap.get(unique[0]);
|
||
return pl ? esc(pl.name) : t('dashboard.unknown_playlist');
|
||
}
|
||
return t('dashboard.mixed_playlists');
|
||
}
|
||
|
||
function renderGroupSection(group, devices, playlists) {
|
||
const onlineCount = devices.filter(d => d.status === 'online').length;
|
||
const playlistLabel = getGroupPlaylistLabel(devices, playlists);
|
||
return `
|
||
<div class="group-section" data-group-id="${group.id}" style="margin-bottom:24px">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid ${esc(group.color || '#3B82F6')}">
|
||
<div style="display:flex;align-items:center;gap:10px">
|
||
<strong style="font-size:15px">${esc(group.name)}</strong>
|
||
<span style="color:var(--text-muted);font-size:12px">${tn('dashboard.devices_count', devices.length)} · ${t('dashboard.online_count', { n: onlineCount })}</span>
|
||
${playlistLabel ? `<span style="font-size:11px;color:var(--text-secondary);background:var(--bg-primary);padding:2px 8px;border-radius:10px">${t('dashboard.playlist_label', { name: playlistLabel })}</span>` : ''}
|
||
</div>
|
||
<div style="display:flex;gap:6px;align-items:center">
|
||
${devices.length > 0 ? `
|
||
<select class="input group-playlist-select" data-group-id="${group.id}" data-group-name="${esc(group.name)}" style="width:160px;padding:4px 8px;font-size:12px;background:var(--bg-input)">
|
||
<option value="">${t('dashboard.set_playlist_placeholder')}</option>
|
||
${(playlists || []).map(p => `<option value="${esc(p.id)}">${esc(p.name)}${p.status === 'draft' ? ' ' + t('dashboard.draft_suffix') : ''}</option>`).join('')}
|
||
</select>
|
||
<select class="input group-cmd-select" data-group-id="${group.id}" data-group-name="${esc(group.name)}" data-device-count="${devices.length}" style="width:150px;padding:4px 8px;font-size:12px;background:var(--bg-input)">
|
||
<option value="">${t('dashboard.send_command_placeholder')}</option>
|
||
${GROUP_COMMANDS.map(c => `<option value="${c.type}" ${c.destructive ? 'style="color:var(--danger)"' : ''}>${t(CMD_LABEL_KEY[c.type])}</option>`).join('')}
|
||
</select>
|
||
` : ''}
|
||
<button class="btn" data-group-manage="${group.id}" style="padding:4px 10px;font-size:12px" title="${t('dashboard.manage_tooltip')}">${t('dashboard.manage')}</button>
|
||
<button class="btn" data-group-delete="${group.id}" style="padding:4px 8px;font-size:12px;color:var(--danger)" title="${t('dashboard.delete_group_tooltip')}">✕</button>
|
||
</div>
|
||
</div>
|
||
<div class="device-grid">
|
||
${devices.length > 0 ? devices.map(renderDeviceCard).join('') : `<div style="color:var(--text-muted);font-size:13px;padding:8px 12px">${t('dashboard.no_devices_in_group')}</div>`}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
export function render(container) {
|
||
container.innerHTML = `
|
||
<div class="page-header">
|
||
<div>
|
||
<h1>${t('dashboard.title')} <span class="help-tip" data-tip="${t('dashboard.help_tip')}">?</span></h1>
|
||
<div class="subtitle">${t('dashboard.subtitle')}</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="btn" id="createGroupBtn">${t('dashboard.create_group')}</button>
|
||
<button class="btn btn-primary" id="addDeviceBtn">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||
</svg>
|
||
${t('dashboard.add')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="selectionBar" style="display:none;align-items:center;gap:10px;padding:8px 12px;margin-bottom:12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px">
|
||
<span id="selectionCount" style="font-weight:500;font-size:13px"></span>
|
||
<button class="btn btn-primary btn-sm" id="createWallBtn">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:4px">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="12" y1="3" x2="12" y2="21"/>
|
||
</svg>
|
||
Create Video Wall
|
||
</button>
|
||
<button class="btn btn-sm" id="clearSelectionBtn">Clear</button>
|
||
</div>
|
||
<div id="dashStats" class="dash-stats-row" style="display:flex;gap:12px;margin-bottom:16px"></div>
|
||
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center">
|
||
<input type="text" id="deviceSearch" class="input" placeholder="${t('dashboard.search')}" style="max-width:300px">
|
||
<select id="deviceFilter" class="input" style="width:140px;background:var(--bg-input)">
|
||
<option value="">${t('dashboard.all_status')}</option>
|
||
<option value="online">${t('dashboard.online')}</option>
|
||
<option value="offline">${t('dashboard.offline')}</option>
|
||
</select>
|
||
</div>
|
||
<div id="groupedDevices"></div>
|
||
`;
|
||
|
||
const addBtn = container.querySelector('#addDeviceBtn');
|
||
addBtn.addEventListener('click', () => {
|
||
document.getElementById('addDeviceModal').style.display = 'flex';
|
||
document.getElementById('pairingCodeInput').value = '';
|
||
document.getElementById('deviceNameInput').value = '';
|
||
document.getElementById('pairingCodeInput').focus();
|
||
});
|
||
|
||
// Search and filter
|
||
document.getElementById('deviceSearch').oninput = () => filterDevices();
|
||
document.getElementById('deviceFilter').onchange = () => filterDevices();
|
||
|
||
function filterDevices() {
|
||
const search = document.getElementById('deviceSearch').value.toLowerCase();
|
||
const status = document.getElementById('deviceFilter').value;
|
||
document.querySelectorAll('.device-card').forEach(card => {
|
||
const name = card.querySelector('.device-card-name')?.textContent.toLowerCase() || '';
|
||
const deviceStatus = card.querySelector('.device-card-status span:last-child')?.textContent || '';
|
||
const matchSearch = !search || name.includes(search);
|
||
const matchStatus = !status || deviceStatus === status;
|
||
card.style.display = (matchSearch && matchStatus) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
// Setup pairing
|
||
const pairBtn = document.getElementById('pairDeviceBtn');
|
||
pairBtn.onclick = async () => {
|
||
const code = document.getElementById('pairingCodeInput').value.trim();
|
||
const name = document.getElementById('deviceNameInput').value.trim();
|
||
if (!code || code.length !== 6) {
|
||
showToast(t('dashboard.error_pairing_code'), 'error');
|
||
return;
|
||
}
|
||
try {
|
||
await api.pairDevice(code, name || undefined);
|
||
document.getElementById('addDeviceModal').style.display = 'none';
|
||
showToast(t('dashboard.toast.display_paired'), 'success');
|
||
loadDashboard();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
};
|
||
|
||
// Create group
|
||
container.querySelector('#createGroupBtn').addEventListener('click', async () => {
|
||
const name = prompt(t('dashboard.prompt_group_name'));
|
||
if (!name) return;
|
||
try {
|
||
await api.createGroup(name);
|
||
showToast(t('dashboard.toast.group_created'), 'success');
|
||
loadDashboard();
|
||
} catch (e) { showToast(e.message, 'error'); }
|
||
});
|
||
|
||
// Multi-select: a checkbox on each device card adds to selectedDeviceIds.
|
||
// The selection bar shows when 1+ are selected; "Create Video Wall" is the
|
||
// primary action — it creates the wall, removes devices from any group,
|
||
// assigns them, and navigates to the editor.
|
||
container.addEventListener('change', (ev) => {
|
||
const cb = ev.target.closest?.('.device-select-cb');
|
||
if (!cb) return;
|
||
const id = cb.dataset.deviceId;
|
||
if (cb.checked) selectedDeviceIds.add(id); else selectedDeviceIds.delete(id);
|
||
cb.closest('.device-card')?.classList.toggle('selected', cb.checked);
|
||
refreshSelectionBar();
|
||
});
|
||
|
||
document.getElementById('clearSelectionBtn').addEventListener('click', () => {
|
||
selectedDeviceIds.clear();
|
||
document.querySelectorAll('.device-select-cb').forEach(cb => { cb.checked = false; });
|
||
document.querySelectorAll('.device-card.selected').forEach(c => c.classList.remove('selected'));
|
||
refreshSelectionBar();
|
||
});
|
||
|
||
document.getElementById('createWallBtn').addEventListener('click', () => createWallFromSelection());
|
||
|
||
// Load everything
|
||
loadDashboard();
|
||
|
||
// Real-time updates
|
||
statusHandler = (data) => {
|
||
const cards = document.querySelectorAll(`[data-device-id="${data.device_id}"]`);
|
||
cards.forEach(card => {
|
||
const statusEl = card.querySelector('.device-card-status');
|
||
if (statusEl) statusEl.innerHTML = `<span class="status-dot ${data.status}"></span><span>${data.status}</span>`;
|
||
});
|
||
};
|
||
|
||
screenshotHandler = (data) => {
|
||
document.querySelectorAll(`#preview-${data.device_id}`).forEach(preview => {
|
||
const imgSrc = data.image_data || (data.url + '&token=' + localStorage.getItem('token'));
|
||
const img = preview.querySelector('img');
|
||
if (img) {
|
||
img.src = imgSrc;
|
||
} else {
|
||
const statusHtml = preview.querySelector('.device-card-status')?.outerHTML || '';
|
||
preview.innerHTML = `<img src="${imgSrc}" alt="Screenshot" loading="lazy">${statusHtml}`;
|
||
}
|
||
});
|
||
};
|
||
|
||
const deviceAddedHandler = () => loadDashboard();
|
||
const deviceRemovedHandler = () => loadDashboard();
|
||
|
||
playbackHandler = (data) => {
|
||
if (!data?.device_id) return;
|
||
playbackByDevice.set(data.device_id, {
|
||
content_name: data.content_name || '',
|
||
duration_sec: data.duration_sec || null,
|
||
started_at: data.started_at || Date.now(),
|
||
});
|
||
renderProgressFor(data.device_id);
|
||
};
|
||
|
||
wallChangedHandler = () => loadDashboard();
|
||
|
||
on('device-status', statusHandler);
|
||
on('screenshot-ready', screenshotHandler);
|
||
on('device-added', deviceAddedHandler);
|
||
on('device-removed', deviceRemovedHandler);
|
||
on('playback-progress', playbackHandler);
|
||
on('wall-changed', wallChangedHandler);
|
||
|
||
progressTickInterval = setInterval(() => {
|
||
for (const id of playbackByDevice.keys()) renderProgressFor(id);
|
||
}, 1000);
|
||
|
||
// Request fresh screenshots on load
|
||
setTimeout(() => {
|
||
document.querySelectorAll('.device-card').forEach(card => {
|
||
requestScreenshot(card.dataset.deviceId);
|
||
});
|
||
}, 2000);
|
||
|
||
refreshInterval = setInterval(() => {
|
||
document.querySelectorAll('.device-card').forEach(card => {
|
||
requestScreenshot(card.dataset.deviceId);
|
||
});
|
||
}, 30000);
|
||
}
|
||
|
||
function refreshSelectionBar() {
|
||
const bar = document.getElementById('selectionBar');
|
||
const count = document.getElementById('selectionCount');
|
||
if (!bar || !count) return;
|
||
const n = selectedDeviceIds.size;
|
||
if (n === 0) { bar.style.display = 'none'; return; }
|
||
bar.style.display = 'flex';
|
||
// Need at least 2 to make a wall - surface the constraint inline so the
|
||
// greyed-out button isn't just silently unresponsive.
|
||
count.textContent = n < 2
|
||
? `${n} display selected - pick 1 more to create a wall`
|
||
: `${n} displays selected`;
|
||
const btn = document.getElementById('createWallBtn');
|
||
btn.disabled = n < 2;
|
||
btn.title = n < 2 ? 'Select at least 2 displays to create a video wall' : '';
|
||
}
|
||
|
||
// Pick a sensible default grid for n devices: prefer near-square layouts,
|
||
// breaking ties toward more columns (more common physical wall layout).
|
||
function defaultGridForCount(n) {
|
||
if (n <= 1) return { cols: 1, rows: 1 };
|
||
if (n === 2) return { cols: 2, rows: 1 };
|
||
if (n === 3) return { cols: 3, rows: 1 };
|
||
if (n === 4) return { cols: 2, rows: 2 };
|
||
if (n === 6) return { cols: 3, rows: 2 };
|
||
if (n === 8) return { cols: 4, rows: 2 };
|
||
if (n === 9) return { cols: 3, rows: 3 };
|
||
// Generic fallback — square-ish, columns >= rows
|
||
const cols = Math.ceil(Math.sqrt(n));
|
||
const rows = Math.ceil(n / cols);
|
||
return { cols, rows };
|
||
}
|
||
|
||
async function createWallFromSelection() {
|
||
const ids = [...selectedDeviceIds];
|
||
if (ids.length < 2) { showToast('Select at least 2 displays', 'error'); return; }
|
||
const name = prompt('Name this video wall:', `Wall ${new Date().toLocaleString()}`);
|
||
if (!name) return;
|
||
const { cols, rows } = defaultGridForCount(ids.length);
|
||
try {
|
||
const wall = await api.createWall({ name, grid_cols: cols, grid_rows: rows });
|
||
// Pack selected devices into row-major order. The user can reposition in
|
||
// the editor; this just gives every selection a sensible starting tile.
|
||
const placement = ids.slice(0, cols * rows).map((id, i) => ({
|
||
device_id: id,
|
||
grid_col: i % cols,
|
||
grid_row: Math.floor(i / cols),
|
||
}));
|
||
await api.setWallDevices(wall.id, placement);
|
||
selectedDeviceIds.clear();
|
||
showToast('Video wall created', 'success');
|
||
window.location.hash = `#/wall/${wall.id}`;
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function loadDashboard() {
|
||
const main = document.getElementById('groupedDevices');
|
||
if (!main) return;
|
||
|
||
try {
|
||
const [rawDevices, groups, playlists, walls] = await Promise.all([
|
||
api.getDevices(), api.getGroups(), api.getPlaylists(), api.getWalls(),
|
||
]);
|
||
|
||
// Deduplicate devices by id — a stale reconnect race can briefly cause the same
|
||
// device to appear twice in the list. Last-write-wins keeps the freshest state.
|
||
const seen = new Map();
|
||
for (const d of rawDevices) seen.set(d.id, d);
|
||
const devices = Array.from(seen.values());
|
||
|
||
// Stats
|
||
const online = devices.filter(d => d.status === 'online').length;
|
||
const offline = devices.filter(d => d.status === 'offline').length;
|
||
const provisioning = devices.filter(d => d.status === 'provisioning').length;
|
||
const statsEl = document.getElementById('dashStats');
|
||
if (statsEl) {
|
||
statsEl.innerHTML = `
|
||
<div class="info-card" style="flex:1;min-width:120px">
|
||
<div class="info-card-label">${t('dashboard.total_displays')}</div>
|
||
<div class="info-card-value">${devices.length}</div>
|
||
</div>
|
||
<div class="info-card" style="flex:1;min-width:120px">
|
||
<div class="info-card-label">${t('dashboard.online')}</div>
|
||
<div class="info-card-value" style="color:var(--success)">${online}</div>
|
||
</div>
|
||
<div class="info-card" style="flex:1;min-width:120px">
|
||
<div class="info-card-label">${t('dashboard.offline')}</div>
|
||
<div class="info-card-value" style="color:${offline > 0 ? 'var(--danger)' : 'var(--text-muted)'}">${offline}</div>
|
||
</div>
|
||
${provisioning > 0 ? `
|
||
<div class="info-card" style="flex:1;min-width:120px">
|
||
<div class="info-card-label">${t('dashboard.awaiting_pairing')}</div>
|
||
<div class="info-card-value" style="color:var(--warning,#f59e0b)">${provisioning}</div>
|
||
</div>` : ''}
|
||
`;
|
||
}
|
||
|
||
if (devices.length === 0 && groups.length === 0) {
|
||
main.innerHTML = `
|
||
<div class="empty-state">
|
||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||
</svg>
|
||
<h3>${t('dashboard.no_displays')}</h3>
|
||
<p>${t('dashboard.no_displays_desc')}</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// Devices that belong to a wall are owned by that wall — they don't appear
|
||
// as their own cards anywhere on the dashboard. The wall's card stands in.
|
||
const walledDeviceIds = new Set();
|
||
for (const w of (walls || [])) for (const d of (w.devices || [])) walledDeviceIds.add(d.device_id);
|
||
const dashboardDevices = devices.filter(d => !walledDeviceIds.has(d.id));
|
||
|
||
// Fetch group memberships
|
||
const groupsWithDevices = await Promise.all(groups.map(async g => {
|
||
const members = await api.getGroupDevices(g.id);
|
||
const memberIds = new Set(members.map(m => m.id));
|
||
// Use full device data from the main devices list (has telemetry/screenshots)
|
||
// and exclude any wall members.
|
||
const fullDevices = dashboardDevices.filter(d => memberIds.has(d.id));
|
||
return { ...g, devices: fullDevices, memberIds };
|
||
}));
|
||
|
||
// Render each device exactly once: the first group it belongs to wins.
|
||
// memberIds is preserved for the Manage modal so multi-group membership info stays accurate.
|
||
const renderedIds = new Set();
|
||
for (const g of groupsWithDevices) {
|
||
g.devices = g.devices.filter(d => {
|
||
if (renderedIds.has(d.id)) return false;
|
||
renderedIds.add(d.id);
|
||
return true;
|
||
});
|
||
}
|
||
const ungrouped = dashboardDevices.filter(d => !renderedIds.has(d.id));
|
||
|
||
let html = '';
|
||
|
||
// Walls render before groups: they're a higher-level construct (multiple
|
||
// physical screens acting as one logical display).
|
||
if ((walls || []).length > 0) {
|
||
html += `
|
||
<div class="wall-section" style="margin-bottom:24px">
|
||
<div style="display:flex;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid #8b5cf6">
|
||
<strong style="font-size:15px">Video Walls</strong>
|
||
<span style="color:var(--text-muted);font-size:12px;margin-left:10px">${walls.length} wall${walls.length === 1 ? '' : 's'}</span>
|
||
</div>
|
||
<div class="device-grid">${walls.map(renderWallCard).join('')}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Render each group with its devices
|
||
for (const g of groupsWithDevices) {
|
||
html += renderGroupSection(g, g.devices, playlists);
|
||
}
|
||
|
||
// Render ungrouped devices. The wrapper is tagged data-ungrouped="1" so
|
||
// attachGroupHandlers can wire it as a drop target — dropping a device here
|
||
// removes it from every group it currently belongs to.
|
||
if (ungrouped.length > 0) {
|
||
html += `
|
||
<div class="ungrouped-section" data-ungrouped="1" style="margin-bottom:24px">
|
||
${groups.length > 0 ? `
|
||
<div style="display:flex;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid var(--text-muted)">
|
||
<strong style="font-size:15px;color:var(--text-muted)">${t('dashboard.ungrouped')}</strong>
|
||
<span style="color:var(--text-muted);font-size:12px;margin-left:10px">${tn('dashboard.devices_count', ungrouped.length)}</span>
|
||
</div>` : ''}
|
||
<div class="device-grid">
|
||
${ungrouped.map(renderDeviceCard).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
main.innerHTML = html;
|
||
attachGroupHandlers(groupsWithDevices, dashboardDevices);
|
||
|
||
// Drop any selections for devices that have since been absorbed into a
|
||
// wall, and update the toolbar.
|
||
for (const id of [...selectedDeviceIds]) {
|
||
if (walledDeviceIds.has(id)) selectedDeviceIds.delete(id);
|
||
}
|
||
refreshSelectionBar();
|
||
|
||
} catch (err) {
|
||
main.innerHTML = `<div class="empty-state"><h3>${t('dashboard.failed_to_load')}</h3><p>${esc(err.message)}</p></div>`;
|
||
}
|
||
}
|
||
|
||
function attachGroupHandlers(groupsWithDevices, allDevices) {
|
||
// Drag-and-drop: device cards are draggable; group sections + the Ungrouped
|
||
// wrapper are drop targets. Drop on a group adds membership (mirrors the
|
||
// Manage modal). Drop on Ungrouped removes the device from every group it's
|
||
// currently a member of.
|
||
const groupsByDeviceId = new Map();
|
||
for (const g of groupsWithDevices) {
|
||
g.memberIds.forEach(id => {
|
||
if (!groupsByDeviceId.has(id)) groupsByDeviceId.set(id, []);
|
||
groupsByDeviceId.get(id).push({ id: g.id, name: g.name });
|
||
});
|
||
}
|
||
|
||
document.querySelectorAll('.device-card').forEach(card => {
|
||
card.addEventListener('dragstart', (e) => {
|
||
e.dataTransfer.setData('text/device-id', card.dataset.deviceId);
|
||
e.dataTransfer.setData('text/device-name', card.dataset.deviceName || '');
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
});
|
||
});
|
||
|
||
function highlightOn(el) { el.style.outline = '2px solid var(--primary)'; el.style.outlineOffset = '2px'; }
|
||
function highlightOff(el) { el.style.outline = ''; el.style.outlineOffset = ''; }
|
||
|
||
document.querySelectorAll('.group-section').forEach(section => {
|
||
section.addEventListener('dragover', (e) => {
|
||
if (!e.dataTransfer.types.includes('text/device-id')) return;
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
highlightOn(section);
|
||
});
|
||
section.addEventListener('dragleave', (e) => {
|
||
// Avoid flicker when moving across child elements
|
||
if (e.target === section) highlightOff(section);
|
||
});
|
||
section.addEventListener('drop', async (e) => {
|
||
e.preventDefault();
|
||
highlightOff(section);
|
||
const deviceId = e.dataTransfer.getData('text/device-id');
|
||
const deviceName = e.dataTransfer.getData('text/device-name') || 'this device';
|
||
if (!deviceId) return;
|
||
const groupId = section.dataset.groupId;
|
||
const targetGroup = groupsWithDevices.find(g => g.id === groupId);
|
||
if (!targetGroup) return;
|
||
// Already in this group — no-op.
|
||
if (targetGroup.memberIds.has(deviceId)) {
|
||
showToast(t('dashboard.toast.already_in_group', { name: deviceName, group: targetGroup.name }), 'info');
|
||
return;
|
||
}
|
||
// If the device is in another group, mirror the Manage modal's confirm.
|
||
const others = (groupsByDeviceId.get(deviceId) || []).map(g => g.name);
|
||
if (others.length > 0) {
|
||
if (!confirm(t('dashboard.confirm_add_to_group', { name: deviceName, groups: others.join(', '), target: targetGroup.name }))) return;
|
||
}
|
||
try {
|
||
await api.addDeviceToGroup(groupId, deviceId);
|
||
showToast(t('dashboard.toast.moved_device', { name: deviceName, group: targetGroup.name }), 'success');
|
||
loadDashboard();
|
||
} catch (err) { showToast(err.message, 'error'); }
|
||
});
|
||
});
|
||
|
||
// Ungrouped wrapper: remove device from every group it's in.
|
||
document.querySelectorAll('[data-ungrouped="1"]').forEach(section => {
|
||
section.addEventListener('dragover', (e) => {
|
||
if (!e.dataTransfer.types.includes('text/device-id')) return;
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
highlightOn(section);
|
||
});
|
||
section.addEventListener('dragleave', (e) => {
|
||
if (e.target === section) highlightOff(section);
|
||
});
|
||
section.addEventListener('drop', async (e) => {
|
||
e.preventDefault();
|
||
highlightOff(section);
|
||
const deviceId = e.dataTransfer.getData('text/device-id');
|
||
const deviceName = e.dataTransfer.getData('text/device-name') || 'this device';
|
||
if (!deviceId) return;
|
||
const memberships = groupsByDeviceId.get(deviceId) || [];
|
||
if (memberships.length === 0) return; // already ungrouped
|
||
try {
|
||
await Promise.all(memberships.map(m => api.removeDeviceFromGroup(m.id, deviceId)));
|
||
showToast(tn('dashboard.toast.removed_device', memberships.length, { name: deviceName }), 'success');
|
||
loadDashboard();
|
||
} catch (err) { showToast(err.message, 'error'); }
|
||
});
|
||
});
|
||
|
||
// Playlist assignment handlers
|
||
document.querySelectorAll('.group-playlist-select').forEach(select => {
|
||
select.addEventListener('change', async (e) => {
|
||
const playlistId = e.target.value;
|
||
if (!playlistId) return;
|
||
const groupId = e.target.dataset.groupId;
|
||
const groupName = e.target.dataset.groupName;
|
||
const playlistName = e.target.options[e.target.selectedIndex].textContent;
|
||
|
||
if (!confirm(t('dashboard.confirm_assign_playlist', { playlist: playlistName, group: groupName }))) {
|
||
e.target.value = '';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const result = await api.groupAssignPlaylist(groupId, playlistId);
|
||
showToast(tn('dashboard.toast.playlist_assigned', result.devices_updated), 'success');
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
e.target.value = '';
|
||
});
|
||
});
|
||
|
||
// Command select handlers
|
||
document.querySelectorAll('.group-cmd-select').forEach(select => {
|
||
select.addEventListener('change', async (e) => {
|
||
const type = e.target.value;
|
||
if (!type) return;
|
||
const groupId = e.target.dataset.groupId;
|
||
const groupName = e.target.dataset.groupName;
|
||
const count = e.target.dataset.deviceCount;
|
||
const cmdLabel = t(CMD_LABEL_KEY[type] || type);
|
||
|
||
if (DESTRUCTIVE_COMMANDS.includes(type)) {
|
||
if (!confirm(t('dashboard.confirm_destructive_command', { cmd: cmdLabel.toUpperCase(), n: count, group: groupName }))) {
|
||
e.target.value = '';
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const result = await api.sendGroupCommand(groupId, type);
|
||
const msg = result.offline > 0
|
||
? t('dashboard.toast.command_sent_with_offline', { cmd: cmdLabel, sent: result.sent, total: result.total, offline: result.offline })
|
||
: t('dashboard.toast.command_sent', { cmd: cmdLabel, sent: result.sent, total: result.total });
|
||
showToast(msg, result.offline > 0 ? 'warning' : 'success');
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
e.target.value = '';
|
||
});
|
||
});
|
||
|
||
// Delete group
|
||
document.querySelectorAll('[data-group-delete]').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
const id = btn.dataset.groupDelete;
|
||
if (!confirm(t('dashboard.confirm_delete_group'))) return;
|
||
try {
|
||
await api.deleteGroup(id);
|
||
showToast(t('dashboard.toast.group_deleted'), 'success');
|
||
loadDashboard();
|
||
} catch (e) { showToast(e.message, 'error'); }
|
||
});
|
||
});
|
||
|
||
// Manage group (add/remove devices)
|
||
document.querySelectorAll('[data-group-manage]').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
const groupId = btn.dataset.groupManage;
|
||
const group = groupsWithDevices.find(g => g.id === groupId);
|
||
const memberIds = new Set(group.devices.map(d => d.id));
|
||
|
||
// Get all groups for multi-group warning
|
||
const otherGroups = groupsWithDevices.filter(g => g.id !== groupId);
|
||
|
||
const modal = document.createElement('div');
|
||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000';
|
||
modal.innerHTML = `
|
||
<div style="background:var(--bg-card);border-radius:12px;padding:24px;max-width:400px;width:90%;max-height:70vh;overflow-y:auto">
|
||
<h3 style="margin:0 0 4px">${esc(group.name)}</h3>
|
||
<p style="margin:0 0 16px;font-size:12px;color:var(--text-muted)">${t('dashboard.manage_group_subtitle')}</p>
|
||
<div style="display:flex;flex-direction:column;gap:6px">
|
||
${allDevices.filter(d => d.status !== 'provisioning').map(d => {
|
||
const inOther = otherGroups.filter(g => g.memberIds.has(d.id)).map(g => g.name);
|
||
return `
|
||
<label style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;background:var(--bg-secondary)">
|
||
<input type="checkbox" data-device-id="${d.id}" data-in-groups="${inOther.join(',')}" ${memberIds.has(d.id) ? 'checked' : ''}>
|
||
<span class="status-dot ${d.status}" style="width:8px;height:8px"></span>
|
||
<span style="font-size:13px;flex:1">${esc(d.name)}</span>
|
||
${inOther.length > 0 ? `<span style="font-size:10px;color:var(--text-muted);background:var(--bg-primary);padding:1px 6px;border-radius:8px">${esc(inOther.join(', '))}</span>` : ''}
|
||
</label>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
<div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end">
|
||
<button class="btn" id="manageGroupClose">${t('common.done')}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
|
||
modal.querySelector('#manageGroupClose').onclick = () => { modal.remove(); loadDashboard(); };
|
||
modal.addEventListener('click', (ev) => { if (ev.target === modal) { modal.remove(); loadDashboard(); } });
|
||
|
||
modal.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||
cb.addEventListener('change', async () => {
|
||
const deviceId = cb.dataset.deviceId;
|
||
const existingGroups = cb.dataset.inGroups;
|
||
const cbName = cb.closest('label')?.querySelector('span:not(.status-dot)')?.textContent || '';
|
||
try {
|
||
if (cb.checked && existingGroups) {
|
||
if (!confirm(t('dashboard.confirm_add_to_group', { name: cbName, groups: existingGroups, target: group.name }))) {
|
||
cb.checked = false;
|
||
return;
|
||
}
|
||
}
|
||
if (cb.checked) {
|
||
await api.addDeviceToGroup(groupId, deviceId);
|
||
} else {
|
||
await api.removeDeviceFromGroup(groupId, deviceId);
|
||
}
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
cb.checked = !cb.checked;
|
||
}
|
||
});
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
export function cleanup() {
|
||
if (statusHandler) off('device-status', statusHandler);
|
||
if (screenshotHandler) off('screenshot-ready', screenshotHandler);
|
||
if (playbackHandler) off('playback-progress', playbackHandler);
|
||
if (wallChangedHandler) off('wall-changed', wallChangedHandler);
|
||
off('device-added', () => {});
|
||
off('device-removed', () => {});
|
||
if (refreshInterval) clearInterval(refreshInterval);
|
||
if (progressTickInterval) clearInterval(progressTickInterval);
|
||
statusHandler = null;
|
||
screenshotHandler = null;
|
||
playbackHandler = null;
|
||
wallChangedHandler = null;
|
||
refreshInterval = null;
|
||
progressTickInterval = null;
|
||
playbackByDevice.clear();
|
||
}
|