Преглед изворни кода

Add settings panel and file change detection

Settings Panel (Phase 3.4):
- SettingsDialog with tile size adjustment (80-300px)
- Default import folder selection with file chooser
- Settings persisted in SQLite via DatabaseManager
- Live reload of shelf view when tile size changes

File Change Detection (Phase 3.5):
- DatabaseManager::get_all_book_files() for scanning
- DatabaseManager::remove_books_by_ids() for batch removal
- BookList::findMissingBooks() detects deleted files
- BookList::removeMissingBooks() cleans up library
- "Remove Missing Files" button in Settings dialog
- Toast notifications for scan results

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Bernardo Magri пре 1 месец
родитељ
комит
9120e72405
10 измењених фајлова са 482 додато и 14 уклоњено
  1. 21 12
      PLAN.md
  2. 1 1
      meson.build
  3. 55 1
      src/BibliothecaWindow.cpp
  4. 7 0
      src/BibliothecaWindow.hpp
  5. 40 0
      src/BookList.cpp
  6. 6 0
      src/BookList.hpp
  7. 102 0
      src/DatabaseManager.cpp
  8. 25 0
      src/DatabaseManager.hpp
  9. 161 0
      src/SettingsDialog.cpp
  10. 64 0
      src/SettingsDialog.hpp

+ 21 - 12
PLAN.md

@@ -171,22 +171,31 @@ Implemented:
 - [x] DatabaseManager methods: get_all_tags(), rename_tag(), delete_tag(), delete_unused_tags()
 
 ### 3.4 Settings Panel
-**Files**: New files required
-**Status**: Not Started
+**Files**: `src/SettingsDialog.cpp`, `src/SettingsDialog.hpp`, `src/BibliothecaWindow.cpp`
+**Status**: COMPLETED
+
+Implemented:
+- [x] Grid tile size adjustment (80-300 pixels, with live reload)
+- [x] Default import folder selection
+- [x] Settings stored in SQLite via DatabaseManager
+- [x] Settings button in header bar
 
-- [ ] Grid tile size adjustment
-- [ ] Default import folder
-- [ ] Cover quality settings
-- [ ] Theme selection (if GTK allows)
+Note: Cover quality and theme selection deferred - GTK handles theme system-wide.
 
 ### 3.5 File Change Detection
-**Files**: `src/DatabaseManager.cpp`, `src/BookList.cpp`
-**Status**: Not Started
+**Files**: `src/DatabaseManager.cpp`, `src/DatabaseManager.hpp`, `src/BookList.cpp`, `src/BookList.hpp`, `src/SettingsDialog.cpp`
+**Status**: COMPLETED
 
-- [ ] Track file modification time (already in schema)
-- [ ] Detect moved/renamed files
-- [ ] Option to re-scan library
-- [ ] Remove entries for deleted files
+Implemented:
+- [x] Track file modification time (already in schema: mtime_unix, size_bytes)
+- [x] DatabaseManager::get_all_book_files() to retrieve file info
+- [x] DatabaseManager::remove_books_by_ids() for batch removal
+- [x] BookList::findMissingBooks() to scan for missing files
+- [x] BookList::removeMissingBooks() to clean up library
+- [x] "Remove Missing Files" button in Settings dialog
+- [x] Toast notification showing results of scan
+
+Note: Moved/renamed file detection deferred - would require fuzzy matching by size/hash.
 
 ---
 

+ 1 - 1
meson.build

@@ -12,7 +12,7 @@ poppler  = dependency('poppler-glib')
 src = ['src/main.cpp', 'src/BibliothecaWindow.cpp', 'src/Book.cpp',
 'src/DatabaseManager.cpp', 'src/Sha256.cpp', 'src/BookList.cpp',
 'src/BookShelf.cpp', 'src/BookTile.cpp', 'src/BookImport.cpp', 'src/BookDetails.cpp',
-'src/TagManagerDialog.cpp']
+'src/TagManagerDialog.cpp', 'src/SettingsDialog.cpp']
 
 executable('bibliotheca',
   src,

+ 55 - 1
src/BibliothecaWindow.cpp

@@ -100,7 +100,9 @@ BibliothecaWindow::BibliothecaWindow(DatabaseManager& db, BookList& bookList)
   m_noResultsLabel.set_use_markup(true);
   m_noResults.append(m_noResultsLabel);
 
-  m_shelf = std::make_unique<BookShelf>(m_bookList, /*coverSize*/ 180);
+  // Load tile size from settings (default: 180)
+  int tileSize = m_db.get_setting_int("tile_size", 180);
+  m_shelf = std::make_unique<BookShelf>(m_bookList, /*coverSize*/ tileSize);
   m_shelf->signalBookActivated().connect(sigc::mem_fun(*this, &BibliothecaWindow::onBookActivated));
   m_shelf->signalSelectionChanged().connect(sigc::mem_fun(*this, &BibliothecaWindow::updateBatchActionBar));
 
@@ -196,6 +198,14 @@ void BibliothecaWindow::buildHeaderBar() {
   m_tagsButton.signal_clicked().connect(sigc::mem_fun(*this, &BibliothecaWindow::onManageTagsClicked));
   m_headerBar.pack_start(m_tagsButton);
 
+  // Settings button
+  m_settingsButton.set_icon_name("emblem-system-symbolic");
+  m_settingsButton.set_has_frame(false);
+  m_settingsButton.add_css_class("flat");
+  m_settingsButton.set_tooltip_text("Settings");
+  m_settingsButton.signal_clicked().connect(sigc::mem_fun(*this, &BibliothecaWindow::onSettingsClicked));
+  m_headerBar.pack_end(m_settingsButton);
+
   // Add (+) button on the right
   m_addBookButton.set_icon_name("list-add-symbolic");
   m_addBookButton.set_has_frame(false);
@@ -416,6 +426,44 @@ void BibliothecaWindow::onManageTagsClicked() {
   dialog->present();
 }
 
+void BibliothecaWindow::onSettingsClicked() {
+  auto dialog = Gtk::make_managed<SettingsDialog>(*this, m_db);
+  dialog->signalSettingsChanged().connect(sigc::mem_fun(*this, &BibliothecaWindow::applySettings));
+  dialog->signalScanRequested().connect(sigc::mem_fun(*this, &BibliothecaWindow::onScanLibrary));
+  dialog->present();
+}
+
+void BibliothecaWindow::onScanLibrary() {
+  int removed = m_bookList.removeMissingBooks();
+  if (removed > 0) {
+    showToast(Glib::ustring::compose("Removed %1 missing book%2", removed, removed == 1 ? "" : "s"), false);
+    updateVisibleView();
+  } else {
+    showToast("No missing files found", false);
+  }
+}
+
+void BibliothecaWindow::applySettings() {
+  // Read tile size setting and recreate shelf with new size
+  int tileSize = m_db.get_setting_int("tile_size", 180);
+
+  // Recreate shelf with new cover size
+  m_stack.remove(*m_shelf);
+
+  m_shelf = std::make_unique<BookShelf>(m_bookList, tileSize);
+  m_shelf->signalBookActivated().connect(sigc::mem_fun(*this, &BibliothecaWindow::onBookActivated));
+  m_shelf->signalSelectionChanged().connect(sigc::mem_fun(*this, &BibliothecaWindow::updateBatchActionBar));
+
+  m_stack.add(*m_shelf, "shelf");
+
+  // Restore filter if any
+  if (!m_lastQuery.empty()) {
+    m_shelf->setFilter(m_lastQuery);
+  }
+
+  updateVisibleView();
+}
+
 void BibliothecaWindow::showPlaceholderIfEmpty() {
   updateVisibleView();
 }
@@ -480,6 +528,12 @@ void BibliothecaWindow::onAddBookClicked() {
   auto dialog = Gtk::FileDialog::create();
   dialog->set_title("Add books");
 
+  // Set initial folder from settings
+  std::string importFolder = m_db.get_setting("import_folder", "");
+  if (!importFolder.empty()) {
+    dialog->set_initial_folder(Gio::File::create_for_path(importFolder));
+  }
+
   // Build filters via a Gio::ListStore (gtkmm-4.18 API)
   auto epub = Gtk::FileFilter::create();
   epub->set_name("EPUB books");

+ 7 - 0
src/BibliothecaWindow.hpp

@@ -20,6 +20,7 @@
 #include "BookShelf.hpp"
 #include "BookDetails.hpp"
 #include "TagManagerDialog.hpp"
+#include "SettingsDialog.hpp"
 
 // Forward-declare the hash helper you already have.
 std::string sha256_file(const std::string& path);
@@ -68,6 +69,11 @@ private:
   // Tag management
   void onManageTagsClicked();
 
+  // Settings
+  void onSettingsClicked();
+  void applySettings();
+  void onScanLibrary();
+
   // Model (not owned)
   DatabaseManager& m_db;
   BookList& m_bookList;
@@ -77,6 +83,7 @@ private:
   Gtk::Button    m_addBookButton;
   Gtk::Button    m_backButton;
   Gtk::Button    m_tagsButton;
+  Gtk::Button    m_settingsButton;
   Gtk::ToggleButton m_searchButton;
 
   Gtk::Box       m_mainBox {Gtk::Orientation::VERTICAL};

+ 40 - 0
src/BookList.cpp

@@ -1,6 +1,7 @@
 #include "BookList.hpp"
 
 #include <algorithm>
+#include <sys/stat.h>
 #include <glibmm/main.h>
 #include <sigc++/signal.h>
 
@@ -116,3 +117,42 @@ void BookList::loadAllEnqueue() {
 	});
 }
 
+// -------------------------------------------------------------------
+// Library scanning
+// -------------------------------------------------------------------
+std::vector<std::string> BookList::findMissingBooks() const {
+	std::vector<std::string> missing_ids;
+	auto files = m_database.get_all_book_files();
+
+	for (const auto& info : files) {
+		struct stat st{};
+		if (::stat(info.file_path.c_str(), &st) != 0) {
+			// File does not exist
+			missing_ids.push_back(info.id);
+		}
+	}
+	return missing_ids;
+}
+
+int BookList::removeMissingBooks() {
+	auto missing_ids = findMissingBooks();
+	if (missing_ids.empty()) return 0;
+
+	int removed = m_database.remove_books_by_ids(missing_ids);
+
+	if (removed > 0) {
+		std::lock_guard<std::mutex> lock(m_mutex);
+		// Remove from in-memory list
+		for (const auto& id : missing_ids) {
+			auto it = std::find_if(m_books.begin(), m_books.end(),
+				[&](const Book& b) { return b.id() == id; });
+			if (it != m_books.end()) {
+				m_books.erase(it);
+			}
+		}
+		m_signalBulkChanged.emit();
+	}
+
+	return removed;
+}
+

+ 6 - 0
src/BookList.hpp

@@ -39,6 +39,12 @@ public:
 	// ---- Bulk operations ----
 	bool upsertMany(const std::vector<Book>& items);
 
+	// ---- Library scanning ----
+	/// Scan the library for missing files and return their IDs.
+	std::vector<std::string> findMissingBooks() const;
+	/// Remove books whose files no longer exist. Returns number removed.
+	int removeMissingBooks();
+
 	// ---- Worker-thread safe enqueue versions ----
 	void upsertEnqueue(Book book);
 	void removeByIdEnqueue(std::string id);

+ 102 - 0
src/DatabaseManager.cpp

@@ -63,6 +63,10 @@ void DatabaseManager::ensure_schema() {
     " FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,"
     " PRIMARY KEY (book_id, tag_id)"
     ");"
+    "CREATE TABLE IF NOT EXISTS settings ("
+    " key TEXT PRIMARY KEY,"
+    " value TEXT NOT NULL DEFAULT ''"
+    ");"
     "COMMIT;";
   char* err = nullptr;
   if (sqlite3_exec(db_, ddl, nullptr, nullptr, &err) != SQLITE_OK) {
@@ -337,3 +341,101 @@ int DatabaseManager::delete_unused_tags() {
   return sqlite3_changes(db_);
 }
 
+std::string DatabaseManager::get_setting(const std::string& key) {
+  return get_setting(key, "");
+}
+
+std::string DatabaseManager::get_setting(const std::string& key, const std::string& default_value) {
+  std::lock_guard lk(mtx_);
+  static const char* sql = "SELECT value FROM settings WHERE key = ?;";
+  sqlite3_stmt* st = nullptr;
+
+  if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK)
+    return default_value;
+
+  sqlite3_bind_text(st, 1, key.c_str(), -1, SQLITE_TRANSIENT);
+
+  std::string result = default_value;
+  if (sqlite3_step(st) == SQLITE_ROW) {
+    const char* val = reinterpret_cast<const char*>(sqlite3_column_text(st, 0));
+    if (val) result = val;
+  }
+  sqlite3_finalize(st);
+  return result;
+}
+
+void DatabaseManager::set_setting(const std::string& key, const std::string& value) {
+  std::lock_guard lk(mtx_);
+  static const char* sql =
+    "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?);";
+  sqlite3_stmt* st = nullptr;
+
+  if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) return;
+
+  sqlite3_bind_text(st, 1, key.c_str(), -1, SQLITE_TRANSIENT);
+  sqlite3_bind_text(st, 2, value.c_str(), -1, SQLITE_TRANSIENT);
+  sqlite3_step(st);
+  sqlite3_finalize(st);
+}
+
+int DatabaseManager::get_setting_int(const std::string& key, int default_value) {
+  auto str = get_setting(key, "");
+  if (str.empty()) return default_value;
+  try {
+    return std::stoi(str);
+  } catch (...) {
+    return default_value;
+  }
+}
+
+void DatabaseManager::set_setting_int(const std::string& key, int value) {
+  set_setting(key, std::to_string(value));
+}
+
+std::vector<DatabaseManager::BookFileInfo> DatabaseManager::get_all_book_files() {
+  std::lock_guard lk(mtx_);
+  static const char* sql =
+    "SELECT id, file_path, mtime_unix, size_bytes FROM books;";
+  sqlite3_stmt* st = nullptr;
+  std::vector<BookFileInfo> rows;
+
+  if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) return rows;
+
+  while (sqlite3_step(st) == SQLITE_ROW) {
+    BookFileInfo info;
+    info.id = reinterpret_cast<const char*>(sqlite3_column_text(st, 0));
+    info.file_path = reinterpret_cast<const char*>(sqlite3_column_text(st, 1));
+    info.mtime_unix = sqlite3_column_int64(st, 2);
+    info.size_bytes = sqlite3_column_int64(st, 3);
+    rows.push_back(std::move(info));
+  }
+  sqlite3_finalize(st);
+  return rows;
+}
+
+int DatabaseManager::remove_books_by_ids(const std::vector<std::string>& ids) {
+  if (ids.empty()) return 0;
+
+  std::lock_guard lk(mtx_);
+
+  // Use a transaction for efficiency
+  sqlite3_exec(db_, "BEGIN;", nullptr, nullptr, nullptr);
+
+  int removed = 0;
+  static const char* sql = "DELETE FROM books WHERE id = ?;";
+
+  for (const auto& id : ids) {
+    sqlite3_stmt* st = nullptr;
+    if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) == SQLITE_OK) {
+      sqlite3_bind_text(st, 1, id.c_str(), -1, SQLITE_TRANSIENT);
+      if (sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(db_) > 0) {
+        ++removed;
+      }
+      sqlite3_finalize(st);
+    }
+  }
+
+  sqlite3_exec(db_, "COMMIT;", nullptr, nullptr, nullptr);
+  return removed;
+}
+

+ 25 - 0
src/DatabaseManager.hpp

@@ -55,6 +55,31 @@ public:
   /// Delete all unused tags (tags with no associated books).
   int delete_unused_tags();
 
+  // Library scanning / file change detection
+  /// Information about a book file for change detection.
+  struct BookFileInfo {
+    std::string id;
+    std::string file_path;
+    std::int64_t mtime_unix;
+    std::int64_t size_bytes;
+  };
+  /// Get all book file paths with their stored mtime/size.
+  std::vector<BookFileInfo> get_all_book_files();
+  /// Remove multiple books by ID in a single transaction.
+  int remove_books_by_ids(const std::vector<std::string>& ids);
+
+  // Settings management
+  /// Get a setting value by key (returns empty string if not found).
+  std::string get_setting(const std::string& key);
+  /// Get a setting value with default.
+  std::string get_setting(const std::string& key, const std::string& default_value);
+  /// Set a setting value.
+  void set_setting(const std::string& key, const std::string& value);
+  /// Get an integer setting.
+  int get_setting_int(const std::string& key, int default_value);
+  /// Set an integer setting.
+  void set_setting_int(const std::string& key, int value);
+
 private:
   static int busy_handler(void* /*opaque*/, int prev_sleep_ms);
   void prepare_statements();

+ 161 - 0
src/SettingsDialog.cpp

@@ -0,0 +1,161 @@
+#include "SettingsDialog.hpp"
+#include <gtkmm/filechooserdialog.h>
+#include <glibmm/miscutils.h>
+
+// Setting keys
+static constexpr const char* SETTING_TILE_SIZE = "tile_size";
+static constexpr const char* SETTING_IMPORT_FOLDER = "import_folder";
+
+// Defaults
+static constexpr int DEFAULT_TILE_SIZE = 180;
+
+SettingsDialog::SettingsDialog(Gtk::Window& parent, DatabaseManager& db)
+  : Gtk::Dialog()
+  , m_db(db)
+{
+  set_title("Settings");
+  set_transient_for(parent);
+  set_modal(true);
+  set_default_size(400, -1);
+
+  // Configure tile size spin button
+  m_tileSizeSpinButton.set_range(80, 300);
+  m_tileSizeSpinButton.set_increments(10, 50);
+  m_tileSizeSpinButton.set_value(DEFAULT_TILE_SIZE);
+
+  // Style hints
+  m_tileSizeHint.add_css_class("dim-label");
+  m_tileSizeHint.set_xalign(0.0f);
+  m_importFolderHint.add_css_class("dim-label");
+  m_importFolderHint.set_xalign(0.0f);
+
+  // Configure import folder button
+  m_importFolderButton.set_hexpand(true);
+  m_importFolderButton.set_halign(Gtk::Align::FILL);
+
+  // Build grid layout
+  m_grid.set_row_spacing(6);
+  m_grid.set_column_spacing(12);
+  m_grid.set_margin(12);
+
+  // Row 0: Tile size
+  m_tileSizeLabel.set_halign(Gtk::Align::END);
+  m_grid.attach(m_tileSizeLabel, 0, 0);
+  m_grid.attach(m_tileSizeSpinButton, 1, 0);
+
+  // Row 1: Tile size hint
+  m_grid.attach(m_tileSizeHint, 1, 1);
+
+  // Row 2: spacing
+  auto* spacer1 = Gtk::make_managed<Gtk::Label>("");
+  spacer1->set_margin_top(6);
+  m_grid.attach(*spacer1, 0, 2);
+
+  // Row 3: Import folder
+  m_importFolderLabel.set_halign(Gtk::Align::END);
+  m_grid.attach(m_importFolderLabel, 0, 3);
+  m_grid.attach(m_importFolderButton, 1, 3);
+
+  // Row 4: Import folder hint
+  m_grid.attach(m_importFolderHint, 1, 4);
+
+  // Row 5: spacing
+  auto* spacer2 = Gtk::make_managed<Gtk::Label>("");
+  spacer2->set_margin_top(12);
+  m_grid.attach(*spacer2, 0, 5);
+
+  // Row 6: Library maintenance
+  m_maintenanceLabel.set_halign(Gtk::Align::END);
+  m_grid.attach(m_maintenanceLabel, 0, 6);
+  m_scanButton.set_hexpand(false);
+  m_scanButton.set_halign(Gtk::Align::START);
+  m_grid.attach(m_scanButton, 1, 6);
+
+  // Row 7: Scan hint
+  m_scanHint.add_css_class("dim-label");
+  m_scanHint.set_xalign(0.0f);
+  m_grid.attach(m_scanHint, 1, 7);
+
+  m_contentBox.append(m_grid);
+
+  // Button box
+  auto* buttonBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
+  buttonBox->set_margin(12);
+  buttonBox->set_halign(Gtk::Align::END);
+  m_cancelButton.add_css_class("destructive-action");
+  m_saveButton.add_css_class("suggested-action");
+  buttonBox->append(m_cancelButton);
+  buttonBox->append(m_saveButton);
+  m_contentBox.append(*buttonBox);
+
+  // Add to dialog
+  get_content_area()->append(m_contentBox);
+
+  // Connect signals
+  m_saveButton.signal_clicked().connect(
+    sigc::mem_fun(*this, &SettingsDialog::onSaveClicked));
+  m_cancelButton.signal_clicked().connect(
+    sigc::mem_fun(*this, &SettingsDialog::onCancelClicked));
+  m_scanButton.signal_clicked().connect([this]() {
+    m_signalScanRequested.emit();
+  });
+
+  // Import folder button opens file chooser
+  m_importFolderButton.signal_clicked().connect([this]() {
+    auto dialog = Gtk::FileChooserDialog(
+      "Select Default Import Folder",
+      Gtk::FileChooser::Action::SELECT_FOLDER);
+    dialog.set_transient_for(*this);
+    dialog.set_modal(true);
+    dialog.add_button("Cancel", Gtk::ResponseType::CANCEL);
+    dialog.add_button("Select", Gtk::ResponseType::ACCEPT);
+
+    if (!m_importFolderPath.empty()) {
+      dialog.set_current_folder(Gio::File::create_for_path(m_importFolderPath));
+    }
+
+    dialog.signal_response().connect([this, &dialog](int response) {
+      if (response == Gtk::ResponseType::ACCEPT) {
+        auto file = dialog.get_file();
+        if (file) {
+          m_importFolderPath = file->get_path();
+          m_importFolderButton.set_label(m_importFolderPath);
+        }
+      }
+      dialog.close();
+    });
+
+    dialog.present();
+  });
+
+  // Load current settings
+  loadSettings();
+}
+
+void SettingsDialog::loadSettings() {
+  int tileSize = m_db.get_setting_int(SETTING_TILE_SIZE, DEFAULT_TILE_SIZE);
+  m_tileSizeSpinButton.set_value(tileSize);
+
+  m_importFolderPath = m_db.get_setting(SETTING_IMPORT_FOLDER,
+    Glib::get_home_dir());
+  if (m_importFolderPath.empty()) {
+    m_importFolderPath = Glib::get_home_dir();
+  }
+  m_importFolderButton.set_label(m_importFolderPath);
+}
+
+void SettingsDialog::saveSettings() {
+  int tileSize = static_cast<int>(m_tileSizeSpinButton.get_value());
+  m_db.set_setting_int(SETTING_TILE_SIZE, tileSize);
+  m_db.set_setting(SETTING_IMPORT_FOLDER, m_importFolderPath);
+}
+
+void SettingsDialog::onSaveClicked() {
+  saveSettings();
+  m_signalSettingsChanged.emit();
+  close();
+}
+
+void SettingsDialog::onCancelClicked() {
+  close();
+}

+ 64 - 0
src/SettingsDialog.hpp

@@ -0,0 +1,64 @@
+#pragma once
+
+#include <gtkmm/dialog.h>
+#include <gtkmm/box.h>
+#include <gtkmm/label.h>
+#include <gtkmm/spinbutton.h>
+#include <gtkmm/button.h>
+#include <gtkmm/grid.h>
+#include <sigc++/signal.h>
+
+#include "DatabaseManager.hpp"
+
+/**
+ * @brief Settings dialog for configuring application preferences.
+ *
+ * Allows users to adjust:
+ * - Grid tile size (book cover display size)
+ * - Default import folder
+ */
+class SettingsDialog : public Gtk::Dialog {
+public:
+  explicit SettingsDialog(Gtk::Window& parent, DatabaseManager& db);
+
+  /// Signal emitted when settings are saved and may affect the UI.
+  sigc::signal<void()>& signalSettingsChanged() { return m_signalSettingsChanged; }
+
+  /// Signal emitted when user requests to scan for missing files.
+  sigc::signal<void()>& signalScanRequested() { return m_signalScanRequested; }
+
+private:
+  void loadSettings();
+  void saveSettings();
+  void onSaveClicked();
+  void onCancelClicked();
+
+  DatabaseManager& m_db;
+
+  // Layout
+  Gtk::Box  m_contentBox {Gtk::Orientation::VERTICAL, 12};
+  Gtk::Grid m_grid;
+
+  // Tile size setting
+  Gtk::Label      m_tileSizeLabel {"Tile Size:"};
+  Gtk::SpinButton m_tileSizeSpinButton;
+  Gtk::Label      m_tileSizeHint {"Size of book covers in the grid (80-300 pixels)"};
+
+  // Default import folder
+  Gtk::Label  m_importFolderLabel {"Default Import Folder:"};
+  Gtk::Button m_importFolderButton;
+  Gtk::Label  m_importFolderHint {"Folder shown when importing books"};
+  std::string m_importFolderPath;
+
+  // Library maintenance
+  Gtk::Label  m_maintenanceLabel {"Library Maintenance:"};
+  Gtk::Button m_scanButton {"Remove Missing Files"};
+  Gtk::Label  m_scanHint {"Scan library and remove books whose files no longer exist"};
+
+  // Buttons
+  Gtk::Button m_saveButton {"Save"};
+  Gtk::Button m_cancelButton {"Cancel"};
+
+  sigc::signal<void()> m_signalSettingsChanged;
+  sigc::signal<void()> m_signalScanRequested;
+};