From f955cf2fb4cc425378409ab786230815348eeaec Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 2 Mar 2026 14:35:36 -0500 Subject: [PATCH] feat: add OPDS server reordering with sortOrder field and numeric stepper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Servers are sorted by a persistent sortOrder field (ties broken alphabetically). On-device editing uses a new NumericStepperActivity with side buttons for ±1 and face buttons for ±10. The web UI gets up/down arrow buttons and a POST /api/opds/reorder endpoint. Made-with: Cursor --- lib/I18n/I18nKeys.h | 1 + lib/I18n/translations/czech.yaml | 1 + lib/I18n/translations/english.yaml | 1 + lib/I18n/translations/french.yaml | 1 + lib/I18n/translations/german.yaml | 1 + lib/I18n/translations/portuguese.yaml | 1 + lib/I18n/translations/romanian.yaml | 1 + lib/I18n/translations/russian.yaml | 1 + lib/I18n/translations/spanish.yaml | 1 + lib/I18n/translations/swedish.yaml | 1 + src/OpdsServerStore.cpp | 64 +++++++++++- src/OpdsServerStore.h | 5 + .../settings/OpdsSettingsActivity.cpp | 57 ++++++++--- .../util/NumericStepperActivity.cpp | 99 +++++++++++++++++++ src/activities/util/NumericStepperActivity.h | 42 ++++++++ src/network/CrossPointWebServer.cpp | 50 +++++++++- src/network/CrossPointWebServer.h | 1 + src/network/html/SettingsPage.html | 25 ++++- 18 files changed, 328 insertions(+), 25 deletions(-) create mode 100644 src/activities/util/NumericStepperActivity.cpp create mode 100644 src/activities/util/NumericStepperActivity.h diff --git a/lib/I18n/I18nKeys.h b/lib/I18n/I18nKeys.h index a3fa4adc..307f7e65 100644 --- a/lib/I18n/I18nKeys.h +++ b/lib/I18n/I18nKeys.h @@ -426,6 +426,7 @@ enum class StrId : uint16_t { STR_SAVE_HERE, STR_SELECT_FOLDER, STR_DOWNLOAD_PATH, + STR_POSITION, // Sentinel - must be last _COUNT }; diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index 0fcaadc4..72f4ee06 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "OPDS servery" STR_SAVE_HERE: "Uložit zde" STR_SELECT_FOLDER: "Vybrat složku" STR_DOWNLOAD_PATH: "Cesta ke stažení" +STR_POSITION: "Pozice" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index dcf51c6f..60d3b325 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -390,3 +390,4 @@ STR_OPDS_SERVERS: "OPDS Servers" STR_SAVE_HERE: "Save Here" STR_SELECT_FOLDER: "Select Folder" STR_DOWNLOAD_PATH: "Download Path" +STR_POSITION: "Position" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 82ffd718..fc41552f 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "Serveurs OPDS" STR_SAVE_HERE: "Enregistrer ici" STR_SELECT_FOLDER: "Sélectionner un dossier" STR_DOWNLOAD_PATH: "Chemin de téléchargement" +STR_POSITION: "Position" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index d3b7b3c0..65229f3a 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "OPDS-Server" STR_SAVE_HERE: "Hier speichern" STR_SELECT_FOLDER: "Ordner auswählen" STR_DOWNLOAD_PATH: "Download-Pfad" +STR_POSITION: "Position" diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index 7ba59f83..906f0fca 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "Servidores OPDS" STR_SAVE_HERE: "Salvar aqui" STR_SELECT_FOLDER: "Selecionar pasta" STR_DOWNLOAD_PATH: "Caminho de download" +STR_POSITION: "Posição" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 52d331b6..9a302479 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -328,3 +328,4 @@ STR_OPDS_SERVERS: "Servere OPDS" STR_SAVE_HERE: "Salvează aici" STR_SELECT_FOLDER: "Selectează dosar" STR_DOWNLOAD_PATH: "Cale descărcare" +STR_POSITION: "Poziție" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 7582e173..cc676c7e 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "Серверы OPDS" STR_SAVE_HERE: "Сохранить здесь" STR_SELECT_FOLDER: "Выбрать папку" STR_DOWNLOAD_PATH: "Путь загрузки" +STR_POSITION: "Позиция" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index ad7640e9..884840d3 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "Servidores OPDS" STR_SAVE_HERE: "Guardar aquí" STR_SELECT_FOLDER: "Seleccionar carpeta" STR_DOWNLOAD_PATH: "Ruta de descarga" +STR_POSITION: "Posición" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 50698909..69bb50ae 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "OPDS-servrar" STR_SAVE_HERE: "Spara här" STR_SELECT_FOLDER: "Välj mapp" STR_DOWNLOAD_PATH: "Nedladdningssökväg" +STR_POSITION: "Position" diff --git a/src/OpdsServerStore.cpp b/src/OpdsServerStore.cpp index 2d57a22b..fac4d9fb 100644 --- a/src/OpdsServerStore.cpp +++ b/src/OpdsServerStore.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include "CrossPointSettings.h" @@ -81,6 +82,7 @@ bool OpdsServerStore::saveToFile() const { obj["username"] = server.username; obj["password_obf"] = obfuscateToBase64(server.password); obj["download_path"] = server.downloadPath; + obj["sort_order"] = server.sortOrder; } String json; @@ -116,13 +118,32 @@ bool OpdsServerStore::loadFromFile() { if (!server.password.empty()) needsResave = true; } server.downloadPath = obj["download_path"] | std::string("/"); + server.sortOrder = obj["sort_order"] | 0; + if (server.sortOrder == 0) needsResave = true; servers.push_back(std::move(server)); } + // Assign sequential sort orders to servers loaded without one + bool anyZero = false; + for (const auto& s : servers) { + if (s.sortOrder == 0) { + anyZero = true; + break; + } + } + if (anyZero) { + for (size_t i = 0; i < servers.size(); i++) { + if (servers[i].sortOrder == 0) { + servers[i].sortOrder = static_cast(i) + 1; + } + } + } + + sortServers(); LOG_DBG("OPS", "Loaded %zu OPDS servers from file", servers.size()); if (needsResave) { - LOG_DBG("OPS", "Resaving JSON with obfuscated passwords"); + LOG_DBG("OPS", "Resaving JSON with sort_order / obfuscated passwords"); saveToFile(); } return true; @@ -149,6 +170,7 @@ bool OpdsServerStore::migrateFromSettings() { server.url = SETTINGS.opdsServerUrl; server.username = SETTINGS.opdsUsername; server.password = SETTINGS.opdsPassword; + server.sortOrder = 1; servers.push_back(std::move(server)); if (saveToFile()) { @@ -171,7 +193,15 @@ bool OpdsServerStore::addServer(const OpdsServer& server) { } servers.push_back(server); - LOG_DBG("OPS", "Added server: %s", server.name.c_str()); + if (servers.back().sortOrder == 0) { + int maxOrder = 0; + for (size_t i = 0; i + 1 < servers.size(); i++) { + maxOrder = std::max(maxOrder, servers[i].sortOrder); + } + servers.back().sortOrder = maxOrder + 1; + } + LOG_DBG("OPS", "Added server: %s (order=%d)", server.name.c_str(), servers.back().sortOrder); + sortServers(); return saveToFile(); } @@ -181,7 +211,8 @@ bool OpdsServerStore::updateServer(size_t index, const OpdsServer& server) { } servers[index] = server; - LOG_DBG("OPS", "Updated server: %s", server.name.c_str()); + sortServers(); + LOG_DBG("OPS", "Updated server: %s (order=%d)", server.name.c_str(), server.sortOrder); return saveToFile(); } @@ -201,3 +232,30 @@ const OpdsServer* OpdsServerStore::getServer(size_t index) const { } return &servers[index]; } + +bool OpdsServerStore::moveServer(size_t index, int direction) { + if (index >= servers.size()) return false; + + size_t target; + if (direction < 0) { + if (index == 0) return false; + target = index - 1; + } else { + if (index + 1 >= servers.size()) return false; + target = index + 1; + } + + std::swap(servers[index].sortOrder, servers[target].sortOrder); + sortServers(); + return saveToFile(); +} + +void OpdsServerStore::sortServers() { + std::sort(servers.begin(), servers.end(), [](const OpdsServer& a, const OpdsServer& b) { + if (a.sortOrder != b.sortOrder) return a.sortOrder < b.sortOrder; + const auto& nameA = a.name.empty() ? a.url : a.name; + const auto& nameB = b.name.empty() ? b.url : b.name; + return std::lexicographical_compare(nameA.begin(), nameA.end(), nameB.begin(), nameB.end(), + [](char ca, char cb) { return tolower(ca) < tolower(cb); }); + }); +} diff --git a/src/OpdsServerStore.h b/src/OpdsServerStore.h index 49e86a8a..43acce14 100644 --- a/src/OpdsServerStore.h +++ b/src/OpdsServerStore.h @@ -8,6 +8,7 @@ struct OpdsServer { std::string username; std::string password; // Plaintext in memory; obfuscated with hardware key on disk std::string downloadPath = "/"; + int sortOrder = 0; // Lower values appear first; ties broken alphabetically by name }; /** @@ -36,6 +37,7 @@ class OpdsServerStore { bool addServer(const OpdsServer& server); bool updateServer(size_t index, const OpdsServer& server); bool removeServer(size_t index); + bool moveServer(size_t index, int direction); const std::vector& getServers() const { return servers; } const OpdsServer* getServer(size_t index) const; @@ -47,6 +49,9 @@ class OpdsServerStore { * Called once during first load if no opds.json exists. */ bool migrateFromSettings(); + + private: + void sortServers(); }; #define OPDS_STORE OpdsServerStore::getInstance() diff --git a/src/activities/settings/OpdsSettingsActivity.cpp b/src/activities/settings/OpdsSettingsActivity.cpp index 1346809b..ba1fbb8e 100644 --- a/src/activities/settings/OpdsSettingsActivity.cpp +++ b/src/activities/settings/OpdsSettingsActivity.cpp @@ -4,18 +4,20 @@ #include #include +#include #include "MappedInputManager.h" #include "OpdsServerStore.h" #include "activities/util/DirectoryPickerActivity.h" #include "activities/util/KeyboardEntryActivity.h" +#include "activities/util/NumericStepperActivity.h" #include "components/UITheme.h" #include "fontIds.h" namespace { -// Editable fields: Name, URL, Username, Password, Download Path. +// Editable fields: Position, Name, URL, Username, Password, Download Path. // Existing servers also show a Delete option (BASE_ITEMS + 1). -constexpr int BASE_ITEMS = 5; +constexpr int BASE_ITEMS = 6; } // namespace int OpdsSettingsActivity::getMenuItemCount() const { @@ -75,17 +77,38 @@ void OpdsSettingsActivity::loop() { void OpdsSettingsActivity::saveServer() { if (isNewServer) { OPDS_STORE.addServer(editServer); - // After the first field is saved, promote to an existing server so - // subsequent field edits update in-place rather than creating duplicates. isNewServer = false; - serverIndex = static_cast(OPDS_STORE.getCount()) - 1; } else { OPDS_STORE.updateServer(static_cast(serverIndex), editServer); } + + // Re-locate our server after add/update may have re-sorted the vector + const auto& servers = OPDS_STORE.getServers(); + for (size_t i = 0; i < servers.size(); i++) { + if (servers[i].url == editServer.url && servers[i].name == editServer.name) { + serverIndex = static_cast(i); + break; + } + } } void OpdsSettingsActivity::handleSelection() { if (selectedIndex == 0) { + // Position (sort order) + exitActivity(); + enterNewActivity(new NumericStepperActivity( + renderer, mappedInput, tr(STR_POSITION), editServer.sortOrder, 1, 99, + [this](int value) { + editServer.sortOrder = value; + saveServer(); + exitActivity(); + requestUpdate(); + }, + [this]() { + exitActivity(); + requestUpdate(); + })); + } else if (selectedIndex == 1) { // Server Name exitActivity(); enterNewActivity(new KeyboardEntryActivity( @@ -100,7 +123,7 @@ void OpdsSettingsActivity::handleSelection() { exitActivity(); requestUpdate(); })); - } else if (selectedIndex == 1) { + } else if (selectedIndex == 2) { // Server URL exitActivity(); enterNewActivity(new KeyboardEntryActivity( @@ -115,7 +138,7 @@ void OpdsSettingsActivity::handleSelection() { exitActivity(); requestUpdate(); })); - } else if (selectedIndex == 2) { + } else if (selectedIndex == 3) { // Username exitActivity(); enterNewActivity(new KeyboardEntryActivity( @@ -130,7 +153,7 @@ void OpdsSettingsActivity::handleSelection() { exitActivity(); requestUpdate(); })); - } else if (selectedIndex == 3) { + } else if (selectedIndex == 4) { // Password exitActivity(); enterNewActivity(new KeyboardEntryActivity( @@ -145,7 +168,7 @@ void OpdsSettingsActivity::handleSelection() { exitActivity(); requestUpdate(); })); - } else if (selectedIndex == 4) { + } else if (selectedIndex == 5) { // Download Path exitActivity(); enterNewActivity(new DirectoryPickerActivity( @@ -162,7 +185,7 @@ void OpdsSettingsActivity::handleSelection() { requestUpdate(); }, editServer.downloadPath)); - } else if (selectedIndex == 5 && !isNewServer) { + } else if (selectedIndex == 6 && !isNewServer) { // Delete server OPDS_STORE.removeServer(static_cast(serverIndex)); onBack(); @@ -184,8 +207,8 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) { const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; const int menuItems = getMenuItemCount(); - const StrId fieldNames[] = {StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL, StrId::STR_USERNAME, - StrId::STR_PASSWORD, StrId::STR_DOWNLOAD_PATH}; + const StrId fieldNames[] = {StrId::STR_POSITION, StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL, + StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_DOWNLOAD_PATH}; GUI.drawList( renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast(selectedIndex), @@ -198,14 +221,16 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) { nullptr, nullptr, [this](int index) { if (index == 0) { - return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name; + return std::to_string(editServer.sortOrder); } else if (index == 1) { - return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url; + return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name; } else if (index == 2) { - return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username; + return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url; } else if (index == 3) { - return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******"); + return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username; } else if (index == 4) { + return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******"); + } else if (index == 5) { return editServer.downloadPath; } return std::string(""); diff --git a/src/activities/util/NumericStepperActivity.cpp b/src/activities/util/NumericStepperActivity.cpp new file mode 100644 index 00000000..060db065 --- /dev/null +++ b/src/activities/util/NumericStepperActivity.cpp @@ -0,0 +1,99 @@ +#include "NumericStepperActivity.h" + +#include +#include + +#include +#include + +#include "MappedInputManager.h" +#include "components/UITheme.h" +#include "fontIds.h" + +void NumericStepperActivity::onEnter() { + Activity::onEnter(); + requestUpdate(); +} + +void NumericStepperActivity::onExit() { Activity::onExit(); } + +void NumericStepperActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + if (onCancel) onCancel(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + if (onComplete) onComplete(value); + return; + } + + // Side buttons: ±1 + if (mappedInput.wasPressed(MappedInputManager::Button::Up)) { + if (value < maxValue) { + value++; + requestUpdate(); + } + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Down)) { + if (value > minValue) { + value--; + requestUpdate(); + } + return; + } + + // Front face buttons: ±10 + if (mappedInput.wasPressed(MappedInputManager::Button::Right)) { + value = std::min(value + 10, maxValue); + requestUpdate(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Left)) { + value = std::max(value - 10, minValue); + requestUpdate(); + return; + } +} + +void NumericStepperActivity::render(Activity::RenderLock&&) { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const int lineHeight12 = renderer.getLineHeight(UI_12_FONT_ID); + + const auto& metrics = UITheme::getInstance().getMetrics(); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, title.c_str()); + + char valueStr[16]; + snprintf(valueStr, sizeof(valueStr), "%d", value); + + const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, valueStr); + const int startX = (pageWidth - textWidth) / 2; + const int valueY = metrics.topPadding + metrics.headerHeight + 60; + + constexpr int highlightPad = 10; + renderer.fillRoundedRect(startX - highlightPad, valueY - 4, textWidth + highlightPad * 2, lineHeight12 + 8, 6, + Color::LightGray); + renderer.drawText(UI_12_FONT_ID, startX, valueY, valueStr, true); + + const int arrowX = pageWidth / 2; + const int arrowUpY = valueY - 20; + const int arrowDownY = valueY + lineHeight12 + 12; + constexpr int arrowSize = 6; + for (int row = 0; row < arrowSize; row++) { + renderer.drawLine(arrowX - row, arrowUpY + row, arrowX + row, arrowUpY + row); + } + for (int row = 0; row < arrowSize; row++) { + renderer.drawLine(arrowX - row, arrowDownY + arrowSize - 1 - row, arrowX + row, arrowDownY + arrowSize - 1 - row); + } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SAVE), "-10", "+10"); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + GUI.drawSideButtonHints(renderer, "+1", "-1"); + + renderer.displayBuffer(); +} diff --git a/src/activities/util/NumericStepperActivity.h b/src/activities/util/NumericStepperActivity.h new file mode 100644 index 00000000..dc687132 --- /dev/null +++ b/src/activities/util/NumericStepperActivity.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include + +#include "activities/Activity.h" + +/** + * Reusable numeric stepper for integer value entry. + * Side buttons (Up/Down) step by 1, face buttons (Left/Right) step by 10. + * Value is clamped within [min, max]. + */ +class NumericStepperActivity final : public Activity { + public: + using OnCompleteCallback = std::function; + using OnCancelCallback = std::function; + + explicit NumericStepperActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string title, + int initialValue, int minValue, int maxValue, OnCompleteCallback onComplete, + OnCancelCallback onCancel) + : Activity("NumericStepper", renderer, mappedInput), + title(std::move(title)), + value(initialValue), + minValue(minValue), + maxValue(maxValue), + onComplete(std::move(onComplete)), + onCancel(std::move(onCancel)) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(Activity::RenderLock&&) override; + + private: + std::string title; + int value; + int minValue; + int maxValue; + OnCompleteCallback onComplete; + OnCancelCallback onCancel; +}; diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index c013a211..bad8b633 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -161,6 +161,7 @@ void CrossPointWebServer::begin() { server->on("/api/opds", HTTP_GET, [this] { handleGetOpdsServers(); }); server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); }); server->on("/api/opds/delete", HTTP_POST, [this] { handleDeleteOpdsServer(); }); + server->on("/api/opds/reorder", HTTP_POST, [this] { handleReorderOpdsServer(); }); server->onNotFound([this] { handleNotFound(); }); LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap()); @@ -1182,6 +1183,7 @@ void CrossPointWebServer::handleGetOpdsServers() const { doc["url"] = servers[i].url; doc["username"] = servers[i].username; doc["hasPassword"] = !servers[i].password.empty(); + doc["sortOrder"] = servers[i].sortOrder; const size_t written = serializeJson(doc, output, outputSize); if (written >= outputSize) continue; @@ -1223,9 +1225,11 @@ void CrossPointWebServer::handlePostOpdsServer() { server->send(400, "text/plain", "Invalid server index"); return; } - if (!hasPasswordField) { - const auto* existing = OPDS_STORE.getServer(static_cast(idx)); - if (existing) password = existing->password; + const auto* existing = OPDS_STORE.getServer(static_cast(idx)); + if (existing) { + if (!hasPasswordField) password = existing->password; + opdsServer.downloadPath = existing->downloadPath; + opdsServer.sortOrder = existing->sortOrder; } opdsServer.password = password; OPDS_STORE.updateServer(static_cast(idx), opdsServer); @@ -1273,6 +1277,46 @@ void CrossPointWebServer::handleDeleteOpdsServer() { server->send(200, "text/plain", "OK"); } +void CrossPointWebServer::handleReorderOpdsServer() { + if (!server->hasArg("plain")) { + server->send(400, "text/plain", "Missing JSON body"); + return; + } + + const String body = server->arg("plain"); + JsonDocument doc; + const DeserializationError err = deserializeJson(doc, body); + if (err) { + server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str()); + return; + } + + if (!doc["index"].is()) { + server->send(400, "text/plain", "Missing index"); + return; + } + + const int idx = doc["index"].as(); + const String direction = doc["direction"] | ""; + int dir = 0; + if (direction == "up") { + dir = -1; + } else if (direction == "down") { + dir = 1; + } else { + server->send(400, "text/plain", "Invalid direction (use \"up\" or \"down\")"); + return; + } + + if (!OPDS_STORE.moveServer(static_cast(idx), dir)) { + server->send(400, "text/plain", "Cannot move server"); + return; + } + + LOG_DBG("WEB", "Reordered OPDS server at index %d (%s)", idx, direction.c_str()); + server->send(200, "text/plain", "OK"); +} + // WebSocket callback trampoline void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { if (wsInstance) { diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 45cd786f..72f0345b 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -110,4 +110,5 @@ class CrossPointWebServer { void handleGetOpdsServers() const; void handlePostOpdsServer(); void handleDeleteOpdsServer(); + void handleReorderOpdsServer(); }; diff --git a/src/network/html/SettingsPage.html b/src/network/html/SettingsPage.html index cc7522e9..fd2abdd5 100644 --- a/src/network/html/SettingsPage.html +++ b/src/network/html/SettingsPage.html @@ -457,9 +457,11 @@ // --- OPDS Server Management --- let opdsServers = []; - function renderOpdsServer(srv, idx) { + function renderOpdsServer(srv, idx, total) { const isNew = idx === -1; const id = isNew ? 'new' : idx; + const isFirst = idx === 0; + const isLast = idx === total - 1; return '
' + '
' + 'Server Name' + @@ -480,6 +482,8 @@ '
' + '' + (isNew ? '' : '') + + (!isNew && !isFirst ? '' : '') + + (!isNew && !isLast ? '' : '') + '
' + '
'; } @@ -491,8 +495,9 @@ if (opdsServers.length === 0) { html += '

No OPDS servers configured

'; } else { + var total = opdsServers.length; opdsServers.forEach(function(srv, idx) { - html += renderOpdsServer(srv, idx); + html += renderOpdsServer(srv, idx, total); }); } @@ -518,7 +523,7 @@ const card = container.querySelector('.card'); const addBtn = card.querySelector('.btn-add').parentElement; if (document.getElementById('opds-new')) return; - addBtn.insertAdjacentHTML('beforebegin', renderOpdsServer({name:'',url:'',username:'',hasPassword:false}, -1)); + addBtn.insertAdjacentHTML('beforebegin', renderOpdsServer({name:'',url:'',username:'',hasPassword:false}, -1, 0)); } async function saveOpdsServer(idx) { @@ -562,6 +567,20 @@ } } + async function reorderOpdsServer(idx, direction) { + try { + const resp = await fetch('/api/opds/reorder', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({index: idx, direction: direction}) + }); + if (!resp.ok) throw new Error(await resp.text()); + await loadOpdsServers(); + } catch (e) { + showMessage('Error: ' + e.message, true); + } + } + loadOpdsServers();