Qt desktop: add manage (edit/delete) and tag autocomplete

Brings the Qt uploader to parity with the web /manage page and the Android
app. Adds a stacked Manage page (reached from the post page) that lists
posts via GET /api/photos with thumbnails from GET /api/thumb/<slug>, edits
caption/date/tags in a QDialog via POST /api/edit, and deletes to trash via
POST /api/delete with a QMessageBox confirm. As a non-browser client it sends
no Origin and so passes the server's same-origin CSRF check.

Tag autocomplete now backs every tags field (post cards and the edit dialog)
via a per-token TagCompleter (QCompleter subclass, MatchContains) fed from
the unguarded GET /api/tags.json.

New files: managepostwidget.{h,cpp}, tagcompleter.{h,cpp}; network methods
and a RemotePhoto struct added to uploadclient. Built and linked clean; not
yet runtime-tested on a real display.
This commit is contained in:
puttaalu committed 2026-11-04 22:04:06 +01:00
1 parent bb8c7109fa
commit 504b96ed31
12 files changed
+677 -2

No files matched your search

+241 -1
View File
@@ -1,17 +1,25 @@
#include "mainwindow.h"
#include "imageprep.h"
#include "managepostwidget.h"
#include "photoitemwidget.h"
#include "tagcompleter.h"
#include "uploadclient.h"
#include <QDate>
#include <QDateEdit>
#include <QDateTime>
#include <QDialog>
#include <QDialogButtonBox>
#include <QFileDialog>
#include <QFileInfo>
#include <QFormLayout>
#include <QFuture>
#include <QFutureWatcher>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QScrollArea>
#include <QSpinBox>
@@ -29,6 +37,7 @@ MainWindow::MainWindow(QWidget *parent)
m_stack = new QStackedWidget(this);
m_stack->addWidget(buildSettingsPage()); // index 0
m_stack->addWidget(buildPostPage()); // index 1
m_stack->addWidget(buildManagePage()); // index 2
setCentralWidget(m_stack);
if (m_settings.isConfigured())
@@ -112,9 +121,51 @@ QWidget *MainWindow::buildPostPage()
m_lastPost->setWordWrap(true);
outer->addWidget(m_lastPost);
auto *bottomRow = new QHBoxLayout;
auto *manageButton = new QPushButton(QStringLiteral("Manage"));
connect(manageButton, &QPushButton::clicked, this, &MainWindow::showManage);
bottomRow->addWidget(manageButton);
auto *settingsButton = new QPushButton(QStringLiteral("Settings"));
connect(settingsButton, &QPushButton::clicked, this, &MainWindow::showSettings);
outer->addWidget(settingsButton);
bottomRow->addWidget(settingsButton);
outer->addLayout(bottomRow);
return page;
}
QWidget *MainWindow::buildManagePage()
{
auto *page = new QWidget;
auto *outer = new QVBoxLayout(page);
outer->setContentsMargins(24, 24, 24, 24);
auto *header = new QHBoxLayout;
auto *title = new QLabel(QStringLiteral("<b>Manage posts</b>"));
header->addWidget(title);
header->addStretch();
m_manageRefresh = new QPushButton(QStringLiteral("Refresh"));
connect(m_manageRefresh, &QPushButton::clicked, this, &MainWindow::loadManage);
header->addWidget(m_manageRefresh);
auto *back = new QPushButton(QStringLiteral("Back"));
connect(back, &QPushButton::clicked, this, &MainWindow::showPost);
header->addWidget(back);
outer->addLayout(header);
m_manageStatus = new QLabel;
m_manageStatus->setWordWrap(true);
outer->addWidget(m_manageStatus);
auto *scroll = new QScrollArea;
scroll->setWidgetResizable(true);
auto *container = new QWidget;
m_manageLayout = new QVBoxLayout(container);
m_manageLayout->setAlignment(Qt::AlignTop);
m_manageEmptyHint = new QLabel(QStringLiteral("No posts."));
m_manageEmptyHint->setAlignment(Qt::AlignCenter);
m_manageLayout->addWidget(m_manageEmptyHint);
scroll->setWidget(container);
outer->addWidget(scroll, 1);
return page;
}
@@ -139,9 +190,21 @@ void MainWindow::showPost()
} else {
m_lastPost->clear();
}
refreshTags();
m_stack->setCurrentIndex(1);
}
void MainWindow::showManage()
{
if (!m_settings.isConfigured()) {
showSettings();
return;
}
m_stack->setCurrentIndex(2);
refreshTags();
loadManage();
}
void MainWindow::saveAndTest()
{
// Concurrency is not a credential; persist it regardless of URL validity.
@@ -162,6 +225,13 @@ void MainWindow::saveAndTest()
m_settings.writePassword(m_password->text());
m_baseUrl->setText(m_settings.baseUrl());
// The manage client caches the credentials it was built with; drop it so
// the next manage action rebuilds with the ones just saved.
if (m_manageClient) {
m_manageClient->deleteLater();
m_manageClient = nullptr;
}
m_saveButton->setEnabled(false);
m_settingsStatus->setText(QStringLiteral("Testing…"));
@@ -192,6 +262,7 @@ void MainWindow::addItem(const QString &path)
{
m_emptyHint->hide();
auto *item = new PhotoItemWidget(path);
item->setTagSuggestions(m_tagSuggestions);
connect(item, &PhotoItemWidget::removeRequested, this, &MainWindow::removeItem);
m_items.append(item);
m_itemsLayout->addWidget(item);
@@ -359,3 +430,172 @@ void MainWindow::refreshPosting()
m_postSummary->setText(summary);
}
}
// --- Manage page -----------------------------------------------------------
UploadClient *MainWindow::manageClient()
{
if (!m_manageClient) {
m_manageClient = new UploadClient(m_settings.baseUrl(), m_settings.username(),
m_settings.readPassword(), this);
// Thumbnails arrive one per request; route each to its row by slug.
connect(m_manageClient, &UploadClient::thumbnailReady, this,
[this](const QString &slug, const QImage &image) {
for (ManagePostWidget *w : std::as_const(m_manageItems))
if (w->slug() == slug)
w->setThumbnail(image);
});
// The tag list feeds both the post cards and the edit dialog.
connect(m_manageClient, &UploadClient::tagsFetched, this,
[this](const QStringList &tags) {
if (tags.isEmpty())
return; // best-effort; keep whatever we had
m_tagSuggestions = tags;
applyTagSuggestions();
});
}
return m_manageClient;
}
void MainWindow::refreshTags()
{
if (m_settings.isConfigured())
manageClient()->fetchTags();
}
void MainWindow::applyTagSuggestions()
{
for (PhotoItemWidget *item : std::as_const(m_items))
item->setTagSuggestions(m_tagSuggestions);
}
void MainWindow::clearManageItems()
{
for (ManagePostWidget *item : std::as_const(m_manageItems))
item->deleteLater();
m_manageItems.clear();
m_manageEmptyHint->show();
}
void MainWindow::loadManage()
{
if (!m_settings.isConfigured()) {
m_manageStatus->setText(QStringLiteral("Set the server URL and credentials first"));
return;
}
m_manageStatus->setText(QStringLiteral("Loading…"));
m_manageRefresh->setEnabled(false);
connect(
manageClient(), &UploadClient::photosListed, this,
[this](const QList<RemotePhoto> &photos, const QString &error) {
m_manageRefresh->setEnabled(true);
clearManageItems();
if (!error.isEmpty()) {
m_manageStatus->setText(error);
return;
}
m_manageStatus->clear();
if (photos.isEmpty())
return;
m_manageEmptyHint->hide();
for (const RemotePhoto &p : photos) {
auto *item = new ManagePostWidget(p.slug, p.caption, p.date, p.tags,
m_settings.baseUrl() + p.url);
connect(item, &ManagePostWidget::editRequested, this, &MainWindow::editItem);
connect(item, &ManagePostWidget::deleteRequested, this, &MainWindow::deleteItem);
m_manageItems.append(item);
m_manageLayout->addWidget(item);
manageClient()->fetchThumbnail(p.slug);
}
},
Qt::SingleShotConnection);
manageClient()->listPhotos();
}
void MainWindow::editItem(ManagePostWidget *item)
{
QDialog dialog(this);
dialog.setWindowTitle(QStringLiteral("Edit %1").arg(item->slug()));
auto *layout = new QVBoxLayout(&dialog);
auto *form = new QFormLayout;
auto *caption = new QLineEdit(item->caption());
auto *date = new QDateEdit(QDate::fromString(item->date(), QStringLiteral("yyyy-MM-dd")));
date->setCalendarPopup(true);
date->setDisplayFormat(QStringLiteral("yyyy-MM-dd"));
if (!date->date().isValid())
date->setDate(QDate::currentDate());
auto *tags = new QLineEdit(item->tags().join(QStringLiteral(", ")));
auto *completer = new TagCompleter(&dialog);
completer->setSuggestions(m_tagSuggestions);
tags->setCompleter(completer);
form->addRow(QStringLiteral("Caption"), caption);
form->addRow(QStringLiteral("Date"), date);
form->addRow(QStringLiteral("Tags"), tags);
layout->addLayout(form);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel);
connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
layout->addWidget(buttons);
if (dialog.exec() != QDialog::Accepted)
return;
const QString newCaption = caption->text();
const QString newDate = date->date().toString(QStringLiteral("yyyy-MM-dd"));
const QString newTags = tags->text();
item->setBusy(true);
connect(
manageClient(), &UploadClient::editFinished, this,
[this, item, newCaption, newDate, newTags](const QString &slug, const QString &error) {
item->setBusy(false);
if (!error.isEmpty()) {
m_manageStatus->setText(QStringLiteral("Edit failed: %1").arg(error));
return;
}
QStringList tagList;
const auto parts = newTags.split(QLatin1Char(','), Qt::SkipEmptyParts);
for (const QString &t : parts) {
const QString trimmed = t.trimmed();
if (!trimmed.isEmpty())
tagList << trimmed;
}
item->applyEdit(newCaption, newDate, tagList);
m_manageStatus->setText(QStringLiteral("Saved %1").arg(slug));
},
Qt::SingleShotConnection);
manageClient()->editPost(item->slug(), newCaption, newDate, newTags);
}
void MainWindow::deleteItem(ManagePostWidget *item)
{
const QString name = item->caption().isEmpty() ? item->slug() : item->caption();
const auto choice = QMessageBox::question(
this, QStringLiteral("Move to trash?"),
QStringLiteral("\"%1\" will be moved to the server's trash.").arg(name));
if (choice != QMessageBox::Yes)
return;
item->setBusy(true);
connect(
manageClient(), &UploadClient::deleteFinished, this,
[this, item](const QString &, const QString &error) {
if (!error.isEmpty()) {
item->setBusy(false);
m_manageStatus->setText(QStringLiteral("Delete failed: %1").arg(error));
return;
}
m_manageItems.removeOne(item);
item->deleteLater();
m_manageStatus->setText(QStringLiteral("Moved to trash"));
if (m_manageItems.isEmpty())
m_manageEmptyHint->show();
},
Qt::SingleShotConnection);
manageClient()->deletePost(item->slug());
}