feat(#150): re-adopt UI — restore a removed device's settings onto a re-paired screen

Fallback for when the automatic fingerprint-match restore can't fire (factory reset / new
hardware / changed fingerprint). From a device's detail view (UX b): 'Restore from removed
device…' opens a picker of the workspace's removed-device snapshots (GET /devices/removed),
showing device_name + last_seen/removed_at + restore summary (orientation/timezone/playlist),
a Blocked badge, and an Apply action (POST /devices/:id/re-adopt) with a confirm — including an
explicit warning that applying a blocked snapshot re-blocks the target. Refreshes the device
view on success; handles 404/403/400; empty state. Fingerprint shown truncated on-hover only.

Frontend only. Local, no bump/tag.
This commit is contained in:
ScreenTinker 2026-07-07 12:52:42 -05:00
parent 2ba06e98ec
commit 74e7062a33
3 changed files with 123 additions and 1 deletions

View file

@ -36,6 +36,10 @@ export const api = {
// no restart. Server enforces via the SNAT-safe identity chain (deviceSocket).
blockDevice: (id) => request(`/devices/${id}/block`, { method: 'POST' }),
unblockDevice: (id) => request(`/devices/${id}/unblock`, { method: 'POST' }),
// #150: fingerprint-keyed settings snapshots of previously-removed devices (this workspace),
// and the re-adopt action that applies a snapshot onto a newly-paired device.
getRemovedDevices: () => request('/devices/removed'),
reAdoptDevice: (id, fingerprint) => request(`/devices/${id}/re-adopt`, { method: 'POST', body: JSON.stringify({ fingerprint }) }),
// #109 PiP overlay: push/clear a floating overlay on a device or group. `id` may be a
// device id OR a group id (the server resolves + expands). Needs full scope (no-op for JWT).

View file

@ -301,6 +301,26 @@ export default {
'device.debug.toggle': 'Debug logging (live)',
'device.debug.hint': 'Streams player/zone logs from this device in real time. Turns off on its own when the device reconnects.',
'device.form.save_settings': 'Save Settings',
// #150 re-adopt: restore a removed device's saved settings onto this one
'device.readopt.button': 'Restore from removed device…',
'device.readopt.button_hint': "Apply a previously-removed device's saved settings onto this one (for a re-paired screen whose fingerprint changed)",
'device.readopt.title': 'Restore settings from a removed device',
'device.readopt.help': 'Pick a previously-removed device to copy its saved settings onto “{name}”. This overwrites the current settings.',
'device.readopt.empty': 'No previously-removed devices in this workspace.',
'device.readopt.unnamed': 'Unnamed device',
'device.readopt.blocked': 'Blocked',
'device.readopt.summary_orientation': 'Orientation',
'device.readopt.summary_timezone': 'Timezone',
'device.readopt.summary_playlist': 'Playlist',
'device.readopt.playlist_none': 'none',
'device.readopt.playlist_removed': '(playlist since deleted)',
'device.readopt.last_seen': 'Last seen',
'device.readopt.removed': 'Removed',
'device.readopt.apply': 'Apply',
'device.readopt.confirm': 'Apply saved settings from “{source}” onto “{target}”? This overwrites the current settings.',
'device.readopt.confirm_blocked': 'This device was BLOCKED. Applying it will re-block the target device — it will immediately refuse to connect and the screen will go dark. Continue?',
'device.readopt.success': 'Settings restored ({orientation})',
'device.readopt.error': 'Could not restore settings',
// Control buttons
'device.ctl.reboot_device': 'Reboot Device',
'device.ctl.screen_off': 'Screen Off',

View file

@ -370,6 +370,7 @@ async function loadDevice(deviceId, activeTab = null) {
<textarea id="deviceNotes" class="input" rows="3" placeholder="${t('device.form.notes_placeholder')}" style="resize:vertical">${esc(device.notes || '')}</textarea>
</div>
<button class="btn btn-secondary btn-sm" id="saveNotesBtn">${t('device.form.save_settings')}</button>
<button class="btn btn-secondary btn-sm" id="reAdoptBtn" style="margin-left:8px" title="${t('device.readopt.button_hint')}">${t('device.readopt.button')}</button>
</div>
<div style="margin-top:20px">
@ -644,7 +645,100 @@ function showDevicePreview(device) {
});
}
async function setupActions(device) {
async // #150 re-adopt fallback: browse the workspace's previously-removed device snapshots and
// apply one onto THIS (usually blank, just-re-paired) device. Primary restore is the silent
// fingerprint-match on re-pair; this is for factory-reset / new-hardware / changed-fingerprint.
const ORIENT_LABELS = {
'landscape': 'device.form.orientation.landscape',
'portrait': 'device.form.orientation.portrait',
'landscape-flipped': 'device.form.orientation.landscape_flipped',
'portrait-flipped': 'device.form.orientation.portrait_flipped',
};
const orientLabel = (o) => t(ORIENT_LABELS[o] || ORIENT_LABELS.landscape);
const fmtTs = (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '—');
async function showReAdoptModal(device) {
let snapshots, playlists;
try {
[snapshots, playlists] = await Promise.all([
api.getRemovedDevices(),
api.getPlaylists().catch(() => []), // best-effort: only used to label the restored playlist
]);
} catch (err) { showToast(err.message || t('device.readopt.error'), 'error'); return; }
const plById = new Map((playlists || []).map(p => [p.id, p.name]));
const playlistLabel = (s) => !s.playlist_id
? t('device.readopt.playlist_none')
: (plById.get(s.playlist_id) || t('device.readopt.playlist_removed'));
const rowsHtml = (snapshots || []).map((s, i) => {
const blockedBadge = s.blocked
? `<span style="background:var(--danger,#dc2626);color:#fff;padding:1px 7px;border-radius:4px;font-size:11px;margin-left:8px;vertical-align:middle">${t('device.readopt.blocked')}</span>`
: '';
// Fingerprint is the key but not an operator-facing identifier — truncated + on-hover only.
const fpShort = (s.fingerprint || '').slice(0, 8);
return `
<div style="border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:8px;display:flex;align-items:center;gap:12px">
<div style="flex:1;min-width:0">
<div style="font-weight:600">${esc(s.device_name || t('device.readopt.unnamed'))}${blockedBadge}</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:3px">
${t('device.readopt.summary_orientation')}: ${esc(orientLabel(s.orientation))}
&nbsp;·&nbsp; ${t('device.readopt.summary_timezone')}: ${esc(s.timezone || 'UTC')}
&nbsp;·&nbsp; ${t('device.readopt.summary_playlist')}: ${esc(playlistLabel(s))}
</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:3px" title="fp ${esc(fpShort)}…">
${t('device.readopt.last_seen')}: ${esc(fmtTs(s.last_seen))} &nbsp;·&nbsp; ${t('device.readopt.removed')}: ${esc(fmtTs(s.removed_at))}
</div>
</div>
<button class="btn btn-primary btn-sm readopt-apply" data-i="${i}">${t('device.readopt.apply')}</button>
</div>`;
}).join('');
const emptyHtml = `<div style="text-align:center;color:var(--text-muted);padding:36px 12px">${t('device.readopt.empty')}</div>`;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
overlay.innerHTML = `
<div class="modal" style="max-width:600px;width:95vw">
<div class="modal-header">
<h3>${t('device.readopt.title')}</h3>
<button class="btn-icon" id="readoptClose">
<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">
<p style="color:var(--text-muted);font-size:13px;margin-top:0">${t('device.readopt.help', { name: esc(device.name || '') })}</p>
${(snapshots && snapshots.length) ? rowsHtml : emptyHtml}
</div>
</div>`;
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.querySelector('#readoptClose').onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
overlay.querySelectorAll('.readopt-apply').forEach((btn) => {
btn.addEventListener('click', async () => {
const s = snapshots[parseInt(btn.dataset.i, 10)];
let msg = t('device.readopt.confirm', { source: s.device_name || t('device.readopt.unnamed'), target: device.name || '' });
if (s.blocked) msg += '\n\n⚠ ' + t('device.readopt.confirm_blocked'); // explicit: target will go dark
if (!confirm(msg)) return;
btn.disabled = true;
try {
await api.reAdoptDevice(device.id, s.fingerprint);
showToast(t('device.readopt.success', { orientation: orientLabel(s.orientation) }), 'success');
close();
loadDevice(device.id); // refresh so restored orientation/name/etc show immediately
} catch (err) {
// Server messages: 404 no snapshot, 403 cross-workspace, 400 bad request.
showToast(err.message || t('device.readopt.error'), 'error');
btn.disabled = false;
}
});
});
}
function setupActions(device) {
// #104 Preview button
document.getElementById('devicePreviewBtn')?.addEventListener('click', () => showDevicePreview(device));
@ -706,6 +800,10 @@ async function setupActions(device) {
}
});
// #150 re-adopt: apply a previously-removed device's saved settings onto THIS device (the
// fallback for when the fingerprint changed and automatic restore couldn't fire).
document.getElementById('reAdoptBtn')?.addEventListener('click', () => showReAdoptModal(device));
// Publish / Discard from device detail
const devicePublishBtn = document.getElementById('devicePublishBtn');
if (devicePublishBtn && device.playlist_id) {