Compare commits

...

7 Commits

Author SHA1 Message Date
Jesse Vincent
ccbf42fe32
Merge 94b869f6d5 into d403044f76 2026-02-04 01:49:42 +08:00
Jesse Vincent
94b869f6d5
Merge branch 'crosspoint-reader:master' into feature/web-settings 2026-02-02 10:19:55 -08:00
Jesse Vincent
3834555a16 style: Apply clang-format to web settings files 2026-02-02 10:09:36 -08:00
Jesse Vincent
76bfe2d35d feat: Add KOReader Sync and OPDS Browser settings to web UI
Web settings now mirror the device UI structure:
- Display, Reader, Controls, System (same as device categories)
- KOReader Sync: username, password, server URL, document matching
- OPDS Browser: server URL, username, password

Adds dynamic getter/setter support to SettingInfo for settings
backed by stores other than CrossPointSettings (KOReaderCredentialStore).
2026-02-01 19:55:14 -08:00
Jesse Vincent
98bd0572e0 refactor: Derive device UI settings from shared SettingsList
Remove duplicated setting definitions from SettingsActivity.cpp.
The device UI now builds its per-category settings from
getSettingsList() at runtime, with Action items appended for
the System category. This eliminates the maintenance hazard of
keeping two copies of every setting definition in sync.
2026-02-01 19:16:34 -08:00
Jesse Vincent
434fc9fb7d feat: Group web settings by category and add missing OPDS credentials
Address code review: settings are now grouped by category (Display,
Reader, Controls, System, Calibre) in both the JSON API and the web
UI. Adds category field to SettingInfo struct. Also adds opdsUsername
and opdsPassword to the Calibre section, which were missing from the
web settings.
2026-02-01 19:16:34 -08:00
Justin Mitchell
84fa55789c 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.
2026-02-01 19:16:34 -08:00
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;
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE };
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING };
struct SettingInfo {
const char* name;
const char* key; // JSON key for web API (nullptr for ACTION types)
const char* name; // Display name of the setting
const char* category; // Category for grouping in web UI (nullptr = uncategorized)
SettingType type;
uint8_t CrossPointSettings::* valuePtr;
uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE/ENUM/VALUE)
char* stringPtr; // Pointer to char array (for STRING type)
size_t stringMaxLen; // Max length for STRING type
std::vector<std::string> enumValues;
struct ValueRange {
@ -26,18 +30,61 @@ struct SettingInfo {
};
ValueRange valueRange;
static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) {
return {name, SettingType::TOGGLE, ptr};
// Dynamic accessors for settings not in CrossPointSettings
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) {
return {name, SettingType::ENUM, ptr, std::move(values)};
static SettingInfo Enum(const char* key, const char* name, const char* category, uint8_t CrossPointSettings::* ptr,
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) {
return {name, SettingType::VALUE, ptr, {}, valueRange};
static SettingInfo Value(const char* key, const char* name, const char* category, uint8_t CrossPointSettings::* ptr,
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 <HardwareSerial.h>
#include "CategorySettingsActivity.h"
#include <cstring>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "SettingsList.h"
#include "fontIds.h"
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) {
auto* self = static_cast<SettingsActivity*>(param);
self->displayTaskLoop();
@ -68,6 +24,23 @@ void SettingsActivity::onEnter() {
// Reset selection to first category
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
updateRequired = true;
@ -132,30 +105,9 @@ void SettingsActivity::enterCategory(int categoryIndex) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
const SettingInfo* settingsList = nullptr;
int settingsCount = 0;
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] {
const auto& settings = categorySettings[categoryIndex];
enterNewActivity(new CategorySettingsActivity(renderer, mappedInput, categoryNames[categoryIndex], settings.data(),
static_cast<int>(settings.size()), [this] {
exitActivity();
updateRequired = true;
}));

View File

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

View File

@ -8,9 +8,12 @@
#include <esp_task_wdt.h>
#include <algorithm>
#include <cstring>
#include "SettingsList.h"
#include "html/FilesPageHtml.generated.h"
#include "html/HomePageHtml.generated.h"
#include "html/SettingsPageHtml.generated.h"
#include "util/StringUtils.h"
namespace {
@ -112,6 +115,11 @@ void CrossPointWebServer::begin() {
// Delete file/folder endpoint
server->on("/delete", HTTP_POST, [this] { handleDelete(); });
// Settings endpoints
server->on("/settings", HTTP_GET, [this] { handleSettingsPage(); });
server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); });
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
server->onNotFound([this] { handleNotFound(); });
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
@ -936,3 +944,168 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
break;
}
}
void CrossPointWebServer::handleSettingsPage() const {
server->send(200, "text/html", SettingsPageHtml);
Serial.printf("[%lu] [WEB] Served settings page\n", millis());
}
void CrossPointWebServer::handleGetSettings() const {
const auto settings = getSettingsList();
JsonDocument doc;
JsonArray settingsArray = doc["settings"].to<JsonArray>();
for (const auto& setting : settings) {
// Skip ACTION types - they don't have web-editable values
if (setting.type == SettingType::ACTION) {
continue;
}
JsonObject obj = settingsArray.add<JsonObject>();
obj["key"] = setting.key;
obj["name"] = setting.name;
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 handleCreateFolder() const;
void handleDelete() const;
// Settings handlers
void handleSettingsPage() const;
void handleGetSettings() const;
void handlePostSettings();
};

View File

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

View File

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

View File

@ -0,0 +1,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>