#include "mainwindow.h" #include "imageprep.h" #include "managepostwidget.h" #include "photoitemwidget.h" #include "tagcompleter.h" #include "uploadclient.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 m_stack->addWidget(buildManagePage()); // index 2 setCentralWidget(m_stack); 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); m_concurrency = new QSpinBox; m_concurrency->setRange(1, Settings::kMaxConcurrency); form->addRow(QStringLiteral("Base URL"), m_baseUrl); form->addRow(QStringLiteral("Username"), m_username); form->addRow(QStringLiteral("Password"), m_password); form->addRow(QStringLiteral("Simultaneous uploads"), m_concurrency); 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 *outer = new QVBoxLayout(page); outer->setContentsMargins(24, 24, 24, 24); auto *choose = new QPushButton(QStringLiteral("Choose photos…")); connect(choose, &QPushButton::clicked, this, &MainWindow::chooseImages); outer->addWidget(choose); // Scrollable list of per-photo cards. auto *scroll = new QScrollArea; scroll->setWidgetResizable(true); auto *container = new QWidget; m_itemsLayout = new QVBoxLayout(container); m_itemsLayout->setAlignment(Qt::AlignTop); m_emptyHint = new QLabel(QStringLiteral("No photos selected")); m_emptyHint->setAlignment(Qt::AlignCenter); m_itemsLayout->addWidget(m_emptyHint); scroll->setWidget(container); outer->addWidget(scroll, 1); m_postButton = new QPushButton(QStringLiteral("Post")); m_postButton->setEnabled(false); connect(m_postButton, &QPushButton::clicked, this, &MainWindow::submit); outer->addWidget(m_postButton); m_postSummary = new QLabel; m_postSummary->setWordWrap(true); outer->addWidget(m_postSummary); m_lastPost = new QLabel; m_lastPost->setOpenExternalLinks(true); 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); 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("Manage posts")); 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; } void MainWindow::showSettings() { m_baseUrl->setText(m_settings.baseUrl()); m_username->setText(m_settings.username()); m_password->setText(m_settings.readPassword()); m_concurrency->setValue(m_settings.concurrency()); 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: %2") .arg(last.url, last.caption.isEmpty() ? last.slug : last.caption)); } 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. m_settings.setConcurrency(m_concurrency->value()); 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()); // 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…")); 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::chooseImages() { const QStringList paths = QFileDialog::getOpenFileNames( this, QStringLiteral("Choose photos"), QString(), QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.heif *.webp *.tif *.tiff *.bmp);;All files (*)")); for (const QString &path : paths) addItem(path); refreshPosting(); } 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); } void MainWindow::removeItem(PhotoItemWidget *item) { m_items.removeOne(item); m_queue.removeOne(item); // may be waiting for a slot — drop it before deleting item->deleteLater(); if (m_items.isEmpty()) m_emptyHint->show(); refreshPosting(); } void MainWindow::clearItems() { for (PhotoItemWidget *item : std::as_const(m_items)) item->deleteLater(); m_items.clear(); m_queue.clear(); m_emptyHint->show(); m_postSummary->clear(); refreshPosting(); } void MainWindow::submit() { if (m_items.isEmpty()) return; if (!m_settings.isConfigured()) { m_postSummary->setText(QStringLiteral("Set the server URL and credentials first")); return; } // Capture the batch's credentials once, then feed the queue through a // concurrency cap rather than firing every photo at the server at once. m_batchCacheDir = QStandardPaths::writableLocation(QStandardPaths::TempLocation); m_batchBaseUrl = m_settings.baseUrl(); m_batchUsername = m_settings.username(); m_batchPassword = m_settings.readPassword(); m_queue.clear(); for (PhotoItemWidget *item : std::as_const(m_items)) { if (item->status() != PhotoItemWidget::Status::Success) m_queue.append(item); // re-posting retries only failures } if (m_queue.isEmpty()) return; m_postSummary->clear(); pumpQueue(); refreshPosting(); } int MainWindow::inFlightCount() const { int n = 0; for (PhotoItemWidget *item : std::as_const(m_items)) { const PhotoItemWidget::Status s = item->status(); if (s == PhotoItemWidget::Status::Preparing || s == PhotoItemWidget::Status::Uploading) ++n; } return n; } void MainWindow::pumpQueue() { const int cap = m_settings.concurrency(); while (inFlightCount() < cap && !m_queue.isEmpty()) startItem(m_queue.takeFirst()); } void MainWindow::startItem(PhotoItemWidget *item) { item->setStatus(PhotoItemWidget::Status::Preparing); // Slug from wall-clock time plus a per-item suffix so a batch posted in the // same second cannot collide (server derives the stored name from the // uploaded filename). The real capture date rides in the date field. const QString slug = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd-HHmmss")) + QLatin1Char('-') + QUuid::createUuid().toString(QUuid::Id128).left(6); const QString source = item->sourcePath(); const QString cacheDir = m_batchCacheDir; const QString baseUrl = m_batchBaseUrl; const QString username = m_batchUsername; const QString password = m_batchPassword; auto *watcher = new QFutureWatcher(this); connect(watcher, &QFutureWatcher::finished, this, [this, watcher, item, baseUrl, username, password]() { const ImagePrep::Prepared prepared = watcher->result(); watcher->deleteLater(); if (!prepared.ok()) { item->setStatus(PhotoItemWidget::Status::Error, prepared.error); pumpQueue(); // free the slot for the next photo refreshPosting(); return; } item->setStatus(PhotoItemWidget::Status::Uploading); const QString caption = item->caption(); auto *client = new UploadClient(baseUrl, username, password, this); connect(client, &UploadClient::uploadFinished, this, [this, client, item, prepared, caption](UploadClient::Result result, const QString &slug, const QString &url, const QString &message) { QFile::remove(prepared.filePath); // no retry queue on the desktop if (result == UploadClient::Result::Success) { m_settings.setLastPost({slug, url, caption, QDateTime::currentDateTime()}); item->setStatus(PhotoItemWidget::Status::Success, {}, url); } else { item->setStatus(PhotoItemWidget::Status::Error, message); } client->deleteLater(); pumpQueue(); // free the slot for the next photo refreshPosting(); }); client->upload(prepared.filePath, QFileInfo(prepared.filePath).fileName(), caption, item->date(), item->tags()); }); watcher->setFuture(QtConcurrent::run([source, cacheDir, slug]() { return ImagePrep::prepare(source, cacheDir, slug); })); } void MainWindow::refreshPosting() { int pending = 0; int inFlight = 0; int succeeded = 0; int failed = 0; for (PhotoItemWidget *item : std::as_const(m_items)) { switch (item->status()) { case PhotoItemWidget::Status::Pending: ++pending; break; case PhotoItemWidget::Status::Preparing: case PhotoItemWidget::Status::Uploading: ++inFlight; break; case PhotoItemWidget::Status::Success: ++succeeded; break; case PhotoItemWidget::Status::Error: ++failed; ++pending; // a failure is re-postable break; } } const bool posting = inFlight > 0; m_postButton->setEnabled(!m_items.isEmpty() && !posting && pending > 0); m_postButton->setText(posting ? QStringLiteral("Posting… (%1/%2)").arg(succeeded).arg(m_items.size()) : pending == 1 ? QStringLiteral("Post 1 photo") : pending > 1 ? QStringLiteral("Post %1 photos").arg(pending) : QStringLiteral("All posted")); if (!posting && (succeeded + failed) > 0) { QString summary = QStringLiteral("%1 posted").arg(succeeded); if (failed > 0) summary += QStringLiteral(", %1 failed").arg(failed); 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 &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()); }