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:
1 parent
bb8c7109fa
commit
504b96ed31
12 files changed
+677
-2
No files matched your search
@@ -266,7 +266,7 @@ nix build # succeeds
|
|||||||
## What's not done yet
|
## What's not done yet
|
||||||
|
|
||||||
- ActivityPub federation (planned later — start with RSS/Atom)
|
- ActivityPub federation (planned later — start with RSS/Atom)
|
||||||
- Edit/delete in the **Qt desktop** app (web `/manage` and the Android app have it; the `/api/` endpoints are there for Qt to adopt). Restoring from trash is still web-only on all clients.
|
- Restoring from trash is still web-only: the `/manage` page has it, but neither the Android nor the Qt desktop app exposes `/api/restore` or `/api/trash` yet. (Edit/delete and tag autocomplete now exist in all three clients.)
|
||||||
- Any auth beyond the app's htpasswd Basic auth (no sessions, no per-user scopes)
|
- Any auth beyond the app's htpasswd Basic auth (no sessions, no per-user scopes)
|
||||||
- The Android app has never been run on real hardware
|
- The Android app has never been run on real hardware
|
||||||
- Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part
|
- Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part
|
||||||
@@ -13,6 +13,8 @@ add_executable(uploader
|
|||||||
main.cpp
|
main.cpp
|
||||||
mainwindow.cpp
|
mainwindow.cpp
|
||||||
photoitemwidget.cpp
|
photoitemwidget.cpp
|
||||||
|
managepostwidget.cpp
|
||||||
|
tagcompleter.cpp
|
||||||
settings.cpp
|
settings.cpp
|
||||||
uploadclient.cpp
|
uploadclient.cpp
|
||||||
imageprep.cpp
|
imageprep.cpp
|
||||||
|
|||||||
+241
-1
@@ -1,17 +1,25 @@
|
|||||||
#include "mainwindow.h"
|
#include "mainwindow.h"
|
||||||
|
|
||||||
#include "imageprep.h"
|
#include "imageprep.h"
|
||||||
|
#include "managepostwidget.h"
|
||||||
#include "photoitemwidget.h"
|
#include "photoitemwidget.h"
|
||||||
|
#include "tagcompleter.h"
|
||||||
#include "uploadclient.h"
|
#include "uploadclient.h"
|
||||||
|
|
||||||
|
#include <QDate>
|
||||||
|
#include <QDateEdit>
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
|
#include <QDialog>
|
||||||
|
#include <QDialogButtonBox>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QFormLayout>
|
#include <QFormLayout>
|
||||||
#include <QFuture>
|
#include <QFuture>
|
||||||
#include <QFutureWatcher>
|
#include <QFutureWatcher>
|
||||||
|
#include <QHBoxLayout>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
|
#include <QMessageBox>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QScrollArea>
|
#include <QScrollArea>
|
||||||
#include <QSpinBox>
|
#include <QSpinBox>
|
||||||
@@ -29,6 +37,7 @@ MainWindow::MainWindow(QWidget *parent)
|
|||||||
m_stack = new QStackedWidget(this);
|
m_stack = new QStackedWidget(this);
|
||||||
m_stack->addWidget(buildSettingsPage()); // index 0
|
m_stack->addWidget(buildSettingsPage()); // index 0
|
||||||
m_stack->addWidget(buildPostPage()); // index 1
|
m_stack->addWidget(buildPostPage()); // index 1
|
||||||
|
m_stack->addWidget(buildManagePage()); // index 2
|
||||||
setCentralWidget(m_stack);
|
setCentralWidget(m_stack);
|
||||||
|
|
||||||
if (m_settings.isConfigured())
|
if (m_settings.isConfigured())
|
||||||
@@ -112,9 +121,51 @@ QWidget *MainWindow::buildPostPage()
|
|||||||
m_lastPost->setWordWrap(true);
|
m_lastPost->setWordWrap(true);
|
||||||
outer->addWidget(m_lastPost);
|
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"));
|
auto *settingsButton = new QPushButton(QStringLiteral("Settings"));
|
||||||
connect(settingsButton, &QPushButton::clicked, this, &MainWindow::showSettings);
|
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;
|
return page;
|
||||||
}
|
}
|
||||||
@@ -139,9 +190,21 @@ void MainWindow::showPost()
|
|||||||
} else {
|
} else {
|
||||||
m_lastPost->clear();
|
m_lastPost->clear();
|
||||||
}
|
}
|
||||||
|
refreshTags();
|
||||||
m_stack->setCurrentIndex(1);
|
m_stack->setCurrentIndex(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MainWindow::showManage()
|
||||||
|
{
|
||||||
|
if (!m_settings.isConfigured()) {
|
||||||
|
showSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_stack->setCurrentIndex(2);
|
||||||
|
refreshTags();
|
||||||
|
loadManage();
|
||||||
|
}
|
||||||
|
|
||||||
void MainWindow::saveAndTest()
|
void MainWindow::saveAndTest()
|
||||||
{
|
{
|
||||||
// Concurrency is not a credential; persist it regardless of URL validity.
|
// 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_settings.writePassword(m_password->text());
|
||||||
m_baseUrl->setText(m_settings.baseUrl());
|
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_saveButton->setEnabled(false);
|
||||||
m_settingsStatus->setText(QStringLiteral("Testing…"));
|
m_settingsStatus->setText(QStringLiteral("Testing…"));
|
||||||
|
|
||||||
@@ -192,6 +262,7 @@ void MainWindow::addItem(const QString &path)
|
|||||||
{
|
{
|
||||||
m_emptyHint->hide();
|
m_emptyHint->hide();
|
||||||
auto *item = new PhotoItemWidget(path);
|
auto *item = new PhotoItemWidget(path);
|
||||||
|
item->setTagSuggestions(m_tagSuggestions);
|
||||||
connect(item, &PhotoItemWidget::removeRequested, this, &MainWindow::removeItem);
|
connect(item, &PhotoItemWidget::removeRequested, this, &MainWindow::removeItem);
|
||||||
m_items.append(item);
|
m_items.append(item);
|
||||||
m_itemsLayout->addWidget(item);
|
m_itemsLayout->addWidget(item);
|
||||||
@@ -359,3 +430,172 @@ void MainWindow::refreshPosting()
|
|||||||
m_postSummary->setText(summary);
|
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());
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <QList>
|
#include <QList>
|
||||||
#include <QMainWindow>
|
#include <QMainWindow>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
#include "settings.h"
|
#include "settings.h"
|
||||||
|
|
||||||
@@ -13,6 +14,8 @@ class QSpinBox;
|
|||||||
class QStackedWidget;
|
class QStackedWidget;
|
||||||
class QVBoxLayout;
|
class QVBoxLayout;
|
||||||
class PhotoItemWidget;
|
class PhotoItemWidget;
|
||||||
|
class ManagePostWidget;
|
||||||
|
class UploadClient;
|
||||||
|
|
||||||
class MainWindow : public QMainWindow
|
class MainWindow : public QMainWindow
|
||||||
{
|
{
|
||||||
@@ -23,9 +26,11 @@ public:
|
|||||||
private:
|
private:
|
||||||
QWidget *buildSettingsPage();
|
QWidget *buildSettingsPage();
|
||||||
QWidget *buildPostPage();
|
QWidget *buildPostPage();
|
||||||
|
QWidget *buildManagePage();
|
||||||
|
|
||||||
void showSettings();
|
void showSettings();
|
||||||
void showPost();
|
void showPost();
|
||||||
|
void showManage();
|
||||||
|
|
||||||
void saveAndTest();
|
void saveAndTest();
|
||||||
void chooseImages();
|
void chooseImages();
|
||||||
@@ -38,6 +43,17 @@ private:
|
|||||||
int inFlightCount() const; // items currently preparing or uploading
|
int inFlightCount() const; // items currently preparing or uploading
|
||||||
void refreshPosting(); // updates the Post button + summary from item states
|
void refreshPosting(); // updates the Post button + summary from item states
|
||||||
|
|
||||||
|
// Manage page.
|
||||||
|
UploadClient *manageClient(); // lazily-built, lives as long as the window
|
||||||
|
void loadManage(); // fetches the post list and thumbnails
|
||||||
|
void clearManageItems();
|
||||||
|
void editItem(ManagePostWidget *item);
|
||||||
|
void deleteItem(ManagePostWidget *item);
|
||||||
|
|
||||||
|
// Tag autocomplete.
|
||||||
|
void refreshTags(); // fetches /api/tags.json
|
||||||
|
void applyTagSuggestions(); // pushes the list onto every post card
|
||||||
|
|
||||||
Settings m_settings;
|
Settings m_settings;
|
||||||
|
|
||||||
QStackedWidget *m_stack = nullptr;
|
QStackedWidget *m_stack = nullptr;
|
||||||
@@ -66,4 +82,16 @@ private:
|
|||||||
QString m_batchUsername;
|
QString m_batchUsername;
|
||||||
QString m_batchPassword;
|
QString m_batchPassword;
|
||||||
QString m_batchCacheDir;
|
QString m_batchCacheDir;
|
||||||
|
|
||||||
|
// Manage page
|
||||||
|
QVBoxLayout *m_manageLayout = nullptr; // holds the ManagePostWidgets
|
||||||
|
QList<ManagePostWidget *> m_manageItems;
|
||||||
|
QLabel *m_manageEmptyHint = nullptr;
|
||||||
|
QLabel *m_manageStatus = nullptr;
|
||||||
|
QPushButton *m_manageRefresh = nullptr;
|
||||||
|
UploadClient *m_manageClient = nullptr; // lazily created, reused for manage ops
|
||||||
|
|
||||||
|
// Known tag names for autocomplete, shared by the post cards and the edit
|
||||||
|
// dialog. Best-effort: empty until /api/tags.json arrives.
|
||||||
|
QStringList m_tagSuggestions;
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#include "managepostwidget.h"
|
||||||
|
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QImage>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QPixmap>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr int kThumbSize = 72;
|
||||||
|
}
|
||||||
|
|
||||||
|
ManagePostWidget::ManagePostWidget(const QString &slug, const QString &caption,
|
||||||
|
const QString &date, const QStringList &tags,
|
||||||
|
const QString &url, QWidget *parent)
|
||||||
|
: QWidget(parent)
|
||||||
|
, m_slug(slug)
|
||||||
|
, m_caption(caption)
|
||||||
|
, m_date(date)
|
||||||
|
, m_tags(tags)
|
||||||
|
, m_url(url)
|
||||||
|
{
|
||||||
|
auto *row = new QHBoxLayout(this);
|
||||||
|
row->setContentsMargins(0, 4, 0, 4);
|
||||||
|
|
||||||
|
m_thumb = new QLabel;
|
||||||
|
m_thumb->setFixedSize(kThumbSize, kThumbSize);
|
||||||
|
m_thumb->setAlignment(Qt::AlignCenter);
|
||||||
|
m_thumb->setFrameShape(QFrame::Box);
|
||||||
|
row->addWidget(m_thumb, 0, Qt::AlignTop);
|
||||||
|
|
||||||
|
auto *fields = new QVBoxLayout;
|
||||||
|
m_captionLabel = new QLabel;
|
||||||
|
m_captionLabel->setTextFormat(Qt::RichText);
|
||||||
|
m_captionLabel->setOpenExternalLinks(true);
|
||||||
|
m_captionLabel->setWordWrap(true);
|
||||||
|
fields->addWidget(m_captionLabel);
|
||||||
|
|
||||||
|
m_metaLabel = new QLabel;
|
||||||
|
m_metaLabel->setWordWrap(true);
|
||||||
|
m_metaLabel->setStyleSheet(QStringLiteral("color:#a89984;"));
|
||||||
|
fields->addWidget(m_metaLabel);
|
||||||
|
row->addLayout(fields, 1);
|
||||||
|
|
||||||
|
m_edit = new QPushButton(QStringLiteral("Edit"));
|
||||||
|
connect(m_edit, &QPushButton::clicked, this, [this]() { emit editRequested(this); });
|
||||||
|
row->addWidget(m_edit, 0, Qt::AlignTop);
|
||||||
|
|
||||||
|
m_delete = new QPushButton(QStringLiteral("Delete"));
|
||||||
|
connect(m_delete, &QPushButton::clicked, this, [this]() { emit deleteRequested(this); });
|
||||||
|
row->addWidget(m_delete, 0, Qt::AlignTop);
|
||||||
|
|
||||||
|
refreshSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ManagePostWidget::setThumbnail(const QImage &image)
|
||||||
|
{
|
||||||
|
if (image.isNull()) {
|
||||||
|
m_thumb->setText(QStringLiteral("?"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_thumb->setPixmap(QPixmap::fromImage(image).scaled(
|
||||||
|
kThumbSize, kThumbSize, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ManagePostWidget::applyEdit(const QString &caption, const QString &date,
|
||||||
|
const QStringList &tags)
|
||||||
|
{
|
||||||
|
m_caption = caption;
|
||||||
|
m_date = date;
|
||||||
|
m_tags = tags;
|
||||||
|
refreshSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ManagePostWidget::setBusy(bool busy)
|
||||||
|
{
|
||||||
|
m_edit->setEnabled(!busy);
|
||||||
|
m_delete->setEnabled(!busy);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ManagePostWidget::refreshSummary()
|
||||||
|
{
|
||||||
|
const QString shown = m_caption.isEmpty() ? m_slug : m_caption;
|
||||||
|
m_captionLabel->setText(
|
||||||
|
QStringLiteral("<a href=\"%1\">%2</a>").arg(m_url, shown.toHtmlEscaped()));
|
||||||
|
|
||||||
|
QString meta = m_date;
|
||||||
|
if (!m_tags.isEmpty())
|
||||||
|
meta += QStringLiteral(" · ") + m_tags.join(QStringLiteral(", "));
|
||||||
|
m_metaLabel->setText(meta);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
class QImage;
|
||||||
|
class QLabel;
|
||||||
|
class QPushButton;
|
||||||
|
|
||||||
|
// One published post on the Manage page: thumbnail, caption/date/tags summary,
|
||||||
|
// and Edit / Delete buttons. Mirrors a row of the web /manage list and the
|
||||||
|
// Android manage screen.
|
||||||
|
class ManagePostWidget : public QWidget
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
ManagePostWidget(const QString &slug, const QString &caption, const QString &date,
|
||||||
|
const QStringList &tags, const QString &url, QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
QString slug() const { return m_slug; }
|
||||||
|
QString caption() const { return m_caption; }
|
||||||
|
QString date() const { return m_date; }
|
||||||
|
QStringList tags() const { return m_tags; }
|
||||||
|
|
||||||
|
void setThumbnail(const QImage &image);
|
||||||
|
void applyEdit(const QString &caption, const QString &date, const QStringList &tags);
|
||||||
|
void setBusy(bool busy); // a save/delete is in flight
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void editRequested(ManagePostWidget *self);
|
||||||
|
void deleteRequested(ManagePostWidget *self);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void refreshSummary();
|
||||||
|
|
||||||
|
QString m_slug;
|
||||||
|
QString m_caption;
|
||||||
|
QString m_date;
|
||||||
|
QStringList m_tags;
|
||||||
|
QString m_url;
|
||||||
|
|
||||||
|
QLabel *m_thumb = nullptr;
|
||||||
|
QLabel *m_captionLabel = nullptr;
|
||||||
|
QLabel *m_metaLabel = nullptr;
|
||||||
|
QPushButton *m_edit = nullptr;
|
||||||
|
QPushButton *m_delete = nullptr;
|
||||||
|
};
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
#include "photoitemwidget.h"
|
#include "photoitemwidget.h"
|
||||||
|
|
||||||
|
#include "tagcompleter.h"
|
||||||
|
|
||||||
#include <QDate>
|
#include <QDate>
|
||||||
#include <QDateEdit>
|
#include <QDateEdit>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
@@ -49,6 +51,8 @@ PhotoItemWidget::PhotoItemWidget(QString sourcePath, QWidget *parent)
|
|||||||
|
|
||||||
m_tags = new QLineEdit;
|
m_tags = new QLineEdit;
|
||||||
m_tags->setPlaceholderText(QStringLiteral("tags (comma-separated)"));
|
m_tags->setPlaceholderText(QStringLiteral("tags (comma-separated)"));
|
||||||
|
m_tagCompleter = new TagCompleter(this);
|
||||||
|
m_tags->setCompleter(m_tagCompleter);
|
||||||
fields->addWidget(m_tags);
|
fields->addWidget(m_tags);
|
||||||
|
|
||||||
m_statusLabel = new QLabel;
|
m_statusLabel = new QLabel;
|
||||||
@@ -79,6 +83,11 @@ QString PhotoItemWidget::tags() const
|
|||||||
return m_tags->text();
|
return m_tags->text();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void PhotoItemWidget::setTagSuggestions(const QStringList &tags)
|
||||||
|
{
|
||||||
|
m_tagCompleter->setSuggestions(tags);
|
||||||
|
}
|
||||||
|
|
||||||
void PhotoItemWidget::setStatus(Status status, const QString &message, const QString &url)
|
void PhotoItemWidget::setStatus(Status status, const QString &message, const QString &url)
|
||||||
{
|
{
|
||||||
m_status = status;
|
m_status = status;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class QDateEdit;
|
|||||||
class QLabel;
|
class QLabel;
|
||||||
class QLineEdit;
|
class QLineEdit;
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
|
class TagCompleter;
|
||||||
|
|
||||||
// One photo in the batch: thumbnail, caption, date, and its own upload status.
|
// One photo in the batch: thumbnail, caption, date, and its own upload status.
|
||||||
// A single-photo post is just a batch of one, so the window runs one code path.
|
// A single-photo post is just a batch of one, so the window runs one code path.
|
||||||
@@ -26,6 +27,9 @@ public:
|
|||||||
|
|
||||||
void setStatus(Status status, const QString &message = {}, const QString &url = {});
|
void setStatus(Status status, const QString &message = {}, const QString &url = {});
|
||||||
|
|
||||||
|
// Fills the tags field's autocomplete with the known tag names.
|
||||||
|
void setTagSuggestions(const QStringList &tags);
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void removeRequested(PhotoItemWidget *self);
|
void removeRequested(PhotoItemWidget *self);
|
||||||
|
|
||||||
@@ -39,4 +43,5 @@ private:
|
|||||||
QLineEdit *m_tags = nullptr;
|
QLineEdit *m_tags = nullptr;
|
||||||
QLabel *m_statusLabel = nullptr;
|
QLabel *m_statusLabel = nullptr;
|
||||||
QPushButton *m_remove = nullptr;
|
QPushButton *m_remove = nullptr;
|
||||||
|
TagCompleter *m_tagCompleter = nullptr;
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#include "tagcompleter.h"
|
||||||
|
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QStringListModel>
|
||||||
|
|
||||||
|
TagCompleter::TagCompleter(QObject *parent)
|
||||||
|
: QCompleter(parent)
|
||||||
|
, m_model(new QStringListModel(this))
|
||||||
|
{
|
||||||
|
setModel(m_model);
|
||||||
|
setCaseSensitivity(Qt::CaseInsensitive);
|
||||||
|
setFilterMode(Qt::MatchContains); // match anywhere in the tag, like Android
|
||||||
|
setCompletionMode(QCompleter::PopupCompletion);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TagCompleter::setSuggestions(const QStringList &tags)
|
||||||
|
{
|
||||||
|
m_model->setStringList(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete against the token after the last comma, ignoring the tags already
|
||||||
|
// entered before it.
|
||||||
|
QStringList TagCompleter::splitPath(const QString &path) const
|
||||||
|
{
|
||||||
|
return QStringList(path.section(QLatin1Char(','), -1).trimmed());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the full field value: everything up to and including the last comma,
|
||||||
|
// then the chosen tag and a trailing ", " ready for the next one.
|
||||||
|
QString TagCompleter::pathFromIndex(const QModelIndex &index) const
|
||||||
|
{
|
||||||
|
const QString completion = QCompleter::pathFromIndex(index);
|
||||||
|
const auto *edit = qobject_cast<const QLineEdit *>(widget());
|
||||||
|
if (!edit)
|
||||||
|
return completion;
|
||||||
|
|
||||||
|
const QString text = edit->text();
|
||||||
|
const int lastComma = text.lastIndexOf(QLatin1Char(','));
|
||||||
|
const QString head =
|
||||||
|
lastComma >= 0 ? text.left(lastComma + 1) + QLatin1Char(' ') : QString();
|
||||||
|
return head + completion + QStringLiteral(", ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QCompleter>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
class QStringListModel;
|
||||||
|
|
||||||
|
// A QCompleter for a comma-separated tags line edit. It completes only the
|
||||||
|
// token currently being typed (the text after the last comma) and, on
|
||||||
|
// selection, splices the chosen tag back in place of that token — the same
|
||||||
|
// per-token behaviour as the web and Android tag fields. Attach with
|
||||||
|
// QLineEdit::setCompleter; call setSuggestions() to (re)fill the tag list.
|
||||||
|
class TagCompleter : public QCompleter
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit TagCompleter(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
void setSuggestions(const QStringList &tags);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
QStringList splitPath(const QString &path) const override;
|
||||||
|
QString pathFromIndex(const QModelIndex &index) const override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QStringListModel *m_model;
|
||||||
|
};
|
||||||
@@ -4,9 +4,13 @@
|
|||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QHttpMultiPart>
|
#include <QHttpMultiPart>
|
||||||
#include <QHttpPart>
|
#include <QHttpPart>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
#include <QNetworkAccessManager>
|
#include <QNetworkAccessManager>
|
||||||
#include <QNetworkReply>
|
#include <QNetworkReply>
|
||||||
#include <QNetworkRequest>
|
#include <QNetworkRequest>
|
||||||
|
#include <QUrlQuery>
|
||||||
|
|
||||||
UploadClient::UploadClient(QString baseUrl, QString username, QString password, QObject *parent)
|
UploadClient::UploadClient(QString baseUrl, QString username, QString password, QObject *parent)
|
||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
@@ -125,3 +129,152 @@ void UploadClient::testConnection()
|
|||||||
emit testFinished(QStringLiteral("Server returned %1").arg(status));
|
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"));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,10 +1,23 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <QImage>
|
||||||
|
#include <QList>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
class QNetworkAccessManager;
|
class QNetworkAccessManager;
|
||||||
|
|
||||||
|
// One published post, as returned by GET /api/photos.
|
||||||
|
struct RemotePhoto {
|
||||||
|
QString slug;
|
||||||
|
QString caption;
|
||||||
|
QString date; // yyyy-MM-dd
|
||||||
|
QStringList tags;
|
||||||
|
QString thumb; // site-relative, e.g. "/thumbs/foo.jpg"
|
||||||
|
QString url; // site-relative, e.g. "/photo/foo/"
|
||||||
|
};
|
||||||
|
|
||||||
// Talks to the gallery's POST /upload endpoint, which sits behind nginx HTTP
|
// Talks to the gallery's POST /upload endpoint, which sits behind nginx HTTP
|
||||||
// Basic auth. The credentials go out preemptively in the Authorization header
|
// Basic auth. The credentials go out preemptively in the Authorization header
|
||||||
// on every request (never via QNetworkAccessManager's authenticationRequired
|
// on every request (never via QNetworkAccessManager's authenticationRequired
|
||||||
@@ -35,12 +48,29 @@ public:
|
|||||||
// /health is not usable: nginx only proxies location /upload.
|
// /health is not usable: nginx only proxies location /upload.
|
||||||
void testConnection();
|
void testConnection();
|
||||||
|
|
||||||
|
// Manage API, mirroring the web /manage and the Android app. The desktop
|
||||||
|
// app is a non-browser client: it sends no Origin/Sec-Fetch-Site header, so
|
||||||
|
// the state-changing posts pass the server's same-origin (CSRF) check.
|
||||||
|
void listPhotos(); // GET /api/photos
|
||||||
|
void fetchTags(); // GET /api/tags.json (unguarded)
|
||||||
|
void fetchThumbnail(const QString &slug); // GET /api/thumb/<slug> (unguarded)
|
||||||
|
void editPost(const QString &slug, const QString &caption,
|
||||||
|
const QString &date, const QString &tags); // POST /api/edit
|
||||||
|
void deletePost(const QString &slug); // POST /api/delete (moves to trash)
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void uploadFinished(UploadClient::Result result, const QString &slug,
|
void uploadFinished(UploadClient::Result result, const QString &slug,
|
||||||
const QString &url, const QString &message);
|
const QString &url, const QString &message);
|
||||||
// errorMessage is empty on success.
|
// errorMessage is empty on success.
|
||||||
void testFinished(const QString &errorMessage);
|
void testFinished(const QString &errorMessage);
|
||||||
|
|
||||||
|
// Manage signals. The error strings are empty on success.
|
||||||
|
void photosListed(const QList<RemotePhoto> &photos, const QString &error);
|
||||||
|
void tagsFetched(const QStringList &tags);
|
||||||
|
void thumbnailReady(const QString &slug, const QImage &image);
|
||||||
|
void editFinished(const QString &slug, const QString &error);
|
||||||
|
void deleteFinished(const QString &slug, const QString &error);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QByteArray authHeader() const;
|
QByteArray authHeader() const;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user