feat: add OPDS server reordering with sortOrder field and numeric stepper
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
This commit is contained in:
@@ -426,6 +426,7 @@ enum class StrId : uint16_t {
|
|||||||
STR_SAVE_HERE,
|
STR_SAVE_HERE,
|
||||||
STR_SELECT_FOLDER,
|
STR_SELECT_FOLDER,
|
||||||
STR_DOWNLOAD_PATH,
|
STR_DOWNLOAD_PATH,
|
||||||
|
STR_POSITION,
|
||||||
// Sentinel - must be last
|
// Sentinel - must be last
|
||||||
_COUNT
|
_COUNT
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "OPDS servery"
|
|||||||
STR_SAVE_HERE: "Uložit zde"
|
STR_SAVE_HERE: "Uložit zde"
|
||||||
STR_SELECT_FOLDER: "Vybrat složku"
|
STR_SELECT_FOLDER: "Vybrat složku"
|
||||||
STR_DOWNLOAD_PATH: "Cesta ke stažení"
|
STR_DOWNLOAD_PATH: "Cesta ke stažení"
|
||||||
|
STR_POSITION: "Pozice"
|
||||||
|
|||||||
@@ -390,3 +390,4 @@ STR_OPDS_SERVERS: "OPDS Servers"
|
|||||||
STR_SAVE_HERE: "Save Here"
|
STR_SAVE_HERE: "Save Here"
|
||||||
STR_SELECT_FOLDER: "Select Folder"
|
STR_SELECT_FOLDER: "Select Folder"
|
||||||
STR_DOWNLOAD_PATH: "Download Path"
|
STR_DOWNLOAD_PATH: "Download Path"
|
||||||
|
STR_POSITION: "Position"
|
||||||
|
|||||||
@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "Serveurs OPDS"
|
|||||||
STR_SAVE_HERE: "Enregistrer ici"
|
STR_SAVE_HERE: "Enregistrer ici"
|
||||||
STR_SELECT_FOLDER: "Sélectionner un dossier"
|
STR_SELECT_FOLDER: "Sélectionner un dossier"
|
||||||
STR_DOWNLOAD_PATH: "Chemin de téléchargement"
|
STR_DOWNLOAD_PATH: "Chemin de téléchargement"
|
||||||
|
STR_POSITION: "Position"
|
||||||
|
|||||||
@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "OPDS-Server"
|
|||||||
STR_SAVE_HERE: "Hier speichern"
|
STR_SAVE_HERE: "Hier speichern"
|
||||||
STR_SELECT_FOLDER: "Ordner auswählen"
|
STR_SELECT_FOLDER: "Ordner auswählen"
|
||||||
STR_DOWNLOAD_PATH: "Download-Pfad"
|
STR_DOWNLOAD_PATH: "Download-Pfad"
|
||||||
|
STR_POSITION: "Position"
|
||||||
|
|||||||
@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "Servidores OPDS"
|
|||||||
STR_SAVE_HERE: "Salvar aqui"
|
STR_SAVE_HERE: "Salvar aqui"
|
||||||
STR_SELECT_FOLDER: "Selecionar pasta"
|
STR_SELECT_FOLDER: "Selecionar pasta"
|
||||||
STR_DOWNLOAD_PATH: "Caminho de download"
|
STR_DOWNLOAD_PATH: "Caminho de download"
|
||||||
|
STR_POSITION: "Posição"
|
||||||
|
|||||||
@@ -328,3 +328,4 @@ STR_OPDS_SERVERS: "Servere OPDS"
|
|||||||
STR_SAVE_HERE: "Salvează aici"
|
STR_SAVE_HERE: "Salvează aici"
|
||||||
STR_SELECT_FOLDER: "Selectează dosar"
|
STR_SELECT_FOLDER: "Selectează dosar"
|
||||||
STR_DOWNLOAD_PATH: "Cale descărcare"
|
STR_DOWNLOAD_PATH: "Cale descărcare"
|
||||||
|
STR_POSITION: "Poziție"
|
||||||
|
|||||||
@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "Серверы OPDS"
|
|||||||
STR_SAVE_HERE: "Сохранить здесь"
|
STR_SAVE_HERE: "Сохранить здесь"
|
||||||
STR_SELECT_FOLDER: "Выбрать папку"
|
STR_SELECT_FOLDER: "Выбрать папку"
|
||||||
STR_DOWNLOAD_PATH: "Путь загрузки"
|
STR_DOWNLOAD_PATH: "Путь загрузки"
|
||||||
|
STR_POSITION: "Позиция"
|
||||||
|
|||||||
@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "Servidores OPDS"
|
|||||||
STR_SAVE_HERE: "Guardar aquí"
|
STR_SAVE_HERE: "Guardar aquí"
|
||||||
STR_SELECT_FOLDER: "Seleccionar carpeta"
|
STR_SELECT_FOLDER: "Seleccionar carpeta"
|
||||||
STR_DOWNLOAD_PATH: "Ruta de descarga"
|
STR_DOWNLOAD_PATH: "Ruta de descarga"
|
||||||
|
STR_POSITION: "Posición"
|
||||||
|
|||||||
@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "OPDS-servrar"
|
|||||||
STR_SAVE_HERE: "Spara här"
|
STR_SAVE_HERE: "Spara här"
|
||||||
STR_SELECT_FOLDER: "Välj mapp"
|
STR_SELECT_FOLDER: "Välj mapp"
|
||||||
STR_DOWNLOAD_PATH: "Nedladdningssökväg"
|
STR_DOWNLOAD_PATH: "Nedladdningssökväg"
|
||||||
|
STR_POSITION: "Position"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <esp_mac.h>
|
#include <esp_mac.h>
|
||||||
#include <mbedtls/base64.h>
|
#include <mbedtls/base64.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
@@ -81,6 +82,7 @@ bool OpdsServerStore::saveToFile() const {
|
|||||||
obj["username"] = server.username;
|
obj["username"] = server.username;
|
||||||
obj["password_obf"] = obfuscateToBase64(server.password);
|
obj["password_obf"] = obfuscateToBase64(server.password);
|
||||||
obj["download_path"] = server.downloadPath;
|
obj["download_path"] = server.downloadPath;
|
||||||
|
obj["sort_order"] = server.sortOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
String json;
|
String json;
|
||||||
@@ -116,13 +118,32 @@ bool OpdsServerStore::loadFromFile() {
|
|||||||
if (!server.password.empty()) needsResave = true;
|
if (!server.password.empty()) needsResave = true;
|
||||||
}
|
}
|
||||||
server.downloadPath = obj["download_path"] | std::string("/");
|
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));
|
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<int>(i) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sortServers();
|
||||||
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", servers.size());
|
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", servers.size());
|
||||||
|
|
||||||
if (needsResave) {
|
if (needsResave) {
|
||||||
LOG_DBG("OPS", "Resaving JSON with obfuscated passwords");
|
LOG_DBG("OPS", "Resaving JSON with sort_order / obfuscated passwords");
|
||||||
saveToFile();
|
saveToFile();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -149,6 +170,7 @@ bool OpdsServerStore::migrateFromSettings() {
|
|||||||
server.url = SETTINGS.opdsServerUrl;
|
server.url = SETTINGS.opdsServerUrl;
|
||||||
server.username = SETTINGS.opdsUsername;
|
server.username = SETTINGS.opdsUsername;
|
||||||
server.password = SETTINGS.opdsPassword;
|
server.password = SETTINGS.opdsPassword;
|
||||||
|
server.sortOrder = 1;
|
||||||
servers.push_back(std::move(server));
|
servers.push_back(std::move(server));
|
||||||
|
|
||||||
if (saveToFile()) {
|
if (saveToFile()) {
|
||||||
@@ -171,7 +193,15 @@ bool OpdsServerStore::addServer(const OpdsServer& server) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
servers.push_back(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();
|
return saveToFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +211,8 @@ bool OpdsServerStore::updateServer(size_t index, const OpdsServer& server) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
servers[index] = 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();
|
return saveToFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,3 +232,30 @@ const OpdsServer* OpdsServerStore::getServer(size_t index) const {
|
|||||||
}
|
}
|
||||||
return &servers[index];
|
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); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ struct OpdsServer {
|
|||||||
std::string username;
|
std::string username;
|
||||||
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
||||||
std::string downloadPath = "/";
|
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 addServer(const OpdsServer& server);
|
||||||
bool updateServer(size_t index, const OpdsServer& server);
|
bool updateServer(size_t index, const OpdsServer& server);
|
||||||
bool removeServer(size_t index);
|
bool removeServer(size_t index);
|
||||||
|
bool moveServer(size_t index, int direction);
|
||||||
|
|
||||||
const std::vector<OpdsServer>& getServers() const { return servers; }
|
const std::vector<OpdsServer>& getServers() const { return servers; }
|
||||||
const OpdsServer* getServer(size_t index) const;
|
const OpdsServer* getServer(size_t index) const;
|
||||||
@@ -47,6 +49,9 @@ class OpdsServerStore {
|
|||||||
* Called once during first load if no opds.json exists.
|
* Called once during first load if no opds.json exists.
|
||||||
*/
|
*/
|
||||||
bool migrateFromSettings();
|
bool migrateFromSettings();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void sortServers();
|
||||||
};
|
};
|
||||||
|
|
||||||
#define OPDS_STORE OpdsServerStore::getInstance()
|
#define OPDS_STORE OpdsServerStore::getInstance()
|
||||||
|
|||||||
@@ -4,18 +4,20 @@
|
|||||||
#include <I18n.h>
|
#include <I18n.h>
|
||||||
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "OpdsServerStore.h"
|
#include "OpdsServerStore.h"
|
||||||
#include "activities/util/DirectoryPickerActivity.h"
|
#include "activities/util/DirectoryPickerActivity.h"
|
||||||
#include "activities/util/KeyboardEntryActivity.h"
|
#include "activities/util/KeyboardEntryActivity.h"
|
||||||
|
#include "activities/util/NumericStepperActivity.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
namespace {
|
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).
|
// Existing servers also show a Delete option (BASE_ITEMS + 1).
|
||||||
constexpr int BASE_ITEMS = 5;
|
constexpr int BASE_ITEMS = 6;
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int OpdsSettingsActivity::getMenuItemCount() const {
|
int OpdsSettingsActivity::getMenuItemCount() const {
|
||||||
@@ -75,17 +77,38 @@ void OpdsSettingsActivity::loop() {
|
|||||||
void OpdsSettingsActivity::saveServer() {
|
void OpdsSettingsActivity::saveServer() {
|
||||||
if (isNewServer) {
|
if (isNewServer) {
|
||||||
OPDS_STORE.addServer(editServer);
|
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;
|
isNewServer = false;
|
||||||
serverIndex = static_cast<int>(OPDS_STORE.getCount()) - 1;
|
|
||||||
} else {
|
} else {
|
||||||
OPDS_STORE.updateServer(static_cast<size_t>(serverIndex), editServer);
|
OPDS_STORE.updateServer(static_cast<size_t>(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<int>(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void OpdsSettingsActivity::handleSelection() {
|
void OpdsSettingsActivity::handleSelection() {
|
||||||
if (selectedIndex == 0) {
|
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
|
// Server Name
|
||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new KeyboardEntryActivity(
|
enterNewActivity(new KeyboardEntryActivity(
|
||||||
@@ -100,7 +123,7 @@ void OpdsSettingsActivity::handleSelection() {
|
|||||||
exitActivity();
|
exitActivity();
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
}));
|
}));
|
||||||
} else if (selectedIndex == 1) {
|
} else if (selectedIndex == 2) {
|
||||||
// Server URL
|
// Server URL
|
||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new KeyboardEntryActivity(
|
enterNewActivity(new KeyboardEntryActivity(
|
||||||
@@ -115,7 +138,7 @@ void OpdsSettingsActivity::handleSelection() {
|
|||||||
exitActivity();
|
exitActivity();
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
}));
|
}));
|
||||||
} else if (selectedIndex == 2) {
|
} else if (selectedIndex == 3) {
|
||||||
// Username
|
// Username
|
||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new KeyboardEntryActivity(
|
enterNewActivity(new KeyboardEntryActivity(
|
||||||
@@ -130,7 +153,7 @@ void OpdsSettingsActivity::handleSelection() {
|
|||||||
exitActivity();
|
exitActivity();
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
}));
|
}));
|
||||||
} else if (selectedIndex == 3) {
|
} else if (selectedIndex == 4) {
|
||||||
// Password
|
// Password
|
||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new KeyboardEntryActivity(
|
enterNewActivity(new KeyboardEntryActivity(
|
||||||
@@ -145,7 +168,7 @@ void OpdsSettingsActivity::handleSelection() {
|
|||||||
exitActivity();
|
exitActivity();
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
}));
|
}));
|
||||||
} else if (selectedIndex == 4) {
|
} else if (selectedIndex == 5) {
|
||||||
// Download Path
|
// Download Path
|
||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new DirectoryPickerActivity(
|
enterNewActivity(new DirectoryPickerActivity(
|
||||||
@@ -162,7 +185,7 @@ void OpdsSettingsActivity::handleSelection() {
|
|||||||
requestUpdate();
|
requestUpdate();
|
||||||
},
|
},
|
||||||
editServer.downloadPath));
|
editServer.downloadPath));
|
||||||
} else if (selectedIndex == 5 && !isNewServer) {
|
} else if (selectedIndex == 6 && !isNewServer) {
|
||||||
// Delete server
|
// Delete server
|
||||||
OPDS_STORE.removeServer(static_cast<size_t>(serverIndex));
|
OPDS_STORE.removeServer(static_cast<size_t>(serverIndex));
|
||||||
onBack();
|
onBack();
|
||||||
@@ -184,8 +207,8 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) {
|
|||||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||||
const int menuItems = getMenuItemCount();
|
const int menuItems = getMenuItemCount();
|
||||||
|
|
||||||
const StrId fieldNames[] = {StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL, StrId::STR_USERNAME,
|
const StrId fieldNames[] = {StrId::STR_POSITION, StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL,
|
||||||
StrId::STR_PASSWORD, StrId::STR_DOWNLOAD_PATH};
|
StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_DOWNLOAD_PATH};
|
||||||
|
|
||||||
GUI.drawList(
|
GUI.drawList(
|
||||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast<int>(selectedIndex),
|
renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast<int>(selectedIndex),
|
||||||
@@ -198,14 +221,16 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) {
|
|||||||
nullptr, nullptr,
|
nullptr, nullptr,
|
||||||
[this](int index) {
|
[this](int index) {
|
||||||
if (index == 0) {
|
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) {
|
} 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) {
|
} 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) {
|
} 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) {
|
} else if (index == 4) {
|
||||||
|
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
||||||
|
} else if (index == 5) {
|
||||||
return editServer.downloadPath;
|
return editServer.downloadPath;
|
||||||
}
|
}
|
||||||
return std::string("");
|
return std::string("");
|
||||||
|
|||||||
99
src/activities/util/NumericStepperActivity.cpp
Normal file
99
src/activities/util/NumericStepperActivity.cpp
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#include "NumericStepperActivity.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <I18n.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
#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();
|
||||||
|
}
|
||||||
42
src/activities/util/NumericStepperActivity.h
Normal file
42
src/activities/util/NumericStepperActivity.h
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#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<void(int)>;
|
||||||
|
using OnCancelCallback = std::function<void()>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -161,6 +161,7 @@ void CrossPointWebServer::begin() {
|
|||||||
server->on("/api/opds", HTTP_GET, [this] { handleGetOpdsServers(); });
|
server->on("/api/opds", HTTP_GET, [this] { handleGetOpdsServers(); });
|
||||||
server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); });
|
server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); });
|
||||||
server->on("/api/opds/delete", HTTP_POST, [this] { handleDeleteOpdsServer(); });
|
server->on("/api/opds/delete", HTTP_POST, [this] { handleDeleteOpdsServer(); });
|
||||||
|
server->on("/api/opds/reorder", HTTP_POST, [this] { handleReorderOpdsServer(); });
|
||||||
|
|
||||||
server->onNotFound([this] { handleNotFound(); });
|
server->onNotFound([this] { handleNotFound(); });
|
||||||
LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap());
|
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["url"] = servers[i].url;
|
||||||
doc["username"] = servers[i].username;
|
doc["username"] = servers[i].username;
|
||||||
doc["hasPassword"] = !servers[i].password.empty();
|
doc["hasPassword"] = !servers[i].password.empty();
|
||||||
|
doc["sortOrder"] = servers[i].sortOrder;
|
||||||
|
|
||||||
const size_t written = serializeJson(doc, output, outputSize);
|
const size_t written = serializeJson(doc, output, outputSize);
|
||||||
if (written >= outputSize) continue;
|
if (written >= outputSize) continue;
|
||||||
@@ -1223,9 +1225,11 @@ void CrossPointWebServer::handlePostOpdsServer() {
|
|||||||
server->send(400, "text/plain", "Invalid server index");
|
server->send(400, "text/plain", "Invalid server index");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasPasswordField) {
|
const auto* existing = OPDS_STORE.getServer(static_cast<size_t>(idx));
|
||||||
const auto* existing = OPDS_STORE.getServer(static_cast<size_t>(idx));
|
if (existing) {
|
||||||
if (existing) password = existing->password;
|
if (!hasPasswordField) password = existing->password;
|
||||||
|
opdsServer.downloadPath = existing->downloadPath;
|
||||||
|
opdsServer.sortOrder = existing->sortOrder;
|
||||||
}
|
}
|
||||||
opdsServer.password = password;
|
opdsServer.password = password;
|
||||||
OPDS_STORE.updateServer(static_cast<size_t>(idx), opdsServer);
|
OPDS_STORE.updateServer(static_cast<size_t>(idx), opdsServer);
|
||||||
@@ -1273,6 +1277,46 @@ void CrossPointWebServer::handleDeleteOpdsServer() {
|
|||||||
server->send(200, "text/plain", "OK");
|
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<int>()) {
|
||||||
|
server->send(400, "text/plain", "Missing index");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int idx = doc["index"].as<int>();
|
||||||
|
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<size_t>(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
|
// WebSocket callback trampoline
|
||||||
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
|
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
|
||||||
if (wsInstance) {
|
if (wsInstance) {
|
||||||
|
|||||||
@@ -110,4 +110,5 @@ class CrossPointWebServer {
|
|||||||
void handleGetOpdsServers() const;
|
void handleGetOpdsServers() const;
|
||||||
void handlePostOpdsServer();
|
void handlePostOpdsServer();
|
||||||
void handleDeleteOpdsServer();
|
void handleDeleteOpdsServer();
|
||||||
|
void handleReorderOpdsServer();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -457,9 +457,11 @@
|
|||||||
// --- OPDS Server Management ---
|
// --- OPDS Server Management ---
|
||||||
let opdsServers = [];
|
let opdsServers = [];
|
||||||
|
|
||||||
function renderOpdsServer(srv, idx) {
|
function renderOpdsServer(srv, idx, total) {
|
||||||
const isNew = idx === -1;
|
const isNew = idx === -1;
|
||||||
const id = isNew ? 'new' : idx;
|
const id = isNew ? 'new' : idx;
|
||||||
|
const isFirst = idx === 0;
|
||||||
|
const isLast = idx === total - 1;
|
||||||
return '<div class="opds-server" id="opds-' + id + '">' +
|
return '<div class="opds-server" id="opds-' + id + '">' +
|
||||||
'<div class="setting-row">' +
|
'<div class="setting-row">' +
|
||||||
'<span class="setting-name">Server Name</span>' +
|
'<span class="setting-name">Server Name</span>' +
|
||||||
@@ -480,6 +482,8 @@
|
|||||||
'<div class="opds-actions">' +
|
'<div class="opds-actions">' +
|
||||||
'<button class="btn-small btn-save-server" onclick="saveOpdsServer(' + idx + ')">Save</button>' +
|
'<button class="btn-small btn-save-server" onclick="saveOpdsServer(' + idx + ')">Save</button>' +
|
||||||
(isNew ? '' : '<button class="btn-small btn-delete" onclick="deleteOpdsServer(' + idx + ')">Delete</button>') +
|
(isNew ? '' : '<button class="btn-small btn-delete" onclick="deleteOpdsServer(' + idx + ')">Delete</button>') +
|
||||||
|
(!isNew && !isFirst ? '<button class="btn-small" onclick="reorderOpdsServer(' + idx + ',\'up\')" title="Move up">\u25B2</button>' : '') +
|
||||||
|
(!isNew && !isLast ? '<button class="btn-small" onclick="reorderOpdsServer(' + idx + ',\'down\')" title="Move down">\u25BC</button>' : '') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
@@ -491,8 +495,9 @@
|
|||||||
if (opdsServers.length === 0) {
|
if (opdsServers.length === 0) {
|
||||||
html += '<p style="color:var(--label-color);text-align:center;">No OPDS servers configured</p>';
|
html += '<p style="color:var(--label-color);text-align:center;">No OPDS servers configured</p>';
|
||||||
} else {
|
} else {
|
||||||
|
var total = opdsServers.length;
|
||||||
opdsServers.forEach(function(srv, idx) {
|
opdsServers.forEach(function(srv, idx) {
|
||||||
html += renderOpdsServer(srv, idx);
|
html += renderOpdsServer(srv, idx, total);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,7 +523,7 @@
|
|||||||
const card = container.querySelector('.card');
|
const card = container.querySelector('.card');
|
||||||
const addBtn = card.querySelector('.btn-add').parentElement;
|
const addBtn = card.querySelector('.btn-add').parentElement;
|
||||||
if (document.getElementById('opds-new')) return;
|
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) {
|
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();
|
loadOpdsServers();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user