A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: A web editor for settings (#667)

## Summary

This is an updated version of @itsthisjustin's #346 that builds on
current master and also deduplicates the settings list so we don't have
two copies of the settings. In the Web UI, it should organize the
settings a little closer to what you see on device.

## Additional Context

I tested this live on device and it seems to play nicely for me. It's
re-based on master since master's settings stuff has moved somewhat
since the original PR and addresses the sole review comment #346 - it
also means that I don't need to manually key in the URL for my OPDS
server. :)

---

### AI Usage

My changes were implemented with Claude Opus 4.5 and Claude Code 2.1.25.
I don't know if @itsthisjustin's original work used AI assistance.

Co-authored-by: Dave Allie <dave@daveallie.com>

authored by

Jesse Vincent
Dave Allie
and committed by
GitHub
cda0a3f8 7f40c3f4

+839 -84
+101
src/SettingsList.h
··· 1 + #pragma once 2 + 3 + #include <vector> 4 + 5 + #include "CrossPointSettings.h" 6 + #include "KOReaderCredentialStore.h" 7 + #include "activities/settings/SettingsActivity.h" 8 + 9 + // Shared settings list used by both the device settings UI and the web settings API. 10 + // Each entry has a key (for JSON API) and category (for grouping). 11 + // ACTION-type entries and entries without a key are device-only. 12 + inline std::vector<SettingInfo> getSettingsList() { 13 + return { 14 + // --- Display --- 15 + SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, 16 + {"Dark", "Light", "Custom", "Cover", "None", "Cover + Custom"}, "sleepScreen", "Display"), 17 + SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}, 18 + "sleepScreenCoverMode", "Display"), 19 + SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter, 20 + {"None", "Contrast", "Inverted"}, "sleepScreenCoverFilter", "Display"), 21 + SettingInfo::Enum( 22 + "Status Bar", &CrossPointSettings::statusBar, 23 + {"None", "No Progress", "Full w/ Percentage", "Full w/ Book Bar", "Book Bar Only", "Full w/ Chapter Bar"}, 24 + "statusBar", "Display"), 25 + SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}, 26 + "hideBatteryPercentage", "Display"), 27 + SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, 28 + {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}, "refreshFrequency", "Display"), 29 + SettingInfo::Enum("UI Theme", &CrossPointSettings::uiTheme, {"Classic", "Lyra"}, "uiTheme", "Display"), 30 + SettingInfo::Toggle("Sunlight Fading Fix", &CrossPointSettings::fadingFix, "fadingFix", "Display"), 31 + 32 + // --- Reader --- 33 + SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"}, 34 + "fontFamily", "Reader"), 35 + SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}, "fontSize", 36 + "Reader"), 37 + SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}, "lineSpacing", 38 + "Reader"), 39 + SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}, "screenMargin", "Reader"), 40 + SettingInfo::Enum("Paragraph Alignment", &CrossPointSettings::paragraphAlignment, 41 + {"Justify", "Left", "Center", "Right", "Book's Style"}, "paragraphAlignment", "Reader"), 42 + SettingInfo::Toggle("Book's Embedded Style", &CrossPointSettings::embeddedStyle, "embeddedStyle", "Reader"), 43 + SettingInfo::Toggle("Hyphenation", &CrossPointSettings::hyphenationEnabled, "hyphenationEnabled", "Reader"), 44 + SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation, 45 + {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}, "orientation", "Reader"), 46 + SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing, 47 + "extraParagraphSpacing", "Reader"), 48 + SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing, "textAntiAliasing", "Reader"), 49 + 50 + // --- Controls --- 51 + SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout, 52 + {"Prev, Next", "Next, Prev"}, "sideButtonLayout", "Controls"), 53 + SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip, "longPressChapterSkip", 54 + "Controls"), 55 + SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"}, 56 + "shortPwrBtn", "Controls"), 57 + 58 + // --- System --- 59 + SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout, 60 + {"1 min", "5 min", "10 min", "15 min", "30 min"}, "sleepTimeout", "System"), 61 + 62 + // --- KOReader Sync (web-only, uses KOReaderCredentialStore) --- 63 + SettingInfo::DynamicString( 64 + "KOReader Username", [] { return KOREADER_STORE.getUsername(); }, 65 + [](const std::string& v) { 66 + KOREADER_STORE.setCredentials(v, KOREADER_STORE.getPassword()); 67 + KOREADER_STORE.saveToFile(); 68 + }, 69 + "koUsername", "KOReader Sync"), 70 + SettingInfo::DynamicString( 71 + "KOReader Password", [] { return KOREADER_STORE.getPassword(); }, 72 + [](const std::string& v) { 73 + KOREADER_STORE.setCredentials(KOREADER_STORE.getUsername(), v); 74 + KOREADER_STORE.saveToFile(); 75 + }, 76 + "koPassword", "KOReader Sync"), 77 + SettingInfo::DynamicString( 78 + "Sync Server URL", [] { return KOREADER_STORE.getServerUrl(); }, 79 + [](const std::string& v) { 80 + KOREADER_STORE.setServerUrl(v); 81 + KOREADER_STORE.saveToFile(); 82 + }, 83 + "koServerUrl", "KOReader Sync"), 84 + SettingInfo::DynamicEnum( 85 + "Document Matching", {"Filename", "Binary"}, 86 + [] { return static_cast<uint8_t>(KOREADER_STORE.getMatchMethod()); }, 87 + [](uint8_t v) { 88 + KOREADER_STORE.setMatchMethod(static_cast<DocumentMatchMethod>(v)); 89 + KOREADER_STORE.saveToFile(); 90 + }, 91 + "koMatchMethod", "KOReader Sync"), 92 + 93 + // --- OPDS Browser (web-only, uses CrossPointSettings char arrays) --- 94 + SettingInfo::String("OPDS Server URL", SETTINGS.opdsServerUrl, sizeof(SETTINGS.opdsServerUrl), "opdsServerUrl", 95 + "OPDS Browser"), 96 + SettingInfo::String("OPDS Username", SETTINGS.opdsUsername, sizeof(SETTINGS.opdsUsername), "opdsUsername", 97 + "OPDS Browser"), 98 + SettingInfo::String("OPDS Password", SETTINGS.opdsPassword, sizeof(SETTINGS.opdsPassword), "opdsPassword", 99 + "OPDS Browser"), 100 + }; 101 + }
+50 -73
src/activities/settings/SettingsActivity.cpp
··· 10 10 #include "KOReaderSettingsActivity.h" 11 11 #include "MappedInputManager.h" 12 12 #include "OtaUpdateActivity.h" 13 + #include "SettingsList.h" 13 14 #include "components/UITheme.h" 14 15 #include "fontIds.h" 15 16 ··· 17 18 18 19 namespace { 19 20 constexpr int changeTabsMs = 700; 20 - constexpr int displaySettingsCount = 8; 21 - const SettingInfo displaySettings[displaySettingsCount] = { 22 - // Should match with SLEEP_SCREEN_MODE 23 - SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, 24 - {"Dark", "Light", "Custom", "Cover", "None", "Cover + Custom"}), 25 - SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}), 26 - SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter, 27 - {"None", "Contrast", "Inverted"}), 28 - SettingInfo::Enum( 29 - "Status Bar", &CrossPointSettings::statusBar, 30 - {"None", "No Progress", "Full w/ Percentage", "Full w/ Book Bar", "Book Bar Only", "Full w/ Chapter Bar"}), 31 - SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}), 32 - SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, 33 - {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), 34 - SettingInfo::Enum("UI Theme", &CrossPointSettings::uiTheme, {"Classic", "Lyra"}), 35 - SettingInfo::Toggle("Sunlight Fading Fix", &CrossPointSettings::fadingFix), 36 - }; 37 - 38 - constexpr int readerSettingsCount = 10; 39 - const SettingInfo readerSettings[readerSettingsCount] = { 40 - SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"}), 41 - SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}), 42 - SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}), 43 - SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}), 44 - SettingInfo::Enum("Paragraph Alignment", &CrossPointSettings::paragraphAlignment, 45 - {"Justify", "Left", "Center", "Right", "Book's Style"}), 46 - SettingInfo::Toggle("Book's Embedded Style", &CrossPointSettings::embeddedStyle), 47 - SettingInfo::Toggle("Hyphenation", &CrossPointSettings::hyphenationEnabled), 48 - SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation, 49 - {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}), 50 - SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing), 51 - SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing)}; 52 - 53 - constexpr int controlsSettingsCount = 4; 54 - const SettingInfo controlsSettings[controlsSettingsCount] = { 55 - // Launches the remap wizard for front buttons. 56 - SettingInfo::Action("Remap Front Buttons"), 57 - SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout, 58 - {"Prev, Next", "Next, Prev"}), 59 - SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip), 60 - SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"})}; 61 - 62 - constexpr int systemSettingsCount = 5; 63 - const SettingInfo systemSettings[systemSettingsCount] = { 64 - SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout, 65 - {"1 min", "5 min", "10 min", "15 min", "30 min"}), 66 - SettingInfo::Action("KOReader Sync"), SettingInfo::Action("OPDS Browser"), SettingInfo::Action("Clear Cache"), 67 - SettingInfo::Action("Check for updates")}; 68 21 } // namespace 69 22 70 23 void SettingsActivity::taskTrampoline(void* param) { ··· 76 29 Activity::onEnter(); 77 30 renderingMutex = xSemaphoreCreateMutex(); 78 31 32 + // Build per-category vectors from the shared settings list 33 + displaySettings.clear(); 34 + readerSettings.clear(); 35 + controlsSettings.clear(); 36 + systemSettings.clear(); 37 + 38 + for (auto& setting : getSettingsList()) { 39 + if (!setting.category) continue; 40 + if (strcmp(setting.category, "Display") == 0) { 41 + displaySettings.push_back(std::move(setting)); 42 + } else if (strcmp(setting.category, "Reader") == 0) { 43 + readerSettings.push_back(std::move(setting)); 44 + } else if (strcmp(setting.category, "Controls") == 0) { 45 + controlsSettings.push_back(std::move(setting)); 46 + } else if (strcmp(setting.category, "System") == 0) { 47 + systemSettings.push_back(std::move(setting)); 48 + } 49 + // Web-only categories (KOReader Sync, OPDS Browser) are skipped for device UI 50 + } 51 + 52 + // Append device-only ACTION items 53 + controlsSettings.insert(controlsSettings.begin(), SettingInfo::Action("Remap Front Buttons")); 54 + systemSettings.push_back(SettingInfo::Action("KOReader Sync")); 55 + systemSettings.push_back(SettingInfo::Action("OPDS Browser")); 56 + systemSettings.push_back(SettingInfo::Action("Clear Cache")); 57 + systemSettings.push_back(SettingInfo::Action("Check for updates")); 58 + 79 59 // Reset selection to first category 80 60 selectedCategoryIndex = 0; 81 61 selectedSettingIndex = 0; 82 62 83 63 // Initialize with first category (Display) 84 - settingsList = displaySettings; 85 - settingsCount = displaySettingsCount; 64 + currentSettings = &displaySettings; 65 + settingsCount = static_cast<int>(displaySettings.size()); 86 66 87 67 // Trigger first update 88 68 updateRequired = true; ··· 162 142 if (hasChangedCategory) { 163 143 selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1; 164 144 switch (selectedCategoryIndex) { 165 - case 0: // Display 166 - settingsList = displaySettings; 167 - settingsCount = displaySettingsCount; 145 + case 0: 146 + currentSettings = &displaySettings; 168 147 break; 169 - case 1: // Reader 170 - settingsList = readerSettings; 171 - settingsCount = readerSettingsCount; 148 + case 1: 149 + currentSettings = &readerSettings; 172 150 break; 173 - case 2: // Controls 174 - settingsList = controlsSettings; 175 - settingsCount = controlsSettingsCount; 151 + case 2: 152 + currentSettings = &controlsSettings; 176 153 break; 177 - case 3: // System 178 - settingsList = systemSettings; 179 - settingsCount = systemSettingsCount; 154 + case 3: 155 + currentSettings = &systemSettings; 180 156 break; 181 157 } 158 + settingsCount = static_cast<int>(currentSettings->size()); 182 159 } 183 160 } 184 161 ··· 188 165 return; 189 166 } 190 167 191 - const auto& setting = settingsList[selectedSetting]; 168 + const auto& setting = (*currentSettings)[selectedSetting]; 192 169 193 170 if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) { 194 171 // Toggle the boolean value using the member pointer ··· 283 260 GUI.drawTabBar(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, tabs, 284 261 selectedSettingIndex == 0); 285 262 263 + const auto& settings = *currentSettings; 286 264 GUI.drawList( 287 265 renderer, 288 266 Rect{0, metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing, pageWidth, 289 267 pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.buttonHintsHeight + 290 268 metrics.verticalSpacing * 2)}, 291 - settingsCount, selectedSettingIndex - 1, [this](int index) { return std::string(settingsList[index].name); }, 269 + settingsCount, selectedSettingIndex - 1, [&settings](int index) { return std::string(settings[index].name); }, 292 270 nullptr, nullptr, 293 - [this](int i) { 294 - const auto& setting = settingsList[i]; 271 + [&settings](int i) { 295 272 std::string valueText = ""; 296 - if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) { 297 - const bool value = SETTINGS.*(settingsList[i].valuePtr); 273 + if (settings[i].type == SettingType::TOGGLE && settings[i].valuePtr != nullptr) { 274 + const bool value = SETTINGS.*(settings[i].valuePtr); 298 275 valueText = value ? "ON" : "OFF"; 299 - } else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) { 300 - const uint8_t value = SETTINGS.*(settingsList[i].valuePtr); 301 - valueText = settingsList[i].enumValues[value]; 302 - } else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) { 303 - valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr)); 276 + } else if (settings[i].type == SettingType::ENUM && settings[i].valuePtr != nullptr) { 277 + const uint8_t value = SETTINGS.*(settings[i].valuePtr); 278 + valueText = settings[i].enumValues[value]; 279 + } else if (settings[i].type == SettingType::VALUE && settings[i].valuePtr != nullptr) { 280 + valueText = std::to_string(SETTINGS.*(settings[i].valuePtr)); 304 281 } 305 282 return valueText; 306 283 });
+97 -11
src/activities/settings/SettingsActivity.h
··· 11 11 12 12 class CrossPointSettings; 13 13 14 - enum class SettingType { TOGGLE, ENUM, ACTION, VALUE }; 14 + enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING }; 15 15 16 16 struct SettingInfo { 17 17 const char* name; 18 18 SettingType type; 19 - uint8_t CrossPointSettings::* valuePtr; 19 + uint8_t CrossPointSettings::* valuePtr = nullptr; 20 20 std::vector<std::string> enumValues; 21 21 22 22 struct ValueRange { ··· 24 24 uint8_t max; 25 25 uint8_t step; 26 26 }; 27 - ValueRange valueRange; 27 + ValueRange valueRange = {}; 28 + 29 + const char* key = nullptr; // JSON API key (nullptr for ACTION types) 30 + const char* category = nullptr; // Category for web UI grouping 31 + 32 + // Direct char[] string fields (for settings stored in CrossPointSettings) 33 + char* stringPtr = nullptr; 34 + size_t stringMaxLen = 0; 35 + 36 + // Dynamic accessors (for settings stored outside CrossPointSettings, e.g. KOReaderCredentialStore) 37 + std::function<uint8_t()> valueGetter; 38 + std::function<void(uint8_t)> valueSetter; 39 + std::function<std::string()> stringGetter; 40 + std::function<void(const std::string&)> stringSetter; 28 41 29 - static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) { 30 - return {name, SettingType::TOGGLE, ptr}; 42 + static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr, const char* key = nullptr, 43 + const char* category = nullptr) { 44 + SettingInfo s; 45 + s.name = name; 46 + s.type = SettingType::TOGGLE; 47 + s.valuePtr = ptr; 48 + s.key = key; 49 + s.category = category; 50 + return s; 31 51 } 32 52 33 - static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector<std::string> values) { 34 - return {name, SettingType::ENUM, ptr, std::move(values)}; 53 + static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector<std::string> values, 54 + const char* key = nullptr, const char* category = nullptr) { 55 + SettingInfo s; 56 + s.name = name; 57 + s.type = SettingType::ENUM; 58 + s.valuePtr = ptr; 59 + s.enumValues = std::move(values); 60 + s.key = key; 61 + s.category = category; 62 + return s; 35 63 } 36 64 37 - static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr}; } 65 + static SettingInfo Action(const char* name) { 66 + SettingInfo s; 67 + s.name = name; 68 + s.type = SettingType::ACTION; 69 + return s; 70 + } 38 71 39 - static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange) { 40 - return {name, SettingType::VALUE, ptr, {}, valueRange}; 72 + static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange, 73 + const char* key = nullptr, const char* category = nullptr) { 74 + SettingInfo s; 75 + s.name = name; 76 + s.type = SettingType::VALUE; 77 + s.valuePtr = ptr; 78 + s.valueRange = valueRange; 79 + s.key = key; 80 + s.category = category; 81 + return s; 82 + } 83 + 84 + static SettingInfo String(const char* name, char* ptr, size_t maxLen, const char* key = nullptr, 85 + const char* category = nullptr) { 86 + SettingInfo s; 87 + s.name = name; 88 + s.type = SettingType::STRING; 89 + s.stringPtr = ptr; 90 + s.stringMaxLen = maxLen; 91 + s.key = key; 92 + s.category = category; 93 + return s; 94 + } 95 + 96 + static SettingInfo DynamicEnum(const char* name, std::vector<std::string> values, std::function<uint8_t()> getter, 97 + std::function<void(uint8_t)> setter, const char* key = nullptr, 98 + const char* category = nullptr) { 99 + SettingInfo s; 100 + s.name = name; 101 + s.type = SettingType::ENUM; 102 + s.enumValues = std::move(values); 103 + s.valueGetter = std::move(getter); 104 + s.valueSetter = std::move(setter); 105 + s.key = key; 106 + s.category = category; 107 + return s; 108 + } 109 + 110 + static SettingInfo DynamicString(const char* name, std::function<std::string()> getter, 111 + std::function<void(const std::string&)> setter, const char* key = nullptr, 112 + const char* category = nullptr) { 113 + SettingInfo s; 114 + s.name = name; 115 + s.type = SettingType::STRING; 116 + s.stringGetter = std::move(getter); 117 + s.stringSetter = std::move(setter); 118 + s.key = key; 119 + s.category = category; 120 + return s; 41 121 } 42 122 }; 43 123 ··· 48 128 int selectedCategoryIndex = 0; // Currently selected category 49 129 int selectedSettingIndex = 0; 50 130 int settingsCount = 0; 51 - const SettingInfo* settingsList = nullptr; 131 + 132 + // Per-category settings derived from shared list + device-only actions 133 + std::vector<SettingInfo> displaySettings; 134 + std::vector<SettingInfo> readerSettings; 135 + std::vector<SettingInfo> controlsSettings; 136 + std::vector<SettingInfo> systemSettings; 137 + const std::vector<SettingInfo>* currentSettings = nullptr; 52 138 53 139 const std::function<void()> onGoHome; 54 140
+170
src/network/CrossPointWebServer.cpp
··· 9 9 10 10 #include <algorithm> 11 11 12 + #include "CrossPointSettings.h" 13 + #include "SettingsList.h" 12 14 #include "html/FilesPageHtml.generated.h" 13 15 #include "html/HomePageHtml.generated.h" 16 + #include "html/SettingsPageHtml.generated.h" 14 17 #include "util/StringUtils.h" 15 18 16 19 namespace { ··· 147 150 148 151 // Delete file/folder endpoint 149 152 server->on("/delete", HTTP_POST, [this] { handleDelete(); }); 153 + 154 + // Settings endpoints 155 + server->on("/settings", HTTP_GET, [this] { handleSettingsPage(); }); 156 + server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); }); 157 + server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); }); 150 158 151 159 server->onNotFound([this] { handleNotFound(); }); 152 160 Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap()); ··· 981 989 Serial.printf("[%lu] [WEB] Failed to delete: %s\n", millis(), itemPath.c_str()); 982 990 server->send(500, "text/plain", "Failed to delete item"); 983 991 } 992 + } 993 + 994 + void CrossPointWebServer::handleSettingsPage() const { 995 + server->send(200, "text/html", SettingsPageHtml); 996 + Serial.printf("[%lu] [WEB] Served settings page\n", millis()); 997 + } 998 + 999 + void CrossPointWebServer::handleGetSettings() const { 1000 + auto settings = getSettingsList(); 1001 + 1002 + server->setContentLength(CONTENT_LENGTH_UNKNOWN); 1003 + server->send(200, "application/json", ""); 1004 + server->sendContent("["); 1005 + 1006 + char output[512]; 1007 + constexpr size_t outputSize = sizeof(output); 1008 + bool seenFirst = false; 1009 + JsonDocument doc; 1010 + 1011 + for (const auto& s : settings) { 1012 + if (!s.key) continue; // Skip ACTION-only entries 1013 + 1014 + doc.clear(); 1015 + doc["key"] = s.key; 1016 + doc["name"] = s.name; 1017 + doc["category"] = s.category; 1018 + 1019 + switch (s.type) { 1020 + case SettingType::TOGGLE: { 1021 + doc["type"] = "toggle"; 1022 + if (s.valuePtr) { 1023 + doc["value"] = static_cast<int>(SETTINGS.*(s.valuePtr)); 1024 + } 1025 + break; 1026 + } 1027 + case SettingType::ENUM: { 1028 + doc["type"] = "enum"; 1029 + if (s.valuePtr) { 1030 + doc["value"] = static_cast<int>(SETTINGS.*(s.valuePtr)); 1031 + } else if (s.valueGetter) { 1032 + doc["value"] = static_cast<int>(s.valueGetter()); 1033 + } 1034 + JsonArray options = doc["options"].to<JsonArray>(); 1035 + for (const auto& opt : s.enumValues) { 1036 + options.add(opt); 1037 + } 1038 + break; 1039 + } 1040 + case SettingType::VALUE: { 1041 + doc["type"] = "value"; 1042 + if (s.valuePtr) { 1043 + doc["value"] = static_cast<int>(SETTINGS.*(s.valuePtr)); 1044 + } 1045 + doc["min"] = s.valueRange.min; 1046 + doc["max"] = s.valueRange.max; 1047 + doc["step"] = s.valueRange.step; 1048 + break; 1049 + } 1050 + case SettingType::STRING: { 1051 + doc["type"] = "string"; 1052 + if (s.stringGetter) { 1053 + doc["value"] = s.stringGetter(); 1054 + } else if (s.stringPtr) { 1055 + doc["value"] = s.stringPtr; 1056 + } 1057 + break; 1058 + } 1059 + default: 1060 + continue; 1061 + } 1062 + 1063 + const size_t written = serializeJson(doc, output, outputSize); 1064 + if (written >= outputSize) { 1065 + Serial.printf("[%lu] [WEB] Skipping oversized setting JSON for: %s\n", millis(), s.key); 1066 + continue; 1067 + } 1068 + 1069 + if (seenFirst) { 1070 + server->sendContent(","); 1071 + } else { 1072 + seenFirst = true; 1073 + } 1074 + server->sendContent(output); 1075 + } 1076 + 1077 + server->sendContent("]"); 1078 + server->sendContent(""); 1079 + Serial.printf("[%lu] [WEB] Served settings API\n", millis()); 1080 + } 1081 + 1082 + void CrossPointWebServer::handlePostSettings() { 1083 + if (!server->hasArg("plain")) { 1084 + server->send(400, "text/plain", "Missing JSON body"); 1085 + return; 1086 + } 1087 + 1088 + const String body = server->arg("plain"); 1089 + JsonDocument doc; 1090 + const DeserializationError err = deserializeJson(doc, body); 1091 + if (err) { 1092 + server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str()); 1093 + return; 1094 + } 1095 + 1096 + auto settings = getSettingsList(); 1097 + int applied = 0; 1098 + 1099 + for (auto& s : settings) { 1100 + if (!s.key) continue; 1101 + if (!doc[s.key].is<JsonVariant>()) continue; 1102 + 1103 + switch (s.type) { 1104 + case SettingType::TOGGLE: { 1105 + const int val = doc[s.key].as<int>() ? 1 : 0; 1106 + if (s.valuePtr) { 1107 + SETTINGS.*(s.valuePtr) = val; 1108 + } 1109 + applied++; 1110 + break; 1111 + } 1112 + case SettingType::ENUM: { 1113 + const int val = doc[s.key].as<int>(); 1114 + if (val >= 0 && val < static_cast<int>(s.enumValues.size())) { 1115 + if (s.valuePtr) { 1116 + SETTINGS.*(s.valuePtr) = static_cast<uint8_t>(val); 1117 + } else if (s.valueSetter) { 1118 + s.valueSetter(static_cast<uint8_t>(val)); 1119 + } 1120 + applied++; 1121 + } 1122 + break; 1123 + } 1124 + case SettingType::VALUE: { 1125 + const int val = doc[s.key].as<int>(); 1126 + if (val >= s.valueRange.min && val <= s.valueRange.max) { 1127 + if (s.valuePtr) { 1128 + SETTINGS.*(s.valuePtr) = static_cast<uint8_t>(val); 1129 + } 1130 + applied++; 1131 + } 1132 + break; 1133 + } 1134 + case SettingType::STRING: { 1135 + const std::string val = doc[s.key].as<std::string>(); 1136 + if (s.stringSetter) { 1137 + s.stringSetter(val); 1138 + } else if (s.stringPtr && s.stringMaxLen > 0) { 1139 + strncpy(s.stringPtr, val.c_str(), s.stringMaxLen - 1); 1140 + s.stringPtr[s.stringMaxLen - 1] = '\0'; 1141 + } 1142 + applied++; 1143 + break; 1144 + } 1145 + default: 1146 + break; 1147 + } 1148 + } 1149 + 1150 + SETTINGS.saveToFile(); 1151 + 1152 + Serial.printf("[%lu] [WEB] Applied %d setting(s)\n", millis(), applied); 1153 + server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)"); 984 1154 } 985 1155 986 1156 // WebSocket callback trampoline
+5
src/network/CrossPointWebServer.h
··· 100 100 void handleRename() const; 101 101 void handleMove() const; 102 102 void handleDelete() const; 103 + 104 + // Settings handlers 105 + void handleSettingsPage() const; 106 + void handleGetSettings() const; 107 + void handlePostSettings(); 103 108 };
+1
src/network/html/FilesPage.html
··· 628 628 <div class="nav-links"> 629 629 <a href="/">Home</a> 630 630 <a href="/files">File Manager</a> 631 + <a href="/settings">Settings</a> 631 632 </div> 632 633 633 634 <div class="page-header">
+1
src/network/html/HomePage.html
··· 77 77 <div class="nav-links"> 78 78 <a href="/">Home</a> 79 79 <a href="/files">File Manager</a> 80 + <a href="/settings">Settings</a> 80 81 </div> 81 82 82 83 <div class="card">
+414
src/network/html/SettingsPage.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>CrossPoint Reader - Settings</title> 7 + <style> 8 + body { 9 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 10 + Oxygen, Ubuntu, sans-serif; 11 + max-width: 800px; 12 + margin: 0 auto; 13 + padding: 20px; 14 + background-color: #f5f5f5; 15 + color: #333; 16 + } 17 + h1 { 18 + color: #2c3e50; 19 + border-bottom: 2px solid #3498db; 20 + padding-bottom: 10px; 21 + } 22 + h2 { 23 + color: #34495e; 24 + margin-top: 0; 25 + } 26 + .card { 27 + background: white; 28 + border-radius: 8px; 29 + padding: 20px; 30 + margin: 15px 0; 31 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 32 + } 33 + .nav-links { 34 + margin: 20px 0; 35 + } 36 + .nav-links a { 37 + display: inline-block; 38 + padding: 10px 20px; 39 + background-color: #3498db; 40 + color: white; 41 + text-decoration: none; 42 + border-radius: 4px; 43 + margin-right: 10px; 44 + } 45 + .nav-links a:hover { 46 + background-color: #2980b9; 47 + } 48 + .setting-row { 49 + display: flex; 50 + justify-content: space-between; 51 + align-items: center; 52 + padding: 10px 0; 53 + border-bottom: 1px solid #eee; 54 + } 55 + .setting-row:last-child { 56 + border-bottom: none; 57 + } 58 + .setting-name { 59 + font-weight: 500; 60 + color: #2c3e50; 61 + flex: 1; 62 + min-width: 0; 63 + padding-right: 12px; 64 + } 65 + .setting-control { 66 + flex-shrink: 0; 67 + } 68 + .setting-control select, 69 + .setting-control input[type="number"], 70 + .setting-control input[type="text"], 71 + .setting-control input[type="password"] { 72 + padding: 6px 10px; 73 + border: 1px solid #ddd; 74 + border-radius: 4px; 75 + font-size: 0.95em; 76 + background: white; 77 + } 78 + .setting-control select { 79 + min-width: 160px; 80 + } 81 + .setting-control input[type="text"], 82 + .setting-control input[type="password"] { 83 + width: 220px; 84 + } 85 + .setting-control input[type="number"] { 86 + width: 80px; 87 + } 88 + /* Toggle switch */ 89 + .toggle-switch { 90 + display: inline-block; 91 + position: relative; 92 + width: 48px; 93 + height: 26px; 94 + } 95 + .toggle-switch input { 96 + opacity: 0; 97 + width: 0; 98 + height: 0; 99 + } 100 + .toggle-slider { 101 + position: absolute; 102 + cursor: pointer; 103 + top: 0; left: 0; right: 0; bottom: 0; 104 + background-color: #ccc; 105 + border-radius: 26px; 106 + transition: 0.3s; 107 + } 108 + .toggle-slider:before { 109 + position: absolute; 110 + content: ""; 111 + height: 20px; 112 + width: 20px; 113 + left: 3px; 114 + bottom: 3px; 115 + background-color: white; 116 + border-radius: 50%; 117 + transition: 0.3s; 118 + } 119 + .toggle-switch input:checked + .toggle-slider { 120 + background-color: #27ae60; 121 + } 122 + .toggle-switch input:checked + .toggle-slider:before { 123 + transform: translateX(22px); 124 + } 125 + .save-container { 126 + text-align: center; 127 + margin: 20px 0; 128 + } 129 + .save-btn { 130 + background-color: #27ae60; 131 + color: white; 132 + padding: 12px 40px; 133 + border: none; 134 + border-radius: 4px; 135 + cursor: pointer; 136 + font-size: 1.1em; 137 + font-weight: 600; 138 + } 139 + .save-btn:hover { 140 + background-color: #219a52; 141 + } 142 + .save-btn:disabled { 143 + background-color: #95a5a6; 144 + cursor: not-allowed; 145 + } 146 + .message { 147 + padding: 12px; 148 + border-radius: 4px; 149 + margin: 15px 0; 150 + text-align: center; 151 + display: none; 152 + } 153 + .message.success { 154 + background-color: #d4edda; 155 + color: #155724; 156 + border: 1px solid #c3e6cb; 157 + } 158 + .message.error { 159 + background-color: #f8d7da; 160 + color: #721c24; 161 + border: 1px solid #f5c6cb; 162 + } 163 + .loader-container { 164 + display: flex; 165 + justify-content: center; 166 + align-items: center; 167 + margin: 20px 0; 168 + } 169 + .loader { 170 + width: 48px; 171 + height: 48px; 172 + border: 5px solid #AAA; 173 + border-bottom-color: transparent; 174 + border-radius: 50%; 175 + display: inline-block; 176 + box-sizing: border-box; 177 + animation: rotation 1s linear infinite; 178 + } 179 + @keyframes rotation { 180 + from { transform: rotate(0deg); } 181 + to { transform: rotate(360deg); } 182 + } 183 + @media (max-width: 600px) { 184 + body { 185 + padding: 10px; 186 + font-size: 14px; 187 + } 188 + .card { 189 + padding: 12px; 190 + margin: 10px 0; 191 + } 192 + h1 { 193 + font-size: 1.3em; 194 + } 195 + .nav-links a { 196 + padding: 8px 12px; 197 + margin-right: 6px; 198 + font-size: 0.9em; 199 + } 200 + .setting-row { 201 + flex-wrap: wrap; 202 + gap: 6px; 203 + } 204 + .setting-control select, 205 + .setting-control input[type="text"], 206 + .setting-control input[type="password"] { 207 + min-width: 0; 208 + width: 100%; 209 + } 210 + } 211 + </style> 212 + </head> 213 + <body> 214 + <h1>⚙️ Settings</h1> 215 + 216 + <div class="nav-links"> 217 + <a href="/">Home</a> 218 + <a href="/files">File Manager</a> 219 + <a href="/settings">Settings</a> 220 + </div> 221 + 222 + <div id="message" class="message"></div> 223 + 224 + <div id="settings-container"> 225 + <div class="loader-container"> 226 + <span class="loader"></span> 227 + </div> 228 + </div> 229 + 230 + <div class="save-container" id="save-container" style="display:none;"> 231 + <button class="save-btn" id="saveBtn" onclick="saveSettings()">Save Settings</button> 232 + </div> 233 + 234 + <div class="card"> 235 + <p style="text-align: center; color: #95a5a6; margin: 0;"> 236 + CrossPoint E-Reader • Open Source 237 + </p> 238 + </div> 239 + 240 + <script> 241 + let allSettings = []; 242 + let originalValues = {}; 243 + 244 + function escapeHtml(unsafe) { 245 + return unsafe 246 + .replaceAll("&", "&amp;") 247 + .replaceAll("<", "&lt;") 248 + .replaceAll(">", "&gt;") 249 + .replaceAll('"', "&quot;") 250 + .replaceAll("'", "&#039;"); 251 + } 252 + 253 + function showMessage(text, isError) { 254 + const msg = document.getElementById('message'); 255 + msg.textContent = text; 256 + msg.className = 'message ' + (isError ? 'error' : 'success'); 257 + msg.style.display = 'block'; 258 + setTimeout(function() { msg.style.display = 'none'; }, 4000); 259 + } 260 + 261 + function renderControl(setting) { 262 + const id = 'setting-' + setting.key; 263 + 264 + if (setting.type === 'toggle') { 265 + const checked = setting.value ? 'checked' : ''; 266 + return '<label class="toggle-switch">' + 267 + '<input type="checkbox" id="' + id + '" ' + checked + ' onchange="markChanged()">' + 268 + '<span class="toggle-slider"></span></label>'; 269 + } 270 + 271 + if (setting.type === 'enum') { 272 + let html = '<select id="' + id + '" onchange="markChanged()">'; 273 + setting.options.forEach(function(opt, idx) { 274 + const selected = idx === setting.value ? ' selected' : ''; 275 + html += '<option value="' + idx + '"' + selected + '>' + escapeHtml(opt) + '</option>'; 276 + }); 277 + html += '</select>'; 278 + return html; 279 + } 280 + 281 + if (setting.type === 'value') { 282 + return '<input type="number" id="' + id + '" value="' + setting.value + '"' + 283 + ' min="' + setting.min + '" max="' + setting.max + '" step="' + setting.step + '"' + 284 + ' onchange="markChanged()">'; 285 + } 286 + 287 + if (setting.type === 'string') { 288 + const inputType = setting.name.toLowerCase().includes('password') ? 'password' : 'text'; 289 + const val = setting.value || ''; 290 + return '<input type="' + inputType + '" id="' + id + '" value="' + escapeHtml(val) + '"' + 291 + ' oninput="markChanged()">'; 292 + } 293 + 294 + return ''; 295 + } 296 + 297 + function getValue(setting) { 298 + const el = document.getElementById('setting-' + setting.key); 299 + if (!el) return undefined; 300 + 301 + if (setting.type === 'toggle') { 302 + return el.checked ? 1 : 0; 303 + } 304 + if (setting.type === 'enum') { 305 + return parseInt(el.value, 10); 306 + } 307 + if (setting.type === 'value') { 308 + return parseInt(el.value, 10); 309 + } 310 + if (setting.type === 'string') { 311 + return el.value; 312 + } 313 + return undefined; 314 + } 315 + 316 + function markChanged() { 317 + document.getElementById('saveBtn').disabled = false; 318 + } 319 + 320 + async function loadSettings() { 321 + try { 322 + const response = await fetch('/api/settings'); 323 + if (!response.ok) { 324 + throw new Error('Failed to load settings: ' + response.status); 325 + } 326 + allSettings = await response.json(); 327 + 328 + // Store original values 329 + originalValues = {}; 330 + allSettings.forEach(function(s) { 331 + originalValues[s.key] = s.value; 332 + }); 333 + 334 + // Group by category 335 + const groups = {}; 336 + allSettings.forEach(function(s) { 337 + if (!groups[s.category]) groups[s.category] = []; 338 + groups[s.category].push(s); 339 + }); 340 + 341 + const container = document.getElementById('settings-container'); 342 + let html = ''; 343 + 344 + for (const category in groups) { 345 + html += '<div class="card"><h2>' + escapeHtml(category) + '</h2>'; 346 + groups[category].forEach(function(s) { 347 + html += '<div class="setting-row">' + 348 + '<span class="setting-name">' + escapeHtml(s.name) + '</span>' + 349 + '<span class="setting-control">' + renderControl(s) + '</span>' + 350 + '</div>'; 351 + }); 352 + html += '</div>'; 353 + } 354 + 355 + container.innerHTML = html; 356 + document.getElementById('save-container').style.display = ''; 357 + document.getElementById('saveBtn').disabled = true; 358 + } catch (e) { 359 + console.error(e); 360 + document.getElementById('settings-container').innerHTML = 361 + '<div class="card"><p style="text-align:center;color:#e74c3c;">Failed to load settings</p></div>'; 362 + } 363 + } 364 + 365 + async function saveSettings() { 366 + const btn = document.getElementById('saveBtn'); 367 + btn.disabled = true; 368 + btn.textContent = 'Saving...'; 369 + 370 + // Collect only changed values 371 + const changes = {}; 372 + allSettings.forEach(function(s) { 373 + const current = getValue(s); 374 + if (current !== undefined && current !== originalValues[s.key]) { 375 + changes[s.key] = current; 376 + } 377 + }); 378 + 379 + if (Object.keys(changes).length === 0) { 380 + showMessage('No changes to save.', false); 381 + btn.textContent = 'Save Settings'; 382 + return; 383 + } 384 + 385 + try { 386 + const response = await fetch('/api/settings', { 387 + method: 'POST', 388 + headers: { 'Content-Type': 'application/json' }, 389 + body: JSON.stringify(changes) 390 + }); 391 + 392 + if (!response.ok) { 393 + const text = await response.text(); 394 + throw new Error(text || 'Save failed'); 395 + } 396 + 397 + // Update original values to new values 398 + for (const key in changes) { 399 + originalValues[key] = changes[key]; 400 + } 401 + 402 + showMessage('Settings saved successfully!', false); 403 + } catch (e) { 404 + console.error(e); 405 + showMessage('Error: ' + e.message, true); 406 + } 407 + 408 + btn.textContent = 'Save Settings'; 409 + } 410 + 411 + loadSettings(); 412 + </script> 413 + </body> 414 + </html>