feat: add remote device settings portal and Pi hot reload
- add /settings/device portal page and portal device-config GET endpoint - add portal navigation and client-side device settings editor UI - hot-reload Account/Car display config after local saves and server syncs - document portal workflow and Phase 25.5b completion - add runtime reload tests for account, car, and sync callback Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
const form = document.getElementById('device-settings-form');
|
||||
const saveButton = document.getElementById('device-settings-save');
|
||||
const message = document.getElementById('device-settings-message');
|
||||
const configVersion = document.getElementById('config-version');
|
||||
const avatarPreview = document.getElementById('avatar-preview');
|
||||
const previewCaption = document.getElementById('preview-caption');
|
||||
|
||||
const fields = {
|
||||
owner_name: document.getElementById('owner_name'),
|
||||
car_model: document.getElementById('car_model'),
|
||||
device_name: document.getElementById('device_name'),
|
||||
avatar_path: document.getElementById('avatar_path'),
|
||||
};
|
||||
|
||||
let initialState = null;
|
||||
|
||||
function showMessage(text, kind) {
|
||||
message.className = `alert alert-${kind}`;
|
||||
message.textContent = text;
|
||||
message.hidden = false;
|
||||
}
|
||||
|
||||
function hideMessage() {
|
||||
message.hidden = true;
|
||||
}
|
||||
|
||||
function currentState() {
|
||||
return {
|
||||
owner_name: fields.owner_name.value.trim(),
|
||||
car_model: fields.car_model.value.trim(),
|
||||
device_name: fields.device_name.value.trim(),
|
||||
avatar_path: fields.avatar_path.value.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function avatarUrl(path) {
|
||||
if (!path) return '/static/img/yaris.png';
|
||||
if (/^https?:\/\//.test(path)) return path;
|
||||
return `/static/${path.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
const state = currentState();
|
||||
avatarPreview.src = avatarUrl(state.avatar_path);
|
||||
previewCaption.textContent = state.owner_name && state.car_model
|
||||
? `${state.owner_name} • ${state.car_model}`
|
||||
: 'Preview updates as you type.';
|
||||
}
|
||||
|
||||
function updateDirtyState() {
|
||||
if (!initialState) {
|
||||
saveButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
saveButton.disabled = JSON.stringify(currentState()) === JSON.stringify(initialState);
|
||||
}
|
||||
|
||||
function populate(config) {
|
||||
fields.owner_name.value = config.owner_name || '';
|
||||
fields.car_model.value = config.car_model || '';
|
||||
fields.device_name.value = config.device_name || '';
|
||||
fields.avatar_path.value = config.avatar_path || '';
|
||||
configVersion.textContent = config.config_version ?? '—';
|
||||
initialState = currentState();
|
||||
updatePreview();
|
||||
updateDirtyState();
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/portal/device-config');
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || 'Could not load device config.');
|
||||
}
|
||||
populate(payload);
|
||||
} catch (error) {
|
||||
showMessage(error.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(event) {
|
||||
event.preventDefault();
|
||||
hideMessage();
|
||||
saveButton.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/device-config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(currentState()),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
const detail = payload.fields
|
||||
? Object.values(payload.fields).join(' ')
|
||||
: (payload.error || 'Could not save device config.');
|
||||
throw new Error(detail);
|
||||
}
|
||||
populate(payload);
|
||||
showMessage('Device settings saved. The Pi will pick them up on its next sync.', 'success');
|
||||
} catch (error) {
|
||||
showMessage(error.message, 'danger');
|
||||
updateDirtyState();
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of Object.values(fields)) {
|
||||
field.addEventListener('input', () => {
|
||||
hideMessage();
|
||||
updatePreview();
|
||||
updateDirtyState();
|
||||
});
|
||||
}
|
||||
|
||||
avatarPreview.addEventListener('error', () => {
|
||||
previewCaption.textContent = 'Preview image not found on the server static path.';
|
||||
});
|
||||
|
||||
form.addEventListener('submit', saveConfig);
|
||||
loadConfig();
|
||||
Reference in New Issue
Block a user