Add Qt6 desktop uploader
A native Qt6/C++ desktop client for the gallery's POST /upload API, in desktop/ with its own flake (kept separate from the Go server module the same way android/ is). Single-photo parity with the Android app: - multipart upload with preemptive HTTP Basic auth and retry-aware outcome classification (uploadclient) - JPEG/PNG metadata stripped byte-for-byte to drop embedded GPS, EXIF orientation baked in, and a quality/scale valve for the 20M nginx cap; HEIC decodes via the qtimageformats plugin (imageprep) - base URL / username in QSettings, password in the system keychain via qtkeychain; https required and a trailing /upload stripped (settings) - settings and post screens in a QStackedWidget (mainwindow) Native Wayland or xcb is selected at runtime. Bulk upload, EXIF capture-date prefill, and moving image prep off the UI thread are left as follow-ups.
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
#include "mainwindow.h"
|
||||
|
||||
#include "imageprep.h"
|
||||
|
||||
#include <QDateEdit>
|
||||
#include <QDateTime>
|
||||
#include <QDesktopServices>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QFormLayout>
|
||||
#include <QFrame>
|
||||
#include <QImageReader>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QPixmap>
|
||||
#include <QPushButton>
|
||||
#include <QStackedWidget>
|
||||
#include <QStandardPaths>
|
||||
#include <QUrl>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace {
|
||||
constexpr int kPreviewSize = 360;
|
||||
}
|
||||
|
||||
MainWindow::MainWindow(QWidget *parent)
|
||||
: QMainWindow(parent)
|
||||
{
|
||||
setWindowTitle(QStringLiteral("Photo Gallery Uploader"));
|
||||
|
||||
m_stack = new QStackedWidget(this);
|
||||
m_stack->addWidget(buildSettingsPage()); // index 0
|
||||
m_stack->addWidget(buildPostPage()); // index 1
|
||||
setCentralWidget(m_stack);
|
||||
|
||||
// Land on settings until there is somewhere to post to.
|
||||
if (m_settings.isConfigured())
|
||||
showPost();
|
||||
else
|
||||
showSettings();
|
||||
}
|
||||
|
||||
QWidget *MainWindow::buildSettingsPage()
|
||||
{
|
||||
auto *page = new QWidget;
|
||||
auto *layout = new QVBoxLayout(page);
|
||||
layout->setContentsMargins(24, 24, 24, 24);
|
||||
|
||||
auto *form = new QFormLayout;
|
||||
m_baseUrl = new QLineEdit;
|
||||
m_baseUrl->setPlaceholderText(QStringLiteral("https://photos.example.com"));
|
||||
m_username = new QLineEdit;
|
||||
m_password = new QLineEdit;
|
||||
m_password->setEchoMode(QLineEdit::Password);
|
||||
form->addRow(QStringLiteral("Base URL"), m_baseUrl);
|
||||
form->addRow(QStringLiteral("Username"), m_username);
|
||||
form->addRow(QStringLiteral("Password"), m_password);
|
||||
layout->addLayout(form);
|
||||
|
||||
m_saveButton = new QPushButton(QStringLiteral("Save && test connection"));
|
||||
connect(m_saveButton, &QPushButton::clicked, this, &MainWindow::saveAndTest);
|
||||
layout->addWidget(m_saveButton);
|
||||
|
||||
m_settingsStatus = new QLabel;
|
||||
m_settingsStatus->setWordWrap(true);
|
||||
layout->addWidget(m_settingsStatus);
|
||||
|
||||
m_settingsBack = new QPushButton(QStringLiteral("Back"));
|
||||
connect(m_settingsBack, &QPushButton::clicked, this, &MainWindow::showPost);
|
||||
layout->addWidget(m_settingsBack);
|
||||
|
||||
layout->addWidget(new QLabel(QStringLiteral(
|
||||
"The password is stored in your system keychain, never in plain text. "
|
||||
"https is required — Basic auth would otherwise send it in the clear.")));
|
||||
layout->addStretch();
|
||||
return page;
|
||||
}
|
||||
|
||||
QWidget *MainWindow::buildPostPage()
|
||||
{
|
||||
auto *page = new QWidget;
|
||||
auto *layout = new QVBoxLayout(page);
|
||||
layout->setContentsMargins(24, 24, 24, 24);
|
||||
|
||||
m_preview = new QLabel(QStringLiteral("No photo selected"));
|
||||
m_preview->setAlignment(Qt::AlignCenter);
|
||||
m_preview->setFixedSize(kPreviewSize, kPreviewSize);
|
||||
m_preview->setFrameShape(QFrame::Box);
|
||||
layout->addWidget(m_preview, 0, Qt::AlignHCenter);
|
||||
|
||||
m_chooseButton = new QPushButton(QStringLiteral("Choose a photo…"));
|
||||
connect(m_chooseButton, &QPushButton::clicked, this, &MainWindow::chooseImage);
|
||||
layout->addWidget(m_chooseButton);
|
||||
|
||||
auto *form = new QFormLayout;
|
||||
m_caption = new QLineEdit;
|
||||
m_date = new QDateEdit(QDate::currentDate());
|
||||
m_date->setCalendarPopup(true);
|
||||
m_date->setDisplayFormat(QStringLiteral("yyyy-MM-dd"));
|
||||
form->addRow(QStringLiteral("Caption"), m_caption);
|
||||
form->addRow(QStringLiteral("Date"), m_date);
|
||||
layout->addLayout(form);
|
||||
|
||||
m_postButton = new QPushButton(QStringLiteral("Post"));
|
||||
m_postButton->setEnabled(false);
|
||||
connect(m_postButton, &QPushButton::clicked, this, &MainWindow::submit);
|
||||
layout->addWidget(m_postButton);
|
||||
|
||||
m_postStatus = new QLabel;
|
||||
m_postStatus->setWordWrap(true);
|
||||
layout->addWidget(m_postStatus);
|
||||
|
||||
m_lastPost = new QLabel;
|
||||
m_lastPost->setOpenExternalLinks(true);
|
||||
m_lastPost->setWordWrap(true);
|
||||
layout->addWidget(m_lastPost);
|
||||
|
||||
auto *settingsButton = new QPushButton(QStringLiteral("Settings"));
|
||||
connect(settingsButton, &QPushButton::clicked, this, &MainWindow::showSettings);
|
||||
layout->addWidget(settingsButton);
|
||||
|
||||
layout->addStretch();
|
||||
return page;
|
||||
}
|
||||
|
||||
void MainWindow::showSettings()
|
||||
{
|
||||
m_baseUrl->setText(m_settings.baseUrl());
|
||||
m_username->setText(m_settings.username());
|
||||
m_password->setText(m_settings.readPassword());
|
||||
m_settingsStatus->clear();
|
||||
m_settingsBack->setVisible(m_settings.isConfigured());
|
||||
m_stack->setCurrentIndex(0);
|
||||
}
|
||||
|
||||
void MainWindow::showPost()
|
||||
{
|
||||
const Settings::LastPost last = m_settings.lastPost();
|
||||
if (last.valid()) {
|
||||
m_lastPost->setText(QStringLiteral("Last post: <a href=\"%1\">%2</a>")
|
||||
.arg(last.url, last.caption.isEmpty() ? last.slug : last.caption));
|
||||
} else {
|
||||
m_lastPost->clear();
|
||||
}
|
||||
m_stack->setCurrentIndex(1);
|
||||
}
|
||||
|
||||
void MainWindow::saveAndTest()
|
||||
{
|
||||
const QString urlError = Settings::validateBaseUrl(m_baseUrl->text());
|
||||
if (!urlError.isEmpty()) {
|
||||
m_settingsStatus->setText(urlError);
|
||||
return;
|
||||
}
|
||||
if (m_username->text().trimmed().isEmpty() || m_password->text().isEmpty()) {
|
||||
m_settingsStatus->setText(QStringLiteral("Username and password are required"));
|
||||
return;
|
||||
}
|
||||
|
||||
m_settings.setBaseUrl(m_baseUrl->text());
|
||||
m_settings.setUsername(m_username->text());
|
||||
m_settings.writePassword(m_password->text());
|
||||
m_baseUrl->setText(m_settings.baseUrl()); // show normalised value
|
||||
|
||||
m_saveButton->setEnabled(false);
|
||||
m_settingsStatus->setText(QStringLiteral("Testing…"));
|
||||
|
||||
auto *client = new UploadClient(m_settings.baseUrl(), m_settings.username(),
|
||||
m_settings.readPassword(), this);
|
||||
connect(client, &UploadClient::testFinished, this, [this, client](const QString &error) {
|
||||
m_saveButton->setEnabled(true);
|
||||
m_settingsBack->setVisible(m_settings.isConfigured());
|
||||
m_settingsStatus->setText(error.isEmpty()
|
||||
? QStringLiteral("Saved — server reachable and credentials accepted")
|
||||
: error);
|
||||
client->deleteLater();
|
||||
});
|
||||
client->testConnection();
|
||||
}
|
||||
|
||||
void MainWindow::chooseImage()
|
||||
{
|
||||
const QString path = QFileDialog::getOpenFileName(
|
||||
this, QStringLiteral("Choose a photo"), QString(),
|
||||
QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.heif *.webp *.tif *.tiff *.bmp);;All files (*)"));
|
||||
if (path.isEmpty())
|
||||
return;
|
||||
|
||||
m_imagePath = path;
|
||||
|
||||
QImageReader reader(path);
|
||||
reader.setAutoTransform(true);
|
||||
const QImage image = reader.read();
|
||||
if (image.isNull()) {
|
||||
m_preview->setText(QStringLiteral("Could not preview this image"));
|
||||
} else {
|
||||
m_preview->setPixmap(QPixmap::fromImage(image).scaled(
|
||||
kPreviewSize, kPreviewSize, Qt::KeepAspectRatio, Qt::SmoothTransformation));
|
||||
}
|
||||
m_postStatus->clear();
|
||||
m_postButton->setEnabled(true);
|
||||
}
|
||||
|
||||
void MainWindow::submit()
|
||||
{
|
||||
if (m_imagePath.isEmpty())
|
||||
return;
|
||||
if (!m_settings.isConfigured()) {
|
||||
m_postStatus->setText(QStringLiteral("Set the server URL and credentials first"));
|
||||
return;
|
||||
}
|
||||
|
||||
m_postButton->setEnabled(false);
|
||||
m_postStatus->setText(QStringLiteral("Preparing…"));
|
||||
|
||||
// Slug from wall-clock time: posts are made one at a time, so this cannot
|
||||
// collide, and re-sending the same filename overwrites the same post. The
|
||||
// real capture date still travels in the date field.
|
||||
const QString slug = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd-HHmmss"));
|
||||
const QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::TempLocation);
|
||||
|
||||
const ImagePrep::Prepared prepared = ImagePrep::prepare(m_imagePath, cacheDir, slug);
|
||||
if (!prepared.ok()) {
|
||||
m_postStatus->setText(prepared.error);
|
||||
m_postButton->setEnabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
m_postStatus->setText(QStringLiteral("Uploading…"));
|
||||
|
||||
const QString caption = m_caption->text();
|
||||
const QString date = m_date->date().toString(QStringLiteral("yyyy-MM-dd"));
|
||||
const QString fileName = QFileInfo(prepared.filePath).fileName();
|
||||
|
||||
auto *client = new UploadClient(m_settings.baseUrl(), m_settings.username(),
|
||||
m_settings.readPassword(), this);
|
||||
connect(client, &UploadClient::uploadFinished, this,
|
||||
[this, client, prepared, caption](UploadClient::Result result, const QString &slug,
|
||||
const QString &url, const QString &message) {
|
||||
QFile::remove(prepared.filePath); // no retry queue in this MVP
|
||||
m_postButton->setEnabled(true);
|
||||
|
||||
if (result == UploadClient::Result::Success) {
|
||||
Settings::LastPost last{slug, url, caption, QDateTime::currentDateTime()};
|
||||
m_settings.setLastPost(last);
|
||||
QString msg = QStringLiteral("Posted. <a href=\"%1\">View</a>").arg(url);
|
||||
if (!prepared.note.isEmpty())
|
||||
msg += QStringLiteral("<br><span>%1</span>").arg(prepared.note);
|
||||
m_postStatus->setText(msg);
|
||||
m_postStatus->setOpenExternalLinks(true);
|
||||
} else {
|
||||
m_postStatus->setText(message);
|
||||
}
|
||||
client->deleteLater();
|
||||
});
|
||||
client->upload(prepared.filePath, fileName, caption, date);
|
||||
}
|
||||
Reference in New Issue
Block a user