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:
@@ -7,6 +7,7 @@
|
||||
#include <esp_mac.h>
|
||||
#include <mbedtls/base64.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#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<int>(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); });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<OpdsServer>& 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()
|
||||
|
||||
@@ -4,18 +4,20 @@
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#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<int>(OPDS_STORE.getCount()) - 1;
|
||||
} else {
|
||||
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() {
|
||||
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<size_t>(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<int>(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("");
|
||||
|
||||
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_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<size_t>(idx));
|
||||
if (existing) password = existing->password;
|
||||
const auto* existing = OPDS_STORE.getServer(static_cast<size_t>(idx));
|
||||
if (existing) {
|
||||
if (!hasPasswordField) password = existing->password;
|
||||
opdsServer.downloadPath = existing->downloadPath;
|
||||
opdsServer.sortOrder = existing->sortOrder;
|
||||
}
|
||||
opdsServer.password = password;
|
||||
OPDS_STORE.updateServer(static_cast<size_t>(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<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
|
||||
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
|
||||
if (wsInstance) {
|
||||
|
||||
@@ -110,4 +110,5 @@ class CrossPointWebServer {
|
||||
void handleGetOpdsServers() const;
|
||||
void handlePostOpdsServer();
|
||||
void handleDeleteOpdsServer();
|
||||
void handleReorderOpdsServer();
|
||||
};
|
||||
|
||||
@@ -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 '<div class="opds-server" id="opds-' + id + '">' +
|
||||
'<div class="setting-row">' +
|
||||
'<span class="setting-name">Server Name</span>' +
|
||||
@@ -480,6 +482,8 @@
|
||||
'<div class="opds-actions">' +
|
||||
'<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 && !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>';
|
||||
}
|
||||
@@ -491,8 +495,9 @@
|
||||
if (opdsServers.length === 0) {
|
||||
html += '<p style="color:var(--label-color);text-align:center;">No OPDS servers configured</p>';
|
||||
} 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();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user