mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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:
parent
2ba06e98ec
commit
74e7062a33
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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))}
|
||||
· ${t('device.readopt.summary_timezone')}: ${esc(s.timezone || 'UTC')}
|
||||
· ${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))} · ${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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue