From 74e7062a334d862aebb4b9169ca017f5fc54cd02 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 7 Jul 2026 12:52:42 -0500 Subject: [PATCH] =?UTF-8?q?feat(#150):=20re-adopt=20UI=20=E2=80=94=20resto?= =?UTF-8?q?re=20a=20removed=20device's=20settings=20onto=20a=20re-paired?= =?UTF-8?q?=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/js/api.js | 4 ++ frontend/js/i18n/en.js | 20 ++++++ frontend/js/views/device-detail.js | 100 ++++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/frontend/js/api.js b/frontend/js/api.js index 990a616..e275d30 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -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). diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 91eeff6..ad0be0a 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -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', diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index cc5ef1c..4e2088a 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -370,6 +370,7 @@ async function loadDevice(deviceId, activeTab = null) { +
@@ -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 + ? `${t('device.readopt.blocked')}` + : ''; + // Fingerprint is the key but not an operator-facing identifier — truncated + on-hover only. + const fpShort = (s.fingerprint || '').slice(0, 8); + return ` +
+
+
${esc(s.device_name || t('device.readopt.unnamed'))}${blockedBadge}
+
+ ${t('device.readopt.summary_orientation')}: ${esc(orientLabel(s.orientation))} +  ·  ${t('device.readopt.summary_timezone')}: ${esc(s.timezone || 'UTC')} +  ·  ${t('device.readopt.summary_playlist')}: ${esc(playlistLabel(s))} +
+
+ ${t('device.readopt.last_seen')}: ${esc(fmtTs(s.last_seen))}  ·  ${t('device.readopt.removed')}: ${esc(fmtTs(s.removed_at))} +
+
+ +
`; + }).join(''); + + const emptyHtml = `
${t('device.readopt.empty')}
`; + + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.display = 'flex'; + overlay.innerHTML = ` + `; + 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) {