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

+153
View File
@@ -4,9 +4,13 @@
#include <QFileInfo>
#include <QHttpMultiPart>
#include <QHttpPart>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrlQuery>
UploadClient::UploadClient(QString baseUrl, QString username, QString password, QObject *parent)
: QObject(parent)
@@ -125,3 +129,152 @@ void UploadClient::testConnection()
emit testFinished(QStringLiteral("Server returned %1").arg(status));
});
}
// --- Manage API ------------------------------------------------------------
namespace {
QString httpError(QNetworkReply *reply, int status)
{
if (status == 401 || status == 403)
return QStringLiteral("Wrong username or password");
if (status == 0)
return reply->errorString();
const QString body = QString::fromUtf8(reply->readAll()).trimmed();
return body.isEmpty() ? QStringLiteral("Server returned %1").arg(status) : body;
}
} // namespace
void UploadClient::listPhotos()
{
QNetworkRequest request(QUrl(m_base + QStringLiteral("/api/photos")));
request.setRawHeader("Authorization", authHeader());
QNetworkReply *reply = m_net->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError || status < 200 || status >= 300) {
emit photosListed({}, httpError(reply, status));
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
if (!doc.isArray()) {
emit photosListed({}, QStringLiteral("Could not read the server's response"));
return;
}
QList<RemotePhoto> photos;
const QJsonArray arr = doc.array();
for (const QJsonValue &v : arr) {
const QJsonObject o = v.toObject();
RemotePhoto p;
p.slug = o.value(QStringLiteral("slug")).toString();
p.caption = o.value(QStringLiteral("caption")).toString();
p.date = o.value(QStringLiteral("date")).toString();
p.thumb = o.value(QStringLiteral("thumb")).toString();
p.url = o.value(QStringLiteral("url")).toString();
const QJsonArray tags = o.value(QStringLiteral("tags")).toArray();
for (const QJsonValue &t : tags)
p.tags << t.toString();
photos << p;
}
emit photosListed(photos, QString());
});
}
void UploadClient::fetchTags()
{
QNetworkRequest request(QUrl(m_base + QStringLiteral("/api/tags.json")));
request.setRawHeader("Authorization", authHeader());
QNetworkReply *reply = m_net->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError || status < 200 || status >= 300) {
emit tagsFetched({}); // best-effort; autocomplete is a convenience
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
QStringList names;
for (const QJsonValue &v : doc.array()) {
const QString name = v.toObject().value(QStringLiteral("tag")).toString();
if (!name.isEmpty())
names << name;
}
emit tagsFetched(names);
});
}
void UploadClient::fetchThumbnail(const QString &slug)
{
QNetworkRequest request(QUrl(m_base + QStringLiteral("/api/thumb/") + slug));
request.setRawHeader("Authorization", authHeader());
QNetworkReply *reply = m_net->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply, slug]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError || status < 200 || status >= 300) {
emit thumbnailReady(slug, QImage()); // null image on any failure
return;
}
QImage image;
image.loadFromData(reply->readAll());
emit thumbnailReady(slug, image);
});
}
void UploadClient::editPost(const QString &slug, const QString &caption,
const QString &date, const QString &tags)
{
QUrlQuery form;
form.addQueryItem(QStringLiteral("slug"), slug);
form.addQueryItem(QStringLiteral("caption"), caption);
form.addQueryItem(QStringLiteral("date"), date);
form.addQueryItem(QStringLiteral("tags"), tags);
QNetworkRequest request(QUrl(m_base + QStringLiteral("/api/edit")));
request.setRawHeader("Authorization", authHeader());
request.setHeader(QNetworkRequest::ContentTypeHeader,
QStringLiteral("application/x-www-form-urlencoded"));
QNetworkReply *reply =
m_net->post(request, form.toString(QUrl::FullyEncoded).toUtf8());
connect(reply, &QNetworkReply::finished, this, [this, reply, slug]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() == QNetworkReply::NoError && status >= 200 && status < 300)
emit editFinished(slug, QString());
else
emit editFinished(slug, httpError(reply, status));
});
}
void UploadClient::deletePost(const QString &slug)
{
QUrlQuery form;
form.addQueryItem(QStringLiteral("slug"), slug);
QNetworkRequest request(QUrl(m_base + QStringLiteral("/api/delete")));
request.setRawHeader("Authorization", authHeader());
request.setHeader(QNetworkRequest::ContentTypeHeader,
QStringLiteral("application/x-www-form-urlencoded"));
QNetworkReply *reply =
m_net->post(request, form.toString(QUrl::FullyEncoded).toUtf8());
connect(reply, &QNetworkReply::finished, this, [this, reply, slug]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError || status < 200 || status >= 300) {
emit deleteFinished(slug, httpError(reply, status));
return;
}
// The server reports how many it moved; 0 means the slug wasn't found.
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
const int deleted = doc.object().value(QStringLiteral("deleted")).toInt(0);
if (deleted > 0)
emit deleteFinished(slug, QString());
else
emit deleteFinished(slug, QStringLiteral("Nothing deleted — not found on the server"));
});
}