Files
photog/desktop/mainwindow.cpp
T
puttaalu d07e3eb0dc 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.
2026-08-26 00:28:35 +02:00

258 lines
9.1 KiB
C++

#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);
}