|
|
@@ -58,6 +58,21 @@ BibliothecaWindow::BibliothecaWindow(DatabaseManager& db, BookList& bookList)
|
|
|
border-radius: 16px;
|
|
|
background-color: rgba(120, 180, 255, 0.12);
|
|
|
}
|
|
|
+ .toast-box {
|
|
|
+ background-color: @theme_bg_color;
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ margin: 12px;
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
|
+ }
|
|
|
+ .toast-box.toast-error {
|
|
|
+ background-color: #c01c28;
|
|
|
+ color: white;
|
|
|
+ }
|
|
|
+ .toast-box.toast-success {
|
|
|
+ background-color: #26a269;
|
|
|
+ color: white;
|
|
|
+ }
|
|
|
|
|
|
)CSS");
|
|
|
|
|
|
@@ -66,6 +81,7 @@ BibliothecaWindow::BibliothecaWindow(DatabaseManager& db, BookList& bookList)
|
|
|
|
|
|
// Build the main views
|
|
|
buildPlaceholder();
|
|
|
+ buildToast();
|
|
|
|
|
|
m_noResults.set_orientation(Gtk::Orientation::VERTICAL);
|
|
|
m_noResults.set_spacing(12);
|
|
|
@@ -110,9 +126,13 @@ BibliothecaWindow::BibliothecaWindow(DatabaseManager& db, BookList& bookList)
|
|
|
m_addBookButton.set_visible(visible_child != "details");
|
|
|
});
|
|
|
|
|
|
+ // Use overlay to show toast on top of content
|
|
|
+ m_overlay.set_child(m_stack);
|
|
|
+ m_overlay.add_overlay(m_toastRevealer);
|
|
|
+
|
|
|
m_mainBox.set_spacing(0);
|
|
|
m_mainBox.append(m_searchBar);
|
|
|
- m_mainBox.append(m_stack);
|
|
|
+ m_mainBox.append(m_overlay);
|
|
|
|
|
|
set_child(m_mainBox);
|
|
|
|
|
|
@@ -176,13 +196,64 @@ void BibliothecaWindow::buildPlaceholder() {
|
|
|
m_placeholderLabel.set_xalign(0.5);
|
|
|
m_placeholderLabel.set_markup(
|
|
|
"<span size='large' weight='bold'>Your Library is Empty</span>\n"
|
|
|
- "<small>Click the ‘+’ button to add your first book.</small>"
|
|
|
+ "<small>Click the '+' button to add your first book.</small>"
|
|
|
);
|
|
|
|
|
|
m_placeholder.append(m_placeholderIcon);
|
|
|
m_placeholder.append(m_placeholderLabel);
|
|
|
}
|
|
|
|
|
|
+void BibliothecaWindow::buildToast() {
|
|
|
+ m_toastRevealer.set_transition_type(Gtk::RevealerTransitionType::SLIDE_UP);
|
|
|
+ m_toastRevealer.set_transition_duration(200);
|
|
|
+ m_toastRevealer.set_halign(Gtk::Align::CENTER);
|
|
|
+ m_toastRevealer.set_valign(Gtk::Align::END);
|
|
|
+ m_toastRevealer.set_reveal_child(false);
|
|
|
+
|
|
|
+ m_toastBox.set_spacing(8);
|
|
|
+ m_toastBox.add_css_class("toast-box");
|
|
|
+
|
|
|
+ m_toastLabel.set_hexpand(true);
|
|
|
+
|
|
|
+ m_toastCloseButton.set_icon_name("window-close-symbolic");
|
|
|
+ m_toastCloseButton.add_css_class("flat");
|
|
|
+ m_toastCloseButton.add_css_class("circular");
|
|
|
+ m_toastCloseButton.signal_clicked().connect(sigc::mem_fun(*this, &BibliothecaWindow::hideToast));
|
|
|
+
|
|
|
+ m_toastBox.append(m_toastLabel);
|
|
|
+ m_toastBox.append(m_toastCloseButton);
|
|
|
+ m_toastRevealer.set_child(m_toastBox);
|
|
|
+}
|
|
|
+
|
|
|
+void BibliothecaWindow::showToast(const Glib::ustring& message, bool is_error) {
|
|
|
+ // Cancel any pending hide timeout
|
|
|
+ if (m_toastTimeout.connected()) {
|
|
|
+ m_toastTimeout.disconnect();
|
|
|
+ }
|
|
|
+
|
|
|
+ m_toastLabel.set_text(message);
|
|
|
+
|
|
|
+ // Update styling
|
|
|
+ m_toastBox.remove_css_class("toast-error");
|
|
|
+ m_toastBox.remove_css_class("toast-success");
|
|
|
+ m_toastBox.add_css_class(is_error ? "toast-error" : "toast-success");
|
|
|
+
|
|
|
+ m_toastRevealer.set_reveal_child(true);
|
|
|
+
|
|
|
+ // Auto-hide after 4 seconds
|
|
|
+ m_toastTimeout = Glib::signal_timeout().connect([this]() {
|
|
|
+ hideToast();
|
|
|
+ return false; // Don't repeat
|
|
|
+ }, 4000);
|
|
|
+}
|
|
|
+
|
|
|
+void BibliothecaWindow::hideToast() {
|
|
|
+ if (m_toastTimeout.connected()) {
|
|
|
+ m_toastTimeout.disconnect();
|
|
|
+ }
|
|
|
+ m_toastRevealer.set_reveal_child(false);
|
|
|
+}
|
|
|
+
|
|
|
void BibliothecaWindow::showPlaceholderIfEmpty() {
|
|
|
updateVisibleView();
|
|
|
}
|
|
|
@@ -195,7 +266,7 @@ void BibliothecaWindow::updateVisibleView() {
|
|
|
m_noResultsLabel.set_markup(
|
|
|
Glib::ustring::compose(
|
|
|
"<span size='large' weight='bold'>No matches</span>\n"
|
|
|
- "<small>No books match “%1”.</small>", escaped));
|
|
|
+ "<small>No books match \"%1\".</small>", escaped));
|
|
|
m_stack.set_visible_child("noresults");
|
|
|
} else {
|
|
|
m_stack.set_visible_child("shelf");
|
|
|
@@ -269,14 +340,20 @@ void BibliothecaWindow::onAddBookClicked() {
|
|
|
try {
|
|
|
files = dialog->open_multiple_finish(res);
|
|
|
} catch (const Glib::Error& e) {
|
|
|
- std::cerr << "File dialog error: " << e.what() << "\n";
|
|
|
+ // User cancelled or error
|
|
|
+ if (e.code() != 2) { // 2 = GTK_DIALOG_ERROR_DISMISSED
|
|
|
+ showToast("Failed to open file dialog", true);
|
|
|
+ }
|
|
|
return;
|
|
|
}
|
|
|
if (files.empty()) return;
|
|
|
|
|
|
+ const size_t total_files = files.size();
|
|
|
+
|
|
|
// Hash and create Book objects off the GTK thread
|
|
|
- std::thread([this, files = std::move(files)]() {
|
|
|
+ std::thread([this, files = std::move(files), total_files]() {
|
|
|
std::vector<Book> imported;
|
|
|
+ std::vector<std::string> errors;
|
|
|
imported.reserve(files.size());
|
|
|
|
|
|
for (const auto& f : files) {
|
|
|
@@ -299,16 +376,29 @@ void BibliothecaWindow::onAddBookClicked() {
|
|
|
|
|
|
imported.push_back(std::move(b));
|
|
|
} catch (const std::exception& e) {
|
|
|
+ errors.push_back(Glib::path_get_basename(f->get_path()) + ": " + e.what());
|
|
|
std::cerr << "Import failed: " << e.what() << "\n";
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Push to model on the GTK main loop
|
|
|
- Glib::signal_idle().connect_once([this, books = std::move(imported)]() mutable {
|
|
|
+ Glib::signal_idle().connect_once([this, books = std::move(imported), errors = std::move(errors), total_files]() mutable {
|
|
|
if (!books.empty()) {
|
|
|
m_bookList.upsertMany(books);
|
|
|
updateVisibleView();
|
|
|
}
|
|
|
+
|
|
|
+ // Show toast with import results
|
|
|
+ const size_t success_count = books.size();
|
|
|
+ const size_t error_count = errors.size();
|
|
|
+
|
|
|
+ if (error_count == 0 && success_count > 0) {
|
|
|
+ showToast(Glib::ustring::compose("Added %1 book%2", success_count, success_count == 1 ? "" : "s"), false);
|
|
|
+ } else if (success_count == 0 && error_count > 0) {
|
|
|
+ showToast(Glib::ustring::compose("Failed to import %1 file%2", error_count, error_count == 1 ? "" : "s"), true);
|
|
|
+ } else if (success_count > 0 && error_count > 0) {
|
|
|
+ showToast(Glib::ustring::compose("Added %1, failed %2", success_count, error_count), true);
|
|
|
+ }
|
|
|
});
|
|
|
}).detach();
|
|
|
}
|