feat: Add settings editing to web UI

Reimplements PR #346 on top of current master. Adds a web settings page
at /settings that allows viewing and editing all device settings via the
hotspot, with GET/POST JSON API at /api/settings.
This commit is contained in:
Justin Mitchell 2026-02-01 19:16:34 -08:00 committed by Jesse Vincent
parent e5c0ddc9fa
commit 84fa55789c
8 changed files with 608 additions and 31 deletions

57
src/SettingsList.h Normal file
View File

@ -0,0 +1,57 @@
#pragma once
#include <vector>
#include "CrossPointSettings.h"
#include "activities/settings/CategorySettingsActivity.h"
// Returns the flat list of all settings for the web API.
// This is used by CrossPointWebServer to expose settings over HTTP.
inline std::vector<SettingInfo> getSettingsList() {
return {
// Display
SettingInfo::Enum("sleepScreen", "Sleep Screen", &CrossPointSettings::sleepScreen,
{"Dark", "Light", "Custom", "Cover", "None"}),
SettingInfo::Enum("sleepScreenCoverMode", "Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode,
{"Fit", "Crop"}),
SettingInfo::Enum("sleepScreenCoverFilter", "Sleep Screen Cover Filter",
&CrossPointSettings::sleepScreenCoverFilter, {"None", "Contrast", "Inverted"}),
SettingInfo::Enum("statusBar", "Status Bar", &CrossPointSettings::statusBar,
{"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}),
SettingInfo::Enum("hideBatteryPercentage", "Hide Battery %", &CrossPointSettings::hideBatteryPercentage,
{"Never", "In Reader", "Always"}),
SettingInfo::Enum("refreshFrequency", "Refresh Frequency", &CrossPointSettings::refreshFrequency,
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
// Reader
SettingInfo::Enum("fontFamily", "Font Family", &CrossPointSettings::fontFamily,
{"Bookerly", "Noto Sans", "Open Dyslexic"}),
SettingInfo::Enum("fontSize", "Font Size", &CrossPointSettings::fontSize,
{"Small", "Medium", "Large", "X Large"}),
SettingInfo::Enum("lineSpacing", "Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}),
SettingInfo::Value("screenMargin", "Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}),
SettingInfo::Enum("paragraphAlignment", "Paragraph Alignment", &CrossPointSettings::paragraphAlignment,
{"Justify", "Left", "Center", "Right"}),
SettingInfo::Toggle("hyphenationEnabled", "Hyphenation", &CrossPointSettings::hyphenationEnabled),
SettingInfo::Enum("orientation", "Reading Orientation", &CrossPointSettings::orientation,
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}),
SettingInfo::Toggle("extraParagraphSpacing", "Extra Paragraph Spacing",
&CrossPointSettings::extraParagraphSpacing),
SettingInfo::Toggle("textAntiAliasing", "Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing),
// Controls
SettingInfo::Enum(
"frontButtonLayout", "Front Button Layout", &CrossPointSettings::frontButtonLayout,
{"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght", "Bck, Cnfrm, Rght, Lft"}),
SettingInfo::Enum("sideButtonLayout", "Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout,
{"Prev, Next", "Next, Prev"}),
SettingInfo::Toggle("longPressChapterSkip", "Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
SettingInfo::Enum("shortPwrBtn", "Short Power Button Click", &CrossPointSettings::shortPwrBtn,
{"Ignore", "Sleep", "Page Turn"}),
// System
SettingInfo::Enum("sleepTimeout", "Time to Sleep", &CrossPointSettings::sleepTimeout,
{"1 min", "5 min", "10 min", "15 min", "30 min"}),
SettingInfo::String("opdsServerUrl", "OPDS Server URL", SETTINGS.opdsServerUrl,
sizeof(SETTINGS.opdsServerUrl) - 1),
};
}

View File

@ -11,12 +11,15 @@
class CrossPointSettings;
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE };
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING };
struct SettingInfo {
const char* name;
const char* key; // JSON key for web API (nullptr for ACTION types)
const char* name; // Display name of the setting
SettingType type;
uint8_t CrossPointSettings::* valuePtr;
uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE/ENUM/VALUE)
char* stringPtr; // Pointer to char array (for STRING type)
size_t stringMaxLen; // Max length for STRING type
std::vector<std::string> enumValues;
struct ValueRange {
@ -26,18 +29,26 @@ struct SettingInfo {
};
ValueRange valueRange;
static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) {
return {name, SettingType::TOGGLE, ptr};
static SettingInfo Toggle(const char* key, const char* name, uint8_t CrossPointSettings::* ptr) {
return {key, name, SettingType::TOGGLE, ptr, nullptr, 0, {}, {}};
}
static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector<std::string> values) {
return {name, SettingType::ENUM, ptr, std::move(values)};
static SettingInfo Enum(const char* key, const char* name, uint8_t CrossPointSettings::* ptr,
std::vector<std::string> values) {
return {key, name, SettingType::ENUM, ptr, nullptr, 0, std::move(values), {}};
}
static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr}; }
static SettingInfo Action(const char* name) {
return {nullptr, name, SettingType::ACTION, nullptr, nullptr, 0, {}, {}};
}
static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange) {
return {name, SettingType::VALUE, ptr, {}, valueRange};
static SettingInfo Value(const char* key, const char* name, uint8_t CrossPointSettings::* ptr,
const ValueRange valueRange) {
return {key, name, SettingType::VALUE, ptr, nullptr, 0, {}, valueRange};
}
static SettingInfo String(const char* key, const char* name, char* ptr, size_t maxLen) {
return {key, name, SettingType::STRING, nullptr, ptr, maxLen, {}, {}};
}
};

View File

@ -14,43 +14,49 @@ namespace {
constexpr int displaySettingsCount = 6;
const SettingInfo displaySettings[displaySettingsCount] = {
// Should match with SLEEP_SCREEN_MODE
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}),
SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter,
{"None", "Contrast", "Inverted"}),
SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar,
SettingInfo::Enum("sleepScreen", "Sleep Screen", &CrossPointSettings::sleepScreen,
{"Dark", "Light", "Custom", "Cover", "None"}),
SettingInfo::Enum("sleepScreenCoverMode", "Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode,
{"Fit", "Crop"}),
SettingInfo::Enum("sleepScreenCoverFilter", "Sleep Screen Cover Filter",
&CrossPointSettings::sleepScreenCoverFilter, {"None", "Contrast", "Inverted"}),
SettingInfo::Enum("statusBar", "Status Bar", &CrossPointSettings::statusBar,
{"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}),
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
SettingInfo::Enum("hideBatteryPercentage", "Hide Battery %", &CrossPointSettings::hideBatteryPercentage,
{"Never", "In Reader", "Always"}),
SettingInfo::Enum("refreshFrequency", "Refresh Frequency", &CrossPointSettings::refreshFrequency,
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"})};
constexpr int readerSettingsCount = 9;
const SettingInfo readerSettings[readerSettingsCount] = {
SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"}),
SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}),
SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}),
SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}),
SettingInfo::Enum("Paragraph Alignment", &CrossPointSettings::paragraphAlignment,
SettingInfo::Enum("fontFamily", "Font Family", &CrossPointSettings::fontFamily,
{"Bookerly", "Noto Sans", "Open Dyslexic"}),
SettingInfo::Enum("fontSize", "Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}),
SettingInfo::Enum("lineSpacing", "Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}),
SettingInfo::Value("screenMargin", "Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}),
SettingInfo::Enum("paragraphAlignment", "Paragraph Alignment", &CrossPointSettings::paragraphAlignment,
{"Justify", "Left", "Center", "Right"}),
SettingInfo::Toggle("Hyphenation", &CrossPointSettings::hyphenationEnabled),
SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation,
SettingInfo::Toggle("hyphenationEnabled", "Hyphenation", &CrossPointSettings::hyphenationEnabled),
SettingInfo::Enum("orientation", "Reading Orientation", &CrossPointSettings::orientation,
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}),
SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing),
SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing)};
SettingInfo::Toggle("extraParagraphSpacing", "Extra Paragraph Spacing",
&CrossPointSettings::extraParagraphSpacing),
SettingInfo::Toggle("textAntiAliasing", "Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing)};
constexpr int controlsSettingsCount = 4;
const SettingInfo controlsSettings[controlsSettingsCount] = {
SettingInfo::Enum(
"Front Button Layout", &CrossPointSettings::frontButtonLayout,
"frontButtonLayout", "Front Button Layout", &CrossPointSettings::frontButtonLayout,
{"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght", "Bck, Cnfrm, Rght, Lft"}),
SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout,
SettingInfo::Enum("sideButtonLayout", "Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout,
{"Prev, Next", "Next, Prev"}),
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"})};
SettingInfo::Toggle("longPressChapterSkip", "Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
SettingInfo::Enum("shortPwrBtn", "Short Power Button Click", &CrossPointSettings::shortPwrBtn,
{"Ignore", "Sleep", "Page Turn"})};
constexpr int systemSettingsCount = 5;
const SettingInfo systemSettings[systemSettingsCount] = {
SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout,
SettingInfo::Enum("sleepTimeout", "Time to Sleep", &CrossPointSettings::sleepTimeout,
{"1 min", "5 min", "10 min", "15 min", "30 min"}),
SettingInfo::Action("KOReader Sync"), SettingInfo::Action("OPDS Browser"), SettingInfo::Action("Clear Cache"),
SettingInfo::Action("Check for updates")};

View File

@ -8,9 +8,12 @@
#include <esp_task_wdt.h>
#include <algorithm>
#include <cstring>
#include "SettingsList.h"
#include "html/FilesPageHtml.generated.h"
#include "html/HomePageHtml.generated.h"
#include "html/SettingsPageHtml.generated.h"
#include "util/StringUtils.h"
namespace {
@ -112,6 +115,11 @@ void CrossPointWebServer::begin() {
// Delete file/folder endpoint
server->on("/delete", HTTP_POST, [this] { handleDelete(); });
// Settings endpoints
server->on("/settings", HTTP_GET, [this] { handleSettingsPage(); });
server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); });
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
server->onNotFound([this] { handleNotFound(); });
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
@ -936,3 +944,144 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
break;
}
}
void CrossPointWebServer::handleSettingsPage() const {
server->send(200, "text/html", SettingsPageHtml);
Serial.printf("[%lu] [WEB] Served settings page\n", millis());
}
void CrossPointWebServer::handleGetSettings() const {
const auto settings = getSettingsList();
JsonDocument doc;
JsonArray settingsArray = doc["settings"].to<JsonArray>();
for (const auto& setting : settings) {
// Skip ACTION types - they don't have web-editable values
if (setting.type == SettingType::ACTION) {
continue;
}
JsonObject obj = settingsArray.add<JsonObject>();
obj["key"] = setting.key;
obj["name"] = setting.name;
switch (setting.type) {
case SettingType::TOGGLE:
obj["type"] = "toggle";
obj["value"] = SETTINGS.*(setting.valuePtr) ? 1 : 0;
break;
case SettingType::ENUM: {
obj["type"] = "enum";
obj["value"] = SETTINGS.*(setting.valuePtr);
JsonArray opts = obj["options"].to<JsonArray>();
for (const auto& opt : setting.enumValues) {
opts.add(opt);
}
break;
}
case SettingType::VALUE:
obj["type"] = "value";
obj["value"] = SETTINGS.*(setting.valuePtr);
obj["min"] = setting.valueRange.min;
obj["max"] = setting.valueRange.max;
obj["step"] = setting.valueRange.step;
break;
case SettingType::STRING:
obj["type"] = "string";
obj["value"] = setting.stringPtr;
obj["maxLength"] = setting.stringMaxLen;
break;
case SettingType::ACTION:
break;
}
}
String json;
serializeJson(doc, json);
server->send(200, "application/json", json);
Serial.printf("[%lu] [WEB] Served settings JSON\n", millis());
}
void CrossPointWebServer::handlePostSettings() {
if (!server->hasArg("plain")) {
server->send(400, "text/plain", "Missing request body");
return;
}
const String body = server->arg("plain");
Serial.printf("[%lu] [WEB] Received settings update: %s\n", millis(), body.c_str());
JsonDocument doc;
const DeserializationError error = deserializeJson(doc, body);
if (error) {
Serial.printf("[%lu] [WEB] JSON parse error: %s\n", millis(), error.c_str());
server->send(400, "text/plain", "Invalid JSON");
return;
}
const auto settings = getSettingsList();
int updatedCount = 0;
for (const auto& setting : settings) {
if (setting.type == SettingType::ACTION || setting.key == nullptr) {
continue;
}
if (doc[setting.key].isNull()) {
continue;
}
switch (setting.type) {
case SettingType::TOGGLE: {
const int value = doc[setting.key].as<int>();
SETTINGS.*(setting.valuePtr) = value ? 1 : 0;
updatedCount++;
break;
}
case SettingType::ENUM: {
const int value = doc[setting.key].as<int>();
if (value >= 0 && value < static_cast<int>(setting.enumValues.size())) {
SETTINGS.*(setting.valuePtr) = static_cast<uint8_t>(value);
updatedCount++;
}
break;
}
case SettingType::VALUE: {
const int value = doc[setting.key].as<int>();
if (value >= setting.valueRange.min && value <= setting.valueRange.max) {
SETTINGS.*(setting.valuePtr) = static_cast<uint8_t>(value);
updatedCount++;
}
break;
}
case SettingType::STRING: {
const char* value = doc[setting.key].as<const char*>();
if (value != nullptr) {
strncpy(setting.stringPtr, value, setting.stringMaxLen);
setting.stringPtr[setting.stringMaxLen] = '\0';
updatedCount++;
}
break;
}
case SettingType::ACTION:
break;
}
}
if (updatedCount > 0) {
SETTINGS.saveToFile();
Serial.printf("[%lu] [WEB] Updated %d settings and saved to file\n", millis(), updatedCount);
}
server->send(200, "text/plain", "Settings updated: " + String(updatedCount));
}

View File

@ -78,4 +78,9 @@ class CrossPointWebServer {
void handleUploadPost() const;
void handleCreateFolder() const;
void handleDelete() const;
// Settings handlers
void handleSettingsPage() const;
void handleGetSettings() const;
void handlePostSettings();
};

View File

@ -575,6 +575,7 @@
<div class="nav-links">
<a href="/">Home</a>
<a href="/files">File Manager</a>
<a href="/settings">Settings</a>
</div>
<div class="page-header">

View File

@ -77,6 +77,7 @@
<div class="nav-links">
<a href="/">Home</a>
<a href="/files">File Manager</a>
<a href="/settings">Settings</a>
</div>
<div class="card">

View File

@ -0,0 +1,347 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Settings - CrossPoint Reader</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
color: #333;
}
h1 {
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
h2 {
color: #34495e;
margin-top: 0;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.nav-links {
margin: 20px 0;
}
.nav-links a {
display: inline-block;
padding: 10px 20px;
background-color: #3498db;
color: white;
text-decoration: none;
border-radius: 4px;
margin-right: 10px;
}
.nav-links a:hover {
background-color: #2980b9;
}
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #eee;
}
.setting-row:last-child {
border-bottom: none;
}
.setting-label {
font-weight: 500;
color: #2c3e50;
}
.setting-control {
min-width: 150px;
text-align: right;
}
select, input[type="number"], input[type="text"] {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 120px;
}
select:focus, input:focus {
outline: none;
border-color: #3498db;
}
input[type="text"] {
min-width: 200px;
}
.toggle-switch {
position: relative;
width: 50px;
height: 26px;
display: inline-block;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.3s;
border-radius: 26px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: #27ae60;
}
input:checked + .toggle-slider:before {
transform: translateX(24px);
}
.save-btn {
display: block;
width: 100%;
padding: 14px;
background-color: #27ae60;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-top: 20px;
}
.save-btn:hover {
background-color: #219a52;
}
.save-btn:disabled {
background-color: #95a5a6;
cursor: not-allowed;
}
.status-message {
padding: 12px;
border-radius: 4px;
margin-top: 15px;
display: none;
}
.status-message.success {
background-color: #d4edda;
color: #155724;
display: block;
}
.status-message.error {
background-color: #f8d7da;
color: #721c24;
display: block;
}
.loading {
text-align: center;
padding: 40px;
color: #7f8c8d;
}
</style>
</head>
<body>
<h1>Settings</h1>
<div class="nav-links">
<a href="/">Home</a>
<a href="/files">File Manager</a>
<a href="/settings">Settings</a>
</div>
<div class="card">
<div id="settings-container">
<div class="loading">Loading settings...</div>
</div>
<button id="save-btn" class="save-btn" style="display: none;">Save Settings</button>
<div id="status-message" class="status-message"></div>
</div>
<div class="card">
<p style="text-align: center; color: #95a5a6; margin: 0">
CrossPoint E-Reader
</p>
</div>
<script>
let originalSettings = {};
let currentSettings = {};
async function fetchSettings() {
try {
const response = await fetch('/api/settings');
if (!response.ok) {
throw new Error('Failed to fetch settings');
}
const data = await response.json();
renderSettings(data.settings);
} catch (error) {
console.error('Error fetching settings:', error);
document.getElementById('settings-container').innerHTML =
'<div class="loading" style="color: #e74c3c;">Failed to load settings. Please refresh the page.</div>';
}
}
function renderSettings(settings) {
const container = document.getElementById('settings-container');
container.innerHTML = '';
settings.forEach(setting => {
originalSettings[setting.key] = setting.value;
currentSettings[setting.key] = setting.value;
const row = document.createElement('div');
row.className = 'setting-row';
const label = document.createElement('span');
label.className = 'setting-label';
label.textContent = setting.name;
const control = document.createElement('div');
control.className = 'setting-control';
switch (setting.type) {
case 'toggle':
control.innerHTML = `
<label class="toggle-switch">
<input type="checkbox" id="${setting.key}" ${setting.value ? 'checked' : ''}>
<span class="toggle-slider"></span>
</label>
`;
break;
case 'enum':
let options = setting.options.map((opt, idx) =>
`<option value="${idx}" ${setting.value === idx ? 'selected' : ''}>${opt}</option>`
).join('');
control.innerHTML = `<select id="${setting.key}">${options}</select>`;
break;
case 'value':
control.innerHTML = `
<input type="number" id="${setting.key}"
value="${setting.value}"
min="${setting.min}"
max="${setting.max}"
step="${setting.step}">
`;
break;
case 'string':
control.innerHTML = `
<input type="text" id="${setting.key}"
value="${setting.value || ''}"
maxlength="${setting.maxLength}"
placeholder="Enter URL...">
`;
break;
}
row.appendChild(label);
row.appendChild(control);
container.appendChild(row);
// Add change listener
const input = control.querySelector('input, select');
if (input) {
input.addEventListener('change', () => updateCurrentValue(setting));
input.addEventListener('input', () => updateCurrentValue(setting));
}
});
document.getElementById('save-btn').style.display = 'block';
}
function updateCurrentValue(setting) {
const element = document.getElementById(setting.key);
if (!element) return;
switch (setting.type) {
case 'toggle':
currentSettings[setting.key] = element.checked ? 1 : 0;
break;
case 'enum':
case 'value':
currentSettings[setting.key] = parseInt(element.value, 10);
break;
case 'string':
currentSettings[setting.key] = element.value;
break;
}
}
async function saveSettings() {
const saveBtn = document.getElementById('save-btn');
const statusMsg = document.getElementById('status-message');
// Find changed settings
const changes = {};
for (const key in currentSettings) {
if (currentSettings[key] !== originalSettings[key]) {
changes[key] = currentSettings[key];
}
}
if (Object.keys(changes).length === 0) {
statusMsg.className = 'status-message success';
statusMsg.textContent = 'No changes to save.';
setTimeout(() => { statusMsg.className = 'status-message'; }, 3000);
return;
}
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
try {
const response = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes)
});
if (!response.ok) {
throw new Error('Failed to save settings');
}
// Update original settings to match current
for (const key in changes) {
originalSettings[key] = changes[key];
}
statusMsg.className = 'status-message success';
statusMsg.textContent = 'Settings saved successfully!';
} catch (error) {
console.error('Error saving settings:', error);
statusMsg.className = 'status-message error';
statusMsg.textContent = 'Failed to save settings. Please try again.';
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Settings';
setTimeout(() => { statusMsg.className = 'status-message'; }, 3000);
}
}
document.getElementById('save-btn').addEventListener('click', saveSettings);
window.onload = fetchSettings;
</script>
</body>
</html>