From 75d7764643b16446f6a01652f0ed8544d31f5746 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 23:59:17 -0500 Subject: [PATCH] [Client] Keep Archidekt navigation tied to its active request (#7723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Keep Archidekt navigation tied to its active request [skip ci] * [Client] Keep Archidekt listing pagination after a failed navigation Only update list mode and the next-page link when a listing or deck response is accepted, so a failed deck navigation leaves the existing listing scrollable. Retry still re-issues the failed request. --------- Co-authored-by: Lukas Brübach --- .../tabs/api/archidekt/tab_archidekt.cpp | 155 +++++++++++++----- .../tabs/api/archidekt/tab_archidekt.h | 23 ++- 2 files changed, 136 insertions(+), 42 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 7f7a2d3fd..9f7369435 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -60,8 +61,6 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor) { // Initialize network networkManager = NetworkManagerFactory::create(this); - networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); - connect(networkManager, &QNetworkAccessManager::finished, this, &TabArchidekt::processApiJson); // Initialize debounce timer searchDebounceTimer = new QTimer(this); @@ -77,6 +76,48 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor) getTopDecks(); } +TabArchidekt::~TabArchidekt() +{ + cancelRequest(); +} + +void TabArchidekt::cancelRequest() +{ + const QPointer previousReply = activeReply; + activeReply = nullptr; + isLoadingMore = false; + if (previousReply) { + disconnect(previousReply, nullptr, this, nullptr); + previousReply->abort(); + previousReply->deleteLater(); + } +} + +void TabArchidekt::startRequest(QUrl url, RequestKind kind, int page) +{ + cancelRequest(); + requestUrl = url; + requestKind = kind; + requestedPage = page; + isLoadingMore = true; + retryButton->hide(); + requestStatusLabel->setText(kind == RequestKind::Deck ? tr("Loading deck…") : tr("Loading decks…")); + requestStatusLabel->show(); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); + auto *reply = networkManager->get(request); + activeReply = reply; + connect(reply, &QNetworkReply::finished, this, [this, reply]() { processApiJson(reply); }); +} + +void TabArchidekt::showRequestError(const QString &error) +{ + requestStatusLabel->setText(tr("Unable to load decks: %1").arg(error)); + requestStatusLabel->show(); + retryButton->show(); +} + void TabArchidekt::initializeUi() { // Main container @@ -194,6 +235,18 @@ void TabArchidekt::initializeUi() mainLayout->addWidget(primaryToolbar); mainLayout->addWidget(secondaryToolbar); + + auto *requestStatusRow = new QHBoxLayout; + requestStatusLabel = new QLabel(container); + requestStatusLabel->setTextFormat(Qt::PlainText); + requestStatusLabel->setWordWrap(true); + retryButton = new QPushButton(tr("Retry"), container); + retryButton->hide(); + connect(retryButton, &QPushButton::clicked, this, + [this]() { startRequest(requestUrl, requestKind, requestedPage); }); + requestStatusRow->addWidget(requestStatusLabel, 1); + requestStatusRow->addWidget(retryButton); + mainLayout->addLayout(requestStatusRow); mainLayout->addWidget(scrollArea); setCentralWidget(container); @@ -404,6 +457,7 @@ void TabArchidekt::retranslateUi() minDeckSizeLogicCombo->setItemText(0, tr("Exact")); searchButton->setText(tr("Search")); + retryButton->setText(tr("Retry")); settingsButton->setToolTip(tr("Display Settings")); } @@ -517,82 +571,95 @@ QString TabArchidekt::buildSearchUrl() void TabArchidekt::doSearch() { - // Reset to first page on new search - currentPage = 1; - // We're searching, so we'll be in list mode - isListMode = true; // Don't debounce - only called by explicit user actions now doSearchImmediate(); } void TabArchidekt::doSearchImmediate() { - QString url = buildSearchUrl(); - QNetworkRequest req{QUrl(url)}; - req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - networkManager->get(req); + searchDebounceTimer->stop(); + currentPage = 1; + startRequest(QUrl(buildSearchUrl()), RequestKind::Search); } void TabArchidekt::loadNextPage() { - if (isLoadingMore) { + if (activeReply || !isListMode || !listingsWidget || nextPageUrl.isEmpty()) { return; } - isLoadingMore = true; - currentPage++; - - QString url = buildSearchUrl(); - QNetworkRequest req{QUrl(url)}; - req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - networkManager->get(req); + // Continue the accepted search, even if the user has edited filters without submitting them. + // Advance currentPage only after this response has been accepted, so failures can retry it. + startRequest(nextPageUrl, RequestKind::NextPage, currentPage + 1); } void TabArchidekt::actNavigatePage(QString url) { - QNetworkRequest request{QUrl(url)}; - request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - networkManager->get(request); + searchDebounceTimer->stop(); + startRequest(QUrl(url), RequestKind::Deck); } void TabArchidekt::getTopDecks() { - currentPage = 1; - QNetworkRequest request{QUrl(buildSearchUrl())}; - request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - networkManager->get(request); + doSearchImmediate(); } void TabArchidekt::processApiJson(QNetworkReply *reply) { - if (reply->error() != QNetworkReply::NoError) { - isLoadingMore = false; + if (reply != activeReply) { reply->deleteLater(); return; } + activeReply = nullptr; + isLoadingMore = false; + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + showRequestError(reply->errorString()); + return; + } QByteArray responseData = reply->readAll(); - QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData); + QJsonParseError parseError; + QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData, &parseError); - if (!jsonDoc.isObject()) { - isLoadingMore = false; - reply->deleteLater(); + if (parseError.error != QJsonParseError::NoError || !jsonDoc.isObject()) { + showRequestError(tr("Invalid response from Archidekt.")); return; } QJsonObject jsonObj = jsonDoc.object(); - QString responseUrl = reply->url().toString(); - - if (responseUrl.startsWith("https://archidekt.com/api/decks/v3/")) { + if (requestKind != RequestKind::Deck) { + if (!jsonObj.value("results").isArray() || + (!jsonObj.value("next").isNull() && !jsonObj.value("next").isString())) { + showRequestError(tr("Invalid deck listing from Archidekt.")); + return; + } + for (const auto &value : jsonObj.value("results").toArray()) { + if (!value.isObject() || value.toObject().value("id").toInt() <= 0) { + showRequestError(tr("Invalid deck listing from Archidekt.")); + return; + } + } + currentPage = requestedPage; + const QString next = jsonObj.value("next").toString(); + nextPageUrl = next.isEmpty() ? QUrl() : reply->url().resolved(QUrl(next)); + // An empty or self-repeating page cannot make forward progress. + if (jsonObj.value("results").toArray().isEmpty() || nextPageUrl == reply->url()) { + nextPageUrl.clear(); + } + requestStatusLabel->setText( + currentPage == 1 && jsonObj.value("results").toArray().isEmpty() ? tr("No decks found.") : QString()); + requestStatusLabel->setVisible(!requestStatusLabel->text().isEmpty()); processTopDecksResponse(jsonObj); - } else if (responseUrl.startsWith("https://archidekt.com/api/decks/")) { - processDeckResponse(jsonObj); } else { - prettyPrintJson(jsonObj, 4); + if (jsonObj.value("id").toInt() <= 0 || !jsonObj.value("cards").isArray()) { + showRequestError(tr("Invalid deck from Archidekt.")); + return; + } + requestStatusLabel->hide(); + processDeckResponse(jsonObj); } - - isLoadingMore = false; - reply->deleteLater(); } void TabArchidekt::processTopDecksResponse(QJsonObject reply) @@ -600,8 +667,12 @@ void TabArchidekt::processTopDecksResponse(QJsonObject reply) ArchidektDeckListingApiResponse deckData; deckData.fromJson(reply); + // An accepted listing restores list mode; a failed deck navigation leaves the listing usable. + isListMode = true; + // New search → clear everything - if (currentPage == 1) { + if (requestKind == RequestKind::Search) { + listingsWidget = nullptr; QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate); listingsWidget = new ArchidektApiResponseDeckListingsDisplayWidget(resultsContainer, deckData, cardSizeSlider); @@ -626,8 +697,10 @@ void TabArchidekt::processDeckResponse(QJsonObject reply) // We're in single deck mode - disable infinite scroll isListMode = false; + nextPageUrl.clear(); // Clear existing results for single deck view + listingsWidget = nullptr; QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate); auto display = new ArchidektApiResponseDeckDisplayWidget(resultsContainer, deckData, cardSizeSlider); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h index c74380b63..81c9502df 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h @@ -8,8 +8,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -55,6 +57,7 @@ public: * connects signals and slots, and triggers an initial fetch of top decks. */ explicit TabArchidekt(TabSupervisor *_tabSupervisor); + ~TabArchidekt() override; /** * @brief Update all UI text to reflect the current language or translation @@ -190,6 +193,17 @@ protected: bool eventFilter(QObject *obj, QEvent *event) override; private: + enum class RequestKind + { + Search, + NextPage, + Deck + }; + + void startRequest(QUrl url, RequestKind kind, int page = 1); + void cancelRequest(); + void showRequestError(const QString &error); + /** * @brief Initialize the main UI layout and toolbars * @@ -236,7 +250,14 @@ private: int currentPage; ///< Current page number for infinite scroll bool isLoadingMore; ///< Flag to prevent multiple simultaneous page loads bool isListMode; - ArchidektApiResponseDeckListingsDisplayWidget *listingsWidget = nullptr; + QPointer activeReply; + RequestKind requestKind = RequestKind::Search; + QUrl requestUrl; + QUrl nextPageUrl; + int requestedPage = 1; + QPointer listingsWidget; + QLabel *requestStatusLabel; + QPushButton *retryButton; // --------------------------------------------------------------------- // Layout Containers