This commit is contained in:
Jesse Vincent 2026-02-04 09:18:12 +11:00 committed by GitHub
commit 7be1359838
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 713 additions and 87 deletions

84
src/SettingsList.h Normal file
View File

@ -0,0 +1,84 @@
#pragma once
#include <vector>
#include "CrossPointSettings.h"
#include "KOReaderCredentialStore.h"
#include "activities/settings/CategorySettingsActivity.h"
// Returns the list of all settings, used by both the device UI and the web API.
// Categories match the device UI grouping. Settings with categories that don't
// match a device UI category (e.g. "KOReader Sync", "OPDS Browser") are
// web-only — they correspond to device sub-screens accessed via Action items.
inline std::vector<SettingInfo> getSettingsList() {
return {
// Display
SettingInfo::Enum("sleepScreen", "Sleep Screen", "Display", &CrossPointSettings::sleepScreen,
{"Dark", "Light", "Custom", "Cover", "None"}),
SettingInfo::Enum("sleepScreenCoverMode", "Sleep Screen Cover Mode", "Display",
&CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}),
SettingInfo::Enum("sleepScreenCoverFilter", "Sleep Screen Cover Filter", "Display",
&CrossPointSettings::sleepScreenCoverFilter, {"None", "Contrast", "Inverted"}),
SettingInfo::Enum("statusBar", "Status Bar", "Display", &CrossPointSettings::statusBar,
{"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}),
SettingInfo::Enum("hideBatteryPercentage", "Hide Battery %", "Display",
&CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
SettingInfo::Enum("refreshFrequency", "Refresh Frequency", "Display", &CrossPointSettings::refreshFrequency,
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
// Reader
SettingInfo::Enum("fontFamily", "Font Family", "Reader", &CrossPointSettings::fontFamily,
{"Bookerly", "Noto Sans", "Open Dyslexic"}),
SettingInfo::Enum("fontSize", "Font Size", "Reader", &CrossPointSettings::fontSize,
{"Small", "Medium", "Large", "X Large"}),
SettingInfo::Enum("lineSpacing", "Line Spacing", "Reader", &CrossPointSettings::lineSpacing,
{"Tight", "Normal", "Wide"}),
SettingInfo::Value("screenMargin", "Screen Margin", "Reader", &CrossPointSettings::screenMargin, {5, 40, 5}),
SettingInfo::Enum("paragraphAlignment", "Paragraph Alignment", "Reader", &CrossPointSettings::paragraphAlignment,
{"Justify", "Left", "Center", "Right"}),
SettingInfo::Toggle("hyphenationEnabled", "Hyphenation", "Reader", &CrossPointSettings::hyphenationEnabled),
SettingInfo::Enum("orientation", "Reading Orientation", "Reader", &CrossPointSettings::orientation,
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}),
SettingInfo::Toggle("extraParagraphSpacing", "Extra Paragraph Spacing", "Reader",
&CrossPointSettings::extraParagraphSpacing),
SettingInfo::Toggle("textAntiAliasing", "Text Anti-Aliasing", "Reader", &CrossPointSettings::textAntiAliasing),
// Controls
SettingInfo::Enum(
"frontButtonLayout", "Front Button Layout", "Controls", &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)", "Controls",
&CrossPointSettings::sideButtonLayout, {"Prev, Next", "Next, Prev"}),
SettingInfo::Toggle("longPressChapterSkip", "Long-press Chapter Skip", "Controls",
&CrossPointSettings::longPressChapterSkip),
SettingInfo::Enum("shortPwrBtn", "Short Power Button Click", "Controls", &CrossPointSettings::shortPwrBtn,
{"Ignore", "Sleep", "Page Turn"}),
// System
SettingInfo::Enum("sleepTimeout", "Time to Sleep", "System", &CrossPointSettings::sleepTimeout,
{"1 min", "5 min", "10 min", "15 min", "30 min"}),
// KOReader Sync (device sub-screen accessed via System > KOReader Sync action)
SettingInfo::DynamicString(
"koUsername", "Username", "KOReader Sync", [] { return KOREADER_STORE.getUsername(); },
[](const std::string& v) { KOREADER_STORE.setCredentials(v, KOREADER_STORE.getPassword()); }, 64),
SettingInfo::DynamicString(
"koPassword", "Password", "KOReader Sync", [] { return KOREADER_STORE.getPassword(); },
[](const std::string& v) { KOREADER_STORE.setCredentials(KOREADER_STORE.getUsername(), v); }, 64),
SettingInfo::DynamicString(
"koServerUrl", "Sync Server URL", "KOReader Sync", [] { return KOREADER_STORE.getServerUrl(); },
[](const std::string& v) { KOREADER_STORE.setServerUrl(v); }, 128),
SettingInfo::DynamicEnum(
"koMatchMethod", "Document Matching", "KOReader Sync",
[] { return static_cast<uint8_t>(KOREADER_STORE.getMatchMethod()); },
[](uint8_t v) { KOREADER_STORE.setMatchMethod(static_cast<DocumentMatchMethod>(v)); },
{"Filename", "Binary"}),
// OPDS Browser (device sub-screen accessed via System > OPDS Browser action)
SettingInfo::String("opdsServerUrl", "Server URL", "OPDS Browser", SETTINGS.opdsServerUrl,
sizeof(SETTINGS.opdsServerUrl) - 1),
SettingInfo::String("opdsUsername", "Username", "OPDS Browser", SETTINGS.opdsUsername,
sizeof(SETTINGS.opdsUsername) - 1),
SettingInfo::String("opdsPassword", "Password", "OPDS Browser", SETTINGS.opdsPassword,
sizeof(SETTINGS.opdsPassword) - 1),
};
}

View File

@ -11,12 +11,16 @@
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
const char* category; // Category for grouping in web UI (nullptr = uncategorized)
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 +30,61 @@ struct SettingInfo {
}; };
ValueRange valueRange; ValueRange valueRange;
static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) { // Dynamic accessors for settings not in CrossPointSettings
return {name, SettingType::TOGGLE, ptr}; std::function<uint8_t()> valueGetter;
std::function<void(uint8_t)> valueSetter;
std::function<std::string()> stringGetter;
std::function<void(const std::string&)> stringSetter;
static SettingInfo Toggle(const char* key, const char* name, const char* category,
uint8_t CrossPointSettings::* ptr) {
return {key, name, category, 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, const char* category, uint8_t CrossPointSettings::* ptr,
return {name, SettingType::ENUM, ptr, std::move(values)}; std::vector<std::string> values) {
return {key, name, category, 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, nullptr, 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, const char* category, uint8_t CrossPointSettings::* ptr,
return {name, SettingType::VALUE, ptr, {}, valueRange}; const ValueRange valueRange) {
return {key, name, category, SettingType::VALUE, ptr, nullptr, 0, {}, valueRange};
}
static SettingInfo String(const char* key, const char* name, const char* category, char* ptr, size_t maxLen) {
return {key, name, category, SettingType::STRING, nullptr, ptr, maxLen, {}, {}};
}
static SettingInfo DynamicEnum(const char* key, const char* name, const char* category,
std::function<uint8_t()> getter, std::function<void(uint8_t)> setter,
std::vector<std::string> values) {
SettingInfo info{};
info.key = key;
info.name = name;
info.category = category;
info.type = SettingType::ENUM;
info.enumValues = std::move(values);
info.valueGetter = std::move(getter);
info.valueSetter = std::move(setter);
return info;
}
static SettingInfo DynamicString(const char* key, const char* name, const char* category,
std::function<std::string()> getter, std::function<void(const std::string&)> setter,
size_t maxLen) {
SettingInfo info{};
info.key = key;
info.name = name;
info.category = category;
info.type = SettingType::STRING;
info.stringMaxLen = maxLen;
info.stringGetter = std::move(getter);
info.stringSetter = std::move(setter);
return info;
} }
}; };

View File

@ -3,59 +3,15 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include "CategorySettingsActivity.h" #include <cstring>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "SettingsList.h"
#include "fontIds.h" #include "fontIds.h"
const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"}; const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"};
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,
{"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,
{"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,
{"Justify", "Left", "Center", "Right"}),
SettingInfo::Toggle("Hyphenation", &CrossPointSettings::hyphenationEnabled),
SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation,
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}),
SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing),
SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing)};
constexpr int controlsSettingsCount = 4;
const SettingInfo controlsSettings[controlsSettingsCount] = {
SettingInfo::Enum(
"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,
{"Prev, Next", "Next, Prev"}),
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
SettingInfo::Enum("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,
{"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")};
} // namespace
void SettingsActivity::taskTrampoline(void* param) { void SettingsActivity::taskTrampoline(void* param) {
auto* self = static_cast<SettingsActivity*>(param); auto* self = static_cast<SettingsActivity*>(param);
self->displayTaskLoop(); self->displayTaskLoop();
@ -68,6 +24,23 @@ void SettingsActivity::onEnter() {
// Reset selection to first category // Reset selection to first category
selectedCategoryIndex = 0; selectedCategoryIndex = 0;
// Build per-category settings from the single source of truth
for (auto& cat : categorySettings) cat.clear();
for (const auto& setting : getSettingsList()) {
if (!setting.category) continue;
for (int i = 0; i < categoryCount; i++) {
if (strcmp(setting.category, categoryNames[i]) == 0) {
categorySettings[i].push_back(setting);
break;
}
}
}
// Append device-only Action items to System (index 3)
categorySettings[3].push_back(SettingInfo::Action("KOReader Sync"));
categorySettings[3].push_back(SettingInfo::Action("OPDS Browser"));
categorySettings[3].push_back(SettingInfo::Action("Clear Cache"));
categorySettings[3].push_back(SettingInfo::Action("Check for updates"));
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;
@ -132,30 +105,9 @@ void SettingsActivity::enterCategory(int categoryIndex) {
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity(); exitActivity();
const SettingInfo* settingsList = nullptr; const auto& settings = categorySettings[categoryIndex];
int settingsCount = 0; enterNewActivity(new CategorySettingsActivity(renderer, mappedInput, categoryNames[categoryIndex], settings.data(),
static_cast<int>(settings.size()), [this] {
switch (categoryIndex) {
case 0: // Display
settingsList = displaySettings;
settingsCount = displaySettingsCount;
break;
case 1: // Reader
settingsList = readerSettings;
settingsCount = readerSettingsCount;
break;
case 2: // Controls
settingsList = controlsSettings;
settingsCount = controlsSettingsCount;
break;
case 3: // System
settingsList = systemSettings;
settingsCount = systemSettingsCount;
break;
}
enterNewActivity(new CategorySettingsActivity(renderer, mappedInput, categoryNames[categoryIndex], settingsList,
settingsCount, [this] {
exitActivity(); exitActivity();
updateRequired = true; updateRequired = true;
})); }));

View File

@ -7,20 +7,19 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "CategorySettingsActivity.h"
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
class CrossPointSettings;
struct SettingInfo;
class SettingsActivity final : public ActivityWithSubactivity { class SettingsActivity final : public ActivityWithSubactivity {
static constexpr int categoryCount = 4;
static const char* categoryNames[categoryCount];
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false; bool updateRequired = false;
int selectedCategoryIndex = 0; // Currently selected category int selectedCategoryIndex = 0; // Currently selected category
const std::function<void()> onGoHome; const std::function<void()> onGoHome;
std::vector<SettingInfo> categorySettings[categoryCount];
static constexpr int categoryCount = 4;
static const char* categoryNames[categoryCount];
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();

View File

@ -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,168 @@ 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;
if (setting.category) {
obj["category"] = setting.category;
}
switch (setting.type) {
case SettingType::TOGGLE:
obj["type"] = "toggle";
obj["value"] = (setting.valueGetter ? setting.valueGetter() : SETTINGS.*(setting.valuePtr)) ? 1 : 0;
break;
case SettingType::ENUM: {
obj["type"] = "enum";
obj["value"] = setting.valueGetter ? setting.valueGetter() : 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"] = setting.valueGetter ? setting.valueGetter() : 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";
if (setting.stringGetter) {
obj["value"] = setting.stringGetter();
} else {
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>();
if (setting.valueSetter) {
setting.valueSetter(value ? 1 : 0);
} else {
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())) {
if (setting.valueSetter) {
setting.valueSetter(static_cast<uint8_t>(value));
} else {
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) {
if (setting.valueSetter) {
setting.valueSetter(static_cast<uint8_t>(value));
} else {
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) {
if (setting.stringSetter) {
setting.stringSetter(value);
} else {
strncpy(setting.stringPtr, value, setting.stringMaxLen);
setting.stringPtr[setting.stringMaxLen] = '\0';
}
updatedCount++;
}
break;
}
case SettingType::ACTION:
break;
}
}
if (updatedCount > 0) {
SETTINGS.saveToFile();
KOREADER_STORE.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 handleUploadPost() const;
void handleCreateFolder() const; void handleCreateFolder() const;
void handleDelete() const; void handleDelete() const;
// Settings handlers
void handleSettingsPage() const;
void handleGetSettings() const;
void handlePostSettings();
}; };

View File

@ -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">

View File

@ -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">

View File

@ -0,0 +1,364 @@
<!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 id="settings-container">
<div class="card"><div class="loading">Loading settings...</div></div>
</div>
<button id="save-btn" class="save-btn" style="display: none;">Save Settings</button>
<div id="status-message" class="status-message"></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 = '';
// Group settings by category
const categories = new Map();
settings.forEach(setting => {
const cat = setting.category || 'Other';
if (!categories.has(cat)) categories.set(cat, []);
categories.get(cat).push(setting);
});
categories.forEach((catSettings, categoryName) => {
const card = document.createElement('div');
card.className = 'card';
const heading = document.createElement('h2');
heading.textContent = categoryName;
card.appendChild(heading);
catSettings.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 value...">
`;
break;
}
row.appendChild(label);
row.appendChild(control);
card.appendChild(row);
// Add change listener
const input = control.querySelector('input, select');
if (input) {
input.addEventListener('change', () => updateCurrentValue(setting));
input.addEventListener('input', () => updateCurrentValue(setting));
}
});
container.appendChild(card);
});
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>