From e005abeee2b5f518d78f2b852b4a61a670dd42e4 Mon Sep 17 00:00:00 2001 From: Daniil Polyakov Date: Wed, 1 Apr 2026 22:55:34 +0300 Subject: [PATCH 1/6] feat: initial project structure --- CMakeLists.txt | 89 +++++++++++++++++++++++++++++++++++++++++ app/CMakeLists.txt | 44 ++++++++++++++++++++ app/main.cpp | 13 ++++++ app/mainwindow.cpp | 60 +++++++++++++++++++++++++++ app/mainwindow.h | 14 +++++++ flake.lock | 27 +++++++++++++ flake.nix | 53 ++++++++++++++++++++++++ interfaces/IComponent.h | 17 ++++++++ metadata.json | 10 +++++ nix/app.nix | 64 +++++++++++++++++++++++++++++ nix/default.nix | 27 +++++++++++++ nix/lib.nix | 45 +++++++++++++++++++++ src/ExplorerPlugin.cpp | 17 ++++++++ src/ExplorerPlugin.h | 18 +++++++++ src/ExplorerWidget.cpp | 38 ++++++++++++++++++ src/ExplorerWidget.h | 13 ++++++ 16 files changed, 549 insertions(+) create mode 100644 CMakeLists.txt create mode 100644 app/CMakeLists.txt create mode 100644 app/main.cpp create mode 100644 app/mainwindow.cpp create mode 100644 app/mainwindow.h create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 interfaces/IComponent.h create mode 100644 metadata.json create mode 100644 nix/app.nix create mode 100644 nix/default.nix create mode 100644 nix/lib.nix create mode 100644 src/ExplorerPlugin.cpp create mode 100644 src/ExplorerPlugin.h create mode 100644 src/ExplorerWidget.cpp create mode 100644 src/ExplorerWidget.h diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..1cb8436 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,89 @@ +cmake_minimum_required(VERSION 3.16) +project(LEZExplorerUIPlugin VERSION 1.0.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +set(CMAKE_AUTOUIC ON) + +# Find Qt packages +find_package(Qt6 REQUIRED COMPONENTS Core Widgets) + +# Try to find the component-interfaces package first +find_package(component-interfaces QUIET) + +# If not found, use the local interfaces folder +if(NOT component-interfaces_FOUND) + include_directories(${CMAKE_CURRENT_SOURCE_DIR}/interfaces) + add_library(component-interfaces INTERFACE) + target_include_directories(component-interfaces INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/interfaces) +endif() + +# Source files +set(SOURCES + src/ExplorerPlugin.cpp + src/ExplorerPlugin.h + src/ExplorerWidget.cpp + src/ExplorerWidget.h +) + +# Create the plugin library +add_library(lez_explorer_ui SHARED ${SOURCES}) + +# Set output name without lib prefix +set_target_properties(lez_explorer_ui PROPERTIES + PREFIX "" + OUTPUT_NAME "lez_explorer_ui" +) + +# Include directories +target_include_directories(lez_explorer_ui PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_BINARY_DIR} +) + +# Link against libraries +target_link_libraries(lez_explorer_ui PRIVATE + Qt6::Core + Qt6::Widgets + component-interfaces +) + +# Set common properties for both platforms +set_target_properties(lez_explorer_ui PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/modules" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/modules" + BUILD_WITH_INSTALL_RPATH TRUE + SKIP_BUILD_RPATH FALSE +) + +if(APPLE) + set_target_properties(lez_explorer_ui PROPERTIES + INSTALL_RPATH "@loader_path" + INSTALL_NAME_DIR "@rpath" + BUILD_WITH_INSTALL_NAME_DIR TRUE + ) + add_custom_command(TARGET lez_explorer_ui POST_BUILD + COMMAND install_name_tool -id "@rpath/lez_explorer_ui.dylib" $ + COMMENT "Updating library paths for macOS" + ) +else() + set_target_properties(lez_explorer_ui PROPERTIES + INSTALL_RPATH "$ORIGIN" + INSTALL_RPATH_USE_LINK_PATH FALSE + ) +endif() + +install(TARGETS lez_explorer_ui + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}/logos/modules + RUNTIME DESTINATION ${CMAKE_INSTALL_LIBDIR}/logos/modules + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}/logos/modules +) + +install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/metadata.json + DESTINATION ${CMAKE_INSTALL_DATADIR}/lez-explorer-ui +) + +message(STATUS "LEZ Explorer UI Plugin configured successfully") diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt new file mode 100644 index 0000000..88bb6fc --- /dev/null +++ b/app/CMakeLists.txt @@ -0,0 +1,44 @@ +cmake_minimum_required(VERSION 3.16) +project(LEZExplorerUIApp LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_INCLUDE_CURRENT_DIR ON) + +# Find Qt packages +find_package(Qt6 REQUIRED COMPONENTS Core Widgets) + +# Set output directories +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) + +# Create the executable +add_executable(lez-explorer-ui-app + main.cpp + mainwindow.cpp + mainwindow.h +) + +# Link libraries +target_link_libraries(lez-explorer-ui-app PRIVATE + Qt6::Core + Qt6::Widgets +) + +# Set RPATH settings for the executable +if(APPLE) + set_target_properties(lez-explorer-ui-app PROPERTIES + INSTALL_RPATH "@executable_path/../lib" + BUILD_WITH_INSTALL_RPATH TRUE + ) +elseif(UNIX) + set_target_properties(lez-explorer-ui-app PROPERTIES + INSTALL_RPATH "$ORIGIN/../lib" + BUILD_WITH_INSTALL_RPATH TRUE + ) +endif() + +# Install rules +install(TARGETS lez-explorer-ui-app + RUNTIME DESTINATION bin +) diff --git a/app/main.cpp b/app/main.cpp new file mode 100644 index 0000000..8d904da --- /dev/null +++ b/app/main.cpp @@ -0,0 +1,13 @@ +#include "mainwindow.h" + +#include + +int main(int argc, char* argv[]) +{ + QApplication app(argc, argv); + + MainWindow window; + window.show(); + + return app.exec(); +} diff --git a/app/mainwindow.cpp b/app/mainwindow.cpp new file mode 100644 index 0000000..67ea477 --- /dev/null +++ b/app/mainwindow.cpp @@ -0,0 +1,60 @@ +#include "mainwindow.h" + +#include + +MainWindow::MainWindow(QWidget* parent) + : QMainWindow(parent) +{ + setupUi(); +} + +MainWindow::~MainWindow() = default; + +void MainWindow::setupUi() +{ + // Determine the appropriate plugin extension based on the platform + QString pluginExtension; +#if defined(Q_OS_WIN) + pluginExtension = ".dll"; +#elif defined(Q_OS_MAC) + pluginExtension = ".dylib"; +#else + pluginExtension = ".so"; +#endif + + QString pluginPath = QCoreApplication::applicationDirPath() + "/../lez_explorer_ui" + pluginExtension; + QPluginLoader loader(pluginPath); + + QWidget* explorerWidget = nullptr; + + if (loader.load()) { + QObject* plugin = loader.instance(); + if (plugin) { + QMetaObject::invokeMethod(plugin, "createWidget", + Qt::DirectConnection, + Q_RETURN_ARG(QWidget*, explorerWidget)); + } + } + + if (explorerWidget) { + setCentralWidget(explorerWidget); + } else { + qWarning() << "Failed to load LEZ Explorer UI plugin from:" << pluginPath; + qWarning() << "Error:" << loader.errorString(); + + auto* fallbackWidget = new QWidget(this); + auto* layout = new QVBoxLayout(fallbackWidget); + + auto* messageLabel = new QLabel("LEZ Explorer UI module not loaded", fallbackWidget); + QFont font = messageLabel->font(); + font.setPointSize(14); + messageLabel->setFont(font); + messageLabel->setAlignment(Qt::AlignCenter); + + layout->addWidget(messageLabel); + setCentralWidget(fallbackWidget); + } + + setWindowTitle("LEZ Explorer"); + resize(1024, 768); +} diff --git a/app/mainwindow.h b/app/mainwindow.h new file mode 100644 index 0000000..90a9e50 --- /dev/null +++ b/app/mainwindow.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +class MainWindow : public QMainWindow { + Q_OBJECT + +public: + explicit MainWindow(QWidget* parent = nullptr); + ~MainWindow() override; + +private: + void setupUi(); +}; diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..fe08f56 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1751274312, + "narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..30b99af --- /dev/null +++ b/flake.nix @@ -0,0 +1,53 @@ +{ + description = "LEZ Explorer UI - A Qt block explorer for the Logos Execution Zone"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; + }; + + outputs = { self, nixpkgs }: + let + systems = [ "aarch64-darwin" "x86_64-darwin" "aarch64-linux" "x86_64-linux" ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f { + pkgs = import nixpkgs { inherit system; }; + }); + in + { + packages = forAllSystems ({ pkgs }: + let + common = import ./nix/default.nix { inherit pkgs; }; + src = ./.; + + lib = import ./nix/lib.nix { inherit pkgs common src; }; + + app = import ./nix/app.nix { + inherit pkgs common src; + lezExplorerUI = lib; + }; + in + { + lez-explorer-ui-lib = lib; + app = app; + lib = lib; + default = lib; + } + ); + + devShells = forAllSystems ({ pkgs }: { + default = pkgs.mkShell { + nativeBuildInputs = [ + pkgs.cmake + pkgs.ninja + pkgs.pkg-config + ]; + buildInputs = [ + pkgs.qt6.qtbase + ]; + + shellHook = '' + echo "LEZ Explorer UI development environment" + ''; + }; + }); + }; +} diff --git a/interfaces/IComponent.h b/interfaces/IComponent.h new file mode 100644 index 0000000..06ad57a --- /dev/null +++ b/interfaces/IComponent.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include + +class LogosAPI; + +class IComponent { +public: + virtual ~IComponent() = default; + virtual QWidget* createWidget(LogosAPI* logosAPI = nullptr) = 0; + virtual void destroyWidget(QWidget* widget) = 0; +}; + +#define IComponent_iid "com.logos.component.IComponent" +Q_DECLARE_INTERFACE(IComponent, IComponent_iid) diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..0907f1f --- /dev/null +++ b/metadata.json @@ -0,0 +1,10 @@ +{ + "name": "lez_explorer_ui", + "version": "1.0.0", + "description": "LEZ Explorer UI - A block explorer for the Logos Execution Zone", + "type": "ui", + "main": "lez_explorer_ui", + "dependencies": [], + "category": "explorer", + "capabilities": ["ui_components"] +} diff --git a/nix/app.nix b/nix/app.nix new file mode 100644 index 0000000..6f96a0d --- /dev/null +++ b/nix/app.nix @@ -0,0 +1,64 @@ +# Builds the lez-explorer-ui-app standalone application +{ pkgs, common, src, lezExplorerUI }: + +pkgs.stdenv.mkDerivation rec { + pname = "lez-explorer-ui-app"; + version = common.version; + + inherit src; + inherit (common) buildInputs meta; + + nativeBuildInputs = common.nativeBuildInputs ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.patchelf ]; + + # This is a GUI application, enable Qt wrapping + dontWrapQtApps = false; + + qtLibPath = pkgs.lib.makeLibraryPath ([ + pkgs.qt6.qtbase + ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ + pkgs.libglvnd + ]); + + qtPluginPath = "${pkgs.qt6.qtbase}/lib/qt-6/plugins"; + + qtWrapperArgs = [ + "--prefix" "LD_LIBRARY_PATH" ":" qtLibPath + "--prefix" "QT_PLUGIN_PATH" ":" qtPluginPath + ]; + + configurePhase = '' + runHook preConfigure + cmake -S app -B build \ + -GNinja \ + -DCMAKE_BUILD_TYPE=Release + runHook postConfigure + ''; + + buildPhase = '' + runHook preBuild + cmake --build build + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out/bin $out/lib + + # Install the app binary + cp build/bin/lez-explorer-ui-app $out/bin/ + + # Determine platform-specific plugin extension + OS_EXT="so" + case "$(uname -s)" in + Darwin) OS_EXT="dylib";; + esac + + # Copy the explorer UI plugin to root directory (loaded relative to binary) + if [ -f "${lezExplorerUI}/lib/lez_explorer_ui.$OS_EXT" ]; then + cp -L "${lezExplorerUI}/lib/lez_explorer_ui.$OS_EXT" "$out/" + fi + + runHook postInstall + ''; +} diff --git a/nix/default.nix b/nix/default.nix new file mode 100644 index 0000000..96cc00b --- /dev/null +++ b/nix/default.nix @@ -0,0 +1,27 @@ +# Common build configuration shared across all packages +{ pkgs }: + +{ + pname = "lez-explorer-ui"; + version = "1.0.0"; + + nativeBuildInputs = [ + pkgs.cmake + pkgs.ninja + pkgs.pkg-config + pkgs.qt6.wrapQtAppsHook + ]; + + buildInputs = [ + pkgs.qt6.qtbase + ]; + + cmakeFlags = [ + "-GNinja" + ]; + + meta = with pkgs.lib; { + description = "LEZ Explorer UI - A Qt block explorer for the Logos Execution Zone"; + platforms = platforms.unix; + }; +} diff --git a/nix/lib.nix b/nix/lib.nix new file mode 100644 index 0000000..1eb260e --- /dev/null +++ b/nix/lib.nix @@ -0,0 +1,45 @@ +# Builds the lez-explorer-ui library (Qt plugin) +{ pkgs, common, src }: + +pkgs.stdenv.mkDerivation { + pname = "${common.pname}-lib"; + version = common.version; + + inherit src; + inherit (common) buildInputs cmakeFlags meta; + nativeBuildInputs = common.nativeBuildInputs; + + # Library (Qt plugin), not an app — no Qt wrapper + dontWrapQtApps = true; + + configurePhase = '' + runHook preConfigure + cmake -S . -B build \ + -GNinja \ + -DCMAKE_BUILD_TYPE=Release + runHook postConfigure + ''; + + buildPhase = '' + runHook preBuild + cmake --build build + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out/lib + if [ -f build/modules/lez_explorer_ui.dylib ]; then + cp build/modules/lez_explorer_ui.dylib $out/lib/ + elif [ -f build/modules/lez_explorer_ui.so ]; then + cp build/modules/lez_explorer_ui.so $out/lib/ + else + echo "Error: No library file found in build/modules/" + ls -la build/modules/ 2>/dev/null || true + exit 1 + fi + + runHook postInstall + ''; +} diff --git a/src/ExplorerPlugin.cpp b/src/ExplorerPlugin.cpp new file mode 100644 index 0000000..58aaeb5 --- /dev/null +++ b/src/ExplorerPlugin.cpp @@ -0,0 +1,17 @@ +#include "ExplorerPlugin.h" +#include "ExplorerWidget.h" + +ExplorerPlugin::ExplorerPlugin(QObject* parent) + : QObject(parent) +{ +} + +QWidget* ExplorerPlugin::createWidget(LogosAPI* /*logosAPI*/) +{ + return new ExplorerWidget(); +} + +void ExplorerPlugin::destroyWidget(QWidget* widget) +{ + delete widget; +} diff --git a/src/ExplorerPlugin.h b/src/ExplorerPlugin.h new file mode 100644 index 0000000..458b9ec --- /dev/null +++ b/src/ExplorerPlugin.h @@ -0,0 +1,18 @@ +#pragma once + +#include "IComponent.h" + +#include +#include + +class ExplorerPlugin : public QObject, public IComponent { + Q_OBJECT + Q_INTERFACES(IComponent) + Q_PLUGIN_METADATA(IID IComponent_iid FILE "metadata.json") + +public: + explicit ExplorerPlugin(QObject* parent = nullptr); + + Q_INVOKABLE QWidget* createWidget(LogosAPI* logosAPI = nullptr) override; + void destroyWidget(QWidget* widget) override; +}; diff --git a/src/ExplorerWidget.cpp b/src/ExplorerWidget.cpp new file mode 100644 index 0000000..76b72bb --- /dev/null +++ b/src/ExplorerWidget.cpp @@ -0,0 +1,38 @@ +#include "ExplorerWidget.h" + +#include +#include +#include + +ExplorerWidget::ExplorerWidget(QWidget* parent) + : QWidget(parent) +{ + auto* layout = new QVBoxLayout(this); + + auto* label = new QLabel("LEZ Explorer", this); + label->setAlignment(Qt::AlignCenter); + QFont font = label->font(); + font.setPointSize(24); + font.setBold(true); + label->setFont(font); + label->setStyleSheet("color: white;"); + + layout->addWidget(label); +} + +void ExplorerWidget::paintEvent(QPaintEvent* /*event*/) +{ + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + + // Draw a blue rectangle filling the widget + painter.setBrush(QColor(30, 60, 120)); + painter.setPen(Qt::NoPen); + painter.drawRect(rect()); + + // Draw a lighter inner rectangle + int margin = 40; + QRect inner = rect().adjusted(margin, margin, -margin, -margin); + painter.setBrush(QColor(40, 80, 160)); + painter.drawRoundedRect(inner, 16, 16); +} diff --git a/src/ExplorerWidget.h b/src/ExplorerWidget.h new file mode 100644 index 0000000..341247f --- /dev/null +++ b/src/ExplorerWidget.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +class ExplorerWidget : public QWidget { + Q_OBJECT + +public: + explicit ExplorerWidget(QWidget* parent = nullptr); + +protected: + void paintEvent(QPaintEvent* event) override; +}; From 871765de2d4023ae0092e989f8e26daa1e9931f0 Mon Sep 17 00:00:00 2001 From: Daniil Polyakov Date: Thu, 2 Apr 2026 00:19:02 +0300 Subject: [PATCH 2/6] feat: first working version --- .gitignore | 1 + CMakeLists.txt | 20 ++ src/ExplorerWidget.cpp | 205 +++++++++++++++--- src/ExplorerWidget.h | 40 +++- src/Style.h | 44 ++++ src/models/Account.h | 11 + src/models/Block.h | 34 +++ src/models/Transaction.h | 47 +++++ src/pages/AccountPage.cpp | 134 ++++++++++++ src/pages/AccountPage.h | 16 ++ src/pages/BlockPage.cpp | 162 +++++++++++++++ src/pages/BlockPage.h | 16 ++ src/pages/MainPage.cpp | 256 +++++++++++++++++++++++ src/pages/MainPage.h | 36 ++++ src/pages/TransactionPage.cpp | 160 +++++++++++++++ src/pages/TransactionPage.h | 15 ++ src/services/IndexerService.h | 28 +++ src/services/MockIndexerService.cpp | 308 ++++++++++++++++++++++++++++ src/services/MockIndexerService.h | 32 +++ src/widgets/ClickableFrame.h | 27 +++ src/widgets/NavigationBar.cpp | 45 ++++ src/widgets/NavigationBar.h | 24 +++ src/widgets/SearchBar.cpp | 34 +++ src/widgets/SearchBar.h | 20 ++ 24 files changed, 1690 insertions(+), 25 deletions(-) create mode 100644 .gitignore create mode 100644 src/Style.h create mode 100644 src/models/Account.h create mode 100644 src/models/Block.h create mode 100644 src/models/Transaction.h create mode 100644 src/pages/AccountPage.cpp create mode 100644 src/pages/AccountPage.h create mode 100644 src/pages/BlockPage.cpp create mode 100644 src/pages/BlockPage.h create mode 100644 src/pages/MainPage.cpp create mode 100644 src/pages/MainPage.h create mode 100644 src/pages/TransactionPage.cpp create mode 100644 src/pages/TransactionPage.h create mode 100644 src/services/IndexerService.h create mode 100644 src/services/MockIndexerService.cpp create mode 100644 src/services/MockIndexerService.h create mode 100644 src/widgets/ClickableFrame.h create mode 100644 src/widgets/NavigationBar.cpp create mode 100644 src/widgets/NavigationBar.h create mode 100644 src/widgets/SearchBar.cpp create mode 100644 src/widgets/SearchBar.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2be92b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +result diff --git a/CMakeLists.txt b/CMakeLists.txt index 1cb8436..e6ae9cc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,26 @@ set(SOURCES src/ExplorerPlugin.h src/ExplorerWidget.cpp src/ExplorerWidget.h + src/Style.h + src/services/MockIndexerService.cpp + src/services/MockIndexerService.h + src/services/IndexerService.h + src/models/Block.h + src/models/Transaction.h + src/models/Account.h + src/widgets/SearchBar.cpp + src/widgets/SearchBar.h + src/widgets/NavigationBar.cpp + src/widgets/NavigationBar.h + src/widgets/ClickableFrame.h + src/pages/MainPage.cpp + src/pages/MainPage.h + src/pages/BlockPage.cpp + src/pages/BlockPage.h + src/pages/TransactionPage.cpp + src/pages/TransactionPage.h + src/pages/AccountPage.cpp + src/pages/AccountPage.h ) # Create the plugin library diff --git a/src/ExplorerWidget.cpp b/src/ExplorerWidget.cpp index 76b72bb..7149ff9 100644 --- a/src/ExplorerWidget.cpp +++ b/src/ExplorerWidget.cpp @@ -1,38 +1,197 @@ #include "ExplorerWidget.h" +#include "services/MockIndexerService.h" +#include "widgets/NavigationBar.h" +#include "widgets/SearchBar.h" +#include "pages/MainPage.h" +#include "pages/BlockPage.h" +#include "pages/TransactionPage.h" +#include "pages/AccountPage.h" -#include #include +#include #include ExplorerWidget::ExplorerWidget(QWidget* parent) : QWidget(parent) + , m_indexer(std::make_unique()) { auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(4); - auto* label = new QLabel("LEZ Explorer", this); - label->setAlignment(Qt::AlignCenter); - QFont font = label->font(); - font.setPointSize(24); - font.setBold(true); - label->setFont(font); - label->setStyleSheet("color: white;"); + // Navigation bar + m_navBar = new NavigationBar(this); + layout->addWidget(m_navBar); - layout->addWidget(label); + // Search bar + m_searchBar = new SearchBar(this); + layout->addWidget(m_searchBar); + + // Page stack + m_stack = new QStackedWidget(this); + layout->addWidget(m_stack, 1); + + // Create main page + m_mainPage = new MainPage(m_indexer.get(), this); + m_stack->addWidget(m_mainPage); + connectPageSignals(m_mainPage); + + m_currentTarget = NavHome{}; + + // Connect navigation + connect(m_navBar, &NavigationBar::backClicked, this, &ExplorerWidget::navigateBack); + connect(m_navBar, &NavigationBar::forwardClicked, this, &ExplorerWidget::navigateForward); + connect(m_navBar, &NavigationBar::homeClicked, this, &ExplorerWidget::navigateHome); + connect(m_searchBar, &SearchBar::searchRequested, this, &ExplorerWidget::onSearch); + + updateNavButtons(); } -void ExplorerWidget::paintEvent(QPaintEvent* /*event*/) +ExplorerWidget::~ExplorerWidget() = default; + +void ExplorerWidget::connectPageSignals(QWidget* page) { - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing); - - // Draw a blue rectangle filling the widget - painter.setBrush(QColor(30, 60, 120)); - painter.setPen(Qt::NoPen); - painter.drawRect(rect()); - - // Draw a lighter inner rectangle - int margin = 40; - QRect inner = rect().adjusted(margin, margin, -margin, -margin); - painter.setBrush(QColor(40, 80, 160)); - painter.drawRoundedRect(inner, 16, 16); + if (auto* mainPage = qobject_cast(page)) { + connect(mainPage, &MainPage::blockClicked, this, [this](quint64 id) { + navigateTo(NavBlock{id}); + }); + connect(mainPage, &MainPage::transactionClicked, this, [this](const QString& hash) { + navigateTo(NavTransaction{hash}); + }); + connect(mainPage, &MainPage::accountClicked, this, [this](const QString& id) { + navigateTo(NavAccount{id}); + }); + } else if (auto* blockPage = qobject_cast(page)) { + connect(blockPage, &BlockPage::blockClicked, this, [this](quint64 id) { + navigateTo(NavBlock{id}); + }); + connect(blockPage, &BlockPage::transactionClicked, this, [this](const QString& hash) { + navigateTo(NavTransaction{hash}); + }); + } else if (auto* txPage = qobject_cast(page)) { + connect(txPage, &TransactionPage::accountClicked, this, [this](const QString& id) { + navigateTo(NavAccount{id}); + }); + } else if (auto* accPage = qobject_cast(page)) { + connect(accPage, &AccountPage::transactionClicked, this, [this](const QString& hash) { + navigateTo(NavTransaction{hash}); + }); + } +} + +void ExplorerWidget::showPage(QWidget* page) +{ + // Remove all pages except main page + while (m_stack->count() > 1) { + auto* w = m_stack->widget(1); + m_stack->removeWidget(w); + w->deleteLater(); + } + + if (page == m_mainPage) { + m_stack->setCurrentWidget(m_mainPage); + } else { + m_stack->addWidget(page); + m_stack->setCurrentWidget(page); + } +} + +void ExplorerWidget::navigateTo(const NavTarget& target, bool addToHistory) +{ + if (addToHistory) { + m_backHistory.push(m_currentTarget); + m_forwardHistory.clear(); + } + + m_currentTarget = target; + + std::visit([this](auto&& t) { + using T = std::decay_t; + + if constexpr (std::is_same_v) { + m_mainPage->clearSearchResults(); + m_mainPage->refresh(); + showPage(m_mainPage); + } else if constexpr (std::is_same_v) { + auto block = m_indexer->getBlockById(t.blockId); + if (block) { + auto* page = new BlockPage(*block); + connectPageSignals(page); + showPage(page); + } + } else if constexpr (std::is_same_v) { + auto tx = m_indexer->getTransaction(t.hash); + if (tx) { + auto* page = new TransactionPage(*tx); + connectPageSignals(page); + showPage(page); + } + } else if constexpr (std::is_same_v) { + auto account = m_indexer->getAccount(t.accountId); + if (account) { + auto* page = new AccountPage(*account, m_indexer.get()); + connectPageSignals(page); + showPage(page); + } + } + }, target); + + updateNavButtons(); +} + +void ExplorerWidget::navigateBack() +{ + if (m_backHistory.isEmpty()) return; + + m_forwardHistory.push(m_currentTarget); + NavTarget target = m_backHistory.pop(); + navigateTo(target, false); +} + +void ExplorerWidget::navigateForward() +{ + if (m_forwardHistory.isEmpty()) return; + + m_backHistory.push(m_currentTarget); + NavTarget target = m_forwardHistory.pop(); + navigateTo(target, false); +} + +void ExplorerWidget::navigateHome() +{ + navigateTo(NavHome{}); +} + +void ExplorerWidget::onSearch(const QString& query) +{ + if (query.trimmed().isEmpty()) return; + + auto results = m_indexer->search(query); + + // If exactly one result, navigate directly to it + int totalResults = results.blocks.size() + results.transactions.size() + results.accounts.size(); + if (totalResults == 1) { + if (!results.blocks.isEmpty()) { + navigateTo(NavBlock{results.blocks.first().blockId}); + return; + } + if (!results.transactions.isEmpty()) { + navigateTo(NavTransaction{results.transactions.first().hash}); + return; + } + if (!results.accounts.isEmpty()) { + navigateTo(NavAccount{results.accounts.first().accountId}); + return; + } + } + + // Show results on main page + navigateTo(NavHome{}); + m_mainPage->showSearchResults(results); +} + +void ExplorerWidget::updateNavButtons() +{ + m_navBar->setBackEnabled(!m_backHistory.isEmpty()); + m_navBar->setForwardEnabled(!m_forwardHistory.isEmpty()); } diff --git a/src/ExplorerWidget.h b/src/ExplorerWidget.h index 341247f..b91f885 100644 --- a/src/ExplorerWidget.h +++ b/src/ExplorerWidget.h @@ -1,13 +1,49 @@ #pragma once +#include "services/IndexerService.h" + #include +#include +#include +#include + +class QStackedWidget; +class NavigationBar; +class SearchBar; +class MainPage; + +struct NavHome {}; +struct NavBlock { quint64 blockId; }; +struct NavTransaction { QString hash; }; +struct NavAccount { QString accountId; }; + +using NavTarget = std::variant; class ExplorerWidget : public QWidget { Q_OBJECT public: explicit ExplorerWidget(QWidget* parent = nullptr); + ~ExplorerWidget() override; -protected: - void paintEvent(QPaintEvent* event) override; +private: + void navigateTo(const NavTarget& target, bool addToHistory = true); + void navigateBack(); + void navigateForward(); + void navigateHome(); + void onSearch(const QString& query); + void updateNavButtons(); + + void showPage(QWidget* page); + void connectPageSignals(QWidget* page); + + std::unique_ptr m_indexer; + NavigationBar* m_navBar = nullptr; + SearchBar* m_searchBar = nullptr; + QStackedWidget* m_stack = nullptr; + MainPage* m_mainPage = nullptr; + + QStack m_backHistory; + QStack m_forwardHistory; + NavTarget m_currentTarget; }; diff --git a/src/Style.h b/src/Style.h new file mode 100644 index 0000000..108c11e --- /dev/null +++ b/src/Style.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +namespace Style { + +inline QString cardFrame() +{ + return "background: #f8f9fa; color: #212529; border: 1px solid #dee2e6; border-radius: 6px; padding: 12px;"; +} + +inline QString cardFrameWithLabels() +{ + return "QFrame { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 12px; }" + " QFrame QLabel { color: #212529; }"; +} + +inline QString clickableRow() +{ + return "background: #f8f9fa; color: #212529; border: 1px solid #dee2e6; border-radius: 6px; padding: 8px; margin: 2px 0;"; +} + +inline QString clickableRowWithLabels(const QString& selector) +{ + return QString("%1 { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 8px; margin: 2px 0; }" + " %1 QLabel { color: #212529; }").arg(selector); +} + +inline QString monoText() +{ + return "font-family: 'Menlo', 'Courier New', 'DejaVu Sans Mono'; font-size: 11px;"; +} + +inline QString mutedText() +{ + return "color: #6c757d;"; +} + +inline QString badge(const QString& bgColor) +{ + return QString("color: white; background: %1; border-radius: 4px; padding: 2px 8px;").arg(bgColor); +} + +} // namespace Style diff --git a/src/models/Account.h b/src/models/Account.h new file mode 100644 index 0000000..d7eaaa1 --- /dev/null +++ b/src/models/Account.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +struct Account { + QString accountId; + QString programOwner; + QString balance; + QString nonce; + int dataSizeBytes = 0; +}; diff --git a/src/models/Block.h b/src/models/Block.h new file mode 100644 index 0000000..6c9acce --- /dev/null +++ b/src/models/Block.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include + +#include "Transaction.h" + +enum class BedrockStatus { + Pending, + Safe, + Finalized +}; + +inline QString bedrockStatusToString(BedrockStatus status) +{ + switch (status) { + case BedrockStatus::Pending: return "Pending"; + case BedrockStatus::Safe: return "Safe"; + case BedrockStatus::Finalized: return "Finalized"; + } + return "Unknown"; +} + +struct Block { + quint64 blockId = 0; + QString hash; + QString prevBlockHash; + QDateTime timestamp; + QString signature; + QVector transactions; + BedrockStatus bedrockStatus = BedrockStatus::Pending; + QString bedrockParentId; +}; diff --git a/src/models/Transaction.h b/src/models/Transaction.h new file mode 100644 index 0000000..09a9246 --- /dev/null +++ b/src/models/Transaction.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +enum class TransactionType { + Public, + PrivacyPreserving, + ProgramDeployment +}; + +inline QString transactionTypeToString(TransactionType type) +{ + switch (type) { + case TransactionType::Public: return "Public"; + case TransactionType::PrivacyPreserving: return "Privacy-Preserving"; + case TransactionType::ProgramDeployment: return "Program Deployment"; + } + return "Unknown"; +} + +struct AccountRef { + QString accountId; + QString nonce; +}; + +struct Transaction { + QString hash; + TransactionType type = TransactionType::Public; + + // Public transaction fields + QString programId; + QVector accounts; + QVector instructionData; + int signatureCount = 0; + int proofSizeBytes = 0; + + // Privacy-preserving transaction fields + int newCommitmentsCount = 0; + int nullifiersCount = 0; + int encryptedStatesCount = 0; + quint64 validityWindowStart = 0; + quint64 validityWindowEnd = 0; + + // Program deployment fields + int bytecodeSizeBytes = 0; +}; diff --git a/src/pages/AccountPage.cpp b/src/pages/AccountPage.cpp new file mode 100644 index 0000000..af37159 --- /dev/null +++ b/src/pages/AccountPage.cpp @@ -0,0 +1,134 @@ +#include "AccountPage.h" +#include "Style.h" +#include "widgets/ClickableFrame.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +QLabel* makeFieldLabel(const QString& text) +{ + auto* label = new QLabel(text); + label->setStyleSheet(Style::mutedText() + " font-weight: bold;"); + return label; +} + +QLabel* makeValueLabel(const QString& text) +{ + auto* label = new QLabel(text); + label->setTextInteractionFlags(Qt::TextSelectableByMouse); + label->setWordWrap(true); + return label; +} + +} // namespace + +AccountPage::AccountPage(const Account& account, IndexerService* indexer, QWidget* parent) + : QWidget(parent) +{ + auto* outerLayout = new QVBoxLayout(this); + + auto* scrollArea = new QScrollArea(this); + scrollArea->setWidgetResizable(true); + scrollArea->setFrameShape(QFrame::NoFrame); + + auto* scrollContent = new QWidget(); + auto* layout = new QVBoxLayout(scrollContent); + layout->setAlignment(Qt::AlignTop); + + // Title + auto* title = new QLabel("Account Details"); + QFont titleFont = title->font(); + titleFont.setPointSize(20); + titleFont.setBold(true); + title->setFont(titleFont); + layout->addWidget(title); + + // Account info grid + auto* infoFrame = new QFrame(); + infoFrame->setFrameShape(QFrame::StyledPanel); + infoFrame->setStyleSheet(Style::cardFrameWithLabels()); + + auto* grid = new QGridLayout(infoFrame); + grid->setColumnStretch(1, 1); + int row = 0; + + grid->addWidget(makeFieldLabel("Account ID"), row, 0); + auto* idVal = makeValueLabel(account.accountId); + idVal->setStyleSheet(Style::monoText()); + grid->addWidget(idVal, row++, 1); + + grid->addWidget(makeFieldLabel("Balance"), row, 0); + grid->addWidget(makeValueLabel(account.balance), row++, 1); + + grid->addWidget(makeFieldLabel("Program Owner"), row, 0); + auto* ownerVal = makeValueLabel(account.programOwner); + ownerVal->setStyleSheet(Style::monoText()); + grid->addWidget(ownerVal, row++, 1); + + grid->addWidget(makeFieldLabel("Nonce"), row, 0); + grid->addWidget(makeValueLabel(account.nonce), row++, 1); + + grid->addWidget(makeFieldLabel("Data Size"), row, 0); + grid->addWidget(makeValueLabel(QString("%1 bytes").arg(account.dataSizeBytes)), row++, 1); + + layout->addWidget(infoFrame); + + // Transaction history + auto transactions = indexer->getTransactionsByAccount(account.accountId, 0, 10); + + if (!transactions.isEmpty()) { + auto* txHeader = new QLabel("Transaction History"); + QFont headerFont = txHeader->font(); + headerFont.setPointSize(16); + headerFont.setBold(true); + txHeader->setFont(headerFont); + txHeader->setStyleSheet("margin-top: 16px; margin-bottom: 4px;"); + layout->addWidget(txHeader); + + for (const auto& tx : transactions) { + auto* frame = new ClickableFrame(); + frame->setFrameShape(QFrame::StyledPanel); + frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); + + auto* txRow = new QHBoxLayout(frame); + + auto* hashLabel = new QLabel(tx.hash.left(16) + "..."); + QFont boldFont = hashLabel->font(); + boldFont.setBold(true); + hashLabel->setFont(boldFont); + + QString typeColor; + switch (tx.type) { + case TransactionType::Public: typeColor = "#007bff"; break; + case TransactionType::PrivacyPreserving: typeColor = "#6f42c1"; break; + case TransactionType::ProgramDeployment: typeColor = "#fd7e14"; break; + } + auto* typeLabel = new QLabel(transactionTypeToString(tx.type)); + typeLabel->setStyleSheet(Style::badge(typeColor)); + + txRow->addWidget(hashLabel); + txRow->addWidget(typeLabel); + txRow->addStretch(); + + QString txHash = tx.hash; + connect(frame, &ClickableFrame::clicked, this, [this, txHash]() { + emit transactionClicked(txHash); + }); + + layout->addWidget(frame); + } + } else { + auto* noTx = new QLabel("No transactions found for this account."); + noTx->setStyleSheet(Style::mutedText() + " margin-top: 16px;"); + layout->addWidget(noTx); + } + + scrollArea->setWidget(scrollContent); + outerLayout->addWidget(scrollArea); +} diff --git a/src/pages/AccountPage.h b/src/pages/AccountPage.h new file mode 100644 index 0000000..083216f --- /dev/null +++ b/src/pages/AccountPage.h @@ -0,0 +1,16 @@ +#pragma once + +#include "models/Account.h" +#include "services/IndexerService.h" + +#include + +class AccountPage : public QWidget { + Q_OBJECT + +public: + explicit AccountPage(const Account& account, IndexerService* indexer, QWidget* parent = nullptr); + +signals: + void transactionClicked(const QString& hash); +}; diff --git a/src/pages/BlockPage.cpp b/src/pages/BlockPage.cpp new file mode 100644 index 0000000..43cc449 --- /dev/null +++ b/src/pages/BlockPage.cpp @@ -0,0 +1,162 @@ +#include "BlockPage.h" +#include "Style.h" +#include "widgets/ClickableFrame.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +QLabel* makeFieldLabel(const QString& text) +{ + auto* label = new QLabel(text); + label->setStyleSheet(Style::mutedText() + " font-weight: bold;"); + return label; +} + +QLabel* makeValueLabel(const QString& text) +{ + auto* label = new QLabel(text); + label->setTextInteractionFlags(Qt::TextSelectableByMouse); + label->setWordWrap(true); + return label; +} + +} // namespace + +BlockPage::BlockPage(const Block& block, QWidget* parent) + : QWidget(parent) +{ + auto* outerLayout = new QVBoxLayout(this); + + auto* scrollArea = new QScrollArea(this); + scrollArea->setWidgetResizable(true); + scrollArea->setFrameShape(QFrame::NoFrame); + + auto* scrollContent = new QWidget(); + auto* layout = new QVBoxLayout(scrollContent); + layout->setAlignment(Qt::AlignTop); + + // Title + auto* title = new QLabel(QString("Block #%1").arg(block.blockId)); + QFont titleFont = title->font(); + titleFont.setPointSize(20); + titleFont.setBold(true); + title->setFont(titleFont); + layout->addWidget(title); + + // Block info grid + auto* infoFrame = new QFrame(); + infoFrame->setFrameShape(QFrame::StyledPanel); + infoFrame->setStyleSheet(Style::cardFrameWithLabels()); + + auto* grid = new QGridLayout(infoFrame); + grid->setColumnStretch(1, 1); + int row = 0; + + grid->addWidget(makeFieldLabel("Block ID"), row, 0); + grid->addWidget(makeValueLabel(QString::number(block.blockId)), row++, 1); + + grid->addWidget(makeFieldLabel("Hash"), row, 0); + grid->addWidget(makeValueLabel(block.hash), row++, 1); + + grid->addWidget(makeFieldLabel("Previous Hash"), row, 0); + auto* prevHashLabel = new QLabel(QString("%1").arg(block.prevBlockHash)); + prevHashLabel->setTextInteractionFlags(Qt::TextBrowserInteraction); + if (block.blockId > 1) { + quint64 prevBlockId = block.blockId - 1; + connect(prevHashLabel, &QLabel::linkActivated, this, [this, prevBlockId]() { + emit blockClicked(prevBlockId); + }); + } + grid->addWidget(prevHashLabel, row++, 1); + + grid->addWidget(makeFieldLabel("Timestamp"), row, 0); + grid->addWidget(makeValueLabel(block.timestamp.toString("yyyy-MM-dd hh:mm:ss UTC")), row++, 1); + + grid->addWidget(makeFieldLabel("Status"), row, 0); + QString statusColor; + switch (block.bedrockStatus) { + case BedrockStatus::Finalized: statusColor = "#28a745"; break; + case BedrockStatus::Safe: statusColor = "#ffc107"; break; + case BedrockStatus::Pending: statusColor = "#6c757d"; break; + } + auto* statusLabel = new QLabel(bedrockStatusToString(block.bedrockStatus)); + statusLabel->setStyleSheet(Style::badge(statusColor) + " max-width: 100px;"); + grid->addWidget(statusLabel, row++, 1); + + grid->addWidget(makeFieldLabel("Signature"), row, 0); + auto* sigLabel = makeValueLabel(block.signature); + sigLabel->setStyleSheet(Style::monoText()); + grid->addWidget(sigLabel, row++, 1); + + grid->addWidget(makeFieldLabel("Transactions"), row, 0); + grid->addWidget(makeValueLabel(QString::number(block.transactions.size())), row++, 1); + + layout->addWidget(infoFrame); + + // Transactions section + if (!block.transactions.isEmpty()) { + auto* txHeader = new QLabel("Transactions"); + QFont headerFont = txHeader->font(); + headerFont.setPointSize(16); + headerFont.setBold(true); + txHeader->setFont(headerFont); + txHeader->setStyleSheet("margin-top: 16px; margin-bottom: 4px;"); + layout->addWidget(txHeader); + + for (const auto& tx : block.transactions) { + auto* frame = new ClickableFrame(); + frame->setFrameShape(QFrame::StyledPanel); + frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); + + auto* txRow = new QHBoxLayout(frame); + + auto* hashLabel = new QLabel(tx.hash.left(16) + "..."); + QFont boldFont = hashLabel->font(); + boldFont.setBold(true); + hashLabel->setFont(boldFont); + + QString typeColor; + switch (tx.type) { + case TransactionType::Public: typeColor = "#007bff"; break; + case TransactionType::PrivacyPreserving: typeColor = "#6f42c1"; break; + case TransactionType::ProgramDeployment: typeColor = "#fd7e14"; break; + } + auto* typeLabel = new QLabel(transactionTypeToString(tx.type)); + typeLabel->setStyleSheet(Style::badge(typeColor)); + + auto* metaLabel = new QLabel(); + switch (tx.type) { + case TransactionType::Public: + metaLabel->setText(QString("%1 accounts").arg(tx.accounts.size())); + break; + case TransactionType::PrivacyPreserving: + metaLabel->setText(QString("%1 accounts, %2 commitments").arg(tx.accounts.size()).arg(tx.newCommitmentsCount)); + break; + case TransactionType::ProgramDeployment: + metaLabel->setText(QString("%1 bytes").arg(tx.bytecodeSizeBytes)); + break; + } + metaLabel->setStyleSheet(Style::mutedText()); + + txRow->addWidget(hashLabel); + txRow->addWidget(typeLabel); + txRow->addWidget(metaLabel, 1); + + QString txHash = tx.hash; + connect(frame, &ClickableFrame::clicked, this, [this, txHash]() { + emit transactionClicked(txHash); + }); + + layout->addWidget(frame); + } + } + + scrollArea->setWidget(scrollContent); + outerLayout->addWidget(scrollArea); +} diff --git a/src/pages/BlockPage.h b/src/pages/BlockPage.h new file mode 100644 index 0000000..d870e0f --- /dev/null +++ b/src/pages/BlockPage.h @@ -0,0 +1,16 @@ +#pragma once + +#include "models/Block.h" + +#include + +class BlockPage : public QWidget { + Q_OBJECT + +public: + explicit BlockPage(const Block& block, QWidget* parent = nullptr); + +signals: + void blockClicked(quint64 blockId); + void transactionClicked(const QString& hash); +}; diff --git a/src/pages/MainPage.cpp b/src/pages/MainPage.cpp new file mode 100644 index 0000000..f9e8c37 --- /dev/null +++ b/src/pages/MainPage.cpp @@ -0,0 +1,256 @@ +#include "MainPage.h" +#include "Style.h" +#include "widgets/ClickableFrame.h" + +#include +#include +#include +#include +#include + +MainPage::MainPage(IndexerService* indexer, QWidget* parent) + : QWidget(parent) + , m_indexer(indexer) +{ + auto* outerLayout = new QVBoxLayout(this); + + // Health indicator + auto* healthRow = new QHBoxLayout(); + m_healthLabel = new QLabel(this); + healthRow->addWidget(m_healthLabel); + healthRow->addStretch(); + outerLayout->addLayout(healthRow); + + // Scroll area for content + auto* scrollArea = new QScrollArea(this); + scrollArea->setWidgetResizable(true); + scrollArea->setFrameShape(QFrame::NoFrame); + + auto* scrollContent = new QWidget(); + m_contentLayout = new QVBoxLayout(scrollContent); + m_contentLayout->setAlignment(Qt::AlignTop); + + scrollArea->setWidget(scrollContent); + outerLayout->addWidget(scrollArea); + + refresh(); +} + +QWidget* MainPage::createSectionHeader(const QString& title) +{ + auto* label = new QLabel(title); + QFont font = label->font(); + font.setPointSize(16); + font.setBold(true); + label->setFont(font); + label->setStyleSheet("margin-top: 12px; margin-bottom: 4px;"); + return label; +} + +void MainPage::addBlockRow(QVBoxLayout* layout, const Block& block) +{ + auto* frame = new ClickableFrame(); + frame->setFrameShape(QFrame::StyledPanel); + frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); + + auto* row = new QHBoxLayout(frame); + + auto* idLabel = new QLabel(QString("Block #%1").arg(block.blockId)); + QFont boldFont = idLabel->font(); + boldFont.setBold(true); + idLabel->setFont(boldFont); + + QString statusColor; + switch (block.bedrockStatus) { + case BedrockStatus::Finalized: statusColor = "#28a745"; break; + case BedrockStatus::Safe: statusColor = "#ffc107"; break; + case BedrockStatus::Pending: statusColor = "#6c757d"; break; + } + auto* statusLabel = new QLabel(bedrockStatusToString(block.bedrockStatus)); + statusLabel->setStyleSheet(Style::badge(statusColor)); + + auto* hashLabel = new QLabel(block.hash.left(16) + "..."); + hashLabel->setStyleSheet(Style::mutedText()); + + auto* txCount = new QLabel(QString("%1 tx").arg(block.transactions.size())); + + auto* timeLabel = new QLabel(block.timestamp.toString("yyyy-MM-dd hh:mm:ss UTC")); + timeLabel->setStyleSheet(Style::mutedText()); + + row->addWidget(idLabel); + row->addWidget(statusLabel); + row->addWidget(hashLabel, 1); + row->addWidget(txCount); + row->addWidget(timeLabel); + + quint64 blockId = block.blockId; + connect(frame, &ClickableFrame::clicked, this, [this, blockId]() { + emit blockClicked(blockId); + }); + + layout->addWidget(frame); +} + +void MainPage::addTransactionRow(QVBoxLayout* layout, const Transaction& tx) +{ + auto* frame = new ClickableFrame(); + frame->setFrameShape(QFrame::StyledPanel); + frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); + + auto* row = new QHBoxLayout(frame); + + auto* hashLabel = new QLabel(tx.hash.left(16) + "..."); + QFont boldFont = hashLabel->font(); + boldFont.setBold(true); + hashLabel->setFont(boldFont); + + QString typeColor; + switch (tx.type) { + case TransactionType::Public: typeColor = "#007bff"; break; + case TransactionType::PrivacyPreserving: typeColor = "#6f42c1"; break; + case TransactionType::ProgramDeployment: typeColor = "#fd7e14"; break; + } + auto* typeLabel = new QLabel(transactionTypeToString(tx.type)); + typeLabel->setStyleSheet(Style::badge(typeColor)); + + auto* metaLabel = new QLabel(); + switch (tx.type) { + case TransactionType::Public: + metaLabel->setText(QString("%1 accounts").arg(tx.accounts.size())); + break; + case TransactionType::PrivacyPreserving: + metaLabel->setText(QString("%1 accounts, %2 commitments").arg(tx.accounts.size()).arg(tx.newCommitmentsCount)); + break; + case TransactionType::ProgramDeployment: + metaLabel->setText(QString("%1 bytes").arg(tx.bytecodeSizeBytes)); + break; + } + metaLabel->setStyleSheet(Style::mutedText()); + + row->addWidget(hashLabel); + row->addWidget(typeLabel); + row->addWidget(metaLabel, 1); + + QString txHash = tx.hash; + connect(frame, &ClickableFrame::clicked, this, [this, txHash]() { + emit transactionClicked(txHash); + }); + + layout->addWidget(frame); +} + +void MainPage::addAccountRow(QVBoxLayout* layout, const Account& account) +{ + auto* frame = new ClickableFrame(); + frame->setFrameShape(QFrame::StyledPanel); + frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); + + auto* row = new QHBoxLayout(frame); + + auto* idLabel = new QLabel(account.accountId.left(16) + "..."); + QFont boldFont = idLabel->font(); + boldFont.setBold(true); + idLabel->setFont(boldFont); + + auto* balanceLabel = new QLabel(QString("Balance: %1").arg(account.balance)); + auto* nonceLabel = new QLabel(QString("Nonce: %1").arg(account.nonce)); + nonceLabel->setStyleSheet(Style::mutedText()); + + row->addWidget(idLabel); + row->addWidget(balanceLabel, 1); + row->addWidget(nonceLabel); + + QString accId = account.accountId; + connect(frame, &ClickableFrame::clicked, this, [this, accId]() { + emit accountClicked(accId); + }); + + layout->addWidget(frame); +} + +void MainPage::refresh() +{ + clearSearchResults(); + + quint64 latestId = m_indexer->getLatestBlockId(); + m_healthLabel->setText(QString("Chain height: %1").arg(latestId)); + m_healthLabel->setStyleSheet("color: #28a745; font-weight: bold; font-size: 14px;"); + + if (m_recentBlocksWidget) { + m_contentLayout->removeWidget(m_recentBlocksWidget); + delete m_recentBlocksWidget; + } + + m_recentBlocksWidget = new QWidget(); + auto* blocksLayout = new QVBoxLayout(m_recentBlocksWidget); + blocksLayout->setContentsMargins(0, 0, 0, 0); + + blocksLayout->addWidget(createSectionHeader("Recent Blocks")); + + auto blocks = m_indexer->getBlocks(std::nullopt, 10); + for (const auto& block : blocks) { + addBlockRow(blocksLayout, block); + } + + m_contentLayout->addWidget(m_recentBlocksWidget); +} + +void MainPage::showSearchResults(const SearchResults& results) +{ + clearSearchResults(); + + if (m_recentBlocksWidget) { + m_recentBlocksWidget->hide(); + } + + m_searchResultsWidget = new QWidget(); + auto* layout = new QVBoxLayout(m_searchResultsWidget); + layout->setContentsMargins(0, 0, 0, 0); + + bool hasResults = false; + + if (!results.blocks.isEmpty()) { + layout->addWidget(createSectionHeader("Blocks")); + for (const auto& block : results.blocks) { + addBlockRow(layout, block); + } + hasResults = true; + } + + if (!results.transactions.isEmpty()) { + layout->addWidget(createSectionHeader("Transactions")); + for (const auto& tx : results.transactions) { + addTransactionRow(layout, tx); + } + hasResults = true; + } + + if (!results.accounts.isEmpty()) { + layout->addWidget(createSectionHeader("Accounts")); + for (const auto& acc : results.accounts) { + addAccountRow(layout, acc); + } + hasResults = true; + } + + if (!hasResults) { + auto* noResults = new QLabel("No results found."); + noResults->setAlignment(Qt::AlignCenter); + noResults->setStyleSheet(Style::mutedText() + " padding: 20px; font-size: 14px;"); + layout->addWidget(noResults); + } + + m_contentLayout->insertWidget(0, m_searchResultsWidget); +} + +void MainPage::clearSearchResults() +{ + if (m_searchResultsWidget) { + m_contentLayout->removeWidget(m_searchResultsWidget); + delete m_searchResultsWidget; + m_searchResultsWidget = nullptr; + } + if (m_recentBlocksWidget) { + m_recentBlocksWidget->show(); + } +} diff --git a/src/pages/MainPage.h b/src/pages/MainPage.h new file mode 100644 index 0000000..7086d6f --- /dev/null +++ b/src/pages/MainPage.h @@ -0,0 +1,36 @@ +#pragma once + +#include "services/IndexerService.h" + +#include + +class QVBoxLayout; +class QLabel; + +class MainPage : public QWidget { + Q_OBJECT + +public: + explicit MainPage(IndexerService* indexer, QWidget* parent = nullptr); + + void refresh(); + void showSearchResults(const SearchResults& results); + void clearSearchResults(); + +signals: + void blockClicked(quint64 blockId); + void transactionClicked(const QString& hash); + void accountClicked(const QString& accountId); + +private: + void addBlockRow(QVBoxLayout* layout, const Block& block); + void addTransactionRow(QVBoxLayout* layout, const Transaction& tx); + void addAccountRow(QVBoxLayout* layout, const Account& account); + QWidget* createSectionHeader(const QString& title); + + IndexerService* m_indexer = nullptr; + QVBoxLayout* m_contentLayout = nullptr; + QWidget* m_searchResultsWidget = nullptr; + QWidget* m_recentBlocksWidget = nullptr; + QLabel* m_healthLabel = nullptr; +}; diff --git a/src/pages/TransactionPage.cpp b/src/pages/TransactionPage.cpp new file mode 100644 index 0000000..58b3109 --- /dev/null +++ b/src/pages/TransactionPage.cpp @@ -0,0 +1,160 @@ +#include "TransactionPage.h" +#include "Style.h" +#include "widgets/ClickableFrame.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +QLabel* makeFieldLabel(const QString& text) +{ + auto* label = new QLabel(text); + label->setStyleSheet(Style::mutedText() + " font-weight: bold;"); + return label; +} + +QLabel* makeValueLabel(const QString& text) +{ + auto* label = new QLabel(text); + label->setTextInteractionFlags(Qt::TextSelectableByMouse); + label->setWordWrap(true); + return label; +} + +} // namespace + +TransactionPage::TransactionPage(const Transaction& tx, QWidget* parent) + : QWidget(parent) +{ + auto* outerLayout = new QVBoxLayout(this); + + auto* scrollArea = new QScrollArea(this); + scrollArea->setWidgetResizable(true); + scrollArea->setFrameShape(QFrame::NoFrame); + + auto* scrollContent = new QWidget(); + auto* layout = new QVBoxLayout(scrollContent); + layout->setAlignment(Qt::AlignTop); + + // Title + auto* title = new QLabel("Transaction Details"); + QFont titleFont = title->font(); + titleFont.setPointSize(20); + titleFont.setBold(true); + title->setFont(titleFont); + layout->addWidget(title); + + // Transaction info grid + auto* infoFrame = new QFrame(); + infoFrame->setFrameShape(QFrame::StyledPanel); + infoFrame->setStyleSheet(Style::cardFrameWithLabels()); + + auto* grid = new QGridLayout(infoFrame); + grid->setColumnStretch(1, 1); + int row = 0; + + grid->addWidget(makeFieldLabel("Hash"), row, 0); + auto* hashVal = makeValueLabel(tx.hash); + hashVal->setStyleSheet(Style::monoText()); + grid->addWidget(hashVal, row++, 1); + + grid->addWidget(makeFieldLabel("Type"), row, 0); + QString typeColor; + switch (tx.type) { + case TransactionType::Public: typeColor = "#007bff"; break; + case TransactionType::PrivacyPreserving: typeColor = "#6f42c1"; break; + case TransactionType::ProgramDeployment: typeColor = "#fd7e14"; break; + } + auto* typeLabel = new QLabel(transactionTypeToString(tx.type)); + typeLabel->setStyleSheet(Style::badge(typeColor) + " max-width: 160px;"); + grid->addWidget(typeLabel, row++, 1); + + // Type-specific fields + switch (tx.type) { + case TransactionType::Public: + grid->addWidget(makeFieldLabel("Program ID"), row, 0); + grid->addWidget(makeValueLabel(tx.programId), row++, 1); + + grid->addWidget(makeFieldLabel("Instruction Data"), row, 0); + grid->addWidget(makeValueLabel(QString("%1 items").arg(tx.instructionData.size())), row++, 1); + + grid->addWidget(makeFieldLabel("Proof Size"), row, 0); + grid->addWidget(makeValueLabel(QString("%1 bytes").arg(tx.proofSizeBytes)), row++, 1); + + grid->addWidget(makeFieldLabel("Signatures"), row, 0); + grid->addWidget(makeValueLabel(QString::number(tx.signatureCount)), row++, 1); + break; + + case TransactionType::PrivacyPreserving: + grid->addWidget(makeFieldLabel("Public Accounts"), row, 0); + grid->addWidget(makeValueLabel(QString::number(tx.accounts.size())), row++, 1); + + grid->addWidget(makeFieldLabel("New Commitments"), row, 0); + grid->addWidget(makeValueLabel(QString::number(tx.newCommitmentsCount)), row++, 1); + + grid->addWidget(makeFieldLabel("Nullifiers"), row, 0); + grid->addWidget(makeValueLabel(QString::number(tx.nullifiersCount)), row++, 1); + + grid->addWidget(makeFieldLabel("Encrypted States"), row, 0); + grid->addWidget(makeValueLabel(QString::number(tx.encryptedStatesCount)), row++, 1); + + grid->addWidget(makeFieldLabel("Proof Size"), row, 0); + grid->addWidget(makeValueLabel(QString("%1 bytes").arg(tx.proofSizeBytes)), row++, 1); + + grid->addWidget(makeFieldLabel("Validity Window"), row, 0); + grid->addWidget(makeValueLabel(QString("[%1, %2)").arg(tx.validityWindowStart).arg(tx.validityWindowEnd)), row++, 1); + break; + + case TransactionType::ProgramDeployment: + grid->addWidget(makeFieldLabel("Bytecode Size"), row, 0); + grid->addWidget(makeValueLabel(QString("%1 bytes").arg(tx.bytecodeSizeBytes)), row++, 1); + break; + } + + layout->addWidget(infoFrame); + + // Accounts section + if (!tx.accounts.isEmpty()) { + auto* accHeader = new QLabel("Accounts"); + QFont headerFont = accHeader->font(); + headerFont.setPointSize(16); + headerFont.setBold(true); + accHeader->setFont(headerFont); + accHeader->setStyleSheet("margin-top: 16px; margin-bottom: 4px;"); + layout->addWidget(accHeader); + + for (const auto& accRef : tx.accounts) { + auto* frame = new ClickableFrame(); + frame->setFrameShape(QFrame::StyledPanel); + frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); + + auto* accRow = new QHBoxLayout(frame); + + auto* idLabel = new QLabel(accRef.accountId.left(20) + "..."); + QFont boldFont = idLabel->font(); + boldFont.setBold(true); + idLabel->setFont(boldFont); + + auto* nonceLabel = new QLabel(QString("Nonce: %1").arg(accRef.nonce)); + nonceLabel->setStyleSheet(Style::mutedText()); + + accRow->addWidget(idLabel, 1); + accRow->addWidget(nonceLabel); + + QString accId = accRef.accountId; + connect(frame, &ClickableFrame::clicked, this, [this, accId]() { + emit accountClicked(accId); + }); + + layout->addWidget(frame); + } + } + + scrollArea->setWidget(scrollContent); + outerLayout->addWidget(scrollArea); +} diff --git a/src/pages/TransactionPage.h b/src/pages/TransactionPage.h new file mode 100644 index 0000000..7f6d978 --- /dev/null +++ b/src/pages/TransactionPage.h @@ -0,0 +1,15 @@ +#pragma once + +#include "models/Transaction.h" + +#include + +class TransactionPage : public QWidget { + Q_OBJECT + +public: + explicit TransactionPage(const Transaction& tx, QWidget* parent = nullptr); + +signals: + void accountClicked(const QString& accountId); +}; diff --git a/src/services/IndexerService.h b/src/services/IndexerService.h new file mode 100644 index 0000000..ac27ba4 --- /dev/null +++ b/src/services/IndexerService.h @@ -0,0 +1,28 @@ +#pragma once + +#include "models/Block.h" +#include "models/Transaction.h" +#include "models/Account.h" + +#include +#include + +struct SearchResults { + QVector blocks; + QVector transactions; + QVector accounts; +}; + +class IndexerService { +public: + virtual ~IndexerService() = default; + + virtual std::optional getAccount(const QString& accountId) = 0; + virtual std::optional getBlockById(quint64 blockId) = 0; + virtual std::optional getBlockByHash(const QString& hash) = 0; + virtual std::optional getTransaction(const QString& hash) = 0; + virtual QVector getBlocks(std::optional before, int limit) = 0; + virtual quint64 getLatestBlockId() = 0; + virtual QVector getTransactionsByAccount(const QString& accountId, int offset, int limit) = 0; + virtual SearchResults search(const QString& query) = 0; +}; diff --git a/src/services/MockIndexerService.cpp b/src/services/MockIndexerService.cpp new file mode 100644 index 0000000..f4262cb --- /dev/null +++ b/src/services/MockIndexerService.cpp @@ -0,0 +1,308 @@ +#include "MockIndexerService.h" + +#include +#include + +namespace { + +QString randomHexString(int bytes) +{ + QString result; + result.reserve(bytes * 2); + auto* rng = QRandomGenerator::global(); + for (int i = 0; i < bytes; ++i) { + result += QString("%1").arg(rng->bounded(256), 2, 16, QChar('0')); + } + return result; +} + +// Generate a base58-like string (simplified — uses alphanumeric chars without 0/O/I/l) +QString randomBase58String(int length) +{ + static const char chars[] = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + QString result; + result.reserve(length); + auto* rng = QRandomGenerator::global(); + for (int i = 0; i < length; ++i) { + result += chars[rng->bounded(static_cast(sizeof(chars) - 1))]; + } + return result; +} + +} // namespace + +MockIndexerService::MockIndexerService() +{ + generateData(); +} + +QString MockIndexerService::randomHash() +{ + return randomHexString(32); +} + +QString MockIndexerService::randomAccountId() +{ + return randomBase58String(44); +} + +Transaction MockIndexerService::generatePublicTransaction() +{ + auto* rng = QRandomGenerator::global(); + + Transaction tx; + tx.hash = randomHash(); + tx.type = TransactionType::Public; + tx.programId = randomBase58String(44); + tx.signatureCount = rng->bounded(1, 4); + tx.proofSizeBytes = rng->bounded(0, 2048); + + int accountCount = rng->bounded(1, 5); + for (int i = 0; i < accountCount; ++i) { + // Reuse existing accounts sometimes + QString accId; + if (!m_accounts.isEmpty() && rng->bounded(100) < 60) { + auto keys = m_accounts.keys(); + accId = keys[rng->bounded(keys.size())]; + } else { + accId = randomAccountId(); + } + tx.accounts.append({accId, QString::number(rng->bounded(1, 100))}); + } + + int instrCount = rng->bounded(1, 8); + for (int i = 0; i < instrCount; ++i) { + tx.instructionData.append(rng->generate()); + } + + return tx; +} + +Transaction MockIndexerService::generatePrivacyPreservingTransaction() +{ + auto* rng = QRandomGenerator::global(); + + Transaction tx; + tx.hash = randomHash(); + tx.type = TransactionType::PrivacyPreserving; + tx.proofSizeBytes = rng->bounded(512, 4096); + tx.newCommitmentsCount = rng->bounded(1, 6); + tx.nullifiersCount = rng->bounded(1, 4); + tx.encryptedStatesCount = rng->bounded(1, 3); + tx.validityWindowStart = rng->bounded(1, 50); + tx.validityWindowEnd = tx.validityWindowStart + rng->bounded(50, 200); + + int accountCount = rng->bounded(1, 4); + for (int i = 0; i < accountCount; ++i) { + QString accId; + if (!m_accounts.isEmpty() && rng->bounded(100) < 60) { + auto keys = m_accounts.keys(); + accId = keys[rng->bounded(keys.size())]; + } else { + accId = randomAccountId(); + } + tx.accounts.append({accId, QString::number(rng->bounded(1, 100))}); + } + + return tx; +} + +Transaction MockIndexerService::generateProgramDeploymentTransaction() +{ + auto* rng = QRandomGenerator::global(); + + Transaction tx; + tx.hash = randomHash(); + tx.type = TransactionType::ProgramDeployment; + tx.bytecodeSizeBytes = rng->bounded(1024, 65536); + tx.signatureCount = 1; + + return tx; +} + +void MockIndexerService::generateData() +{ + auto* rng = QRandomGenerator::global(); + + // Generate accounts + for (int i = 0; i < 15; ++i) { + Account acc; + acc.accountId = randomAccountId(); + acc.programOwner = randomBase58String(44); + acc.balance = QString::number(rng->bounded(0, 1000000)); + acc.nonce = QString::number(rng->bounded(0, 500)); + acc.dataSizeBytes = rng->bounded(0, 4096); + m_accounts[acc.accountId] = acc; + } + + // Generate blocks + QString prevHash = QString(64, '0'); + QDateTime timestamp = QDateTime::currentDateTimeUtc().addSecs(-25 * 12); // ~5 min ago + + for (quint64 id = 1; id <= 25; ++id) { + Block block; + block.blockId = id; + block.prevBlockHash = prevHash; + block.hash = randomHash(); + block.timestamp = timestamp; + block.signature = randomHexString(64); + block.bedrockParentId = randomHexString(32); + + // Older blocks are finalized, middle ones safe, recent ones pending + if (id <= 15) { + block.bedrockStatus = BedrockStatus::Finalized; + } else if (id <= 22) { + block.bedrockStatus = BedrockStatus::Safe; + } else { + block.bedrockStatus = BedrockStatus::Pending; + } + + // Generate 1-5 transactions per block + int txCount = rng->bounded(1, 6); + for (int t = 0; t < txCount; ++t) { + Transaction tx; + int typeRoll = rng->bounded(100); + if (typeRoll < 60) { + tx = generatePublicTransaction(); + } else if (typeRoll < 85) { + tx = generatePrivacyPreservingTransaction(); + } else { + tx = generateProgramDeploymentTransaction(); + } + block.transactions.append(tx); + m_transactionsByHash[tx.hash] = tx; + + // Ensure accounts referenced in transactions exist + for (const auto& accRef : tx.accounts) { + if (!m_accounts.contains(accRef.accountId)) { + Account acc; + acc.accountId = accRef.accountId; + acc.programOwner = randomBase58String(44); + acc.balance = QString::number(rng->bounded(0, 1000000)); + acc.nonce = accRef.nonce; + acc.dataSizeBytes = rng->bounded(0, 4096); + m_accounts[acc.accountId] = acc; + } + } + } + + m_blocks.append(block); + m_blocksByHash[block.hash] = block; + prevHash = block.hash; + timestamp = timestamp.addSecs(12); + } +} + +std::optional MockIndexerService::getAccount(const QString& accountId) +{ + auto it = m_accounts.find(accountId); + if (it != m_accounts.end()) { + return *it; + } + return std::nullopt; +} + +std::optional MockIndexerService::getBlockById(quint64 blockId) +{ + if (blockId >= 1 && blockId <= static_cast(m_blocks.size())) { + return m_blocks[static_cast(blockId - 1)]; + } + return std::nullopt; +} + +std::optional MockIndexerService::getBlockByHash(const QString& hash) +{ + auto it = m_blocksByHash.find(hash); + if (it != m_blocksByHash.end()) { + return *it; + } + return std::nullopt; +} + +std::optional MockIndexerService::getTransaction(const QString& hash) +{ + auto it = m_transactionsByHash.find(hash); + if (it != m_transactionsByHash.end()) { + return *it; + } + return std::nullopt; +} + +QVector MockIndexerService::getBlocks(std::optional before, int limit) +{ + QVector result; + int startIdx = m_blocks.size() - 1; + + if (before.has_value()) { + startIdx = static_cast(*before) - 2; // -1 for 0-index, -1 for "before" + } + + for (int i = startIdx; i >= 0 && result.size() < limit; --i) { + result.append(m_blocks[i]); + } + + return result; +} + +quint64 MockIndexerService::getLatestBlockId() +{ + if (m_blocks.isEmpty()) return 0; + return m_blocks.last().blockId; +} + +QVector MockIndexerService::getTransactionsByAccount(const QString& accountId, int offset, int limit) +{ + QVector result; + + // Search all transactions for ones referencing this account + for (auto it = m_transactionsByHash.begin(); it != m_transactionsByHash.end(); ++it) { + for (const auto& accRef : it->accounts) { + if (accRef.accountId == accountId) { + result.append(*it); + break; + } + } + } + + // Apply offset and limit + if (offset >= result.size()) return {}; + return result.mid(offset, limit); +} + +SearchResults MockIndexerService::search(const QString& query) +{ + SearchResults results; + QString trimmed = query.trimmed(); + + if (trimmed.isEmpty()) return results; + + // Try as block ID (numeric) + bool ok = false; + quint64 blockId = trimmed.toULongLong(&ok); + if (ok) { + auto block = getBlockById(blockId); + if (block) { + results.blocks.append(*block); + } + } + + // Try as hash (hex string, 64 chars) + if (trimmed.size() == 64) { + auto block = getBlockByHash(trimmed); + if (block) { + results.blocks.append(*block); + } + auto tx = getTransaction(trimmed); + if (tx) { + results.transactions.append(*tx); + } + } + + // Try as account ID + auto account = getAccount(trimmed); + if (account) { + results.accounts.append(*account); + } + + return results; +} diff --git a/src/services/MockIndexerService.h b/src/services/MockIndexerService.h new file mode 100644 index 0000000..9b6b1b2 --- /dev/null +++ b/src/services/MockIndexerService.h @@ -0,0 +1,32 @@ +#pragma once + +#include "IndexerService.h" + +#include + +class MockIndexerService : public IndexerService { +public: + MockIndexerService(); + + std::optional getAccount(const QString& accountId) override; + std::optional getBlockById(quint64 blockId) override; + std::optional getBlockByHash(const QString& hash) override; + std::optional getTransaction(const QString& hash) override; + QVector getBlocks(std::optional before, int limit) override; + quint64 getLatestBlockId() override; + QVector getTransactionsByAccount(const QString& accountId, int offset, int limit) override; + SearchResults search(const QString& query) override; + +private: + void generateData(); + QString randomHash(); + QString randomAccountId(); + Transaction generatePublicTransaction(); + Transaction generatePrivacyPreservingTransaction(); + Transaction generateProgramDeploymentTransaction(); + + QVector m_blocks; + QMap m_blocksByHash; + QMap m_transactionsByHash; + QMap m_accounts; +}; diff --git a/src/widgets/ClickableFrame.h b/src/widgets/ClickableFrame.h new file mode 100644 index 0000000..38b4661 --- /dev/null +++ b/src/widgets/ClickableFrame.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +class ClickableFrame : public QFrame { + Q_OBJECT + +public: + explicit ClickableFrame(QWidget* parent = nullptr) + : QFrame(parent) + { + setCursor(Qt::PointingHandCursor); + } + +signals: + void clicked(); + +protected: + void mousePressEvent(QMouseEvent* event) override + { + if (event->button() == Qt::LeftButton) { + emit clicked(); + } + QFrame::mousePressEvent(event); + } +}; diff --git a/src/widgets/NavigationBar.cpp b/src/widgets/NavigationBar.cpp new file mode 100644 index 0000000..44c62ab --- /dev/null +++ b/src/widgets/NavigationBar.cpp @@ -0,0 +1,45 @@ +#include "NavigationBar.h" + +#include +#include + +NavigationBar::NavigationBar(QWidget* parent) + : QWidget(parent) +{ + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + + m_backBtn = new QPushButton("<", this); + m_backBtn->setFixedWidth(40); + m_backBtn->setMinimumHeight(32); + m_backBtn->setEnabled(false); + m_backBtn->setToolTip("Back"); + + m_forwardBtn = new QPushButton(">", this); + m_forwardBtn->setFixedWidth(40); + m_forwardBtn->setMinimumHeight(32); + m_forwardBtn->setEnabled(false); + m_forwardBtn->setToolTip("Forward"); + + auto* homeBtn = new QPushButton("Home", this); + homeBtn->setMinimumHeight(32); + + layout->addWidget(m_backBtn); + layout->addWidget(m_forwardBtn); + layout->addWidget(homeBtn); + layout->addStretch(); + + connect(m_backBtn, &QPushButton::clicked, this, &NavigationBar::backClicked); + connect(m_forwardBtn, &QPushButton::clicked, this, &NavigationBar::forwardClicked); + connect(homeBtn, &QPushButton::clicked, this, &NavigationBar::homeClicked); +} + +void NavigationBar::setBackEnabled(bool enabled) +{ + m_backBtn->setEnabled(enabled); +} + +void NavigationBar::setForwardEnabled(bool enabled) +{ + m_forwardBtn->setEnabled(enabled); +} diff --git a/src/widgets/NavigationBar.h b/src/widgets/NavigationBar.h new file mode 100644 index 0000000..836efe3 --- /dev/null +++ b/src/widgets/NavigationBar.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +class QPushButton; + +class NavigationBar : public QWidget { + Q_OBJECT + +public: + explicit NavigationBar(QWidget* parent = nullptr); + + void setBackEnabled(bool enabled); + void setForwardEnabled(bool enabled); + +signals: + void backClicked(); + void forwardClicked(); + void homeClicked(); + +private: + QPushButton* m_backBtn = nullptr; + QPushButton* m_forwardBtn = nullptr; +}; diff --git a/src/widgets/SearchBar.cpp b/src/widgets/SearchBar.cpp new file mode 100644 index 0000000..1aff9ab --- /dev/null +++ b/src/widgets/SearchBar.cpp @@ -0,0 +1,34 @@ +#include "SearchBar.h" + +#include +#include +#include + +SearchBar::SearchBar(QWidget* parent) + : QWidget(parent) +{ + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + + m_input = new QLineEdit(this); + m_input->setPlaceholderText("Search by block ID / block hash / tx hash / account ID..."); + m_input->setMinimumHeight(32); + + auto* searchBtn = new QPushButton("Search", this); + searchBtn->setMinimumHeight(32); + + layout->addWidget(m_input, 1); + layout->addWidget(searchBtn); + + connect(m_input, &QLineEdit::returnPressed, this, [this]() { + emit searchRequested(m_input->text()); + }); + connect(searchBtn, &QPushButton::clicked, this, [this]() { + emit searchRequested(m_input->text()); + }); +} + +void SearchBar::clear() +{ + m_input->clear(); +} diff --git a/src/widgets/SearchBar.h b/src/widgets/SearchBar.h new file mode 100644 index 0000000..09a7235 --- /dev/null +++ b/src/widgets/SearchBar.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +class QLineEdit; + +class SearchBar : public QWidget { + Q_OBJECT + +public: + explicit SearchBar(QWidget* parent = nullptr); + + void clear(); + +signals: + void searchRequested(const QString& query); + +private: + QLineEdit* m_input = nullptr; +}; From 578f0d2b15fd5993fd47248e57a66a2b23f14c4f Mon Sep 17 00:00:00 2001 From: Daniil Polyakov Date: Thu, 2 Apr 2026 16:14:26 +0300 Subject: [PATCH 3/6] feat: better styling --- CMakeLists.txt | 5 +- nix/app.nix | 2 +- nix/default.nix | 1 + src/ExplorerWidget.cpp | 7 +- src/Style.h | 132 ++++++++++++++++++++++++++---- src/explorer_resources.qrc | 12 +++ src/icons/activity.svg | 3 + src/icons/arrow-left.svg | 4 + src/icons/arrow-right.svg | 4 + src/icons/box.svg | 5 ++ src/icons/file-text.svg | 7 ++ src/icons/home.svg | 4 + src/icons/search.svg | 4 + src/icons/user.svg | 4 + src/pages/AccountPage.cpp | 62 ++++++++++---- src/pages/BlockPage.cpp | 122 +++++++++++++++++++-------- src/pages/MainPage.cpp | 150 +++++++++++++++++++++++++--------- src/pages/MainPage.h | 8 +- src/pages/TransactionPage.cpp | 61 ++++++++++---- src/widgets/ClickableFrame.h | 20 +++++ src/widgets/NavigationBar.cpp | 34 ++++++-- src/widgets/SearchBar.cpp | 10 ++- 22 files changed, 520 insertions(+), 141 deletions(-) create mode 100644 src/explorer_resources.qrc create mode 100644 src/icons/activity.svg create mode 100644 src/icons/arrow-left.svg create mode 100644 src/icons/arrow-right.svg create mode 100644 src/icons/box.svg create mode 100644 src/icons/file-text.svg create mode 100644 src/icons/home.svg create mode 100644 src/icons/search.svg create mode 100644 src/icons/user.svg diff --git a/CMakeLists.txt b/CMakeLists.txt index e6ae9cc..34362a3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,7 +8,7 @@ set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) # Find Qt packages -find_package(Qt6 REQUIRED COMPONENTS Core Widgets) +find_package(Qt6 REQUIRED COMPONENTS Core Widgets Svg SvgWidgets) # Try to find the component-interfaces package first find_package(component-interfaces QUIET) @@ -46,6 +46,7 @@ set(SOURCES src/pages/TransactionPage.h src/pages/AccountPage.cpp src/pages/AccountPage.h + src/explorer_resources.qrc ) # Create the plugin library @@ -68,6 +69,8 @@ target_include_directories(lez_explorer_ui PRIVATE target_link_libraries(lez_explorer_ui PRIVATE Qt6::Core Qt6::Widgets + Qt6::Svg + Qt6::SvgWidgets component-interfaces ) diff --git a/nix/app.nix b/nix/app.nix index 6f96a0d..0e9f287 100644 --- a/nix/app.nix +++ b/nix/app.nix @@ -19,7 +19,7 @@ pkgs.stdenv.mkDerivation rec { pkgs.libglvnd ]); - qtPluginPath = "${pkgs.qt6.qtbase}/lib/qt-6/plugins"; + qtPluginPath = "${pkgs.qt6.qtbase}/lib/qt-6/plugins:${pkgs.qt6.qtsvg}/lib/qt-6/plugins"; qtWrapperArgs = [ "--prefix" "LD_LIBRARY_PATH" ":" qtLibPath diff --git a/nix/default.nix b/nix/default.nix index 96cc00b..838f23b 100644 --- a/nix/default.nix +++ b/nix/default.nix @@ -14,6 +14,7 @@ buildInputs = [ pkgs.qt6.qtbase + pkgs.qt6.qtsvg ]; cmakeFlags = [ diff --git a/src/ExplorerWidget.cpp b/src/ExplorerWidget.cpp index 7149ff9..91e002f 100644 --- a/src/ExplorerWidget.cpp +++ b/src/ExplorerWidget.cpp @@ -1,4 +1,5 @@ #include "ExplorerWidget.h" +#include "Style.h" #include "services/MockIndexerService.h" #include "widgets/NavigationBar.h" #include "widgets/SearchBar.h" @@ -15,9 +16,11 @@ ExplorerWidget::ExplorerWidget(QWidget* parent) : QWidget(parent) , m_indexer(std::make_unique()) { + setStyleSheet(Style::appBackground()); + auto* layout = new QVBoxLayout(this); - layout->setContentsMargins(8, 8, 8, 8); - layout->setSpacing(4); + layout->setContentsMargins(16, 12, 16, 12); + layout->setSpacing(8); // Navigation bar m_navBar = new NavigationBar(this); diff --git a/src/Style.h b/src/Style.h index 108c11e..e38d75c 100644 --- a/src/Style.h +++ b/src/Style.h @@ -4,41 +4,139 @@ namespace Style { +// --- Color palette --- + +namespace Color { + inline const char* bg() { return "#1e1e2e"; } + inline const char* surface() { return "#2a2a3c"; } + inline const char* surfaceHover() { return "#33334a"; } + inline const char* border() { return "#3b3b52"; } + inline const char* text() { return "#cdd6f4"; } + inline const char* subtext() { return "#7f849c"; } + inline const char* accent() { return "#89b4fa"; } + inline const char* green() { return "#a6e3a1"; } + inline const char* yellow() { return "#f9e2af"; } + inline const char* red() { return "#f38ba8"; } + inline const char* blue() { return "#89b4fa"; } + inline const char* mauve() { return "#cba6f7"; } + inline const char* peach() { return "#fab387"; } + inline const char* teal() { return "#94e2d5"; } +} + +// --- Base font size applied to the entire app --- + +inline int baseFontSize() { return 14; } + +// --- Widget styles --- + +inline QString appBackground() +{ + return QString("background-color: %1; color: %2; font-size: %3px;") + .arg(Color::bg(), Color::text()) + .arg(baseFontSize()); +} + inline QString cardFrame() { - return "background: #f8f9fa; color: #212529; border: 1px solid #dee2e6; border-radius: 6px; padding: 12px;"; + return QString( + "background: %1; border: 1px solid %2; border-radius: 8px; padding: 18px;" + ).arg(Color::surface(), Color::border()); } inline QString cardFrameWithLabels() { - return "QFrame { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 12px; }" - " QFrame QLabel { color: #212529; }"; -} - -inline QString clickableRow() -{ - return "background: #f8f9fa; color: #212529; border: 1px solid #dee2e6; border-radius: 6px; padding: 8px; margin: 2px 0;"; + return QString( + "QFrame { background: %1; border: 1px solid %2; border-radius: 8px; padding: 18px; }" + " QFrame QLabel { color: %3; font-size: %4px; }" + ).arg(Color::surface(), Color::border(), Color::text()) + .arg(baseFontSize()); } inline QString clickableRowWithLabels(const QString& selector) { - return QString("%1 { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 8px; margin: 2px 0; }" - " %1 QLabel { color: #212529; }").arg(selector); -} - -inline QString monoText() -{ - return "font-family: 'Menlo', 'Courier New', 'DejaVu Sans Mono'; font-size: 11px;"; + return QString( + "%1 { background: %2; border: 1px solid %3; border-radius: 8px; padding: 12px 16px; margin: 3px 0; }" + " %1 QLabel { color: %4; font-size: %5px; }" + ).arg(selector, Color::surface(), Color::border(), Color::text()) + .arg(baseFontSize()); } inline QString mutedText() { - return "color: #6c757d;"; + return QString("color: %1;").arg(Color::subtext()); +} + +inline QString monoText() +{ + return QString("font-family: 'Menlo', 'Courier New'; font-size: %1px; color: %2;") + .arg(baseFontSize() - 1) + .arg(Color::accent()); } inline QString badge(const QString& bgColor) { - return QString("color: white; background: %1; border-radius: 4px; padding: 2px 8px;").arg(bgColor); + return QString("color: %1; background: %2; border-radius: 4px; padding: 3px 12px; font-weight: bold; font-size: %3px;") + .arg(Color::bg(), bgColor) + .arg(baseFontSize() - 1); +} + +inline QString sectionHeader() +{ + return QString("color: %1;").arg(Color::text()); +} + +inline QString navButton() +{ + return QString( + "QPushButton { background: %1; color: %2; border: 1px solid %3; border-radius: 6px; padding: 6px 14px; font-size: %7px; }" + " QPushButton:hover { background: %4; border-color: %5; }" + " QPushButton:disabled { color: %6; background: %1; border-color: %3; opacity: 0.4; }" + ).arg(Color::surface(), Color::text(), Color::border(), Color::surfaceHover(), Color::accent(), Color::subtext()) + .arg(baseFontSize() + 2); +} + +inline QString searchInput() +{ + return QString( + "QLineEdit { background: %1; color: %2; border: 1px solid %3; border-radius: 6px; padding: 10px 14px; font-size: %6px; }" + " QLineEdit:focus { border-color: %4; }" + " QLineEdit::placeholder { color: %5; }" + ).arg(Color::surface(), Color::text(), Color::border(), Color::accent(), Color::subtext()) + .arg(baseFontSize()); +} + +inline QString searchButton() +{ + return QString( + "QPushButton { background: %1; color: %2; border: none; border-radius: 6px; padding: 10px 20px; font-size: %4px; font-weight: bold; }" + " QPushButton:hover { background: %3; }" + ).arg(Color::accent(), Color::bg(), Color::mauve()) + .arg(baseFontSize()); +} + +inline QString healthLabel() +{ + return QString("color: %1; font-weight: bold; font-size: %2px;") + .arg(Color::green()) + .arg(baseFontSize()); +} + +// --- Status colors --- + +inline QString statusColor(const QString& status) +{ + if (status == "Finalized") return Color::green(); + if (status == "Safe") return Color::yellow(); + return Color::subtext(); // Pending +} + +// --- Transaction type colors --- + +inline QString txTypeColor(const QString& type) +{ + if (type == "Public") return Color::blue(); + if (type == "Privacy-Preserving") return Color::mauve(); + return Color::peach(); // Program Deployment } } // namespace Style diff --git a/src/explorer_resources.qrc b/src/explorer_resources.qrc new file mode 100644 index 0000000..9060833 --- /dev/null +++ b/src/explorer_resources.qrc @@ -0,0 +1,12 @@ + + + icons/arrow-left.svg + icons/arrow-right.svg + icons/home.svg + icons/search.svg + icons/box.svg + icons/file-text.svg + icons/user.svg + icons/activity.svg + + diff --git a/src/icons/activity.svg b/src/icons/activity.svg new file mode 100644 index 0000000..a0cca09 --- /dev/null +++ b/src/icons/activity.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/arrow-left.svg b/src/icons/arrow-left.svg new file mode 100644 index 0000000..3847265 --- /dev/null +++ b/src/icons/arrow-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/arrow-right.svg b/src/icons/arrow-right.svg new file mode 100644 index 0000000..782123f --- /dev/null +++ b/src/icons/arrow-right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/box.svg b/src/icons/box.svg new file mode 100644 index 0000000..29c5fcf --- /dev/null +++ b/src/icons/box.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/icons/file-text.svg b/src/icons/file-text.svg new file mode 100644 index 0000000..064dd36 --- /dev/null +++ b/src/icons/file-text.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/icons/home.svg b/src/icons/home.svg new file mode 100644 index 0000000..e351604 --- /dev/null +++ b/src/icons/home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/search.svg b/src/icons/search.svg new file mode 100644 index 0000000..526470d --- /dev/null +++ b/src/icons/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/user.svg b/src/icons/user.svg new file mode 100644 index 0000000..53b88cc --- /dev/null +++ b/src/icons/user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/pages/AccountPage.cpp b/src/pages/AccountPage.cpp index af37159..c8afb76 100644 --- a/src/pages/AccountPage.cpp +++ b/src/pages/AccountPage.cpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace { @@ -32,30 +33,45 @@ AccountPage::AccountPage(const Account& account, IndexerService* indexer, QWidge : QWidget(parent) { auto* outerLayout = new QVBoxLayout(this); + outerLayout->setContentsMargins(0, 0, 0, 0); auto* scrollArea = new QScrollArea(this); scrollArea->setWidgetResizable(true); scrollArea->setFrameShape(QFrame::NoFrame); + scrollArea->setStyleSheet("QScrollArea { background: transparent; border: none; } QWidget#scrollContent { background: transparent; }"); auto* scrollContent = new QWidget(); + scrollContent->setObjectName("scrollContent"); auto* layout = new QVBoxLayout(scrollContent); layout->setAlignment(Qt::AlignTop); + layout->setContentsMargins(0, 0, 0, 0); - // Title + // Title with icon + auto* titleRow = new QHBoxLayout(); + titleRow->setSpacing(10); + auto* titleIcon = new QLabel(); + titleIcon->setPixmap(QIcon(":/icons/user.svg").pixmap(30, 30)); auto* title = new QLabel("Account Details"); QFont titleFont = title->font(); - titleFont.setPointSize(20); + titleFont.setPointSize(24); titleFont.setBold(true); title->setFont(titleFont); - layout->addWidget(title); + title->setStyleSheet(QString("color: %1;").arg(Style::Color::text())); + titleRow->addWidget(titleIcon); + titleRow->addWidget(title); + titleRow->addStretch(); + layout->addLayout(titleRow); + layout->addSpacing(8); // Account info grid auto* infoFrame = new QFrame(); - infoFrame->setFrameShape(QFrame::StyledPanel); + infoFrame->setFrameShape(QFrame::NoFrame); infoFrame->setStyleSheet(Style::cardFrameWithLabels()); auto* grid = new QGridLayout(infoFrame); grid->setColumnStretch(1, 1); + grid->setVerticalSpacing(10); + grid->setHorizontalSpacing(20); int row = 0; grid->addWidget(makeFieldLabel("Account ID"), row, 0); @@ -64,7 +80,9 @@ AccountPage::AccountPage(const Account& account, IndexerService* indexer, QWidge grid->addWidget(idVal, row++, 1); grid->addWidget(makeFieldLabel("Balance"), row, 0); - grid->addWidget(makeValueLabel(account.balance), row++, 1); + auto* balVal = makeValueLabel(account.balance); + balVal->setStyleSheet(QString("color: %1; font-weight: bold;").arg(Style::Color::green())); + grid->addWidget(balVal, row++, 1); grid->addWidget(makeFieldLabel("Program Owner"), row, 0); auto* ownerVal = makeValueLabel(account.programOwner); @@ -83,34 +101,42 @@ AccountPage::AccountPage(const Account& account, IndexerService* indexer, QWidge auto transactions = indexer->getTransactionsByAccount(account.accountId, 0, 10); if (!transactions.isEmpty()) { + auto* headerRow = new QHBoxLayout(); + headerRow->setContentsMargins(0, 16, 0, 6); + headerRow->setSpacing(8); + auto* headerIcon = new QLabel(); + headerIcon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(24, 24)); auto* txHeader = new QLabel("Transaction History"); QFont headerFont = txHeader->font(); - headerFont.setPointSize(16); + headerFont.setPointSize(20); headerFont.setBold(true); txHeader->setFont(headerFont); - txHeader->setStyleSheet("margin-top: 16px; margin-bottom: 4px;"); - layout->addWidget(txHeader); + txHeader->setStyleSheet(Style::sectionHeader()); + headerRow->addWidget(headerIcon); + headerRow->addWidget(txHeader); + headerRow->addStretch(); + layout->addLayout(headerRow); for (const auto& tx : transactions) { auto* frame = new ClickableFrame(); - frame->setFrameShape(QFrame::StyledPanel); + frame->setFrameShape(QFrame::NoFrame); frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); auto* txRow = new QHBoxLayout(frame); + txRow->setSpacing(10); - auto* hashLabel = new QLabel(tx.hash.left(16) + "..."); + auto* icon = new QLabel(); + icon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(20, 20)); + txRow->addWidget(icon); + + auto* hashLabel = new QLabel(tx.hash); QFont boldFont = hashLabel->font(); boldFont.setBold(true); hashLabel->setFont(boldFont); - QString typeColor; - switch (tx.type) { - case TransactionType::Public: typeColor = "#007bff"; break; - case TransactionType::PrivacyPreserving: typeColor = "#6f42c1"; break; - case TransactionType::ProgramDeployment: typeColor = "#fd7e14"; break; - } - auto* typeLabel = new QLabel(transactionTypeToString(tx.type)); - typeLabel->setStyleSheet(Style::badge(typeColor)); + QString typeStr = transactionTypeToString(tx.type); + auto* typeLabel = new QLabel(typeStr); + typeLabel->setStyleSheet(Style::badge(Style::txTypeColor(typeStr))); txRow->addWidget(hashLabel); txRow->addWidget(typeLabel); diff --git a/src/pages/BlockPage.cpp b/src/pages/BlockPage.cpp index 43cc449..d9396e9 100644 --- a/src/pages/BlockPage.cpp +++ b/src/pages/BlockPage.cpp @@ -8,9 +8,34 @@ #include #include #include +#include +#include +#include namespace { +class PrevHashClickFilter : public QObject { +public: + PrevHashClickFilter(quint64 blockId, BlockPage* page, QObject* parent) + : QObject(parent), m_blockId(blockId), m_page(page) {} + +protected: + bool eventFilter(QObject* obj, QEvent* event) override { + if (event->type() == QEvent::MouseButtonRelease) { + auto* me = static_cast(event); + if (me->button() == Qt::LeftButton) { + emit m_page->blockClicked(m_blockId); + return true; + } + } + return QObject::eventFilter(obj, event); + } + +private: + quint64 m_blockId; + BlockPage* m_page; +}; + QLabel* makeFieldLabel(const QString& text) { auto* label = new QLabel(text); @@ -32,66 +57,89 @@ BlockPage::BlockPage(const Block& block, QWidget* parent) : QWidget(parent) { auto* outerLayout = new QVBoxLayout(this); + outerLayout->setContentsMargins(0, 0, 0, 0); auto* scrollArea = new QScrollArea(this); scrollArea->setWidgetResizable(true); scrollArea->setFrameShape(QFrame::NoFrame); + scrollArea->setStyleSheet("QScrollArea { background: transparent; border: none; } QWidget#scrollContent { background: transparent; }"); auto* scrollContent = new QWidget(); + scrollContent->setObjectName("scrollContent"); auto* layout = new QVBoxLayout(scrollContent); layout->setAlignment(Qt::AlignTop); + layout->setContentsMargins(0, 0, 0, 0); - // Title + // Title with icon + auto* titleRow = new QHBoxLayout(); + titleRow->setSpacing(10); + auto* titleIcon = new QLabel(); + titleIcon->setPixmap(QIcon(":/icons/box.svg").pixmap(30, 30)); auto* title = new QLabel(QString("Block #%1").arg(block.blockId)); QFont titleFont = title->font(); - titleFont.setPointSize(20); + titleFont.setPointSize(24); titleFont.setBold(true); title->setFont(titleFont); - layout->addWidget(title); + title->setStyleSheet(QString("color: %1;").arg(Style::Color::text())); + titleRow->addWidget(titleIcon); + titleRow->addWidget(title); + titleRow->addStretch(); + layout->addLayout(titleRow); + layout->addSpacing(8); // Block info grid auto* infoFrame = new QFrame(); - infoFrame->setFrameShape(QFrame::StyledPanel); + infoFrame->setFrameShape(QFrame::NoFrame); infoFrame->setStyleSheet(Style::cardFrameWithLabels()); auto* grid = new QGridLayout(infoFrame); grid->setColumnStretch(1, 1); + grid->setVerticalSpacing(10); + grid->setHorizontalSpacing(20); int row = 0; grid->addWidget(makeFieldLabel("Block ID"), row, 0); grid->addWidget(makeValueLabel(QString::number(block.blockId)), row++, 1); grid->addWidget(makeFieldLabel("Hash"), row, 0); - grid->addWidget(makeValueLabel(block.hash), row++, 1); + auto* hashVal = makeValueLabel(block.hash); + hashVal->setStyleSheet(Style::monoText()); + grid->addWidget(hashVal, row++, 1); grid->addWidget(makeFieldLabel("Previous Hash"), row, 0); - auto* prevHashLabel = new QLabel(QString("%1").arg(block.prevBlockHash)); - prevHashLabel->setTextInteractionFlags(Qt::TextBrowserInteraction); if (block.blockId > 1) { + auto* prevHashLabel = new QLabel(block.prevBlockHash); + prevHashLabel->setStyleSheet(QString("color: %1; text-decoration: underline;").arg(Style::Color::accent())); + prevHashLabel->setCursor(Qt::PointingHandCursor); + prevHashLabel->setWordWrap(true); + prevHashLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); quint64 prevBlockId = block.blockId - 1; - connect(prevHashLabel, &QLabel::linkActivated, this, [this, prevBlockId]() { - emit blockClicked(prevBlockId); - }); + prevHashLabel->installEventFilter(new PrevHashClickFilter(prevBlockId, this, this)); + grid->addWidget(prevHashLabel, row++, 1); + } else { + grid->addWidget(makeValueLabel(block.prevBlockHash), row++, 1); } - grid->addWidget(prevHashLabel, row++, 1); grid->addWidget(makeFieldLabel("Timestamp"), row, 0); grid->addWidget(makeValueLabel(block.timestamp.toString("yyyy-MM-dd hh:mm:ss UTC")), row++, 1); grid->addWidget(makeFieldLabel("Status"), row, 0); - QString statusColor; - switch (block.bedrockStatus) { - case BedrockStatus::Finalized: statusColor = "#28a745"; break; - case BedrockStatus::Safe: statusColor = "#ffc107"; break; - case BedrockStatus::Pending: statusColor = "#6c757d"; break; - } - auto* statusLabel = new QLabel(bedrockStatusToString(block.bedrockStatus)); - statusLabel->setStyleSheet(Style::badge(statusColor) + " max-width: 100px;"); + QString statusStr = bedrockStatusToString(block.bedrockStatus); + auto* statusLabel = new QLabel(statusStr); + statusLabel->setStyleSheet(Style::badge(Style::statusColor(statusStr))); + statusLabel->setMaximumWidth(120); grid->addWidget(statusLabel, row++, 1); grid->addWidget(makeFieldLabel("Signature"), row, 0); - auto* sigLabel = makeValueLabel(block.signature); + // Insert zero-width spaces every 32 chars so QLabel can word-wrap the long hex string + QString wrappableSig = block.signature; + for (int i = 32; i < wrappableSig.size(); i += 33) { + wrappableSig.insert(i, QChar(0x200B)); + } + auto* sigLabel = new QLabel(wrappableSig); sigLabel->setStyleSheet(Style::monoText()); + sigLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + sigLabel->setWordWrap(true); grid->addWidget(sigLabel, row++, 1); grid->addWidget(makeFieldLabel("Transactions"), row, 0); @@ -101,34 +149,42 @@ BlockPage::BlockPage(const Block& block, QWidget* parent) // Transactions section if (!block.transactions.isEmpty()) { + auto* headerRow = new QHBoxLayout(); + headerRow->setContentsMargins(0, 16, 0, 6); + headerRow->setSpacing(8); + auto* headerIcon = new QLabel(); + headerIcon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(24, 24)); auto* txHeader = new QLabel("Transactions"); QFont headerFont = txHeader->font(); - headerFont.setPointSize(16); + headerFont.setPointSize(20); headerFont.setBold(true); txHeader->setFont(headerFont); - txHeader->setStyleSheet("margin-top: 16px; margin-bottom: 4px;"); - layout->addWidget(txHeader); + txHeader->setStyleSheet(Style::sectionHeader()); + headerRow->addWidget(headerIcon); + headerRow->addWidget(txHeader); + headerRow->addStretch(); + layout->addLayout(headerRow); for (const auto& tx : block.transactions) { auto* frame = new ClickableFrame(); - frame->setFrameShape(QFrame::StyledPanel); + frame->setFrameShape(QFrame::NoFrame); frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); auto* txRow = new QHBoxLayout(frame); + txRow->setSpacing(10); - auto* hashLabel = new QLabel(tx.hash.left(16) + "..."); + auto* icon = new QLabel(); + icon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(20, 20)); + txRow->addWidget(icon); + + auto* hashLabel = new QLabel(tx.hash); QFont boldFont = hashLabel->font(); boldFont.setBold(true); hashLabel->setFont(boldFont); - QString typeColor; - switch (tx.type) { - case TransactionType::Public: typeColor = "#007bff"; break; - case TransactionType::PrivacyPreserving: typeColor = "#6f42c1"; break; - case TransactionType::ProgramDeployment: typeColor = "#fd7e14"; break; - } - auto* typeLabel = new QLabel(transactionTypeToString(tx.type)); - typeLabel->setStyleSheet(Style::badge(typeColor)); + QString typeStr = transactionTypeToString(tx.type); + auto* typeLabel = new QLabel(typeStr); + typeLabel->setStyleSheet(Style::badge(Style::txTypeColor(typeStr))); auto* metaLabel = new QLabel(); switch (tx.type) { diff --git a/src/pages/MainPage.cpp b/src/pages/MainPage.cpp index f9e8c37..516eac9 100644 --- a/src/pages/MainPage.cpp +++ b/src/pages/MainPage.cpp @@ -7,16 +7,22 @@ #include #include #include +#include +#include MainPage::MainPage(IndexerService* indexer, QWidget* parent) : QWidget(parent) , m_indexer(indexer) { auto* outerLayout = new QVBoxLayout(this); + outerLayout->setContentsMargins(0, 0, 0, 0); // Health indicator auto* healthRow = new QHBoxLayout(); + auto* healthIcon = new QLabel(this); + healthIcon->setPixmap(QIcon(":/icons/activity.svg").pixmap(20, 20)); m_healthLabel = new QLabel(this); + healthRow->addWidget(healthIcon); healthRow->addWidget(m_healthLabel); healthRow->addStretch(); outerLayout->addLayout(healthRow); @@ -25,10 +31,13 @@ MainPage::MainPage(IndexerService* indexer, QWidget* parent) auto* scrollArea = new QScrollArea(this); scrollArea->setWidgetResizable(true); scrollArea->setFrameShape(QFrame::NoFrame); + scrollArea->setStyleSheet("QScrollArea { background: transparent; border: none; } QWidget#scrollContent { background: transparent; }"); auto* scrollContent = new QWidget(); + scrollContent->setObjectName("scrollContent"); m_contentLayout = new QVBoxLayout(scrollContent); m_contentLayout->setAlignment(Qt::AlignTop); + m_contentLayout->setContentsMargins(0, 0, 0, 0); scrollArea->setWidget(scrollContent); outerLayout->addWidget(scrollArea); @@ -36,52 +45,77 @@ MainPage::MainPage(IndexerService* indexer, QWidget* parent) refresh(); } -QWidget* MainPage::createSectionHeader(const QString& title) +QWidget* MainPage::createSectionHeader(const QString& title, const QString& iconPath) { + auto* container = new QWidget(); + auto* layout = new QHBoxLayout(container); + layout->setContentsMargins(0, 12, 0, 6); + layout->setSpacing(8); + layout->setAlignment(Qt::AlignVCenter); + + if (!iconPath.isEmpty()) { + auto* icon = new QLabel(); + icon->setPixmap(QIcon(iconPath).pixmap(24, 24)); + icon->setFixedSize(24, 24); + layout->addWidget(icon); + } + auto* label = new QLabel(title); QFont font = label->font(); - font.setPointSize(16); + font.setPointSize(20); font.setBold(true); label->setFont(font); - label->setStyleSheet("margin-top: 12px; margin-bottom: 4px;"); - return label; + label->setStyleSheet(Style::sectionHeader()); + layout->addWidget(label); + layout->addStretch(); + + return container; } void MainPage::addBlockRow(QVBoxLayout* layout, const Block& block) { auto* frame = new ClickableFrame(); - frame->setFrameShape(QFrame::StyledPanel); + frame->setFrameShape(QFrame::NoFrame); frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); auto* row = new QHBoxLayout(frame); + row->setSpacing(10); + + auto* icon = new QLabel(); + icon->setPixmap(QIcon(":/icons/box.svg").pixmap(20, 20)); + row->addWidget(icon); auto* idLabel = new QLabel(QString("Block #%1").arg(block.blockId)); QFont boldFont = idLabel->font(); boldFont.setBold(true); idLabel->setFont(boldFont); - QString statusColor; - switch (block.bedrockStatus) { - case BedrockStatus::Finalized: statusColor = "#28a745"; break; - case BedrockStatus::Safe: statusColor = "#ffc107"; break; - case BedrockStatus::Pending: statusColor = "#6c757d"; break; - } - auto* statusLabel = new QLabel(bedrockStatusToString(block.bedrockStatus)); - statusLabel->setStyleSheet(Style::badge(statusColor)); + QString statusStr = bedrockStatusToString(block.bedrockStatus); + auto* statusLabel = new QLabel(statusStr); + statusLabel->setStyleSheet(Style::badge(Style::statusColor(statusStr))); - auto* hashLabel = new QLabel(block.hash.left(16) + "..."); - hashLabel->setStyleSheet(Style::mutedText()); + auto* hashLabel = new QLabel(block.hash); + hashLabel->setStyleSheet(Style::mutedText() + " font-family: 'Menlo', 'Courier New'; font-size: 11px;"); + hashLabel->setFixedWidth(470); - auto* txCount = new QLabel(QString("%1 tx").arg(block.transactions.size())); + auto* txIcon = new QLabel(); + txIcon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(18, 18)); + + auto* txCount = new QLabel(QString::number(block.transactions.size())); auto* timeLabel = new QLabel(block.timestamp.toString("yyyy-MM-dd hh:mm:ss UTC")); timeLabel->setStyleSheet(Style::mutedText()); + statusLabel->setFixedWidth(90); + statusLabel->setAlignment(Qt::AlignCenter); + row->addWidget(idLabel); - row->addWidget(statusLabel); - row->addWidget(hashLabel, 1); + row->addWidget(hashLabel); + row->addStretch(1); + row->addWidget(txIcon); row->addWidget(txCount); row->addWidget(timeLabel); + row->addWidget(statusLabel); quint64 blockId = block.blockId; connect(frame, &ClickableFrame::clicked, this, [this, blockId]() { @@ -94,24 +128,24 @@ void MainPage::addBlockRow(QVBoxLayout* layout, const Block& block) void MainPage::addTransactionRow(QVBoxLayout* layout, const Transaction& tx) { auto* frame = new ClickableFrame(); - frame->setFrameShape(QFrame::StyledPanel); + frame->setFrameShape(QFrame::NoFrame); frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); auto* row = new QHBoxLayout(frame); + row->setSpacing(10); - auto* hashLabel = new QLabel(tx.hash.left(16) + "..."); + auto* icon = new QLabel(); + icon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(20, 20)); + row->addWidget(icon); + + auto* hashLabel = new QLabel(tx.hash); QFont boldFont = hashLabel->font(); boldFont.setBold(true); hashLabel->setFont(boldFont); - QString typeColor; - switch (tx.type) { - case TransactionType::Public: typeColor = "#007bff"; break; - case TransactionType::PrivacyPreserving: typeColor = "#6f42c1"; break; - case TransactionType::ProgramDeployment: typeColor = "#fd7e14"; break; - } - auto* typeLabel = new QLabel(transactionTypeToString(tx.type)); - typeLabel->setStyleSheet(Style::badge(typeColor)); + QString typeStr = transactionTypeToString(tx.type); + auto* typeLabel = new QLabel(typeStr); + typeLabel->setStyleSheet(Style::badge(Style::txTypeColor(typeStr))); auto* metaLabel = new QLabel(); switch (tx.type) { @@ -142,12 +176,17 @@ void MainPage::addTransactionRow(QVBoxLayout* layout, const Transaction& tx) void MainPage::addAccountRow(QVBoxLayout* layout, const Account& account) { auto* frame = new ClickableFrame(); - frame->setFrameShape(QFrame::StyledPanel); + frame->setFrameShape(QFrame::NoFrame); frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); auto* row = new QHBoxLayout(frame); + row->setSpacing(10); - auto* idLabel = new QLabel(account.accountId.left(16) + "..."); + auto* icon = new QLabel(); + icon->setPixmap(QIcon(":/icons/user.svg").pixmap(20, 20)); + row->addWidget(icon); + + auto* idLabel = new QLabel(account.accountId); QFont boldFont = idLabel->font(); boldFont.setBold(true); idLabel->setFont(boldFont); @@ -174,27 +213,60 @@ void MainPage::refresh() quint64 latestId = m_indexer->getLatestBlockId(); m_healthLabel->setText(QString("Chain height: %1").arg(latestId)); - m_healthLabel->setStyleSheet("color: #28a745; font-weight: bold; font-size: 14px;"); + m_healthLabel->setStyleSheet(Style::healthLabel()); if (m_recentBlocksWidget) { m_contentLayout->removeWidget(m_recentBlocksWidget); delete m_recentBlocksWidget; + m_recentBlocksWidget = nullptr; + m_blocksLayout = nullptr; + m_loadMoreBtn = nullptr; } - m_recentBlocksWidget = new QWidget(); - auto* blocksLayout = new QVBoxLayout(m_recentBlocksWidget); - blocksLayout->setContentsMargins(0, 0, 0, 0); + m_oldestLoadedBlockId = std::nullopt; - blocksLayout->addWidget(createSectionHeader("Recent Blocks")); + m_recentBlocksWidget = new QWidget(); + auto* outerLayout = new QVBoxLayout(m_recentBlocksWidget); + outerLayout->setContentsMargins(0, 0, 0, 0); + outerLayout->addWidget(createSectionHeader("Recent Blocks", ":/icons/box.svg")); + + // Dedicated layout for block rows — button is appended after this in outerLayout + auto* blockRowsWidget = new QWidget(); + m_blocksLayout = new QVBoxLayout(blockRowsWidget); + m_blocksLayout->setContentsMargins(0, 0, 0, 0); + outerLayout->addWidget(blockRowsWidget); auto blocks = m_indexer->getBlocks(std::nullopt, 10); for (const auto& block : blocks) { - addBlockRow(blocksLayout, block); + addBlockRow(m_blocksLayout, block); + if (!m_oldestLoadedBlockId || block.blockId < *m_oldestLoadedBlockId) + m_oldestLoadedBlockId = block.blockId; } + m_loadMoreBtn = new QPushButton("Load more"); + m_loadMoreBtn->setStyleSheet(Style::searchButton()); + m_loadMoreBtn->setVisible(m_oldestLoadedBlockId && *m_oldestLoadedBlockId > 1); + connect(m_loadMoreBtn, &QPushButton::clicked, this, &MainPage::loadMoreBlocks); + outerLayout->addWidget(m_loadMoreBtn, 0, Qt::AlignHCenter); + m_contentLayout->addWidget(m_recentBlocksWidget); } +void MainPage::loadMoreBlocks() +{ + if (!m_oldestLoadedBlockId || *m_oldestLoadedBlockId <= 1) + return; + + auto blocks = m_indexer->getBlocks(m_oldestLoadedBlockId, 10); + for (const auto& block : blocks) { + addBlockRow(m_blocksLayout, block); + if (block.blockId < *m_oldestLoadedBlockId) + m_oldestLoadedBlockId = block.blockId; + } + + m_loadMoreBtn->setVisible(*m_oldestLoadedBlockId > 1 && !blocks.isEmpty()); +} + void MainPage::showSearchResults(const SearchResults& results) { clearSearchResults(); @@ -210,7 +282,7 @@ void MainPage::showSearchResults(const SearchResults& results) bool hasResults = false; if (!results.blocks.isEmpty()) { - layout->addWidget(createSectionHeader("Blocks")); + layout->addWidget(createSectionHeader("Blocks", ":/icons/box.svg")); for (const auto& block : results.blocks) { addBlockRow(layout, block); } @@ -218,7 +290,7 @@ void MainPage::showSearchResults(const SearchResults& results) } if (!results.transactions.isEmpty()) { - layout->addWidget(createSectionHeader("Transactions")); + layout->addWidget(createSectionHeader("Transactions", ":/icons/file-text.svg")); for (const auto& tx : results.transactions) { addTransactionRow(layout, tx); } @@ -226,7 +298,7 @@ void MainPage::showSearchResults(const SearchResults& results) } if (!results.accounts.isEmpty()) { - layout->addWidget(createSectionHeader("Accounts")); + layout->addWidget(createSectionHeader("Accounts", ":/icons/user.svg")); for (const auto& acc : results.accounts) { addAccountRow(layout, acc); } diff --git a/src/pages/MainPage.h b/src/pages/MainPage.h index 7086d6f..92504e7 100644 --- a/src/pages/MainPage.h +++ b/src/pages/MainPage.h @@ -3,9 +3,11 @@ #include "services/IndexerService.h" #include +#include class QVBoxLayout; class QLabel; +class QPushButton; class MainPage : public QWidget { Q_OBJECT @@ -26,11 +28,15 @@ private: void addBlockRow(QVBoxLayout* layout, const Block& block); void addTransactionRow(QVBoxLayout* layout, const Transaction& tx); void addAccountRow(QVBoxLayout* layout, const Account& account); - QWidget* createSectionHeader(const QString& title); + QWidget* createSectionHeader(const QString& title, const QString& iconPath = {}); + void loadMoreBlocks(); IndexerService* m_indexer = nullptr; QVBoxLayout* m_contentLayout = nullptr; QWidget* m_searchResultsWidget = nullptr; QWidget* m_recentBlocksWidget = nullptr; + QVBoxLayout* m_blocksLayout = nullptr; + QPushButton* m_loadMoreBtn = nullptr; QLabel* m_healthLabel = nullptr; + std::optional m_oldestLoadedBlockId; }; diff --git a/src/pages/TransactionPage.cpp b/src/pages/TransactionPage.cpp index 58b3109..9e514bc 100644 --- a/src/pages/TransactionPage.cpp +++ b/src/pages/TransactionPage.cpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace { @@ -32,30 +33,45 @@ TransactionPage::TransactionPage(const Transaction& tx, QWidget* parent) : QWidget(parent) { auto* outerLayout = new QVBoxLayout(this); + outerLayout->setContentsMargins(0, 0, 0, 0); auto* scrollArea = new QScrollArea(this); scrollArea->setWidgetResizable(true); scrollArea->setFrameShape(QFrame::NoFrame); + scrollArea->setStyleSheet("QScrollArea { background: transparent; border: none; } QWidget#scrollContent { background: transparent; }"); auto* scrollContent = new QWidget(); + scrollContent->setObjectName("scrollContent"); auto* layout = new QVBoxLayout(scrollContent); layout->setAlignment(Qt::AlignTop); + layout->setContentsMargins(0, 0, 0, 0); - // Title + // Title with icon + auto* titleRow = new QHBoxLayout(); + titleRow->setSpacing(10); + auto* titleIcon = new QLabel(); + titleIcon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(30, 30)); auto* title = new QLabel("Transaction Details"); QFont titleFont = title->font(); - titleFont.setPointSize(20); + titleFont.setPointSize(24); titleFont.setBold(true); title->setFont(titleFont); - layout->addWidget(title); + title->setStyleSheet(QString("color: %1;").arg(Style::Color::text())); + titleRow->addWidget(titleIcon); + titleRow->addWidget(title); + titleRow->addStretch(); + layout->addLayout(titleRow); + layout->addSpacing(8); // Transaction info grid auto* infoFrame = new QFrame(); - infoFrame->setFrameShape(QFrame::StyledPanel); + infoFrame->setFrameShape(QFrame::NoFrame); infoFrame->setStyleSheet(Style::cardFrameWithLabels()); auto* grid = new QGridLayout(infoFrame); grid->setColumnStretch(1, 1); + grid->setVerticalSpacing(10); + grid->setHorizontalSpacing(20); int row = 0; grid->addWidget(makeFieldLabel("Hash"), row, 0); @@ -64,21 +80,17 @@ TransactionPage::TransactionPage(const Transaction& tx, QWidget* parent) grid->addWidget(hashVal, row++, 1); grid->addWidget(makeFieldLabel("Type"), row, 0); - QString typeColor; - switch (tx.type) { - case TransactionType::Public: typeColor = "#007bff"; break; - case TransactionType::PrivacyPreserving: typeColor = "#6f42c1"; break; - case TransactionType::ProgramDeployment: typeColor = "#fd7e14"; break; - } - auto* typeLabel = new QLabel(transactionTypeToString(tx.type)); - typeLabel->setStyleSheet(Style::badge(typeColor) + " max-width: 160px;"); + QString typeStr = transactionTypeToString(tx.type); + auto* typeLabel = new QLabel(typeStr); + typeLabel->setStyleSheet(Style::badge(Style::txTypeColor(typeStr))); + typeLabel->setMaximumWidth(180); grid->addWidget(typeLabel, row++, 1); // Type-specific fields switch (tx.type) { case TransactionType::Public: grid->addWidget(makeFieldLabel("Program ID"), row, 0); - grid->addWidget(makeValueLabel(tx.programId), row++, 1); + { auto* v = makeValueLabel(tx.programId); v->setStyleSheet(Style::monoText()); grid->addWidget(v, row++, 1); } grid->addWidget(makeFieldLabel("Instruction Data"), row, 0); grid->addWidget(makeValueLabel(QString("%1 items").arg(tx.instructionData.size())), row++, 1); @@ -120,22 +132,35 @@ TransactionPage::TransactionPage(const Transaction& tx, QWidget* parent) // Accounts section if (!tx.accounts.isEmpty()) { + auto* headerRow = new QHBoxLayout(); + headerRow->setContentsMargins(0, 16, 0, 6); + headerRow->setSpacing(8); + auto* headerIcon = new QLabel(); + headerIcon->setPixmap(QIcon(":/icons/user.svg").pixmap(24, 24)); auto* accHeader = new QLabel("Accounts"); QFont headerFont = accHeader->font(); - headerFont.setPointSize(16); + headerFont.setPointSize(20); headerFont.setBold(true); accHeader->setFont(headerFont); - accHeader->setStyleSheet("margin-top: 16px; margin-bottom: 4px;"); - layout->addWidget(accHeader); + accHeader->setStyleSheet(Style::sectionHeader()); + headerRow->addWidget(headerIcon); + headerRow->addWidget(accHeader); + headerRow->addStretch(); + layout->addLayout(headerRow); for (const auto& accRef : tx.accounts) { auto* frame = new ClickableFrame(); - frame->setFrameShape(QFrame::StyledPanel); + frame->setFrameShape(QFrame::NoFrame); frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); auto* accRow = new QHBoxLayout(frame); + accRow->setSpacing(10); - auto* idLabel = new QLabel(accRef.accountId.left(20) + "..."); + auto* icon = new QLabel(); + icon->setPixmap(QIcon(":/icons/user.svg").pixmap(20, 20)); + accRow->addWidget(icon); + + auto* idLabel = new QLabel(accRef.accountId); QFont boldFont = idLabel->font(); boldFont.setBold(true); idLabel->setFont(boldFont); diff --git a/src/widgets/ClickableFrame.h b/src/widgets/ClickableFrame.h index 38b4661..c250672 100644 --- a/src/widgets/ClickableFrame.h +++ b/src/widgets/ClickableFrame.h @@ -1,5 +1,7 @@ #pragma once +#include "Style.h" + #include #include @@ -24,4 +26,22 @@ protected: } QFrame::mousePressEvent(event); } + + void enterEvent(QEnterEvent* event) override + { + m_baseStyleSheet = styleSheet(); + setStyleSheet(m_baseStyleSheet + + QString(" ClickableFrame { background: %1 !important; border-color: %2 !important; }") + .arg(Style::Color::surfaceHover(), Style::Color::accent())); + QFrame::enterEvent(event); + } + + void leaveEvent(QEvent* event) override + { + setStyleSheet(m_baseStyleSheet); + QFrame::leaveEvent(event); + } + +private: + QString m_baseStyleSheet; }; diff --git a/src/widgets/NavigationBar.cpp b/src/widgets/NavigationBar.cpp index 44c62ab..c45045e 100644 --- a/src/widgets/NavigationBar.cpp +++ b/src/widgets/NavigationBar.cpp @@ -1,32 +1,50 @@ #include "NavigationBar.h" +#include "Style.h" #include #include +#include +#include NavigationBar::NavigationBar(QWidget* parent) : QWidget(parent) { auto* layout = new QHBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(6); - m_backBtn = new QPushButton("<", this); - m_backBtn->setFixedWidth(40); - m_backBtn->setMinimumHeight(32); + const QSize iconSize(22, 22); + + m_backBtn = new QPushButton(this); + m_backBtn->setIcon(QIcon(":/icons/arrow-left.svg")); + m_backBtn->setIconSize(iconSize); + m_backBtn->setFixedSize(42, 42); m_backBtn->setEnabled(false); m_backBtn->setToolTip("Back"); + m_backBtn->setStyleSheet(Style::navButton()); - m_forwardBtn = new QPushButton(">", this); - m_forwardBtn->setFixedWidth(40); - m_forwardBtn->setMinimumHeight(32); + m_forwardBtn = new QPushButton(this); + m_forwardBtn->setIcon(QIcon(":/icons/arrow-right.svg")); + m_forwardBtn->setIconSize(iconSize); + m_forwardBtn->setFixedSize(42, 42); m_forwardBtn->setEnabled(false); m_forwardBtn->setToolTip("Forward"); + m_forwardBtn->setStyleSheet(Style::navButton()); - auto* homeBtn = new QPushButton("Home", this); - homeBtn->setMinimumHeight(32); + auto* homeBtn = new QPushButton(this); + homeBtn->setIcon(QIcon(":/icons/home.svg")); + homeBtn->setIconSize(iconSize); + homeBtn->setFixedSize(42, 42); + homeBtn->setToolTip("Home"); + homeBtn->setStyleSheet(Style::navButton()); + + auto* titleLabel = new QLabel("LEZ Explorer"); + titleLabel->setStyleSheet(QString("color: %1; font-size: 14px; font-weight: bold; margin-left: 8px;").arg(Style::Color::accent())); layout->addWidget(m_backBtn); layout->addWidget(m_forwardBtn); layout->addWidget(homeBtn); + layout->addWidget(titleLabel); layout->addStretch(); connect(m_backBtn, &QPushButton::clicked, this, &NavigationBar::backClicked); diff --git a/src/widgets/SearchBar.cpp b/src/widgets/SearchBar.cpp index 1aff9ab..3f9aece 100644 --- a/src/widgets/SearchBar.cpp +++ b/src/widgets/SearchBar.cpp @@ -1,4 +1,5 @@ #include "SearchBar.h" +#include "Style.h" #include #include @@ -8,14 +9,17 @@ SearchBar::SearchBar(QWidget* parent) : QWidget(parent) { auto* layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout->setContentsMargins(0, 4, 0, 4); + layout->setSpacing(8); m_input = new QLineEdit(this); m_input->setPlaceholderText("Search by block ID / block hash / tx hash / account ID..."); - m_input->setMinimumHeight(32); + m_input->setMinimumHeight(38); + m_input->setStyleSheet(Style::searchInput()); auto* searchBtn = new QPushButton("Search", this); - searchBtn->setMinimumHeight(32); + searchBtn->setMinimumHeight(38); + searchBtn->setStyleSheet(Style::searchButton()); layout->addWidget(m_input, 1); layout->addWidget(searchBtn); From 5b1d74f237a3d8832021fdbc1ab8ef2fd1b9ee7e Mon Sep 17 00:00:00 2001 From: Daniil Polyakov Date: Thu, 2 Apr 2026 22:36:51 +0300 Subject: [PATCH 4/6] feat: live block streaming --- src/ExplorerWidget.cpp | 4 ++ src/pages/MainPage.cpp | 13 ++++++ src/pages/MainPage.h | 1 + src/services/IndexerService.h | 11 +++++- src/services/MockIndexerService.cpp | 61 ++++++++++++++++++++++++++++- src/services/MockIndexerService.h | 10 ++++- 6 files changed, 96 insertions(+), 4 deletions(-) diff --git a/src/ExplorerWidget.cpp b/src/ExplorerWidget.cpp index 91e002f..0d96a83 100644 --- a/src/ExplorerWidget.cpp +++ b/src/ExplorerWidget.cpp @@ -47,6 +47,10 @@ ExplorerWidget::ExplorerWidget(QWidget* parent) connect(m_navBar, &NavigationBar::homeClicked, this, &ExplorerWidget::navigateHome); connect(m_searchBar, &SearchBar::searchRequested, this, &ExplorerWidget::onSearch); + connect(m_indexer.get(), &IndexerService::newBlockAdded, this, [this](const Block& block) { + m_mainPage->onNewBlock(block); + }); + updateNavButtons(); } diff --git a/src/pages/MainPage.cpp b/src/pages/MainPage.cpp index 516eac9..e976f12 100644 --- a/src/pages/MainPage.cpp +++ b/src/pages/MainPage.cpp @@ -315,6 +315,19 @@ void MainPage::showSearchResults(const SearchResults& results) m_contentLayout->insertWidget(0, m_searchResultsWidget); } +void MainPage::onNewBlock(const Block& block) +{ + if (!m_blocksLayout) return; + + m_healthLabel->setText(QString("Chain height: %1").arg(block.blockId)); + + // Append to layout then move to top (index 0) + addBlockRow(m_blocksLayout, block); + auto* newRow = m_blocksLayout->itemAt(m_blocksLayout->count() - 1)->widget(); + m_blocksLayout->removeWidget(newRow); + m_blocksLayout->insertWidget(0, newRow); +} + void MainPage::clearSearchResults() { if (m_searchResultsWidget) { diff --git a/src/pages/MainPage.h b/src/pages/MainPage.h index 92504e7..38926d2 100644 --- a/src/pages/MainPage.h +++ b/src/pages/MainPage.h @@ -18,6 +18,7 @@ public: void refresh(); void showSearchResults(const SearchResults& results); void clearSearchResults(); + void onNewBlock(const Block& block); signals: void blockClicked(quint64 blockId); diff --git a/src/services/IndexerService.h b/src/services/IndexerService.h index ac27ba4..4e43325 100644 --- a/src/services/IndexerService.h +++ b/src/services/IndexerService.h @@ -4,6 +4,7 @@ #include "models/Transaction.h" #include "models/Account.h" +#include #include #include @@ -13,9 +14,12 @@ struct SearchResults { QVector accounts; }; -class IndexerService { +class IndexerService : public QObject { + Q_OBJECT + public: - virtual ~IndexerService() = default; + explicit IndexerService(QObject* parent = nullptr) : QObject(parent) {} + ~IndexerService() override = default; virtual std::optional getAccount(const QString& accountId) = 0; virtual std::optional getBlockById(quint64 blockId) = 0; @@ -25,4 +29,7 @@ public: virtual quint64 getLatestBlockId() = 0; virtual QVector getTransactionsByAccount(const QString& accountId, int offset, int limit) = 0; virtual SearchResults search(const QString& query) = 0; + +signals: + void newBlockAdded(Block block); }; diff --git a/src/services/MockIndexerService.cpp b/src/services/MockIndexerService.cpp index f4262cb..96b6d40 100644 --- a/src/services/MockIndexerService.cpp +++ b/src/services/MockIndexerService.cpp @@ -31,9 +31,68 @@ QString randomBase58String(int length) } // namespace -MockIndexerService::MockIndexerService() +MockIndexerService::MockIndexerService(QObject* parent) + : IndexerService(parent) { generateData(); + + connect(&m_blockTimer, &QTimer::timeout, this, &MockIndexerService::onGenerateBlock); + m_blockTimer.start(30000); // 30 seconds +} + +Block MockIndexerService::generateBlock(quint64 blockId, const QString& prevHash) +{ + auto* rng = QRandomGenerator::global(); + + Block block; + block.blockId = blockId; + block.prevBlockHash = prevHash; + block.hash = randomHash(); + block.timestamp = QDateTime::currentDateTimeUtc(); + block.signature = randomHexString(64); + block.bedrockParentId = randomHexString(32); + block.bedrockStatus = BedrockStatus::Pending; + + int txCount = rng->bounded(1, 6); + for (int t = 0; t < txCount; ++t) { + Transaction tx; + int typeRoll = rng->bounded(100); + if (typeRoll < 60) { + tx = generatePublicTransaction(); + } else if (typeRoll < 85) { + tx = generatePrivacyPreservingTransaction(); + } else { + tx = generateProgramDeploymentTransaction(); + } + block.transactions.append(tx); + m_transactionsByHash[tx.hash] = tx; + + for (const auto& accRef : tx.accounts) { + if (!m_accounts.contains(accRef.accountId)) { + Account acc; + acc.accountId = accRef.accountId; + acc.programOwner = randomBase58String(44); + acc.balance = QString::number(rng->bounded(0, 1000000)); + acc.nonce = accRef.nonce; + acc.dataSizeBytes = rng->bounded(0, 4096); + m_accounts[acc.accountId] = acc; + } + } + } + + return block; +} + +void MockIndexerService::onGenerateBlock() +{ + quint64 newId = m_blocks.isEmpty() ? 1 : m_blocks.last().blockId + 1; + QString prevHash = m_blocks.isEmpty() ? QString(64, '0') : m_blocks.last().hash; + + Block block = generateBlock(newId, prevHash); + m_blocks.append(block); + m_blocksByHash[block.hash] = block; + + emit newBlockAdded(block); } QString MockIndexerService::randomHash() diff --git a/src/services/MockIndexerService.h b/src/services/MockIndexerService.h index 9b6b1b2..a833ffe 100644 --- a/src/services/MockIndexerService.h +++ b/src/services/MockIndexerService.h @@ -3,10 +3,13 @@ #include "IndexerService.h" #include +#include class MockIndexerService : public IndexerService { + Q_OBJECT + public: - MockIndexerService(); + explicit MockIndexerService(QObject* parent = nullptr); std::optional getAccount(const QString& accountId) override; std::optional getBlockById(quint64 blockId) override; @@ -17,6 +20,9 @@ public: QVector getTransactionsByAccount(const QString& accountId, int offset, int limit) override; SearchResults search(const QString& query) override; +private slots: + void onGenerateBlock(); + private: void generateData(); QString randomHash(); @@ -24,9 +30,11 @@ private: Transaction generatePublicTransaction(); Transaction generatePrivacyPreservingTransaction(); Transaction generateProgramDeploymentTransaction(); + Block generateBlock(quint64 blockId, const QString& prevHash); QVector m_blocks; QMap m_blocksByHash; QMap m_transactionsByHash; QMap m_accounts; + QTimer m_blockTimer; }; From b7bce31cdcce7ddd982d244f985a92caff22fd11 Mon Sep 17 00:00:00 2001 From: Daniil Polyakov Date: Thu, 2 Apr 2026 22:54:49 +0300 Subject: [PATCH 5/6] fix: fix some minor bugs --- src/ExplorerWidget.cpp | 15 ++++++++++++--- src/pages/MainPage.cpp | 12 +++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/ExplorerWidget.cpp b/src/ExplorerWidget.cpp index 0d96a83..0549dc5 100644 --- a/src/ExplorerWidget.cpp +++ b/src/ExplorerWidget.cpp @@ -122,23 +122,32 @@ void ExplorerWidget::navigateTo(const NavTarget& target, bool addToHistory) } else if constexpr (std::is_same_v) { auto block = m_indexer->getBlockById(t.blockId); if (block) { - auto* page = new BlockPage(*block); + auto* page = new BlockPage(*block, this); connectPageSignals(page); showPage(page); + } else { + qWarning("Block #%llu not found", static_cast(t.blockId)); + showPage(m_mainPage); } } else if constexpr (std::is_same_v) { auto tx = m_indexer->getTransaction(t.hash); if (tx) { - auto* page = new TransactionPage(*tx); + auto* page = new TransactionPage(*tx, this); connectPageSignals(page); showPage(page); + } else { + qWarning("Transaction %s not found", qPrintable(t.hash)); + showPage(m_mainPage); } } else if constexpr (std::is_same_v) { auto account = m_indexer->getAccount(t.accountId); if (account) { - auto* page = new AccountPage(*account, m_indexer.get()); + auto* page = new AccountPage(*account, m_indexer.get(), this); connectPageSignals(page); showPage(page); + } else { + qWarning("Account %s not found", qPrintable(t.accountId)); + showPage(m_mainPage); } } }, target); diff --git a/src/pages/MainPage.cpp b/src/pages/MainPage.cpp index e976f12..ce7ca7e 100644 --- a/src/pages/MainPage.cpp +++ b/src/pages/MainPage.cpp @@ -217,7 +217,7 @@ void MainPage::refresh() if (m_recentBlocksWidget) { m_contentLayout->removeWidget(m_recentBlocksWidget); - delete m_recentBlocksWidget; + m_recentBlocksWidget->deleteLater(); m_recentBlocksWidget = nullptr; m_blocksLayout = nullptr; m_loadMoreBtn = nullptr; @@ -322,8 +322,14 @@ void MainPage::onNewBlock(const Block& block) m_healthLabel->setText(QString("Chain height: %1").arg(block.blockId)); // Append to layout then move to top (index 0) + int countBefore = m_blocksLayout->count(); addBlockRow(m_blocksLayout, block); - auto* newRow = m_blocksLayout->itemAt(m_blocksLayout->count() - 1)->widget(); + if (m_blocksLayout->count() <= countBefore) return; + + QLayoutItem* item = m_blocksLayout->itemAt(m_blocksLayout->count() - 1); + if (!item || !item->widget()) return; + + QWidget* newRow = item->widget(); m_blocksLayout->removeWidget(newRow); m_blocksLayout->insertWidget(0, newRow); } @@ -332,7 +338,7 @@ void MainPage::clearSearchResults() { if (m_searchResultsWidget) { m_contentLayout->removeWidget(m_searchResultsWidget); - delete m_searchResultsWidget; + m_searchResultsWidget->deleteLater(); m_searchResultsWidget = nullptr; } if (m_recentBlocksWidget) { From 35ccd60be1eec8e2e4eeea2945f6284a9b2bd5b8 Mon Sep 17 00:00:00 2001 From: Daniil Polyakov Date: Thu, 2 Apr 2026 23:01:37 +0300 Subject: [PATCH 6/6] feat: add README --- README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..1dff78f --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# LEZ Explorer UI + +A Qt6/C++ block explorer for the **Logos Execution Zone** (LEZ). Builds as both a shared library (Qt plugin) and a standalone desktop application. + +## Features + +- Browse recent blocks with live streaming of new blocks +- View block details: hash, previous hash, timestamp, status, signature, transactions +- Navigate to previous blocks via clickable hash links +- View transaction details (Public, Privacy-Preserving, Program Deployment) +- View account details with paginated transaction history +- Full-text search by block ID, block hash, transaction hash, or account ID +- Browser-style back/forward navigation +- Pagination with "Load more" for older blocks +- Dark theme (Catppuccin Mocha) + +## Building with Nix + +### Standalone application + +```sh +nix build '.#app' +nix run '.#app' +``` + +### Shared library (Qt plugin) + +```sh +nix build '.#lib' +``` + +### Development shell + +```sh +nix develop +``` + +## License + +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE-v2](LICENSE-APACHE-v2)) +- MIT License ([LICENSE-MIT](LICENSE-MIT)) + +at your option.