mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 14:47:37 +03:00
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:
parent
e5c0ddc9fa
commit
84fa55789c
57
src/SettingsList.h
Normal file
57
src/SettingsList.h
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -11,12 +11,15 @@
|
|||||||
|
|
||||||
class CrossPointSettings;
|
class CrossPointSettings;
|
||||||
|
|
||||||
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE };
|
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING };
|
||||||
|
|
||||||
struct SettingInfo {
|
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;
|
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;
|
std::vector<std::string> enumValues;
|
||||||
|
|
||||||
struct ValueRange {
|
struct ValueRange {
|
||||||
@ -26,18 +29,26 @@ struct SettingInfo {
|
|||||||
};
|
};
|
||||||
ValueRange valueRange;
|
ValueRange valueRange;
|
||||||
|
|
||||||
static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) {
|
static SettingInfo Toggle(const char* key, const char* name, uint8_t CrossPointSettings::* ptr) {
|
||||||
return {name, SettingType::TOGGLE, ptr};
|
return {key, name, SettingType::TOGGLE, ptr, nullptr, 0, {}, {}};
|
||||||
}
|
}
|
||||||
|
|
||||||
static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector<std::string> values) {
|
static SettingInfo Enum(const char* key, const char* name, uint8_t CrossPointSettings::* ptr,
|
||||||
return {name, SettingType::ENUM, ptr, std::move(values)};
|
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) {
|
static SettingInfo Value(const char* key, const char* name, uint8_t CrossPointSettings::* ptr,
|
||||||
return {name, SettingType::VALUE, ptr, {}, valueRange};
|
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, {}, {}};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -14,43 +14,49 @@ namespace {
|
|||||||
constexpr int displaySettingsCount = 6;
|
constexpr int displaySettingsCount = 6;
|
||||||
const SettingInfo displaySettings[displaySettingsCount] = {
|
const SettingInfo displaySettings[displaySettingsCount] = {
|
||||||
// Should match with SLEEP_SCREEN_MODE
|
// Should match with SLEEP_SCREEN_MODE
|
||||||
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
|
SettingInfo::Enum("sleepScreen", "Sleep Screen", &CrossPointSettings::sleepScreen,
|
||||||
SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}),
|
{"Dark", "Light", "Custom", "Cover", "None"}),
|
||||||
SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter,
|
SettingInfo::Enum("sleepScreenCoverMode", "Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode,
|
||||||
{"None", "Contrast", "Inverted"}),
|
{"Fit", "Crop"}),
|
||||||
SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar,
|
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"}),
|
{"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}),
|
||||||
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
|
SettingInfo::Enum("hideBatteryPercentage", "Hide Battery %", &CrossPointSettings::hideBatteryPercentage,
|
||||||
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
|
{"Never", "In Reader", "Always"}),
|
||||||
|
SettingInfo::Enum("refreshFrequency", "Refresh Frequency", &CrossPointSettings::refreshFrequency,
|
||||||
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"})};
|
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"})};
|
||||||
|
|
||||||
constexpr int readerSettingsCount = 9;
|
constexpr int readerSettingsCount = 9;
|
||||||
const SettingInfo readerSettings[readerSettingsCount] = {
|
const SettingInfo readerSettings[readerSettingsCount] = {
|
||||||
SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"}),
|
SettingInfo::Enum("fontFamily", "Font Family", &CrossPointSettings::fontFamily,
|
||||||
SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}),
|
{"Bookerly", "Noto Sans", "Open Dyslexic"}),
|
||||||
SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}),
|
SettingInfo::Enum("fontSize", "Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}),
|
||||||
SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}),
|
SettingInfo::Enum("lineSpacing", "Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}),
|
||||||
SettingInfo::Enum("Paragraph Alignment", &CrossPointSettings::paragraphAlignment,
|
SettingInfo::Value("screenMargin", "Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}),
|
||||||
|
SettingInfo::Enum("paragraphAlignment", "Paragraph Alignment", &CrossPointSettings::paragraphAlignment,
|
||||||
{"Justify", "Left", "Center", "Right"}),
|
{"Justify", "Left", "Center", "Right"}),
|
||||||
SettingInfo::Toggle("Hyphenation", &CrossPointSettings::hyphenationEnabled),
|
SettingInfo::Toggle("hyphenationEnabled", "Hyphenation", &CrossPointSettings::hyphenationEnabled),
|
||||||
SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation,
|
SettingInfo::Enum("orientation", "Reading Orientation", &CrossPointSettings::orientation,
|
||||||
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}),
|
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}),
|
||||||
SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing),
|
SettingInfo::Toggle("extraParagraphSpacing", "Extra Paragraph Spacing",
|
||||||
SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing)};
|
&CrossPointSettings::extraParagraphSpacing),
|
||||||
|
SettingInfo::Toggle("textAntiAliasing", "Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing)};
|
||||||
|
|
||||||
constexpr int controlsSettingsCount = 4;
|
constexpr int controlsSettingsCount = 4;
|
||||||
const SettingInfo controlsSettings[controlsSettingsCount] = {
|
const SettingInfo controlsSettings[controlsSettingsCount] = {
|
||||||
SettingInfo::Enum(
|
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"}),
|
{"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"}),
|
{"Prev, Next", "Next, Prev"}),
|
||||||
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
|
SettingInfo::Toggle("longPressChapterSkip", "Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
|
||||||
SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"})};
|
SettingInfo::Enum("shortPwrBtn", "Short Power Button Click", &CrossPointSettings::shortPwrBtn,
|
||||||
|
{"Ignore", "Sleep", "Page Turn"})};
|
||||||
|
|
||||||
constexpr int systemSettingsCount = 5;
|
constexpr int systemSettingsCount = 5;
|
||||||
const SettingInfo systemSettings[systemSettingsCount] = {
|
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"}),
|
{"1 min", "5 min", "10 min", "15 min", "30 min"}),
|
||||||
SettingInfo::Action("KOReader Sync"), SettingInfo::Action("OPDS Browser"), SettingInfo::Action("Clear Cache"),
|
SettingInfo::Action("KOReader Sync"), SettingInfo::Action("OPDS Browser"), SettingInfo::Action("Clear Cache"),
|
||||||
SettingInfo::Action("Check for updates")};
|
SettingInfo::Action("Check for updates")};
|
||||||
|
|||||||
@ -8,9 +8,12 @@
|
|||||||
#include <esp_task_wdt.h>
|
#include <esp_task_wdt.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "SettingsList.h"
|
||||||
#include "html/FilesPageHtml.generated.h"
|
#include "html/FilesPageHtml.generated.h"
|
||||||
#include "html/HomePageHtml.generated.h"
|
#include "html/HomePageHtml.generated.h"
|
||||||
|
#include "html/SettingsPageHtml.generated.h"
|
||||||
#include "util/StringUtils.h"
|
#include "util/StringUtils.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@ -112,6 +115,11 @@ void CrossPointWebServer::begin() {
|
|||||||
// Delete file/folder endpoint
|
// Delete file/folder endpoint
|
||||||
server->on("/delete", HTTP_POST, [this] { handleDelete(); });
|
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(); });
|
server->onNotFound([this] { handleNotFound(); });
|
||||||
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
|
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;
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@ -78,4 +78,9 @@ class CrossPointWebServer {
|
|||||||
void handleUploadPost() const;
|
void handleUploadPost() const;
|
||||||
void handleCreateFolder() const;
|
void handleCreateFolder() const;
|
||||||
void handleDelete() const;
|
void handleDelete() const;
|
||||||
|
|
||||||
|
// Settings handlers
|
||||||
|
void handleSettingsPage() const;
|
||||||
|
void handleGetSettings() const;
|
||||||
|
void handlePostSettings();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -575,6 +575,7 @@
|
|||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="/">Home</a>
|
<a href="/">Home</a>
|
||||||
<a href="/files">File Manager</a>
|
<a href="/files">File Manager</a>
|
||||||
|
<a href="/settings">Settings</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
|
|||||||
@ -77,6 +77,7 @@
|
|||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="/">Home</a>
|
<a href="/">Home</a>
|
||||||
<a href="/files">File Manager</a>
|
<a href="/files">File Manager</a>
|
||||||
|
<a href="/settings">Settings</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
347
src/network/html/SettingsPage.html
Normal file
347
src/network/html/SettingsPage.html
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user