mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 06:37:38 +03:00
feat: Add settings editing to web UI
Reimplements PR #346 on top of current master. Adds a web settings page at /settings that allows viewing and editing all device settings via the hotspot, with GET/POST JSON API at /api/settings.
This commit is contained in:
parent
e5c0ddc9fa
commit
84fa55789c
57
src/SettingsList.h
Normal file
57
src/SettingsList.h
Normal file
@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
#include <vector>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "activities/settings/CategorySettingsActivity.h"
|
||||
|
||||
// Returns the flat list of all settings for the web API.
|
||||
// This is used by CrossPointWebServer to expose settings over HTTP.
|
||||
inline std::vector<SettingInfo> getSettingsList() {
|
||||
return {
|
||||
// Display
|
||||
SettingInfo::Enum("sleepScreen", "Sleep Screen", &CrossPointSettings::sleepScreen,
|
||||
{"Dark", "Light", "Custom", "Cover", "None"}),
|
||||
SettingInfo::Enum("sleepScreenCoverMode", "Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode,
|
||||
{"Fit", "Crop"}),
|
||||
SettingInfo::Enum("sleepScreenCoverFilter", "Sleep Screen Cover Filter",
|
||||
&CrossPointSettings::sleepScreenCoverFilter, {"None", "Contrast", "Inverted"}),
|
||||
SettingInfo::Enum("statusBar", "Status Bar", &CrossPointSettings::statusBar,
|
||||
{"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}),
|
||||
SettingInfo::Enum("hideBatteryPercentage", "Hide Battery %", &CrossPointSettings::hideBatteryPercentage,
|
||||
{"Never", "In Reader", "Always"}),
|
||||
SettingInfo::Enum("refreshFrequency", "Refresh Frequency", &CrossPointSettings::refreshFrequency,
|
||||
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
|
||||
|
||||
// Reader
|
||||
SettingInfo::Enum("fontFamily", "Font Family", &CrossPointSettings::fontFamily,
|
||||
{"Bookerly", "Noto Sans", "Open Dyslexic"}),
|
||||
SettingInfo::Enum("fontSize", "Font Size", &CrossPointSettings::fontSize,
|
||||
{"Small", "Medium", "Large", "X Large"}),
|
||||
SettingInfo::Enum("lineSpacing", "Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}),
|
||||
SettingInfo::Value("screenMargin", "Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}),
|
||||
SettingInfo::Enum("paragraphAlignment", "Paragraph Alignment", &CrossPointSettings::paragraphAlignment,
|
||||
{"Justify", "Left", "Center", "Right"}),
|
||||
SettingInfo::Toggle("hyphenationEnabled", "Hyphenation", &CrossPointSettings::hyphenationEnabled),
|
||||
SettingInfo::Enum("orientation", "Reading Orientation", &CrossPointSettings::orientation,
|
||||
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}),
|
||||
SettingInfo::Toggle("extraParagraphSpacing", "Extra Paragraph Spacing",
|
||||
&CrossPointSettings::extraParagraphSpacing),
|
||||
SettingInfo::Toggle("textAntiAliasing", "Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing),
|
||||
|
||||
// Controls
|
||||
SettingInfo::Enum(
|
||||
"frontButtonLayout", "Front Button Layout", &CrossPointSettings::frontButtonLayout,
|
||||
{"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght", "Bck, Cnfrm, Rght, Lft"}),
|
||||
SettingInfo::Enum("sideButtonLayout", "Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout,
|
||||
{"Prev, Next", "Next, Prev"}),
|
||||
SettingInfo::Toggle("longPressChapterSkip", "Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
|
||||
SettingInfo::Enum("shortPwrBtn", "Short Power Button Click", &CrossPointSettings::shortPwrBtn,
|
||||
{"Ignore", "Sleep", "Page Turn"}),
|
||||
|
||||
// System
|
||||
SettingInfo::Enum("sleepTimeout", "Time to Sleep", &CrossPointSettings::sleepTimeout,
|
||||
{"1 min", "5 min", "10 min", "15 min", "30 min"}),
|
||||
SettingInfo::String("opdsServerUrl", "OPDS Server URL", SETTINGS.opdsServerUrl,
|
||||
sizeof(SETTINGS.opdsServerUrl) - 1),
|
||||
};
|
||||
}
|
||||
@ -11,12 +11,15 @@
|
||||
|
||||
class CrossPointSettings;
|
||||
|
||||
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE };
|
||||
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING };
|
||||
|
||||
struct SettingInfo {
|
||||
const char* name;
|
||||
const char* key; // JSON key for web API (nullptr for ACTION types)
|
||||
const char* name; // Display name of the setting
|
||||
SettingType type;
|
||||
uint8_t CrossPointSettings::* valuePtr;
|
||||
uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE/ENUM/VALUE)
|
||||
char* stringPtr; // Pointer to char array (for STRING type)
|
||||
size_t stringMaxLen; // Max length for STRING type
|
||||
std::vector<std::string> enumValues;
|
||||
|
||||
struct ValueRange {
|
||||
@ -26,18 +29,26 @@ struct SettingInfo {
|
||||
};
|
||||
ValueRange valueRange;
|
||||
|
||||
static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) {
|
||||
return {name, SettingType::TOGGLE, ptr};
|
||||
static SettingInfo Toggle(const char* key, const char* name, uint8_t CrossPointSettings::* ptr) {
|
||||
return {key, name, SettingType::TOGGLE, ptr, nullptr, 0, {}, {}};
|
||||
}
|
||||
|
||||
static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector<std::string> values) {
|
||||
return {name, SettingType::ENUM, ptr, std::move(values)};
|
||||
static SettingInfo Enum(const char* key, const char* name, uint8_t CrossPointSettings::* ptr,
|
||||
std::vector<std::string> values) {
|
||||
return {key, name, SettingType::ENUM, ptr, nullptr, 0, std::move(values), {}};
|
||||
}
|
||||
|
||||
static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr}; }
|
||||
static SettingInfo Action(const char* name) {
|
||||
return {nullptr, name, SettingType::ACTION, nullptr, nullptr, 0, {}, {}};
|
||||
}
|
||||
|
||||
static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange) {
|
||||
return {name, SettingType::VALUE, ptr, {}, valueRange};
|
||||
static SettingInfo Value(const char* key, const char* name, uint8_t CrossPointSettings::* ptr,
|
||||
const ValueRange valueRange) {
|
||||
return {key, name, SettingType::VALUE, ptr, nullptr, 0, {}, valueRange};
|
||||
}
|
||||
|
||||
static SettingInfo String(const char* key, const char* name, char* ptr, size_t maxLen) {
|
||||
return {key, name, SettingType::STRING, nullptr, ptr, maxLen, {}, {}};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -14,43 +14,49 @@ namespace {
|
||||
constexpr int displaySettingsCount = 6;
|
||||
const SettingInfo displaySettings[displaySettingsCount] = {
|
||||
// Should match with SLEEP_SCREEN_MODE
|
||||
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
|
||||
SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}),
|
||||
SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter,
|
||||
{"None", "Contrast", "Inverted"}),
|
||||
SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar,
|
||||
SettingInfo::Enum("sleepScreen", "Sleep Screen", &CrossPointSettings::sleepScreen,
|
||||
{"Dark", "Light", "Custom", "Cover", "None"}),
|
||||
SettingInfo::Enum("sleepScreenCoverMode", "Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode,
|
||||
{"Fit", "Crop"}),
|
||||
SettingInfo::Enum("sleepScreenCoverFilter", "Sleep Screen Cover Filter",
|
||||
&CrossPointSettings::sleepScreenCoverFilter, {"None", "Contrast", "Inverted"}),
|
||||
SettingInfo::Enum("statusBar", "Status Bar", &CrossPointSettings::statusBar,
|
||||
{"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}),
|
||||
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
|
||||
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
|
||||
SettingInfo::Enum("hideBatteryPercentage", "Hide Battery %", &CrossPointSettings::hideBatteryPercentage,
|
||||
{"Never", "In Reader", "Always"}),
|
||||
SettingInfo::Enum("refreshFrequency", "Refresh Frequency", &CrossPointSettings::refreshFrequency,
|
||||
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"})};
|
||||
|
||||
constexpr int readerSettingsCount = 9;
|
||||
const SettingInfo readerSettings[readerSettingsCount] = {
|
||||
SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"}),
|
||||
SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}),
|
||||
SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}),
|
||||
SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}),
|
||||
SettingInfo::Enum("Paragraph Alignment", &CrossPointSettings::paragraphAlignment,
|
||||
SettingInfo::Enum("fontFamily", "Font Family", &CrossPointSettings::fontFamily,
|
||||
{"Bookerly", "Noto Sans", "Open Dyslexic"}),
|
||||
SettingInfo::Enum("fontSize", "Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}),
|
||||
SettingInfo::Enum("lineSpacing", "Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}),
|
||||
SettingInfo::Value("screenMargin", "Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}),
|
||||
SettingInfo::Enum("paragraphAlignment", "Paragraph Alignment", &CrossPointSettings::paragraphAlignment,
|
||||
{"Justify", "Left", "Center", "Right"}),
|
||||
SettingInfo::Toggle("Hyphenation", &CrossPointSettings::hyphenationEnabled),
|
||||
SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation,
|
||||
SettingInfo::Toggle("hyphenationEnabled", "Hyphenation", &CrossPointSettings::hyphenationEnabled),
|
||||
SettingInfo::Enum("orientation", "Reading Orientation", &CrossPointSettings::orientation,
|
||||
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}),
|
||||
SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing),
|
||||
SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing)};
|
||||
SettingInfo::Toggle("extraParagraphSpacing", "Extra Paragraph Spacing",
|
||||
&CrossPointSettings::extraParagraphSpacing),
|
||||
SettingInfo::Toggle("textAntiAliasing", "Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing)};
|
||||
|
||||
constexpr int controlsSettingsCount = 4;
|
||||
const SettingInfo controlsSettings[controlsSettingsCount] = {
|
||||
SettingInfo::Enum(
|
||||
"Front Button Layout", &CrossPointSettings::frontButtonLayout,
|
||||
"frontButtonLayout", "Front Button Layout", &CrossPointSettings::frontButtonLayout,
|
||||
{"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght", "Bck, Cnfrm, Rght, Lft"}),
|
||||
SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout,
|
||||
SettingInfo::Enum("sideButtonLayout", "Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout,
|
||||
{"Prev, Next", "Next, Prev"}),
|
||||
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
|
||||
SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"})};
|
||||
SettingInfo::Toggle("longPressChapterSkip", "Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
|
||||
SettingInfo::Enum("shortPwrBtn", "Short Power Button Click", &CrossPointSettings::shortPwrBtn,
|
||||
{"Ignore", "Sleep", "Page Turn"})};
|
||||
|
||||
constexpr int systemSettingsCount = 5;
|
||||
const SettingInfo systemSettings[systemSettingsCount] = {
|
||||
SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout,
|
||||
SettingInfo::Enum("sleepTimeout", "Time to Sleep", &CrossPointSettings::sleepTimeout,
|
||||
{"1 min", "5 min", "10 min", "15 min", "30 min"}),
|
||||
SettingInfo::Action("KOReader Sync"), SettingInfo::Action("OPDS Browser"), SettingInfo::Action("Clear Cache"),
|
||||
SettingInfo::Action("Check for updates")};
|
||||
|
||||
@ -8,9 +8,12 @@
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "SettingsList.h"
|
||||
#include "html/FilesPageHtml.generated.h"
|
||||
#include "html/HomePageHtml.generated.h"
|
||||
#include "html/SettingsPageHtml.generated.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
namespace {
|
||||
@ -112,6 +115,11 @@ void CrossPointWebServer::begin() {
|
||||
// Delete file/folder endpoint
|
||||
server->on("/delete", HTTP_POST, [this] { handleDelete(); });
|
||||
|
||||
// Settings endpoints
|
||||
server->on("/settings", HTTP_GET, [this] { handleSettingsPage(); });
|
||||
server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); });
|
||||
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
|
||||
|
||||
server->onNotFound([this] { handleNotFound(); });
|
||||
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
|
||||
|
||||
@ -936,3 +944,144 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleSettingsPage() const {
|
||||
server->send(200, "text/html", SettingsPageHtml);
|
||||
Serial.printf("[%lu] [WEB] Served settings page\n", millis());
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleGetSettings() const {
|
||||
const auto settings = getSettingsList();
|
||||
|
||||
JsonDocument doc;
|
||||
JsonArray settingsArray = doc["settings"].to<JsonArray>();
|
||||
|
||||
for (const auto& setting : settings) {
|
||||
// Skip ACTION types - they don't have web-editable values
|
||||
if (setting.type == SettingType::ACTION) {
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonObject obj = settingsArray.add<JsonObject>();
|
||||
obj["key"] = setting.key;
|
||||
obj["name"] = setting.name;
|
||||
|
||||
switch (setting.type) {
|
||||
case SettingType::TOGGLE:
|
||||
obj["type"] = "toggle";
|
||||
obj["value"] = SETTINGS.*(setting.valuePtr) ? 1 : 0;
|
||||
break;
|
||||
|
||||
case SettingType::ENUM: {
|
||||
obj["type"] = "enum";
|
||||
obj["value"] = SETTINGS.*(setting.valuePtr);
|
||||
JsonArray opts = obj["options"].to<JsonArray>();
|
||||
for (const auto& opt : setting.enumValues) {
|
||||
opts.add(opt);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case SettingType::VALUE:
|
||||
obj["type"] = "value";
|
||||
obj["value"] = SETTINGS.*(setting.valuePtr);
|
||||
obj["min"] = setting.valueRange.min;
|
||||
obj["max"] = setting.valueRange.max;
|
||||
obj["step"] = setting.valueRange.step;
|
||||
break;
|
||||
|
||||
case SettingType::STRING:
|
||||
obj["type"] = "string";
|
||||
obj["value"] = setting.stringPtr;
|
||||
obj["maxLength"] = setting.stringMaxLen;
|
||||
break;
|
||||
|
||||
case SettingType::ACTION:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
server->send(200, "application/json", json);
|
||||
Serial.printf("[%lu] [WEB] Served settings JSON\n", millis());
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handlePostSettings() {
|
||||
if (!server->hasArg("plain")) {
|
||||
server->send(400, "text/plain", "Missing request body");
|
||||
return;
|
||||
}
|
||||
|
||||
const String body = server->arg("plain");
|
||||
Serial.printf("[%lu] [WEB] Received settings update: %s\n", millis(), body.c_str());
|
||||
|
||||
JsonDocument doc;
|
||||
const DeserializationError error = deserializeJson(doc, body);
|
||||
|
||||
if (error) {
|
||||
Serial.printf("[%lu] [WEB] JSON parse error: %s\n", millis(), error.c_str());
|
||||
server->send(400, "text/plain", "Invalid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
const auto settings = getSettingsList();
|
||||
int updatedCount = 0;
|
||||
|
||||
for (const auto& setting : settings) {
|
||||
if (setting.type == SettingType::ACTION || setting.key == nullptr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (doc[setting.key].isNull()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (setting.type) {
|
||||
case SettingType::TOGGLE: {
|
||||
const int value = doc[setting.key].as<int>();
|
||||
SETTINGS.*(setting.valuePtr) = value ? 1 : 0;
|
||||
updatedCount++;
|
||||
break;
|
||||
}
|
||||
|
||||
case SettingType::ENUM: {
|
||||
const int value = doc[setting.key].as<int>();
|
||||
if (value >= 0 && value < static_cast<int>(setting.enumValues.size())) {
|
||||
SETTINGS.*(setting.valuePtr) = static_cast<uint8_t>(value);
|
||||
updatedCount++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case SettingType::VALUE: {
|
||||
const int value = doc[setting.key].as<int>();
|
||||
if (value >= setting.valueRange.min && value <= setting.valueRange.max) {
|
||||
SETTINGS.*(setting.valuePtr) = static_cast<uint8_t>(value);
|
||||
updatedCount++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case SettingType::STRING: {
|
||||
const char* value = doc[setting.key].as<const char*>();
|
||||
if (value != nullptr) {
|
||||
strncpy(setting.stringPtr, value, setting.stringMaxLen);
|
||||
setting.stringPtr[setting.stringMaxLen] = '\0';
|
||||
updatedCount++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case SettingType::ACTION:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedCount > 0) {
|
||||
SETTINGS.saveToFile();
|
||||
Serial.printf("[%lu] [WEB] Updated %d settings and saved to file\n", millis(), updatedCount);
|
||||
}
|
||||
|
||||
server->send(200, "text/plain", "Settings updated: " + String(updatedCount));
|
||||
}
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
347
src/network/html/SettingsPage.html
Normal file
347
src/network/html/SettingsPage.html
Normal file
@ -0,0 +1,347 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Settings - CrossPoint Reader</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
border-bottom: 2px solid #3498db;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
h2 {
|
||||
color: #34495e;
|
||||
margin-top: 0;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 15px 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.nav-links {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.nav-links a {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.nav-links a:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
.setting-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.setting-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.setting-label {
|
||||
font-weight: 500;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.setting-control {
|
||||
min-width: 150px;
|
||||
text-align: right;
|
||||
}
|
||||
select, input[type="number"], input[type="text"] {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
min-width: 120px;
|
||||
}
|
||||
select:focus, input:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
}
|
||||
input[type="text"] {
|
||||
min-width: 200px;
|
||||
}
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
display: inline-block;
|
||||
}
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: 0.3s;
|
||||
border-radius: 26px;
|
||||
}
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
input:checked + .toggle-slider {
|
||||
background-color: #27ae60;
|
||||
}
|
||||
input:checked + .toggle-slider:before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
.save-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.save-btn:hover {
|
||||
background-color: #219a52;
|
||||
}
|
||||
.save-btn:disabled {
|
||||
background-color: #95a5a6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.status-message {
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-top: 15px;
|
||||
display: none;
|
||||
}
|
||||
.status-message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
display: block;
|
||||
}
|
||||
.status-message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
display: block;
|
||||
}
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Settings</h1>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/">Home</a>
|
||||
<a href="/files">File Manager</a>
|
||||
<a href="/settings">Settings</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div id="settings-container">
|
||||
<div class="loading">Loading settings...</div>
|
||||
</div>
|
||||
<button id="save-btn" class="save-btn" style="display: none;">Save Settings</button>
|
||||
<div id="status-message" class="status-message"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p style="text-align: center; color: #95a5a6; margin: 0">
|
||||
CrossPoint E-Reader
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let originalSettings = {};
|
||||
let currentSettings = {};
|
||||
|
||||
async function fetchSettings() {
|
||||
try {
|
||||
const response = await fetch('/api/settings');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch settings');
|
||||
}
|
||||
const data = await response.json();
|
||||
renderSettings(data.settings);
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
document.getElementById('settings-container').innerHTML =
|
||||
'<div class="loading" style="color: #e74c3c;">Failed to load settings. Please refresh the page.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSettings(settings) {
|
||||
const container = document.getElementById('settings-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
settings.forEach(setting => {
|
||||
originalSettings[setting.key] = setting.value;
|
||||
currentSettings[setting.key] = setting.value;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'setting-row';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'setting-label';
|
||||
label.textContent = setting.name;
|
||||
|
||||
const control = document.createElement('div');
|
||||
control.className = 'setting-control';
|
||||
|
||||
switch (setting.type) {
|
||||
case 'toggle':
|
||||
control.innerHTML = `
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="${setting.key}" ${setting.value ? 'checked' : ''}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'enum':
|
||||
let options = setting.options.map((opt, idx) =>
|
||||
`<option value="${idx}" ${setting.value === idx ? 'selected' : ''}>${opt}</option>`
|
||||
).join('');
|
||||
control.innerHTML = `<select id="${setting.key}">${options}</select>`;
|
||||
break;
|
||||
|
||||
case 'value':
|
||||
control.innerHTML = `
|
||||
<input type="number" id="${setting.key}"
|
||||
value="${setting.value}"
|
||||
min="${setting.min}"
|
||||
max="${setting.max}"
|
||||
step="${setting.step}">
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'string':
|
||||
control.innerHTML = `
|
||||
<input type="text" id="${setting.key}"
|
||||
value="${setting.value || ''}"
|
||||
maxlength="${setting.maxLength}"
|
||||
placeholder="Enter URL...">
|
||||
`;
|
||||
break;
|
||||
}
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(control);
|
||||
container.appendChild(row);
|
||||
|
||||
// Add change listener
|
||||
const input = control.querySelector('input, select');
|
||||
if (input) {
|
||||
input.addEventListener('change', () => updateCurrentValue(setting));
|
||||
input.addEventListener('input', () => updateCurrentValue(setting));
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('save-btn').style.display = 'block';
|
||||
}
|
||||
|
||||
function updateCurrentValue(setting) {
|
||||
const element = document.getElementById(setting.key);
|
||||
if (!element) return;
|
||||
|
||||
switch (setting.type) {
|
||||
case 'toggle':
|
||||
currentSettings[setting.key] = element.checked ? 1 : 0;
|
||||
break;
|
||||
case 'enum':
|
||||
case 'value':
|
||||
currentSettings[setting.key] = parseInt(element.value, 10);
|
||||
break;
|
||||
case 'string':
|
||||
currentSettings[setting.key] = element.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const saveBtn = document.getElementById('save-btn');
|
||||
const statusMsg = document.getElementById('status-message');
|
||||
|
||||
// Find changed settings
|
||||
const changes = {};
|
||||
for (const key in currentSettings) {
|
||||
if (currentSettings[key] !== originalSettings[key]) {
|
||||
changes[key] = currentSettings[key];
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(changes).length === 0) {
|
||||
statusMsg.className = 'status-message success';
|
||||
statusMsg.textContent = 'No changes to save.';
|
||||
setTimeout(() => { statusMsg.className = 'status-message'; }, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(changes)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save settings');
|
||||
}
|
||||
|
||||
// Update original settings to match current
|
||||
for (const key in changes) {
|
||||
originalSettings[key] = changes[key];
|
||||
}
|
||||
|
||||
statusMsg.className = 'status-message success';
|
||||
statusMsg.textContent = 'Settings saved successfully!';
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
statusMsg.className = 'status-message error';
|
||||
statusMsg.textContent = 'Failed to save settings. Please try again.';
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Save Settings';
|
||||
setTimeout(() => { statusMsg.className = 'status-message'; }, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('save-btn').addEventListener('click', saveSettings);
|
||||
window.onload = fetchSettings;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user