diff --git a/.gitmodules b/.gitmodules index 0d3934b..d0199ef 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,6 +10,6 @@ [submodule "vendor/logos-storage-nim"] path = vendor/logos-storage-nim url = https://github.com/logos-storage/logos-storage-nim -[submodule "logos-design-system"] - path = logos-design-system +[submodule "vendor/logos-design-system"] + path = vendor/logos-design-system url = https://github.com/logos-co/logos-design-system diff --git a/CMakeLists.txt b/CMakeLists.txt index 791805a..51b882b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,6 +5,7 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) +set(LOGOS_STORAGE_UI_USE_VENDOR OFF) ########### DEPENDENCIES SECTION ########### @@ -176,6 +177,8 @@ if(_cpp_sdk_is_source) ${LOGOS_CPP_SDK_ROOT}/cpp/token_manager.h ${LOGOS_CPP_SDK_ROOT}/cpp/module_proxy.cpp ${LOGOS_CPP_SDK_ROOT}/cpp/module_proxy.h + ${LOGOS_CPP_SDK_ROOT}/cpp/logos_types.cpp + ${LOGOS_CPP_SDK_ROOT}/cpp/logos_types.h ) endif() diff --git a/logos-design-system b/logos-design-system deleted file mode 160000 index 596811c..0000000 --- a/logos-design-system +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 596811cbb0a0644322267368e87fab80e34203d8 diff --git a/src/StorageBackend.cpp b/src/StorageBackend.cpp index 494e0dc..8c3d2b1 100644 --- a/src/StorageBackend.cpp +++ b/src/StorageBackend.cpp @@ -45,13 +45,35 @@ StorageBackend::~StorageBackend() m_logos = nullptr; } +void StorageBackend::reportError(const QString& message) { + debug(message, "warning"); + emit error(message); +} + +void StorageBackend::debug(const QString& log, const QString& level) { + if (!m_debugLogs.isEmpty()) { + m_debugLogs += "\n"; + } + + QString timestamp = QDateTime::currentDateTime().toString(Qt::ISODate); + m_debugLogs += timestamp + ": " + log; + + // Notify the property bind debugLogs + emit debugLogsChanged(); + + if (level == "warning") { + qWarning() << "StorageBackend: " << log; + } else { + qDebug() << "StorageBackend: " << log; + } +} + LogosResult StorageBackend::init(const QString& configJson) { qDebug() << "StorageBackend::initStorage called"; m_config = QJsonDocument::fromJson(configJson.toUtf8()); if (m_config.isNull()) { - qDebug() << "StorageBackend::initStorage invalid json config" << configJson; - reportError("Failed to create the storage: invalid JSON config"); + reportError("Failed to create the storage: invalid JSON config:" + configJson); return {false, "", "Failed to create the storage, invalid json config"}; } @@ -73,14 +95,23 @@ LogosResult StorageBackend::init(const QString& configJson) { if (!success) { QString message = data[1].toString(); setStatus(Stopped); - debug("Failed to start Storage module:" + message); + + // Used in StartNode component to detect + // failure in the onboarding. emit startFailed(message); - reportError("Failed to start: " + message); + + reportError("Failed to start Storage Module: " + message); } else { setStatus(Running); + debug("Storage module started."); - // QMetaObject::invokeMethod(this, &StorageBackend::downloadManifests, Qt::QueuedConnection); - // QMetaObject::invokeMethod(this, &StorageBackend::space, Qt::QueuedConnection); + + // Fetch data to in order to come on the app + // with fresh data. + StorageBackend::fetchWidgetsData(); + + // Used in StartNode component to detect + // success in the onboarding. emit startCompleted(); } })) { @@ -91,59 +122,32 @@ LogosResult StorageBackend::init(const QString& configJson) { bool success = data[0].toBool(); if (!success) { - QString message = data[1].toString(); setStatus(Running); - debug("Failed to stop Storage module:" + message); + + QString message = data[1].toString(); + reportError("Failed to stop Storage module:" + message); } else { setStatus(Stopped); + debug("Storage module stopped."); } + // Send stop completed signal event on failure + // to not block the shutdown process emit stopCompleted(); })) { qWarning() << "StorageWidget: failed to subscribe to storageStop events"; } - if (!m_logos->storage_module.on("storageConnect", [this](const QVariantList& data) { - bool success = data[0].toBool(); - - if (!success) { - QString message = data[1].toString(); - debug("Failed to connect: " + message); - } else { - // TODO add the peer id - debug("Successfully connected to peer."); - } - })) { - qWarning() << "StorageWidget: failed to subscribe to storageConnect events"; - } - if (!m_logos->storage_module.on("storageUploadProgress", [this](const QVariantList& data) { bool success = data[0].toBool(); if (!success) { QString message = data[1].toString(); - debug("Failure during upload progress: " + message); - m_uploadStatus = "Error: " + message; - emit uploadStatusChanged(); + reportError("Failure during upload progress: " + message); } else { - QString sessionId = data[1].toString(); qint64 len = data[2].toLongLong(); - - m_uploadedBytes += len; - - // Calcule le pourcentage - if (m_uploadTotalBytes > 0) { - m_uploadProgress = (m_uploadedBytes * 100) / m_uploadTotalBytes; - } - - m_uploadStatus = QString("Uploading: %1 / %2 bytes (%3%)") - .arg(m_uploadedBytes) - .arg(m_uploadTotalBytes) - .arg(m_uploadProgress); - - emit uploadProgressChanged(); - emit uploadStatusChanged(); + emit uploadChunk(len); } })) { qWarning() << "StorageWidget: failed to subscribe to storageUploadProgress events"; @@ -154,24 +158,14 @@ LogosResult StorageBackend::init(const QString& configJson) { if (!success) { QString message = data[1].toString(); - debug("Failed to upload: " + message); - m_uploadProgress = 0; - m_uploadStatus = "Upload failed"; - emit uploadProgressChanged(); - emit uploadStatusChanged(); + reportError("Failed to upload: " + message); } else { QString sessionId = data[1].toString(); - m_cid = data[2].toString(); - emit cidChanged(); - debug("Upload completed for session " + sessionId + " with CID " + m_cid); - - // Complète la progress bar - m_uploadProgress = 100; - m_uploadStatus = "Upload completed!"; - emit uploadProgressChanged(); - emit uploadStatusChanged(); - - QMetaObject::invokeMethod(this, &StorageBackend::space, Qt::QueuedConnection); + QString cid = data[2].toString(); + debug("Upload completed for session " + sessionId + " with CID " + cid); + emit uploadCompleted(cid); + QMetaObject::invokeMethod(this, &StorageBackend::refreshSpace, Qt::QueuedConnection); + QMetaObject::invokeMethod(this, &StorageBackend::downloadManifests, Qt::QueuedConnection); } })) { qWarning() << "StorageWidget: failed to subscribe to storageUploadProgress events"; @@ -182,11 +176,12 @@ LogosResult StorageBackend::init(const QString& configJson) { if (!success) { QString message = data[1].toString(); - debug("Failure during download progress: " + message); + reportError("Failure during download progress: " + message); } else { QString sessionId = data[1].toString(); int len = data[2].toInt(); debug("Downloaded " + QString::number(len) + " bytes for session " + sessionId); + // TODO display progress here } })) { qWarning() << "StorageWidget: failed to subscribe to storageDownloadProgress events"; @@ -197,18 +192,24 @@ LogosResult StorageBackend::init(const QString& configJson) { if (!success) { QString message = data[1].toString(); - debug("Failed to download: " + message); + reportError("Failed to download: " + message); } else { QString sessionId = data[1].toString(); - m_cid = data[2].toString(); - emit cidChanged(); - debug("Download completed for session " + sessionId + " with CID " + m_cid); + QString cid = data[2].toString(); + + // Notify the QML to display something (TBU) on download completed + emit downloadCompleted(cid); + + // Call refreshSpace to fetch the new space used by the node + // and reflect on the widget. + QMetaObject::invokeMethod(this, &StorageBackend::refreshSpace, Qt::QueuedConnection); + + debug("Download completed for session " + sessionId + " with CID " + cid); } })) { qWarning() << "StorageWidget: failed to subscribe to storageDownloadProgress events"; } - emit initCompleted(); debug("new config is: " + configJson); return {true, ""}; @@ -241,13 +242,13 @@ LogosResult StorageBackend::start(const QString& newConfigJson) { setStatus(Starting); debug("Starting Storage module..."); - // TODO trach the start attempts in a file + // TODO trace the start attempts in a file auto result = m_logos->storage_module.start(); if (!result) { setStatus(Stopped); - debug("Failed to start storage"); + reportError("Failed to start storage"); return {false, "", "Failed to start storage"}; } @@ -261,7 +262,6 @@ void StorageBackend::stop() { if (m_status == StorageStatus::Stopping) { debug("The Storage Module is already stopping."); - emit stopCompleted(); return; } @@ -278,7 +278,7 @@ void StorageBackend::stop() { if (!result.success) { setStatus(Running); - debug(result.getError()); + reportError("Error when trying to stop: " + result.getError()); return; } @@ -288,267 +288,87 @@ void StorageBackend::stop() { void StorageBackend::destroy() { qDebug() << "StorageBackend: destroy method called"; - StorageStatus status = m_status; auto result = m_logos->storage_module.destroy(); if (!result.success) { - debug(result.getError()); + reportError("Error when trying to destroy: " + result.getError()); return; } qDebug() << "StorageBackend: Storage module destroyed."; } -QString StorageBackend::debugLogs() const { return m_debugLogs; }; - -void StorageBackend::reportError(const QString& message) { - debug(message); - emit error(message); -} - -void StorageBackend::debug(const QString& log) { - if (!m_debugLogs.isEmpty()) { - m_debugLogs += "\n"; - } - - QString timestamp = QDateTime::currentDateTime().toString(Qt::ISODate); - m_debugLogs += timestamp + ": " + log; - emit debugLogsChanged(); - - qDebug() << "StorageBackend: " << log; -} - -void StorageBackend::tryDebug() { +void StorageBackend::logDebugInfo() { auto result = m_logos->storage_module.debug(); - debug("Debug " + result.getString()); -} -void StorageBackend::tryPeerConnect(const QString& peerId) { - qDebug().noquote() << "StorageBackend: tryPeerConnect called with peerId=" << peerId; + debug("Peer ID: " + result.getString("id")); + debug("SPR: " + result.getString("spr")); - // LogosResult result2 = m_logos->storage_module.space(); - // QVariantMap space = result2.getValue(); - // int quotaMaxBytes = space["quotaMaxBytes"].toInt(); - // int quotaUsedBytes = space["quotaUsedBytes"].toInt(); - // int quotaReservedBytes = space["quotaReservedBytes"].toInt(); + QStringList addrs = result.getValue("addrs"); + for (const QString& addr : addrs) { + debug("Listen address: " + addr); + } - // int totalBlocks = result2.getValue("totalBlocks"); + QStringList announceAddresses = result.getValue("announceAddresses"); + for (const QString& addr : announceAddresses) { + debug("Announce address: " + addr); + } - // debug("totalBlocks " + QString::number(totalBlocks)); - // debug("quotaMaxBytes " + QString::number(quotaMaxBytes)); - // debug("quotaUsedBytes " + QString::number(quotaUsedBytes)); - // debug("quotaReservedBytes " + QString::number(quotaReservedBytes)); + QVariantMap table = result.getValue("table"); + QVariantList nodes = table["nodes"].toList(); - // LogosResult result = m_logos->storage_module.dataDir(); - // QString myDataDir = result.getString(); - // qDebug() << "StorageBackend: tryPeerConnect dataDir=" << myDataDir; + for (const QVariant& nodeVar : nodes) { + QVariantMap node = nodeVar.toMap(); + QString peerId = node["peerId"].toString(); + bool seen = node["seen"].toBool(); + debug("Peer found, peerId=" + peerId + ", seen=" + (seen ? "true" : "false")); + } - // QString peerId = m_logos->storage_module.peerId(); - - // if (peerId.isEmpty()) { - // qDebug() << "StorageBackend: Peer ID is empty."; - // return; - // } - auto result = m_logos->storage_module.connect(peerId, QStringList()); - - qDebug() << "StorageBackend: peerConnect result =" << result.value; - // auto result = m_logos->storage_module.debug(); - - // debug("Debug " + result.getString()); - // QString filename = "test.txt"; - // QString sessionId = m_logos->storage_module.uploadInit(filename); - - // qDebug() << "StorageBackend: uploadInit sessionId =" << sessionId; - - // bool result = m_logos->storage_module.uploadCancel(sessionId); - - // qDebug() << "StorageBackend: uploadCancel result =" << result; + emit peersUpdated(nodes.size()); } -void StorageBackend::tryUpload() { - qDebug() << "StorageBackend: tryUpload called"; - - // QString filename = "test.txt"; - // m_sessionId = m_logos->storage_module.uploadInit(filename); - - // qDebug() << "StorageBackend: uploadInit sessionId =" << m_sessionId; - - // QByteArray chunk = "Sample data chunk for upload."; - // bool result = m_logos->storage_module.uploadChunk(m_sessionId, chunk); - - // qDebug() << "StorageBackend: uploadChunk result =" << result; -} - -void StorageBackend::tryUploadFinalize() { - qDebug() << "StorageBackend: tryFinalize called"; - - // m_cid = m_logos->storage_module.uploadFinalize(m_sessionId); - - // qDebug() << "StorageBackend: uploadFinalize result =" << m_cid; - - // emit cidChanged(); -} - -void StorageBackend::tryUploadFile(const QUrl& url) { - qDebug() << "StorageBackend: tryUploadFile called"; - qDebug() << " URL toString():" << url.toString(); - qDebug() << " URL toLocalFile():" << url.toLocalFile(); - qDebug() << " URL path():" << url.path(); +void StorageBackend::uploadFile(const QUrl& url) { + qDebug() << "StorageBackend: uploadFile called"; if (!url.isLocalFile()) { - qWarning() << "Not a local file"; - debug("The provided URL is not a local file."); + reportError("The provided URL is not a local file."); return; } - // Reset and initialize progress tracking - m_uploadProgress = 0; - m_uploadedBytes = 0; - m_uploadTotalBytes = QFileInfo(url.toLocalFile()).size(); - m_uploadStatus = "Starting upload..."; - emit uploadProgressChanged(); - emit uploadStatusChanged(); + qint64 totalBytes = QFileInfo(url.toLocalFile()).size(); + debug(QString("Starting upload of file: %1 bytes").arg(totalBytes)); + emit uploadStarted(totalBytes); - debug(QString("Starting upload of file: %1 bytes").arg(m_uploadTotalBytes)); - - // QString filename = url.toLocalFile(); - - // // QString filename = "/home/arnaud/Work/logos/logos-storage-ui/README.md"; - // QString sessionId = m_logos->storage_module.uploadInit(filename); - - // qDebug() << "StorageBackend: uploadInit sessionId =" << sessionId; - - // QtConcurrent::run([this, url]() { - // LogosResult result = m_logos->storage_module.uploadUrl(url); - - // Go back to the main thread - // // Better to use signal - // QMetaObject::invokeMethod( - // this, - // [this, result]() { - // if (!result.success) { - // setStatus(m_status, result.getString()); - // return; - // } - - // QString sessionId = result.value.value(); - - // qDebug() << "StorageBackend: uploadFromPath result =" << sessionId; - // }, - // Qt::QueuedConnection); - // }); - // QTimer::singleShot(0, this, [this, url]() { LogosResult result = m_logos->storage_module.uploadUrl(url); if (!result.success) { - debug(result.getError()); + reportError("Failed to upload file:" + result.getError()); return; } QString sessionId = result.value.value(); - //}); - qDebug() << "StorageBackend: tryUploadFile result =" << sessionId; + + qDebug() << "StorageBackend: uploadFile result =" << sessionId; } -// void StorageBackend::tryUploadFile(const QUrl& url) { -// qDebug() << "StorageBackend:tryUploadFile called"; - -// if (!url.isLocalFile()) { -// qWarning() << "Not a local file"; -// m_statusText = "The provided URL is not a local file."; -// emit statusChanged(); -// return; -// } - -// QString localPath = url.toLocalFile(); -// qDebug() << " Uploading from:" << localPath; - -// QFile file(localPath); -// if (!file.open(QIODevice::ReadOnly)) { -// qWarning() << "Cannot open file for reading:" << localPath; -// m_statusText = "Cannot open file for reading: " + localPath; -// emit statusChanged(); -// return; -// } - -// const qint64 chunkSize = 1024 * 64; // 64KB -// qint64 totalSize = file.size(); -// qint64 bytesRead = 0; - -// QFileInfo fileInfo(localPath); -// QString filename = fileInfo.fileName(); - -// LogosResult result = m_logos->storage_module.uploadInit("test.txt", chunkSize); - -// if (!result.success) { -// debug(result.getString()); -// file.close(); -// return; -// } - -// QString sessionId = result.getString(); - -// while (!file.atEnd()) { -// QByteArray chunk = file.read(chunkSize); -// bytesRead += chunk.size(); - -// qDebug() << " Read chunk:" << chunk.size() << "bytes" -// << "Progress:" << bytesRead << "/" << totalSize; - -// result = m_logos->storage_module.uploadChunk(sessionId, chunk); - -// if (!result.success) { -// qWarning("StorageBackend:tryUploadFile failed to send uploadChunk command"); -// file.close(); -// return; -// } - -// // Calculate progress percentage -// int progress = (bytesRead * 100) / totalSize; -// qDebug() << " Progress:" << progress << "%"; -// } - -// file.close(); - -// result = m_logos->storage_module.uploadFinalize(sessionId); - -// if (!result.success) { -// qWarning("StorageBackend:tryUploadFile failed to send uploadFinalize command"); -// file.close(); -// return; -// } - -// qDebug() << "Upload complete, CID:" << result.getString(); - -// file.close(); -// } - -void StorageBackend::tryDownloadFile(const QString& cid, const QUrl& url) { - qDebug() << "StorageBackend: tryDownloadFile called"; +void StorageBackend::downloadFile(const QString& cid, const QUrl& url) { + qDebug() << "StorageBackend: downloadFile called"; if (!url.isLocalFile()) { - qWarning() << "Not a local file"; - debug("The provided URL is not a local file."); + reportError("The provided URL is not a local file."); return; } - // QString filename = url.toLocalFile(); - - // // QString filename = "/home/arnaud/Work/logos/logos-storage-ui/README.md"; - // QString sessionId = m_logos->storage_module.uploadInit(filename); - - // qDebug() << "StorageBackend: uploadInit sessionId =" << sessionId; - LogosResult result = m_logos->storage_module.downloadToUrl(cid, url, false); if (!result.success) { - debug(result.getError()); + reportError("Failed to download file:" + result.getError()); return; } QString sessionId = result.value.value(); - qDebug() << "StorageBackend: tryDownloadFile result =" << sessionId; + qDebug() << "StorageBackend: downloadFile result =" << sessionId; } void StorageBackend::exists(const QString& cid) { @@ -557,7 +377,7 @@ void StorageBackend::exists(const QString& cid) { LogosResult result = m_logos->storage_module.exists(cid); if (!result.success) { - debug("StorageBackend::exists failed with error=" + result.getError()); + reportError("StorageBackend::exists failed with error=" + result.getError()); return; } @@ -565,27 +385,30 @@ void StorageBackend::exists(const QString& cid) { } void StorageBackend::remove(const QString& cid) { - qDebug() << "StorageBackend::remove called"; + qDebug() << "StorageBackend::remove called with cid=" << cid; - LogosResult result = m_logos->storage_module.remove(cid); + LogosResult result = m_logos->storage_module.exists(cid); if (!result.success) { - // Log but continue — manifest might not have local data, remove it from the list anyway - debug("StorageBackend::remove: storage returned error=" + result.getError() + " (removing from list regardless)"); - } else { - debug("Cid " + cid + " removed from storage."); + reportError("Failed to check exists: " + result.getError()); + return; } - // Always remove from manifests list - for (int i = 0; i < m_manifests.size(); ++i) { - if (m_manifests[i].toMap().value("cid").toString() == cid) { - m_manifests.removeAt(i); - emit manifestsChanged(); - break; - } + if (!result.getBool()) { + debug("Blocks don't exist in store."); + return; } - QMetaObject::invokeMethod(this, &StorageBackend::space, Qt::QueuedConnection); + result = m_logos->storage_module.remove(cid); + if (!result.success) { + reportError("Failed to remove " + cid + ": " + result.getError()); + return; + } + + debug("Cid " + cid + " removed from local storage."); + + // Refresh space data for Disk widget + QMetaObject::invokeMethod(this, &StorageBackend::refreshSpace, Qt::QueuedConnection); } void StorageBackend::fetch(const QString& cid) { @@ -594,59 +417,59 @@ void StorageBackend::fetch(const QString& cid) { LogosResult result = m_logos->storage_module.fetch(cid); if (!result.success) { - debug("StorageBackend::fetch failed with error=" + result.getError()); + reportError("Failed to fetch cid " + cid + ": " + result.getError()); return; } debug("Cid " + cid + " fetched."); } -void StorageBackend::version() { +void StorageBackend::logVersion() { qDebug() << "StorageBackend::version called"; LogosResult result = m_logos->storage_module.version(); if (!result.success) { - debug("StorageBackend::version failed with error=" + result.getError()); + reportError("Failed to log version: " + result.getError()); return; } debug("Version: " + result.getString()); } -void StorageBackend::showPeerId() { +void StorageBackend::logPeerId() { qDebug() << "StorageBackend::peerId called"; LogosResult result = m_logos->storage_module.peerId(); if (!result.success) { - debug("StorageBackend::peerId failed with error=" + result.getError()); + reportError("Failed to log peerId: " + result.getError()); return; } debug("Peer ID: " + result.getString()); } -void StorageBackend::spr() { +void StorageBackend::logSpr() { qDebug() << "StorageBackend::spr called"; LogosResult result = m_logos->storage_module.spr(); if (!result.success) { - debug("StorageBackend::spr failed with error=" + result.getError()); + reportError("Failed to log spr: " + result.getError()); return; } debug("SPR: " + result.getString()); } -void StorageBackend::dataDir() { +void StorageBackend::logDataDir() { qDebug() << "StorageBackend::dataDir called"; LogosResult result = m_logos->storage_module.dataDir(); if (!result.success) { - debug("StorageBackend::dataDir failed with error=" + result.getError()); + reportError("Failed to log dataDir: " + result.getError()); return; } @@ -659,7 +482,7 @@ void StorageBackend::downloadManifest(const QString& cid) { LogosResult result = m_logos->storage_module.downloadManifest(cid); if (!result.success) { - debug("StorageBackend::downloadManifest failed with error=" + result.getError()); + reportError("Failed to download manifest cid " + cid + ": " + result.getError()); return; } @@ -669,113 +492,48 @@ void StorageBackend::downloadManifest(const QString& cid) { QString filename = result.getString("filename"); QString mimetype = result.getString("mimetype"); - debug("Manifest tree cid: " + treeCid); - debug(QString("Manifest datasetSize %1").arg(datasetSize)); - debug(QString("Manifest blockSize %1").arg(blockSize)); - debug("Manifest filename: " + filename); - debug("Manifest mimetype: " + mimetype); - - // Add to manifests list QVariantMap manifest; - manifest["cid"] = cid; - manifest["treeCid"] = treeCid; - manifest["filename"] = filename; - manifest["mimetype"] = mimetype; + manifest["cid"] = cid; + manifest["treeCid"] = treeCid; + manifest["filename"] = filename; + manifest["mimetype"] = mimetype; manifest["datasetSize"] = datasetSize; - manifest["blockSize"] = blockSize; + manifest["blockSize"] = blockSize; - m_manifests.append(manifest); - emit manifestsChanged(); + // Refresh the table + downloadManifests(); } -QVariantList StorageBackend::manifests() const { return m_manifests; } - void StorageBackend::downloadManifests() { qDebug() << "StorageBackend::downloadManifests called"; LogosResult result = m_logos->storage_module.manifests(); if (!result.success) { - debug("StorageBackend::downloadManifests failed with error=" + result.getError()); + reportError("Failed to download manifests: " + result.getError()); return; } - QVariantList manifestsList = result.getList(); - int count = manifestsList.size(); - debug(QString("Found %1 manifests").arg(count)); - - m_manifests.clear(); - - for (const QVariant& manifestVariant : manifestsList) { - QVariantMap src = manifestVariant.toMap(); - - QVariantMap manifest; - manifest["cid"] = src.value("cid").toString(); - manifest["treeCid"] = src.value("treeCid").toString(); - manifest["filename"] = src.value("filename").toString(); - manifest["mimetype"] = src.value("mimetype").toString(); - manifest["datasetSize"] = src.value("datasetSize").toLongLong(); - manifest["blockSize"] = src.value("blockSize").toLongLong(); - - m_manifests.append(manifest); - } - - emit manifestsChanged(); + emit manifestsUpdated(result.getList()); } -void StorageBackend::space() { - qDebug() << "StorageBackend::space called"; +void StorageBackend::refreshSpace() { + qDebug() << "StorageBackend::refreshSpace called"; LogosResult result = m_logos->storage_module.space(); if (!result.success) { - debug("StorageBackend::space failed with error=" + result.getError()); + reportError("Failed to refresh space: " + result.getError()); return; } - qDebug() << "StorageBackend::space raw value:" << result.value; + const qlonglong total = result.getValue("quotaMaxBytes"); + const qlonglong used = + result.getValue("quotaUsedBytes") + result.getValue("quotaReservedBytes"); - static constexpr qint64 DEFAULT_QUOTA = 20LL * 1024 * 1024 * 1024; // 20 GB - - // Check config for a quota-max-bytes override - qint64 configQuota = m_config.object().value("quota-max-bytes").toVariant().toLongLong(); - qint64 apiQuota = result.getInt("quotaMaxBytes"); - m_quotaMaxBytes = apiQuota > 0 ? apiQuota : (configQuota > 0 ? configQuota : DEFAULT_QUOTA); - m_quotaUsedBytes = result.getInt("quotaUsedBytes"); - m_quotaReservedBytes = result.getInt("quotaReservedBytes"); - emit quotaChanged(); - - debug(QString("Space totalBlocks %1").arg(result.getInt("totalBlocks"))); - debug(QString("Space quotaMaxBytes %1").arg(m_quotaMaxBytes)); - debug(QString("Space quotaUsedBytes %1").arg(m_quotaUsedBytes)); - debug(QString("Space quotaReservedBytes %1").arg(m_quotaReservedBytes)); + emit spaceUpdated(total, used); } -qint64 StorageBackend::quotaMaxBytes() const { return m_quotaMaxBytes; } -qint64 StorageBackend::quotaUsedBytes() const { return m_quotaUsedBytes; } -qint64 StorageBackend::quotaReservedBytes() const { return m_quotaReservedBytes; } - -void StorageBackend::updateLogLevel(const QString& logLevel) { - qDebug() << "StorageBackend::updateLogLevel called with logLevel=" << logLevel; - - LogosResult result = m_logos->storage_module.updateLogLevel(logLevel); - - if (!result.success) { - debug("StorageBackend::updateLogLevel failed with error=" + result.getError()); - return; - } - - debug("Log level updated to " + logLevel); -} - -StorageBackend::StorageStatus StorageBackend::status() const { return m_status; } - -QString StorageBackend::cid() const { return m_cid; } - -int StorageBackend::uploadProgress() const { return m_uploadProgress; } - -QString StorageBackend::uploadStatus() const { return m_uploadStatus; } - void StorageBackend::reloadIfChanged(const QString& configJson) { QJsonDocument config = QJsonDocument::fromJson(configJson.toUtf8()); if (config.isNull()) { @@ -800,7 +558,7 @@ void StorageBackend::reloadIfChanged(const QString& configJson) { LogosResult result = m_logos->storage_module.destroy(); if (!result.success) { - debug("Failed to destroy the context error=" + result.getError()); + reportError("Failed to destroy the context error=" + result.getError()); return; } else { setStatus(StorageStatus::Destroyed); @@ -810,7 +568,7 @@ void StorageBackend::reloadIfChanged(const QString& configJson) { LogosResult result = init(configJson); if (!result.success) { - debug("Failed to init context with new config: " + result.getError()); + reportError("Failed to init context with new config: " + result.getError()); return; } @@ -822,7 +580,7 @@ void StorageBackend::reloadIfChanged(const QString& configJson) { void StorageBackend::saveCurrentConfig() { qDebug() << "StorageBackend::saveUserConfig"; - saveUserConfig(QString::fromUtf8(m_config.toJson(QJsonDocument::Indented))); + saveUserConfig(configJson()); } void StorageBackend::saveUserConfig(const QString& configJson) { @@ -836,8 +594,16 @@ void StorageBackend::saveUserConfig(const QString& configJson) { file.close(); debug("Config saved to " + USER_CONFIG_PATH); } else { - debug("Failed to save config to " + USER_CONFIG_PATH); + reportError("Failed to save config to " + USER_CONFIG_PATH); } + + QJsonDocument config = QJsonDocument::fromJson(configJson.toUtf8()); + if (config.isNull()) { + reportError("Invalid json config" + configJson); + return; + } + + m_config = config; } QJsonDocument StorageBackend::defaultConfig() { @@ -866,6 +632,7 @@ void StorageBackend::enableUpnpConfig() { reloadIfChanged(QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Indented))); } +// Note that debug is not used because the debug panel is not accessible yet void StorageBackend::enableNatExtConfig(int tcpPort) { qDebug() << "StorageBackend::enableNatExtConfig called with tcpPort" << tcpPort; @@ -876,8 +643,7 @@ void StorageBackend::enableNatExtConfig(int tcpPort) { obj["listen-addrs"] = listenAddrs; // Fetch the public IP asynchronously so we can set nat=extip:IP in the config. - // If the request fails, we proceed without the IP (node will still start, just without extip NAT). - debug("Retrieving public IP..."); + qDebug() << "StorageBackend:: Retrieving public IP..."; QNetworkAccessManager* manager = new QNetworkAccessManager(this); QNetworkRequest request(ECHO_PROVIDER); @@ -892,7 +658,7 @@ void StorageBackend::enableNatExtConfig(int tcpPort) { manager->deleteLater(); if (reply->error() != QNetworkReply::NoError) { - debug("Failed to retrieve public IP: " + reply->errorString() + ". Proceeding without extip NAT."); + qWarning() << "Failed to retrieve public IP: " << reply->errorString() << ". Proceeding without extip NAT."; } else { QString ip = QString::fromUtf8(reply->readAll()).trimmed(); debug("Public IP: " + ip); @@ -904,6 +670,7 @@ void StorageBackend::enableNatExtConfig(int tcpPort) { }); } +// Note that debug is not used because the debug panel is not accessible yet void StorageBackend::checkNodeIsUp() { qDebug() << "StorageBackend::checkNodeIsUp called."; @@ -911,7 +678,7 @@ void StorageBackend::checkNodeIsUp() { // the announceAddresses LogosResult result = m_logos->storage_module.debug(); if (!result.success) { - qDebug() << "Failed to get node debug info: " << result.getError(); + qWarning() << "StorageBackend::checkNodeIsUp Failed to get node debug info: " << result.getError(); emit nodeIsntUp("Failed to get node debug info: " + result.getError()); return; } @@ -921,66 +688,71 @@ void StorageBackend::checkNodeIsUp() { QVariantList nodes = table["nodes"].toList(); debug(QString("Connected peers: %1").arg(nodes.size())); + if (nodes.isEmpty()) { + qWarning() << "StorageBackend::checkNodeIsUp Not peers connected"; emit nodeIsntUp("No peers connected. " "Try modifying the discovery port (default 8090) in the advanced settings."); return; } - debug("DHT seems okay, found peers"); + qDebug() << "StorageBackend::checkNodeIsUp DHT seems okay, found peers"; - // Extract TCP ports from announceAddresses. + // Extract IP+port pairs from announceAddresses. // Format: "/ip4/1.2.3.4/tcp/PORT" QVariantList announceAddresses = result.getValue("announceAddresses"); - QList ports; + QList> endpoints; for (const QVariant& addr : announceAddresses) { - QStringList parts = addr.toString().split("/"); - // "/ip4/1.2.3.4/tcp/8079" splits to ["", "ip4", "1.2.3.4", "tcp", "8079"] - int tcpIndex = parts.indexOf("tcp"); - if (tcpIndex >= 0 && tcpIndex + 1 < parts.size()) { - int port = parts[tcpIndex + 1].toInt(); - if (port > 0 && !ports.contains(port)) { - ports.append(port); - } + const QStringList parts = addr.toString().split("/"); + // ["", "ip4", "1.2.3.4", "tcp", "8079"] + const int tcpIndex = parts.indexOf("tcp"); + if (tcpIndex >= 1 && tcpIndex + 1 < parts.size()) { + const QString ip = parts[tcpIndex - 1]; + const int port = parts[tcpIndex + 1].toInt(); + if (port > 0 && !ip.isEmpty()) + endpoints.append({ ip, port }); } } QString nat = m_config.object()["nat"].toString(); - if (ports.isEmpty()) { - debug("No TCP ports found in announce addresses, considering node as not up"); + if (endpoints.isEmpty()) { + qDebug() << "StorageBackend::checkNodeIsUp No TCP endpoints found in announce addresses"; + if (nat == "upnp") { emit nodeIsntUp("UPnP is configured but there is no announced addresses. " "Try going back and configure port forwarding manually on your router."); } else { - emit nodeIsntUp("No announced addresses found. Your TCP port is propably incorrect. " + emit nodeIsntUp("No announced addresses found. Your TCP port is probably incorrect. " "Try going back and check your port forwarding configuration."); } - return; } - debug(QString("Checking reachability for %1 port(s)...").arg(ports.size())); + qDebug() << "Checking reachability for " << endpoints.size() << "endpoint(s)..."; - // Check each port via the echo service, one by one. bool foundReachable = false; - for (int port : ports) { + for (const auto& [ip, port] : endpoints) { QNetworkAccessManager manager; - QNetworkRequest request(QUrl(QString("%1/port/%2").arg(ECHO_PROVIDER).arg(port))); - QNetworkReply* reply = manager.get(request); + const QUrl url(QString("%1/%2/%3").arg(PORT_CHECKER_PROVIDER).arg(ip).arg(port)); + QNetworkReply* reply = manager.get(QNetworkRequest(url)); QEventLoop loop; connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); loop.exec(); if (reply->error() == QNetworkReply::NoError) { - bool reachable = QJsonDocument::fromJson(reply->readAll()).object()["reachable"].toBool(); - debug("Port " + QString::number(port) + (reachable ? " is reachable" : " is not reachable")); + const bool reachable = reply->readAll() == "True"; + + QString status = reachable ? "reachable" : "not reachable"; + qDebug() << "StorageBackend::checkNodeIsUp " << ip << ":" << port << status; + if (reachable) { foundReachable = true; } } else { - debug("Port check failed for port " + QString::number(port) + ": " + reply->errorString()); + qDebug() << "StorageBackend::checkNodeIsUp Port check failed for" << ip << ":" << port + << reply->errorString(); } reply->deleteLater(); @@ -999,7 +771,11 @@ void StorageBackend::checkNodeIsUp() { } } -void StorageBackend::status(StorageStatus status) { m_status = status; } +void StorageBackend::fetchWidgetsData() { + QMetaObject::invokeMethod(this, &StorageBackend::logDebugInfo, Qt::QueuedConnection); + QMetaObject::invokeMethod(this, &StorageBackend::refreshSpace, Qt::QueuedConnection); + QMetaObject::invokeMethod(this, &StorageBackend::downloadManifests, Qt::QueuedConnection); +} void StorageBackend::loadUserConfig() { qDebug() << "StorageBackend::loadUserConfig called."; @@ -1010,13 +786,20 @@ void StorageBackend::loadUserConfig() { if (file.exists() && file.open(QIODevice::ReadOnly | QIODevice::Text)) { result = init(QString::fromUtf8(file.readAll())); } else { - qWarning() << "StorageBackend::loadUserConfig Failed to read the user config file, fallback to default config"; + debug("Failed to read the user config file, fallback to default config"); result = init(QString::fromUtf8(defaultConfig().toJson(QJsonDocument::Indented))); } if (!result.success) { - qWarning() << "StorageBackend::loadUserConfig Failed to load the user config: " + result.getError(); + reportError("Failed to load the user config: " + result.getError()); } else { debug("User config loaded successfully"); } } + +QString StorageBackend::debugLogs() const { return m_debugLogs; }; + +QString StorageBackend::configJson() const { return QString::fromUtf8(m_config.toJson(QJsonDocument::Indented)); } + +StorageBackend::StorageStatus StorageBackend::status() const { return m_status; } + diff --git a/src/StorageBackend.h b/src/StorageBackend.h index 06f8eab..3113aac 100644 --- a/src/StorageBackend.h +++ b/src/StorageBackend.h @@ -11,7 +11,9 @@ static const int RET_OK = 0; static const int RET_PROGRESS = 3; -static const QString ECHO_PROVIDER = "https://echo.codex.storage/"; +// static const QString ECHO_PROVIDER = "https://echo.codex.storage/"; +static const QString ECHO_PROVIDER = "https://ipv4.icanhazip.com"; +static const QString PORT_CHECKER_PROVIDER = "https://portchecker.io/api/"; static const QString APP_HOME = QDir::homePath() + "/.logos_storage"; static const QString DEFAULT_DATA_DIR = APP_HOME + "/data"; static const QString USER_CONFIG_PATH = APP_HOME + "/config.json"; @@ -33,15 +35,7 @@ class StorageBackend : public QObject { Q_OBJECT QML_ELEMENT Q_PROPERTY(QString debugLogs READ debugLogs NOTIFY debugLogsChanged) - Q_PROPERTY(StorageStatus status READ status WRITE status NOTIFY statusChanged) - Q_PROPERTY(QString cid READ cid NOTIFY cidChanged) - Q_PROPERTY(int uploadProgress READ uploadProgress NOTIFY uploadProgressChanged) - Q_PROPERTY(QString uploadStatus READ uploadStatus NOTIFY uploadStatusChanged) - Q_PROPERTY(QVariantList manifests READ manifests NOTIFY manifestsChanged) - Q_PROPERTY(qint64 quotaMaxBytes READ quotaMaxBytes NOTIFY quotaChanged) - Q_PROPERTY(qint64 quotaUsedBytes READ quotaUsedBytes NOTIFY quotaChanged) - Q_PROPERTY(qint64 quotaReservedBytes READ quotaReservedBytes NOTIFY quotaChanged) - + Q_PROPERTY(StorageStatus status READ status NOTIFY statusChanged) public: enum StorageStatus { // Stopped means that the context is created but the module is not started @@ -59,44 +53,76 @@ class StorageBackend : public QObject { }; Q_ENUM(StorageStatus) - QString cid() const; QString debugLogs() const; StorageStatus status() const; - int uploadProgress() const; - QString uploadStatus() const; - QVariantList manifests() const; - qint64 quotaMaxBytes() const; - qint64 quotaUsedBytes() const; - qint64 quotaReservedBytes() const; + Q_INVOKABLE QString configJson() const; + // Provide a default config for onboarding static QJsonDocument defaultConfig(); explicit StorageBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr); ~StorageBackend(); public slots: + // Init the Storage Module using the config json + // passed in parameter. + // It subscribes to events: + // 1- storageStart + // 2- storageStop + // 3- storageUploadProgress + // 4- storageUploadDone + // 5- storageDownloadProgress + // 6- storageDownloadProgress + LogosResult init(const QString& configJson); + + // Start the node + // If a configuration is passed (not empty string), + // the configuration will be reloaded before trying + // to start. LogosResult start(const QString& configJson = ""); + + // Destroy the Storage Module void destroy(); + + // Emit stopCompleted() on completion of it the module is not started void stop(); - void tryPeerConnect(const QString& peerId); - void tryDebug(); - void tryUpload(); - void tryUploadFinalize(); + + // Log debug info + // Emit peersUpdated(int peers) + void logDebugInfo(); + + // Other log methods for debug + void logDataDir(); + void logVersion(); + void logSpr(); + void logPeerId(); + void exists(const QString& cid); void remove(const QString& cid); + + // Fetch a cid in background void fetch(const QString& cid); - void tryUploadFile(const QUrl& url); - void tryDownloadFile(const QString& cid, const QUrl& url); - void dataDir(); - void version(); - void spr(); - void showPeerId(); + + // Upload a file from the url + // Emit uploadStarted(totalBytes) when the upload begins + // Emit uploadChunk(len) on each storageUploadProgress event + // Emit uploadCompleted(cid) on storageUploadDone + void uploadFile(const QUrl& url); + + // Upload a file from the url + // Emit downloadCompleted(cid) on storageDownloadDone + void downloadFile(const QString& cid, const QUrl& url); + + // Emit manifestsUpdated void downloadManifest(const QString& cid); + + // Download all the manifests and notify + // Emit manifestsUpdated void downloadManifests(); - void space(); - LogosResult init(const QString& configJson); - void updateLogLevel(const QString& logLevel); - void status(StorageStatus status); + + // Call space from the Storage Module + // Emit spaceUpdated to refresh the widget + void refreshSpace(); // Save the user config passed in parameter // into the user config json. @@ -120,8 +146,6 @@ class StorageBackend : public QObject { // // On success, the status will be set to Stopped. // - // Emit initCompleted on success. - // Emit initFailed on failure. void reloadIfChanged(const QString& configJson); // Enables the upnp in the config @@ -130,6 +154,7 @@ class StorageBackend : public QObject { // Enables the net external in the config // and re-create a context with the new configuration + // Emit natExtConfigCompleted void enableNatExtConfig(int tcpPort); // This method will ensure that the node is ready to be used. @@ -146,20 +171,50 @@ class StorageBackend : public QObject { // Emit nodeIsntUp(error) on failure void checkNodeIsUp(); + // Fetch multiple data for the widgets: manifests, debug.. + void fetchWidgetsData(); + signals: + // Used to start the Storage Module + // if the onboarding is already done void ready(); + + // Used in StartNode component to detect + // success in the onboarding. void startCompleted(); + + // Used in StartNode component to detect + // failure in the onboarding. void startFailed(const QString& error); + + // Refresh the node state indicator void statusChanged(); + + // Refresh the debug logs panel. void debugLogsChanged(); + + // Used in the shutdown process void stopCompleted(); - void cidChanged(); - void uploadProgressChanged(); - void uploadStatusChanged(); - void manifestsChanged(); - void quotaChanged(); - void initCompleted(); + + // Used to refresh the disk widgets + void spaceUpdated(qlonglong total, qlonglong used); + + // Emitted when an upload starts, with the total file size + void uploadStarted(qint64 totalBytes); + + // Emitted for each chunk received during upload + void uploadChunk(qint64 len); + + // Used to refresh the Manifests table + void manifestsUpdated(const QVariantList& manifests); + + // Used in the on boarding to detect success void natExtConfigCompleted(); + + void uploadCompleted(const QString& cid); + void downloadCompleted(const QString& cid); + + // Display a toast message on error void error(const QString& message); // Emitted when the node port is reachable from the internet @@ -168,26 +223,38 @@ class StorageBackend : public QObject { // Emitted when the node port is not reachable, with a reason void nodeIsntUp(const QString& reason); + // Emitted when the peer count changes (from checkNodeIsUp) + void peersUpdated(int count); + private slots: private: + // Update the status + // Emit statusUpdated if the status was different from the previous status void setStatus(StorageStatus newStatus); - void peerConnect(const QString& peerId); - void debug(const QString& log); + + // Display debug (or message) in the terminal and + // add it to the debugLogs to make it accessible + // from the debug panel. + // Default level is debug, can be "warning" to display warning + // messages. + void debug(const QString& log, const QString& level = "debug"); + + // Display log and add it to debugLogs + // Emit error(message) void reportError(const QString& message); + // Logos related variables LogosAPI* m_logosAPI; LogosModules* m_logos; + + // Status of the Storage Module StorageStatus m_status; + + // List of debug logs displayed to the application. QString m_debugLogs; - QString m_cid; - int m_uploadProgress = 0; - QString m_uploadStatus = ""; - qint64 m_uploadTotalBytes = 0; - qint64 m_uploadedBytes = 0; - QVariantList m_manifests; - qint64 m_quotaMaxBytes = 0; - qint64 m_quotaUsedBytes = 0; - qint64 m_quotaReservedBytes = 0; + + // Internal configuration object. It can be updated by + // upnp or port forwarning methods. QJsonDocument m_config; }; diff --git a/src/StorageUIPlugin.cpp b/src/StorageUIPlugin.cpp index 0513c92..22fecbe 100644 --- a/src/StorageUIPlugin.cpp +++ b/src/StorageUIPlugin.cpp @@ -26,19 +26,6 @@ QWidget* StorageUIPlugin::createWidget(LogosAPI* logosAPI) { qDebug() << "StorageUIPlugin: Loading settings..."; - // Default constructor uses QCoreApplication org/domain/app — same path as QML QtCore.Settings - // QSettings settings; - // int discoveryPort = settings.value("Storage/discoveryPort", 8090).toInt(); - // int tcpPort = settings.value("Storage/tcpPort", 0).toInt(); - // QString dataDir = settings.value("Storage/dataDir", "").toString(); - // bool onboardingCompleted = settings.value("Storage/onboardingCompleted", false).toBool(); - - // qDebug() << "StorageUIPlugin: Settings file:" << settings.fileName(); - // qDebug() << "StorageUIPlugin: onboardingCompleted=" << onboardingCompleted; - // qDebug() << "StorageUIPlugin: dataDir=" << dataDir; - // qDebug() << "StorageUIPlugin: discoveryPort=" << discoveryPort; - // qDebug() << "StorageUIPlugin: tcpPort=" << tcpPort; - // Always load Main.qml — QML handles navigation (onboarding vs startNode) StorageBackend* backend = new StorageBackend(logosAPI, quickWidget); @@ -54,18 +41,10 @@ QWidget* StorageUIPlugin::createWidget(LogosAPI* logosAPI) { root->setProperty("backend", QVariant::fromValue(static_cast(backend))); + // Here we emit an event ready because + // if the onboarding is already done, + // the backend can be init using onReady subscription backend->ready(); - // Storage init is done in the QML - // Build config from settings if onboarding was done, otherwise use empty config - // QString configJson = StorageBackend::getUserConfig(); - // qDebug() << "UserConfig" << StorageBackend::getUserConfigPath(); - // qDebug() << "configJson" << configJson; - - // LogosResult result = backend->init(configJson); - - // if (!result.success) { - // qWarning() << "StorageUIPlugin: Failed to init backend:" << result.getError(); - // } return quickWidget; } diff --git a/src/qml/AdvancedSetup.qml b/src/qml/AdvancedSetup.qml index 8e23e22..4a284cd 100644 --- a/src/qml/AdvancedSetup.qml +++ b/src/qml/AdvancedSetup.qml @@ -7,7 +7,7 @@ import Logos.Controls LogosStorageLayout { id: root - property var backend: null + property var backend: MockBackend signal back signal completed @@ -32,50 +32,13 @@ LogosStorageLayout { Layout.fillWidth: true } - // ── JSON editor ────────────────────────────────────────────────── - Rectangle { + JsonEditor { + id: jsonEditor Layout.fillWidth: true Layout.fillHeight: true - color: Theme.palette.backgroundElevated - radius: 8 - border.color: jsonArea.isValid ? Theme.palette.borderSecondary : Theme.palette.error - border.width: 1 - - ScrollView { - anchors.fill: parent - anchors.margins: 2 - - TextArea { - id: jsonArea - font.family: "monospace" - font.pixelSize: 12 - color: Theme.palette.text - wrapMode: Text.WrapAnywhere - background: Item {} - - property bool isValid: true - - Component.onCompleted: { - text = (root.backend - && root.backend.configJson) ? root.backend.configJson : "{}" - validate() - } - - function validate() { - try { - JSON.parse(text) - isValid = true - } catch (e) { - isValid = false - } - } - - onTextChanged: validate() - } - } + Component.onCompleted: load(root.backend.configJson() || "{}") } - // ── Buttons ────────────────────────────────────────────────────── RowLayout { Layout.alignment: Qt.AlignHCenter spacing: Theme.spacing.medium @@ -88,10 +51,9 @@ LogosStorageLayout { LogosStorageButton { text: "Validate" variant: "success" - enabled: jsonArea.isValid + enabled: jsonEditor.isValid onClicked: { - root.backend.saveUserConfig(jsonArea.text) - root.backend.reloadIfChanged(jsonArea.text) + root.backend.saveUserConfig(jsonEditor.text) root.completed() } } diff --git a/src/qml/ArcGauge.qml b/src/qml/ArcGauge.qml new file mode 100644 index 0000000..0b997f0 --- /dev/null +++ b/src/qml/ArcGauge.qml @@ -0,0 +1,56 @@ +import QtQuick +import Logos.Theme + +// Reusable arc gauge — same visual style as the Nothing OS ring widgets. +// +// Usage: +// ArcGauge { +// anchors.fill: parent +// fraction: 0.65 +// fillColor: Theme.palette.success +// } +Canvas { + id: root + + // 0.0 – 1.0 (clamped internally) + property real fraction: 0.0 + property color trackColor: Theme.palette.textMuted + property color fillColor: Theme.palette.text + property real arcRadius: 46 + property real arcWidth: 8 + + onFractionChanged: requestPaint() + onTrackColorChanged: requestPaint() + onFillColorChanged: requestPaint() + + Component.onCompleted: requestPaint() + + onPaint: { + var ctx = getContext("2d") + ctx.reset() + + var cx = width / 2 + var cy = height / 2 + var startRad = 130 * Math.PI / 180 + var totalRad = 280 * Math.PI / 180 + + // ── Track (full arc, muted) ─────────────────────────────────────────── + ctx.beginPath() + ctx.arc(cx, cy, root.arcRadius, startRad, startRad + totalRad) + ctx.strokeStyle = root.trackColor.toString() + ctx.lineWidth = root.arcWidth + ctx.lineCap = "round" + ctx.stroke() + + // ── Fill (proportional) ─────────────────────────────────────────────── + var f = Math.min(Math.max(root.fraction, 0.0), 1.0) + if (f > 0) { + ctx.beginPath() + ctx.arc(cx, cy, root.arcRadius, startRad, startRad + totalRad * f) + ctx.strokeStyle = root.fillColor.toString() + ctx.lineWidth = root.arcWidth + ctx.lineCap = "round" + ctx.stroke() + } + } +} diff --git a/src/qml/ArcWidget.qml b/src/qml/ArcWidget.qml new file mode 100644 index 0000000..820e0e6 --- /dev/null +++ b/src/qml/ArcWidget.qml @@ -0,0 +1,46 @@ +import QtQuick +import Logos.Theme + +// Reusable ring widget — Rectangle + ArcGauge + content overlay. +// +// Usage: +// ArcWidget { +// fraction: 0.65 +// fillColor: Theme.palette.success +// +// ColumnLayout { anchors.centerIn: parent; ... } +// } +// +// Children are placed inside an overlay Item that fills the widget, +// so anchors such as `anchors.centerIn: parent` work as expected. +Rectangle { + id: root + + width: 140 + height: 140 + radius: 14 + color: Theme.palette.backgroundSecondary + border.color: Theme.palette.borderSecondary + border.width: 1 + + // ── Arc properties ──────────────────────────────────────────────────────── + property real fraction: 0.0 + property color fillColor: Theme.palette.text + property color trackColor: Theme.palette.textMuted + + // ── Content slot ────────────────────────────────────────────────────────── + // Children declared inside ArcWidget { … } land here, on top of the arc. + default property alias content: overlay.data + + ArcGauge { + anchors.fill: parent + fraction: root.fraction + trackColor: root.trackColor + fillColor: root.fillColor + } + + Item { + id: overlay + anchors.fill: parent + } +} diff --git a/src/qml/CMakeLists.txt b/src/qml/CMakeLists.txt index a96ce58..776243d 100644 --- a/src/qml/CMakeLists.txt +++ b/src/qml/CMakeLists.txt @@ -119,6 +119,8 @@ target_link_libraries(appqml PRIVATE ) # Define the qml module and the StorageBackend sources. +set_source_files_properties(MockBackend.qml PROPERTIES QML_SINGLETON TRUE) + qt_add_qml_module(appqml URI StorageBackend VERSION 1.0 @@ -138,12 +140,31 @@ qt_add_qml_module(appqml HealthIndicator.qml ModeSelector.qml AdvancedSetup.qml - DotIcon.qml - NodeStatusIcon.qml - GuideIcon.qml - AdvancedIcon.qml - UpnpIcon.qml - PortIcon.qml + icons/DotIcon.qml + icons/NodeStatusIcon.qml + icons/GuideIcon.qml + icons/AdvancedIcon.qml + icons/UpnpIcon.qml + icons/PortIcon.qml + icons/StorageIcon.qml + icons/PlayIcon.qml + icons/StopIcon.qml + icons/SettingsIcon.qml + icons/UploadIcon.qml + icons/DownloadIcon.qml + icons/DeleteIcon.qml + icons/ArcWidget.qml + DiskWidget.qml + UploadWidget.qml + PeersWidget.qml + JsonEditor.qml + SettingsPopup.qml + ManifestTable.qml + NodeHeader.qml + Widgets.qml + DebugPanel.qml + Utils.js + MockBackend.qml ) # Set up QML module directory for runtime diff --git a/src/qml/DebugPanel.qml b/src/qml/DebugPanel.qml new file mode 100644 index 0000000..a09dc88 --- /dev/null +++ b/src/qml/DebugPanel.qml @@ -0,0 +1,92 @@ +import QtQuick +import QtQuick.Layouts +import Logos.Theme +import Logos.Controls + +// qmllint disable unqualified +Rectangle { + id: root + + property var backend: MockBackend + property bool running: false + + color: Theme.palette.backgroundElevated + border.color: Theme.palette.borderSecondary + border.width: 1 + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 10 + Layout.topMargin: 6 + Layout.bottomMargin: 4 + spacing: 6 + + LogosStorageButton { + text: "Debug" + enabled: root.running + onClicked: root.backend.logDebugInfo() + } + LogosStorageButton { + text: "Peer ID" + enabled: root.running + onClicked: root.backend.logPeerId() + } + LogosStorageButton { + text: "Data dir" + enabled: root.running + onClicked: root.backend.logDataDir() + } + LogosStorageButton { + text: "SPR" + enabled: root.running + onClicked: root.backend.logSpr() + } + LogosStorageButton { + text: "Version" + enabled: root.running + onClicked: root.backend.logVersion() + } + + Item { + Layout.fillWidth: true + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.palette.borderSecondary + } + + Flickable { + id: logFlick + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + contentWidth: width + contentHeight: debugText.implicitHeight + + TextEdit { + id: debugText + width: logFlick.width + text: root.backend.debugLogs + color: Theme.palette.textSecondary + font.family: "monospace" + font.pixelSize: 11 + wrapMode: Text.WrapAnywhere + readOnly: true + padding: 8 + bottomPadding: 20 + + onTextChanged: Qt.callLater(function () { + logFlick.contentY = Math.max( + 0, logFlick.contentHeight - logFlick.height) + }) + } + } + } +} diff --git a/src/qml/DeleteIcon.qml b/src/qml/DeleteIcon.qml new file mode 100644 index 0000000..1db7cd4 --- /dev/null +++ b/src/qml/DeleteIcon.qml @@ -0,0 +1,6 @@ +import QtQuick + +// qmllint disable unqualified +DotIcon { + pattern: [1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1] +} diff --git a/src/qml/DiskWidget.qml b/src/qml/DiskWidget.qml new file mode 100644 index 0000000..7b818e3 --- /dev/null +++ b/src/qml/DiskWidget.qml @@ -0,0 +1,52 @@ +import QtQuick +import QtQuick.Layouts +import Logos.Theme +import Logos.Controls +import "Utils.js" as Utils + +ArcWidget { + id: root + + property var backend: MockBackend + property double total: 0 + property double used: 0 + + fraction: root.total > 0 ? Math.min(root.used / root.total, 1.0) : 0 + + function refreshSpace() { + let space = root.backend.space() + root.total = space.total + root.used = space.used + } + + Connections { + target: root.backend + + function onSpaceUpdated(total, used) { + root.total = total + root.used = used + } + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 2 + + LogosText { + text: root.total > 0 ? Utils.formatBytes(root.used) : "—" + font.pixelSize: 15 + font.bold: true + Layout.alignment: Qt.AlignHCenter + } + + LogosText { + text: root.total > 0 ? "/ " + Utils.formatBytes(root.total) : "STORAGE" + font.pixelSize: 9 + color: Theme.palette.textTertiary + font.letterSpacing: 1.3 + Layout.alignment: Qt.AlignHCenter + } + } + + +} diff --git a/src/qml/DotIcon.qml b/src/qml/DotIcon.qml index de2a32a..dc22a0c 100644 --- a/src/qml/DotIcon.qml +++ b/src/qml/DotIcon.qml @@ -1,8 +1,6 @@ import QtQuick -// Generic Nothing OS style dot grid icon. -// Static mode : set `pattern` (flat array of 0/1) and leave `animated: false` -// Animated mode: set `animated: true` — wave expands from center automatically +// qmllint disable unqualified Item { id: root @@ -53,8 +51,8 @@ Item { opacity: { if (!root.animated) { - return (index < root.pattern.length && root.pattern[index]) - ? root.activeOpacity : root.inactiveOpacity + return (index < root.pattern.length + && root.pattern[index]) ? root.activeOpacity : root.inactiveOpacity } // Wave from center const cx = Math.floor(root.columns / 2) @@ -64,8 +62,10 @@ Item { const d = Math.abs(col - cx) + Math.abs(row - cy) const wave = root.animPhase % root.columns const diff = Math.abs(d - wave) - if (diff === 0) return root.activeOpacity - if (diff === 1) return 0.35 + if (diff === 0) + return root.activeOpacity + if (diff === 1) + return 0.35 return root.inactiveOpacity } } diff --git a/src/qml/DownloadIcon.qml b/src/qml/DownloadIcon.qml new file mode 100644 index 0000000..f6e9c2e --- /dev/null +++ b/src/qml/DownloadIcon.qml @@ -0,0 +1,12 @@ +import QtQuick + +// Downward arrow — download (mirrored UploadIcon) +DotIcon { + pattern: [ + 0, 0, 1, 0, 0, + 0, 0, 1, 0, 0, + 1, 1, 1, 1, 1, + 0, 1, 1, 1, 0, + 0, 0, 1, 0, 0 + ] +} diff --git a/src/qml/HealthIndicator.qml b/src/qml/HealthIndicator.qml index 3f18b7c..4f4a5d5 100644 --- a/src/qml/HealthIndicator.qml +++ b/src/qml/HealthIndicator.qml @@ -1,31 +1,45 @@ import QtQuick -import Logos.Theme -Item { +QtObject { id: root + property var backend: MockBackend property bool nodeIsUp: false - property var backend: mockBackend + property bool blinkOn: true + readonly property int threeMinutes: 180000 + + // Backend status readonly property int running: 2 - Timer { - readonly property int threeMinutes: 180000 - - interval: threeMinutes + // 600 ms blink toggle + property Timer blinkTimer: Timer { + interval: 600 repeat: true - running: root.backend.status == root.running - triggeredOnStart: true - onTriggered: root.backend.checkNodeIsUp() + running: true + onTriggered: root.blinkOn = !root.blinkOn } - Connections { + // Reachability check every 3 minutes while running + property Timer checkTimer: Timer { + interval: root.threeMinutes + repeat: true + running: root.backend !== null && root.backend.status === root.running + triggeredOnStart: true + onTriggered: function () { + if (root.backend) { + root.backend.checkNodeIsUp() + } + } + } + + property Connections connections: Connections { target: root.backend function onNodeIsUp() { root.nodeIsUp = true } - function onNodeIsntUp(reason) { + function onNodeIsntUp(r) { root.nodeIsUp = false } @@ -35,45 +49,4 @@ Item { } } } - - property bool blinkOn: true - - Timer { - interval: 600 - repeat: true - running: true - onTriggered: root.blinkOn = !root.blinkOn - } - - Row { - id: nodeStatusBadge - anchors.top: parent.top - anchors.right: parent.right - anchors.topMargin: 18 - anchors.rightMargin: 20 - spacing: 7 - - Rectangle { - width: 10 - height: 10 - radius: 5 - anchors.verticalCenter: parent.verticalCenter - color: root.nodeIsUp ? Theme.palette.success : Theme.palette.error - opacity: root.blinkOn ? 1.0 : 0.15 - } - - Text { - anchors.verticalCenter: parent.verticalCenter - text: root.nodeIsUp ? "Node reachable" : "Node unreachable" - color: root.nodeIsUp ? Theme.palette.success : Theme.palette.error - font.pixelSize: 12 - } - } - - QtObject { - id: mockBackend - - signal nodeIsUp - signal nodeIsntUp(string reason) - } } diff --git a/src/qml/JsonEditor.qml b/src/qml/JsonEditor.qml new file mode 100644 index 0000000..3946419 --- /dev/null +++ b/src/qml/JsonEditor.qml @@ -0,0 +1,57 @@ +import QtQuick +import QtQuick.Controls +import Logos.Theme + +// Reusable JSON editor with live validation. +// Usage: +// JsonEditor { +// id: editor +// Layout.fillWidth: true +// Layout.fillHeight: true +// } +// // Load content (e.g. when a popup opens): +// editor.load(backend.configJson() || "{}") +// // Read back: +// editor.text // current text +// editor.isValid // false when JSON.parse would throw +Rectangle { + id: root + + property alias text: jsonArea.text + property bool isValid: true + + Component.onCompleted: root.validate() + + color: Theme.palette.backgroundElevated + radius: 8 + border.color: root.isValid ? Theme.palette.borderSecondary : Theme.palette.error + border.width: 1 + + function load(_text) { + text = _text + } + + function validate() { + try { + JSON.parse(jsonArea.text) + isValid = true + } catch (e) { + isValid = false + } + } + + ScrollView { + anchors.fill: parent + anchors.margins: 2 + + TextArea { + id: jsonArea + font.family: "monospace" + font.pixelSize: 12 + color: Theme.palette.text + wrapMode: Text.WrapAnywhere + background: Item {} + onTextChanged: root.validate() + } + } +} diff --git a/src/qml/LogosStorageButton.qml b/src/qml/LogosStorageButton.qml index 90fcddf..c8e919d 100644 --- a/src/qml/LogosStorageButton.qml +++ b/src/qml/LogosStorageButton.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import Logos.Theme +// qmllint disable unqualified Button { id: control padding: Theme.spacing.small @@ -27,18 +28,22 @@ Button { background: Rectangle { color: { - if (!control.enabled) + if (!control.enabled) { return Theme.palette.backgroundElevated - if (control.isSuccess) + } + if (control.isSuccess) { return Theme.palette.success + } return Theme.palette.backgroundSecondary } border.width: 1 border.color: { - if (!control.enabled) + if (!control.enabled) { return Theme.palette.border - if (control.isSuccess) + } + if (control.isSuccess) { return Theme.palette.success + } return Theme.palette.border } radius: Theme.spacing.tiny diff --git a/src/qml/LogosStorageLayout.qml b/src/qml/LogosStorageLayout.qml index 0ca2ac8..7c22f0e 100644 --- a/src/qml/LogosStorageLayout.qml +++ b/src/qml/LogosStorageLayout.qml @@ -5,8 +5,6 @@ import Logos.Theme Rectangle { id: root color: Theme.palette.background - Layout.fillWidth: true - Layout.fillHeight: true implicitWidth: 600 implicitHeight: 400 } diff --git a/src/qml/LogosTextField.qml b/src/qml/LogosTextField.qml index 63cd9d5..68b101e 100644 --- a/src/qml/LogosTextField.qml +++ b/src/qml/LogosTextField.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import Logos.Theme +// qmllint disable unqualified TextField { id: root @@ -11,6 +12,7 @@ TextField { color: isValid ? Theme.palette.text : Theme.palette.error selectByMouse: true background: Rectangle { + Rectangle { anchors.fill: parent color: Theme.palette.backgroundSecondary diff --git a/src/qml/Main.qml b/src/qml/Main.qml index a5c0a31..f611da8 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Controls +import QtQuick.Layouts import QtCore import Logos.Theme @@ -22,24 +23,14 @@ Item { id: root implicitWidth: 800 implicitHeight: 800 + Layout.fillWidth: true + Layout.fillHeight: true - property var backend: mockBackend + property var backend: MockBackend Connections { target: root.backend - // The node is stopped during the onboarding - // when the user try to change his settings - // and click on "Back", - // In that case, we pop the navigation after - // the node is stopped. - // function onStopCompleted() { - // if (!settings.onboardingCompleted) { - - // // stackView.pop() - // } - // } - // When the onboarding is completed, // the user should have a config save in his // home folder. @@ -168,46 +159,4 @@ Item { anchors.bottomMargin: Theme.spacing.medium } - QtObject { - id: mockBackend - - readonly property bool isMock: true - property int status - - signal startCompleted - signal startFailed - signal stopCompleted - signal initCompleted - signal ready - signal error - signal natExtConfigCompleted - signal nodeIsUp - signal nodeIsntUp - - function start() { - console.log("mock start called") - } - - function saveUserConfig() {} - - function loadUserConfig() {} - - function reloadIfChanged() {} - - function enableUpnpConfig() {} - - function enableNatExtConfig() { - natExtConfigCompleted() - } - - function saveCurrentConfig() {} - - function stop() {} - - function checkNodeIsUp() {} - - function guessResolution() { - return "" - } - } } diff --git a/src/qml/ManifestTable.qml b/src/qml/ManifestTable.qml new file mode 100644 index 0000000..9369549 --- /dev/null +++ b/src/qml/ManifestTable.qml @@ -0,0 +1,282 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Dialogs +import QtQuick.Layouts +import QtCore +import Logos.Theme +import Logos.Controls +import "Utils.js" as Utils + +// qmllint disable unqualified +ColumnLayout { + id: root + + property var backend: MockBackend + property bool running: false + property var manifests: [] + + spacing: Theme.spacing.small + + FileDialog { + id: saveDialog + + property var pendingManifest: null + + fileMode: FileDialog.SaveFile + onAccepted: { + if (pendingManifest) { + root.backend.downloadFile(pendingManifest.cid, selectedFile) + pendingManifest = null + } + } + onRejected: pendingManifest = null + } + + + Connections { + target: root.backend + + onManifestsUpdated: function (manifests) { + root.manifests = manifests + } + } + + LogosText { + text: "MANIFESTS" + font.pixelSize: 11 + color: Theme.palette.textTertiary + font.letterSpacing: 1.5 + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + + LogosTextField { + id: cidInput + Layout.fillWidth: true + height: getManifestBtn.implicitHeight + placeholderText: "Enter CID to download manifest…" + isValid: true + } + + LogosStorageButton { + id: getManifestBtn + text: "GET MANIFEST" + enabled: root.running && cidInput.text.length > 0 + onClicked: { + root.backend.downloadManifest(cidInput.text) + cidInput.clear() + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 30 + color: Theme.palette.backgroundElevated + radius: 4 + + Row { + anchors.fill: parent + anchors.leftMargin: 10 + + Text { + width: 160 + text: "CID" + color: Theme.palette.textSecondary + font.pixelSize: 11 + font.bold: true + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + } + Text { + width: 130 + text: "Filename" + color: Theme.palette.textSecondary + font.pixelSize: 11 + font.bold: true + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + } + Text { + width: 90 + text: "MIME" + color: Theme.palette.textSecondary + font.pixelSize: 11 + font.bold: true + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + } + Text { + width: 80 + text: "Size" + color: Theme.palette.textSecondary + font.pixelSize: 11 + font.bold: true + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 240 + color: Theme.palette.background + border.color: Theme.palette.borderSecondary + border.width: 1 + radius: 4 + clip: true + + ListView { + id: manifestList + anchors.fill: parent + model: root.manifests + clip: true + + delegate: Rectangle { + id: delegateItem + width: manifestList.width + height: 36 + color: index % 2 + === 0 ? Theme.palette.background : Theme.palette.backgroundSecondary + + Row { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 8 + + Text { + width: 160 + text: modelData.cid + color: Theme.palette.text + font.pixelSize: 11 + font.family: "monospace" + elide: Text.ElideMiddle + anchors.verticalCenter: parent.verticalCenter + ToolTip.visible: cidHover.hovered + ToolTip.text: modelData.cid + HoverHandler { + id: cidHover + } + } + Text { + width: 130 + text: modelData.filename + color: Theme.palette.textSecondary + font.pixelSize: 11 + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + } + Text { + width: 90 + text: modelData.mimetype + color: Theme.palette.textSecondary + font.pixelSize: 11 + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + } + Text { + width: 80 + text: Utils.formatBytes(parseInt(modelData.datasetSize)) + color: Theme.palette.textSecondary + font.pixelSize: 11 + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + } + + Row { + spacing: 6 + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + width: 28 + height: 28 + radius: 4 + color: dlHover.hovered ? Theme.palette.backgroundElevated : "transparent" + border.color: Theme.palette.borderSecondary + border.width: 1 + opacity: root.running ? 1.0 : 0.35 + + DownloadIcon { + anchors.centerIn: parent + dotColor: Theme.palette.text + dotSize: 3 + dotSpacing: 1 + } + HoverHandler { + id: dlHover + } + MouseArea { + anchors.fill: parent + enabled: root.running + cursorShape: Qt.PointingHandCursor + onClicked: { + saveDialog.pendingManifest = modelData + saveDialog.currentFile = StandardPaths.writableLocation( + StandardPaths.HomeLocation) + + "/" + (modelData.filename + || modelData.cid + || "download") + saveDialog.open() + } + } + } + + Rectangle { + width: 28 + height: 28 + radius: 4 + color: rmHover.hovered ? Theme.palette.backgroundElevated : "transparent" + border.color: Theme.palette.borderSecondary + border.width: 1 + opacity: root.running ? 1.0 : 0.35 + + DeleteIcon { + anchors.centerIn: parent + dotColor: Theme.palette.error + dotSize: 3 + dotSpacing: 1 + } + HoverHandler { + id: rmHover + } + MouseArea { + anchors.fill: parent + enabled: root.running + cursorShape: Qt.PointingHandCursor + onClicked: { + if (modelData.cid.length > 0) { + root.backend.remove(modelData.cid) + } + } + } + } + } + } + } + + // Empty state + ColumnLayout { + anchors.centerIn: parent + spacing: 10 + visible: manifestList.count === 0 + + DotIcon { + pattern: [0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0] + dotColor: Theme.palette.textMuted + activeOpacity: 0.25 + Layout.alignment: Qt.AlignHCenter + } + + LogosText { + text: "No manifests yet" + color: Theme.palette.textMuted + font.pixelSize: 12 + Layout.alignment: Qt.AlignHCenter + } + } + } + } +} diff --git a/src/qml/MockBackend.qml b/src/qml/MockBackend.qml new file mode 100644 index 0000000..d350a17 --- /dev/null +++ b/src/qml/MockBackend.qml @@ -0,0 +1,47 @@ +pragma Singleton +import QtQuick + +QtObject { + readonly property bool isMock: true + property int status: 0 + property string debugLogs: "Hello!" + + signal ready + signal startCompleted + signal startFailed(string error) + signal error(string message) + signal natExtConfigCompleted + signal nodeIsUp + signal nodeIsntUp(string reason) + signal peersUpdated(int count) + signal uploadStarted(real totalBytes) + signal uploadChunk(real len) + signal uploadCompleted(string cid) + signal downloadCompleted(string cid) + signal spaceUpdated(real total, real used) + signal manifestsUpdated(var manifests) + signal stopCompleted + + function start() { status = 2 } + function stop() { status = 0 } + function destroy() {} + function checkNodeIsUp() {} + function fetchWidgetsData() {} + function uploadFile(url) {} + function downloadFile(cid, url) {} + function downloadManifest(cid) {} + function downloadManifests() {} + function remove(cid) {} + function logDebugInfo() {} + function logPeerId() {} + function logDataDir() {} + function logSpr() {} + function logVersion() {} + function saveUserConfig(json) {} + function saveCurrentConfig() {} + function loadUserConfig() {} + function reloadIfChanged(json) {} + function enableUpnpConfig() {} + function enableNatExtConfig(tcpPort) { natExtConfigCompleted() } + function configJson() { return "{}" } +} diff --git a/src/qml/ModeSelector.qml b/src/qml/ModeSelector.qml index eaac2c9..142c118 100644 --- a/src/qml/ModeSelector.qml +++ b/src/qml/ModeSelector.qml @@ -30,19 +30,21 @@ LogosStorageLayout { Layout.fillWidth: true } - Item { height: Theme.spacing.medium } + Item { + Layout.preferredHeight: Theme.spacing.medium + } Row { spacing: Theme.spacing.medium Layout.alignment: Qt.AlignHCenter - // ── Guide card ─────────────────────────────────────────────── Rectangle { width: 190 height: 230 radius: 14 color: root.selectedMode === 0 ? Theme.palette.overlayLight : "transparent" - border.color: root.selectedMode === 0 ? Theme.palette.text : Theme.palette.borderTertiaryMuted + border.color: root.selectedMode + === 0 ? Theme.palette.text : Theme.palette.borderTertiaryMuted border.width: root.selectedMode === 0 ? 2 : 1 ColumnLayout { @@ -80,13 +82,13 @@ LogosStorageLayout { } } - // ── Advanced card ──────────────────────────────────────────── Rectangle { width: 190 height: 230 radius: 14 color: root.selectedMode === 1 ? Theme.palette.overlayLight : "transparent" - border.color: root.selectedMode === 1 ? Theme.palette.text : Theme.palette.borderTertiaryMuted + border.color: root.selectedMode + === 1 ? Theme.palette.text : Theme.palette.borderTertiaryMuted border.width: root.selectedMode === 1 ? 2 : 1 ColumnLayout { @@ -125,7 +127,9 @@ LogosStorageLayout { } } - Item { height: Theme.spacing.small } + Item { + Layout.preferredHeight: Theme.spacing.small + } LogosStorageButton { text: "Continue" diff --git a/src/qml/NodeHeader.qml b/src/qml/NodeHeader.qml new file mode 100644 index 0000000..5ef18aa --- /dev/null +++ b/src/qml/NodeHeader.qml @@ -0,0 +1,147 @@ +import QtQuick +import QtQuick.Layouts +import Logos.Theme +import Logos.Controls + +// qmllint disable unqualified +RowLayout { + id: root + + property var backend: MockBackend + property bool nodeIsUp: false + property bool blinkOn: false + + readonly property int stopped: 0 + readonly property int starting: 1 + readonly property int running: 2 + readonly property int stopping: 3 + readonly property int destroyed: 4 + + signal settingsRequested() + + spacing: Theme.spacing.medium + + StorageIcon { + animated: root.backend.status === root.starting + || root.backend.status === root.stopping + dotColor: { + if (root.backend.status === root.starting) + return Theme.palette.warning + if (root.backend.status !== root.running) + return Theme.palette.textMuted + return root.nodeIsUp ? Theme.palette.success : Theme.palette.error + } + } + + ColumnLayout { + spacing: 6 + + LogosText { + text: "Logos Storage" + font.pixelSize: Theme.typography.titleText + } + + RowLayout { + spacing: 7 + + Rectangle { + Layout.preferredWidth: 7 + Layout.preferredHeight: 7 + radius: 3.5 + Layout.alignment: Qt.AlignVCenter + color: { + if (root.backend.status === root.starting) + return Theme.palette.warning + if (root.backend.status !== root.running) + return Theme.palette.textMuted + return root.nodeIsUp ? Theme.palette.success : Theme.palette.error + } + opacity: root.backend.status === root.running + ? (root.blinkOn ? 1.0 : 0.15) : 1.0 + } + + LogosText { + text: { + switch (root.backend.status) { + case root.stopped: return "Stopped" + case root.starting: return "Starting…" + case root.running: return "Running" + case root.stopping: return "Stopping…" + case root.destroyed: return "Not initialised" + default: return "" + } + } + font.pixelSize: Theme.typography.primaryText + color: Theme.palette.textSecondary + Layout.alignment: Qt.AlignVCenter + } + } + } + + Item { + Layout.fillWidth: true + } + + Rectangle { + Layout.preferredWidth: 44 + Layout.preferredHeight: 44 + radius: 8 + color: settingsHover.hovered ? Theme.palette.backgroundElevated : "transparent" + border.color: Theme.palette.borderSecondary + border.width: 1 + + SettingsIcon { + anchors.centerIn: parent + dotColor: Theme.palette.text + dotSize: 5 + dotSpacing: 2 + } + + HoverHandler { + id: settingsHover + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.settingsRequested() + } + } + + Rectangle { + Layout.preferredWidth: 44 + Layout.preferredHeight: 44 + radius: 8 + color: startStopHover.hovered ? Theme.palette.backgroundElevated : "transparent" + border.color: Theme.palette.borderSecondary + border.width: 1 + opacity: (root.backend.status === root.running + || root.backend.status === root.stopped) ? 1.0 : 0.4 + + PlayIcon { + anchors.centerIn: parent + dotColor: Theme.palette.text + dotSize: 5 + dotSpacing: 2 + visible: root.backend.status !== root.running + } + StopIcon { + anchors.centerIn: parent + dotColor: Theme.palette.text + dotSize: 5 + dotSpacing: 2 + visible: root.backend.status === root.running + } + + HoverHandler { + id: startStopHover + } + MouseArea { + anchors.fill: parent + enabled: root.backend.status === root.running + || root.backend.status === root.stopped + cursorShape: Qt.PointingHandCursor + onClicked: root.backend.status === root.running + ? root.backend.stop() : root.backend.start() + } + } +} diff --git a/src/qml/NodeStatusIcon.qml b/src/qml/NodeStatusIcon.qml index 8c42999..4df6fab 100644 --- a/src/qml/NodeStatusIcon.qml +++ b/src/qml/NodeStatusIcon.qml @@ -1,11 +1,7 @@ import QtQuick import Logos.Theme -// 7x7 animated dot grid for the StartNode screen. -// States: -// starting=true → white wave expanding from center -// starting=false, success → all dots green (Theme.palette.success) -// starting=false, !success → red X pattern (Theme.palette.error) +// qmllint disable unqualified Item { id: root @@ -42,8 +38,10 @@ Item { radius: root.dotSize * 0.25 color: { - if (root.success) return Theme.palette.success - if (!root.starting) return Theme.palette.error + if (root.success) + return Theme.palette.success + if (!root.starting) + return Theme.palette.error return Theme.palette.text } @@ -55,12 +53,15 @@ Item { if (root.starting) { const wave = root.animPhase % root.columns const diff = Math.abs(d - wave) - if (diff === 0) return 0.9 - if (diff === 1) return 0.35 + if (diff === 0) + return 0.9 + if (diff === 1) + return 0.35 return 0.1 } - if (root.success) return 0.85 + if (root.success) + return 0.85 // Error — X pattern return (col === row || col + row === 6) ? 0.9 : 0.1 diff --git a/src/qml/OnBoarding.qml b/src/qml/OnBoarding.qml index 4843c89..1562a21 100644 --- a/src/qml/OnBoarding.qml +++ b/src/qml/OnBoarding.qml @@ -6,7 +6,7 @@ import Logos.Controls LogosStorageLayout { id: root - property var backend: mockBackend + property var backend: MockBackend signal back signal completed(bool upnpEnabled) @@ -33,19 +33,21 @@ LogosStorageLayout { Layout.fillWidth: true } - Item { height: Theme.spacing.medium } + Item { + Layout.preferredHeight: Theme.spacing.medium + } Row { spacing: Theme.spacing.medium Layout.alignment: Qt.AlignHCenter - // ── UPnP card ──────────────────────────────────────────────── Rectangle { width: 190 height: 230 radius: 14 color: root.selectedMode === 0 ? Theme.palette.overlayLight : "transparent" - border.color: root.selectedMode === 0 ? Theme.palette.text : Theme.palette.borderTertiaryMuted + border.color: root.selectedMode + === 0 ? Theme.palette.text : Theme.palette.borderTertiaryMuted border.width: root.selectedMode === 0 ? 2 : 1 ColumnLayout { @@ -83,13 +85,13 @@ LogosStorageLayout { } } - // ── Port Forwarding card ───────────────────────────────────── Rectangle { width: 190 height: 230 radius: 14 color: root.selectedMode === 1 ? Theme.palette.overlayLight : "transparent" - border.color: root.selectedMode === 1 ? Theme.palette.text : Theme.palette.borderTertiaryMuted + border.color: root.selectedMode + === 1 ? Theme.palette.text : Theme.palette.borderTertiaryMuted border.width: root.selectedMode === 1 ? 2 : 1 ColumnLayout { @@ -128,7 +130,9 @@ LogosStorageLayout { } } - Item { height: Theme.spacing.small } + Item { + Layout.preferredHeight: Theme.spacing.small + } RowLayout { Layout.alignment: Qt.AlignHCenter @@ -152,9 +156,4 @@ LogosStorageLayout { } } - QtObject { - id: mockBackend - - function enableUpnpConfig() {} - } } diff --git a/src/qml/PeersWidget.qml b/src/qml/PeersWidget.qml new file mode 100644 index 0000000..4903874 --- /dev/null +++ b/src/qml/PeersWidget.qml @@ -0,0 +1,42 @@ +import QtQuick +import QtQuick.Layouts +import Logos.Theme +import Logos.Controls + +ArcWidget { + id: root + + property var backend: MockBackend + property int peers: 0 + property int maxPeers: 20 + + fraction: root.maxPeers > 0 ? Math.min(root.peers / root.maxPeers, 1.0) : 0 + + ColumnLayout { + anchors.centerIn: parent + spacing: 2 + + LogosText { + text: root.peers + font.pixelSize: 22 + font.bold: true + Layout.alignment: Qt.AlignHCenter + } + + LogosText { + text: "PEERS" + font.pixelSize: 9 + color: Theme.palette.textTertiary + font.letterSpacing: 1.3 + Layout.alignment: Qt.AlignHCenter + } + } + + Connections { + target: root.backend + + function onPeersUpdated(peers) { + root.peers = peers + } + } +} diff --git a/src/qml/PlayIcon.qml b/src/qml/PlayIcon.qml new file mode 100644 index 0000000..f7dac71 --- /dev/null +++ b/src/qml/PlayIcon.qml @@ -0,0 +1,12 @@ +import QtQuick + +// Right-pointing triangle — play / start +DotIcon { + pattern: [ + 1, 0, 0, 0, 0, + 1, 1, 0, 0, 0, + 1, 1, 1, 0, 0, + 1, 1, 0, 0, 0, + 1, 0, 0, 0, 0 + ] +} diff --git a/src/qml/PortForwarding.qml b/src/qml/PortForwarding.qml index 26bc48b..d457a6b 100644 --- a/src/qml/PortForwarding.qml +++ b/src/qml/PortForwarding.qml @@ -8,7 +8,7 @@ LogosStorageLayout { property var tcpPort: 0 property bool loading: false - property var backend: mockBackend + property var backend: MockBackend signal back signal completed(int port) @@ -50,6 +50,7 @@ LogosStorageLayout { LogosTextField { Layout.fillWidth: true + height: 60 id: tcpPortTextField placeholderText: "Enter the TCP port" text: root.tcpPort @@ -95,9 +96,4 @@ LogosStorageLayout { } } - QtObject { - id: mockBackend - - function enableNatExtConfig(port) {} - } } diff --git a/src/qml/SettingsIcon.qml b/src/qml/SettingsIcon.qml new file mode 100644 index 0000000..e18d4ed --- /dev/null +++ b/src/qml/SettingsIcon.qml @@ -0,0 +1,11 @@ +import QtQuick + +// Gear / cog icon — 4 cardinal teeth + ring with center hole +// . . ●. . +// . ● ● ● . +// ● ● . ● ● +// . ● ● ● . +// . . ● . . +DotIcon { + pattern: [0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0] +} diff --git a/src/qml/SettingsPopup.qml b/src/qml/SettingsPopup.qml new file mode 100644 index 0000000..d4e7096 --- /dev/null +++ b/src/qml/SettingsPopup.qml @@ -0,0 +1,75 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Logos.Theme +import Logos.Controls + +Popup { + id: root + + property var backend: MockBackend + + modal: true + width: 520 + height: 400 + anchors.centerIn: Overlay.overlay + padding: 24 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + // Reload the live config every time the popup opens + onOpened: jsonEditor.load(root.backend.configJson() || "{}") + + background: Rectangle { + color: Theme.palette.backgroundSecondary + border.color: Theme.palette.borderSecondary + border.width: 1 + radius: 14 + } + + ColumnLayout { + anchors.fill: parent + spacing: Theme.spacing.small + + LogosText { + text: "Configuration" + font.pixelSize: Theme.typography.titleText + Layout.alignment: Qt.AlignHCenter + } + + LogosText { + text: "Edit the JSON configuration below, then click Save." + font.pixelSize: Theme.typography.primaryText + color: Theme.palette.textSecondary + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + JsonEditor { + id: jsonEditor + Layout.fillWidth: true + Layout.fillHeight: true + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Theme.spacing.medium + + LogosStorageButton { + text: "Cancel" + onClicked: root.close() + } + + LogosStorageButton { + text: "Save" + variant: "success" + enabled: jsonEditor.isValid + onClicked: { + root.backend.saveUserConfig(jsonEditor.text) + root.close() + } + } + } + } +} diff --git a/src/qml/StartNode.qml b/src/qml/StartNode.qml index 7c466fb..ad8b5f5 100644 --- a/src/qml/StartNode.qml +++ b/src/qml/StartNode.qml @@ -1,13 +1,12 @@ import QtQuick import QtQuick.Layouts -import QtQuick.Controls import Logos.Controls import Logos.Theme LogosStorageLayout { id: root - property var backend: mockBackend + property var backend: MockBackend property string status: "" property string title: "Starting your node" property string resolution: "" @@ -132,19 +131,4 @@ LogosStorageLayout { onTriggered: root.onNodeStarted() } - QtObject { - id: mockBackend - - readonly property bool isMock: true - - signal startCompleted - signal startFailed(string error) - signal nodeIsUp - signal nodeIsntUp(string reason) - - function checkNodeIsUp() {} - function stop() {} - function saveCurrentConfig() {} - function start() {} - } } diff --git a/src/qml/StopIcon.qml b/src/qml/StopIcon.qml new file mode 100644 index 0000000..e531f3b --- /dev/null +++ b/src/qml/StopIcon.qml @@ -0,0 +1,12 @@ +import QtQuick + +// Filled square — stop +DotIcon { + pattern: [ + 0, 0, 0, 0, 0, + 0, 1, 1, 1, 0, + 0, 1, 1, 1, 0, + 0, 1, 1, 1, 0, + 0, 0, 0, 0, 0 + ] +} diff --git a/src/qml/StorageIcon.qml b/src/qml/StorageIcon.qml new file mode 100644 index 0000000..121e720 --- /dev/null +++ b/src/qml/StorageIcon.qml @@ -0,0 +1,14 @@ +import QtQuick + +// Ring / node pattern — used in the StorageView header +DotIcon { + pattern: [ + 0, 1, 1, 1, 0, + 1, 0, 0, 0, 1, + 1, 0, 1, 0, 1, + 1, 0, 0, 0, 1, + 0, 1, 1, 1, 0 + ] + dotSize: 7 + dotSpacing: 5 +} diff --git a/src/qml/StorageView.qml b/src/qml/StorageView.qml index 78a55a0..7d7d1ff 100644 --- a/src/qml/StorageView.qml +++ b/src/qml/StorageView.qml @@ -1,888 +1,115 @@ import QtQuick import QtQuick.Controls -import QtQuick.Dialogs import QtQuick.Layouts -import QtCore +import Logos.Theme +import Logos.Controls // qmllint disable unqualified LogosStorageLayout { id: root - property var backend: mockBackend - readonly property int stopped: 0 - readonly property int starting: 1 - readonly property int running: 2 - readonly property int stopping: 3 - readonly property int destroyed: 4 - property string peerId: "" - property string downloadDestination: "" - property url downloadCid: "" - property string logLevel: "" + property var backend: MockBackend property bool showDebug: false - property var pendingDownloadManifest: null - property url uploadCid: root.backend.cid - property url configJson: root.backend.configJson - - function getStatusLabel() { - switch (backend.status) { - case stopped: - return "Logos Storage stopped." - case starting: - return "Logos Storage is starting..." - case running: - return "Logos Storage started." - case stopping: - return "Logos Storage is stopping..." - case destroyed: - return "Logos Storage is not initialised." - } - } - - function startStopText() { - if (backend.status == running) { - return "Stop" - } - return "Start" - } - - function canStartStop() { - return backend.status == running || backend.status == stopped - } function isRunning() { - return backend.status == running + return backend.status === 2 // StorageBackend.Running } - Component.onCompleted: root.backend.start() - - QtObject { - id: mockBackend - - property var status: root.stopped - property var debugLogs: "Hello !" - property var configJson: "{}" - property string uploadStatus: "" - property int uploadProgress: 0 - property var manifests: [] - property var quotaMaxBytes: 20 * 1024 * 1024 * 1024 // 20 GB default - property var quotaUsedBytes: 0 - property var quotaReservedBytes: 0 - - function start(newConfigJson) { - status = root.running + Component.onCompleted: function () { + if (isRunning()) { + root.backend.fetchWidgetsData() + } else { + root.backend.start() } - - function stop() { - status = root.stopped - } - } - - function formatBytes(bytes) { - if (bytes <= 0) - return "0 B" - if (bytes < 1024) - return bytes + " B" - if (bytes < 1024 * 1024) - return (bytes / 1024).toFixed(1) + " KB" - if (bytes < 1024 * 1024 * 1024) - return (bytes / (1024 * 1024)).toFixed(1) + " MB" - return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB" } HealthIndicator { + id: health backend: root.backend + } + + SettingsPopup { + id: settingsPopup + backend: root.backend + } + + Shortcut { + sequence: "Ctrl+D" + onActivated: root.showDebug = !root.showDebug + } + + ScrollView { + id: mainScroll anchors.fill: parent - } - - Text { - id: statusTextElement - objectName: "status" - text: root.getStatusLabel() - color: "white" - font.pointSize: 20 - anchors.top: parent.top - anchors.topMargin: 20 - anchors.horizontalCenter: parent.horizontalCenter - } - - Button { - property var isStopped: root.backend.status == root.stopped - - id: startStopButton - objectName: "startStopButton" - anchors.leftMargin: 50 - text: root.startStopText() - enabled: root.canStartStop() - onClicked: isStopped ? root.backend.start( - jsonEditor.text) : root.backend.stop() - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: statusTextElement.bottom - anchors.topMargin: 10 - } - - TextEdit { - id: cidTextEdit - objectName: "cid" - color: "white" - font.pointSize: 14 - readOnly: true - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: startStopButton.bottom - anchors.topMargin: 10 - text: root.uploadCid - } - - Button { - id: openFile - text: "Open file" - onClicked: fileDialog.open() - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: cidTextEdit.bottom - anchors.topMargin: 15 - enabled: root.isRunning - } - - Column { - id: uploadProgressColumn - anchors.top: openFile.bottom - anchors.topMargin: 10 - anchors.horizontalCenter: parent.horizontalCenter - width: 300 - spacing: 5 - visible: root.backend.uploadProgress > 0 - - ProgressBar { - width: parent.width - value: root.backend.uploadProgress / 100.0 - - background: Rectangle { - color: "#333333" - radius: 3 - implicitWidth: 300 - implicitHeight: 6 - } - - contentItem: Item { - implicitWidth: 300 - implicitHeight: 6 - - Rectangle { - width: parent.width * control.visualPosition - height: parent.height - radius: 3 - color: "#4CAF50" - } - } - } - - Text { - text: root.backend.uploadStatus - color: "#888888" - font.pixelSize: 10 - anchors.horizontalCenter: parent.horizontalCenter - } - } - - // TextField { - // id: peerIdField - // placeholderText: "Enter the peer Id" - // placeholderTextColor: "#999999" - // color: "#000000" - // selectByMouse: true - // text: root.peerId - // onTextChanged: root.peerId = text - // anchors.top: uploadProgressColumn.bottom - // anchors.topMargin: 50 - // anchors.horizontalCenter: parent.horizontalCenter - // } - - // Button { - // id: peerConnectButton - // objectName: "peerConnectButton" - // text: "Peer connect" - // onClicked: root.backend.tryPeerConnect(root.peerId) - // anchors.top: peerIdField.bottom - // anchors.horizontalCenter: parent.horizontalCenter - // enabled: root.isRunning - // anchors.topMargin: 10 - // } - Button { - id: debugButton - objectName: "debugButton" - text: "Debug" - onClicked: root.backend.tryDebug() - anchors.top: uploadProgressColumn.bottom - anchors.horizontalCenter: parent.horizontalCenter - enabled: root.isRunning - anchors.topMargin: 50 - } - - Button { - id: peerIdButton - objectName: "peerIdButton" - text: "Peer Id" - onClicked: root.backend.showPeerId() - anchors.top: uploadProgressColumn.bottom - anchors.right: debugButton.left - enabled: root.isRunning - anchors.topMargin: 50 - } - - Button { - id: dataDirButton - objectName: "dataDirButton" - text: "Data dir" - onClicked: root.backend.dataDir() - anchors.top: uploadProgressColumn.bottom - anchors.right: peerIdButton.left - enabled: root.isRunning - anchors.topMargin: 50 - } - - Button { - id: sprButton - objectName: "sprButton" - text: "SPR" - onClicked: root.backend.spr() - anchors.top: uploadProgressColumn.bottom - anchors.left: debugButton.right - enabled: root.isRunning - anchors.topMargin: 50 - } - - Button { - id: versionButton - objectName: "versionButton" - text: "Version" - onClicked: root.backend.version() - anchors.top: uploadProgressColumn.bottom - anchors.left: sprButton.right - enabled: root.isRunning - anchors.topMargin: 50 - } - - // TextField { - // id: cidDownloadField - // placeholderTextColor: "#999999" - // placeholderText: "Enter the cid to download" - // color: "black" - // // text: root.downloadCid - // onTextChanged: root.downloadCid = text - // anchors.top: debugButton.bottom - // anchors.topMargin: 50 - // anchors.horizontalCenter: parent.horizontalCenter - // } - - // Button { - // id: openFile2 - // text: "Open file" - // onClicked: fileDialog2.open() - // anchors.horizontalCenter: parent.horizontalCenter - // anchors.top: cidDownloadField.bottom - // anchors.topMargin: 15 - // enabled: root.isRunning - // } - - // Button { - // id: cidDownloadButton - // objectName: "cidDownloadButton" - // text: "Download" - // onClicked: root.backend.tryDownloadFile(root.downloadCid, - // root.downloadDestination) - // anchors.top: openFile2.bottom - // anchors.horizontalCenter: parent.horizontalCenter - // enabled: root.isRunning - // anchors.topMargin: 10 - // } - - // Button { - // id: existsButton - // objectName: "existsButton" - // text: "Exists" - // onClicked: root.backend.exists(root.downloadCid) - // anchors.top: openFile2.bottom - // anchors.left: cidDownloadButton.right - // enabled: root.isRunning - // anchors.topMargin: 10 - // } - - // Button { - // id: fetchButton - // objectName: "fetchButton" - // text: "Fetch" - // onClicked: root.backend.fetch(root.downloadCid) - // anchors.top: openFile2.bottom - // anchors.left: existsButton.right - // enabled: root.isRunning - // anchors.topMargin: 10 - // } - - // Button { - // id: removeButton - // objectName: "removeButton" - // text: "Remove" - // onClicked: root.backend.remove(root.downloadCid) - // anchors.top: openFile2.bottom - // anchors.right: cidDownloadButton.left - // enabled: root.isRunning - // anchors.topMargin: 10 - // } - - // Button { - // id: downloadManifestButton - // objectName: "downloadManifestButton" - // text: "Download manifest" - // onClicked: root.backend.downloadManifest(root.downloadCid) - // anchors.top: openFile2.bottom - // anchors.right: removeButton.left - // enabled: root.isRunning - // anchors.topMargin: 10 - // } - - - /*Button { - id: downloadManifestsButton - objectName: "downloadManifestsButton" - text: "Manifests" - onClicked: root.backend.downloadManifests() - anchors.top: cidDownloadButton.bottom - anchors.horizontalCenter: parent.horizontalCenter - enabled: root.isRunning - anchors.topMargin: 10 - }*/ - - // ── Manifests section ────────────────────────────────────────────────── - Text { - id: manifestsTitle - text: "Manifests" - color: "white" - font.pixelSize: 14 - font.bold: true - anchors.top: versionButton.bottom - anchors.topMargin: 30 - anchors.horizontalCenter: parent.horizontalCenter - } - - // ── Disk space bar ──────────────────────────────────────────────────── - Item { - id: spaceBarSection - anchors.top: manifestsTitle.bottom - anchors.topMargin: 10 - anchors.horizontalCenter: parent.horizontalCenter - width: parent.width - 40 - height: root.backend.quotaMaxBytes > 0 ? 36 : 20 - - readonly property real total: root.backend.quotaMaxBytes - readonly property real used: root.backend.quotaUsedBytes - readonly property real reserved: root.backend.quotaReservedBytes - - // No quota configured - Text { - anchors.centerIn: parent - text: "No quota configured" - color: "#555555" - font.pixelSize: 11 - visible: spaceBarSection.total <= 0 - } - - // Background track - Rectangle { - id: spaceBarTrack - visible: spaceBarSection.total > 0 - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - height: 14 - radius: 7 - color: "#2a2a2a" - border.color: "#3a3a3a" - border.width: 1 - clip: true - - // Used (green) - Rectangle { - width: Math.min( - parent.width * (spaceBarSection.used / spaceBarSection.total), - parent.width) - height: parent.height - radius: parent.radius - color: "#4CAF50" - } - - // Reserved (orange), stacked after used - Rectangle { - x: parent.width * (spaceBarSection.used / spaceBarSection.total) - width: Math.min( - parent.width * (spaceBarSection.reserved / spaceBarSection.total), - parent.width - x) - height: parent.height - color: "#FF9800" - } - } - - // Labels - Row { - visible: spaceBarSection.total > 0 - anchors.top: spaceBarTrack.bottom - anchors.topMargin: 4 - anchors.horizontalCenter: parent.horizontalCenter - spacing: 16 - - Text { - text: "Used: " + root.formatBytes(spaceBarSection.used) - color: "#4CAF50" - font.pixelSize: 10 - } - Text { - text: "Reserved: " + root.formatBytes(spaceBarSection.reserved) - color: "#FF9800" - font.pixelSize: 10 - } - Text { - text: "Free: " + root.formatBytes( - spaceBarSection.total - spaceBarSection.used - spaceBarSection.reserved) - color: "#888888" - font.pixelSize: 10 - } - Text { - text: "Total: " + root.formatBytes(spaceBarSection.total) - color: "#555555" - font.pixelSize: 10 - } - } - } - - Row { - id: manifestInputRow - spacing: 8 - anchors.top: spaceBarSection.bottom - anchors.topMargin: 16 - anchors.horizontalCenter: parent.horizontalCenter - - TextField { - id: manifestCidField - width: 380 - placeholderText: "Enter CID to download manifest" - placeholderTextColor: "#999999" - color: "#000000" - selectByMouse: true - } - - Button { - id: addManifestButton - text: "Download Manifest" - enabled: root.isRunning() && manifestCidField.text.length > 0 - onClicked: { - root.backend.downloadManifest(manifestCidField.text) - manifestCidField.clear() - } - } - } - - // Table header - Rectangle { - id: manifestTableHeader - anchors.top: manifestInputRow.bottom - anchors.topMargin: 8 - anchors.horizontalCenter: parent.horizontalCenter - width: parent.width - 40 - height: 28 - color: "#222222" - radius: 2 - - Row { - anchors.fill: parent - anchors.leftMargin: 6 - - Text { - width: 150 - text: "CID" - color: "#aaaaaa" - font.pixelSize: 11 - font.bold: true - elide: Text.ElideRight - anchors.verticalCenter: parent.verticalCenter - } - Text { - width: 120 - text: "Filename" - color: "#aaaaaa" - font.pixelSize: 11 - font.bold: true - elide: Text.ElideRight - anchors.verticalCenter: parent.verticalCenter - } - Text { - width: 85 - text: "MIME type" - color: "#aaaaaa" - font.pixelSize: 11 - font.bold: true - elide: Text.ElideRight - anchors.verticalCenter: parent.verticalCenter - } - Text { - width: 75 - text: "Size (bytes)" - color: "#aaaaaa" - font.pixelSize: 11 - font.bold: true - elide: Text.ElideRight - anchors.verticalCenter: parent.verticalCenter - } - Text { - width: 110 - text: "" - color: "#aaaaaa" - font.pixelSize: 11 - font.bold: true - anchors.verticalCenter: parent.verticalCenter - } - } - } - - Rectangle { - id: manifestTableContainer - anchors.top: manifestTableHeader.bottom - anchors.left: manifestTableHeader.left - anchors.right: manifestTableHeader.right - height: 280 - color: "#111111" - border.color: "#333333" - border.width: 1 + anchors.bottomMargin: root.showDebug ? debugPanel.height : 0 + contentWidth: availableWidth clip: true - ListView { - id: manifestListView - anchors.fill: parent - model: root.backend.manifests - clip: true + ColumnLayout { + width: mainScroll.availableWidth + spacing: 0 - delegate: Rectangle { - width: manifestListView.width - height: 36 - color: index % 2 === 0 ? "#181818" : "#1e1e1e" - - Row { - anchors.fill: parent - anchors.leftMargin: 6 - anchors.rightMargin: 4 - spacing: 0 - - Text { - width: 150 - text: modelData["cid"] ?? "" - color: "#dddddd" - font.pixelSize: 11 - font.family: "monospace" - elide: Text.ElideMiddle - anchors.verticalCenter: parent.verticalCenter - // ToolTip.visible: hovered - ToolTip.text: modelData["cid"] ?? "" - HoverHandler {} - } - Text { - width: 120 - text: modelData["filename"] ?? "" - color: "#dddddd" - font.pixelSize: 11 - elide: Text.ElideRight - anchors.verticalCenter: parent.verticalCenter - } - Text { - width: 85 - text: modelData["mimetype"] ?? "" - color: "#dddddd" - font.pixelSize: 11 - elide: Text.ElideRight - anchors.verticalCenter: parent.verticalCenter - } - Text { - width: 75 - text: modelData["datasetSize"] ?? "" - color: "#dddddd" - font.pixelSize: 11 - elide: Text.ElideRight - anchors.verticalCenter: parent.verticalCenter - } - Row { - spacing: 4 - anchors.verticalCenter: parent.verticalCenter - - Button { - width: 50 - height: 26 - text: "↓" - enabled: root.isRunning() - onClicked: { - root.pendingDownloadManifest = modelData - var filename = modelData["filename"] - || modelData["cid"] || "download" - manifestSaveDialog.currentFile = StandardPaths.writableLocation( - StandardPaths.HomeLocation) + "/" + filename - manifestSaveDialog.open() - } - } - - Button { - width: 50 - height: 26 - text: "🗑" - enabled: root.isRunning() - onClicked: root.backend.remove( - modelData["cid"] ?? "") - } - } - } + NodeHeader { + Layout.fillWidth: true + Layout.leftMargin: 24 + Layout.rightMargin: 24 + Layout.topMargin: 24 + Layout.bottomMargin: 20 + backend: root.backend + nodeIsUp: health.nodeIsUp + blinkOn: health.blinkOn + onSettingsRequested: settingsPopup.open() } - Text { - anchors.centerIn: parent - text: "No manifests yet" - color: "#555555" - font.pixelSize: 12 - visible: manifestListView.count === 0 + Rectangle { + Layout.fillWidth: true + Layout.leftMargin: 24 + Layout.rightMargin: 24 + Layout.preferredHeight: 1 + color: Theme.palette.borderSecondary + } + + Widgets { + Layout.fillWidth: true + Layout.leftMargin: 24 + Layout.rightMargin: 24 + Layout.topMargin: 20 + backend: root.backend + running: root.isRunning() + } + + Rectangle { + Layout.fillWidth: true + Layout.leftMargin: 24 + Layout.rightMargin: 24 + Layout.preferredHeight: 1 + color: Theme.palette.borderSecondary + } + + ManifestTable { + Layout.fillWidth: true + Layout.leftMargin: 24 + Layout.rightMargin: 24 + Layout.topMargin: 20 + Layout.bottomMargin: 20 + backend: root.backend + running: root.isRunning() + } + + Item { + Layout.preferredHeight: 20 } } } - Button { - id: spaceButton - objectName: "spaceButton" - text: "Space" - onClicked: root.backend.space() - anchors.top: manifestTableContainer.bottom - enabled: root.isRunning - anchors.topMargin: 10 - anchors.horizontalCenter: parent.horizontalCenter - } - - // ── Log level section ────────────────────────────────────────────────── - // TextField { - // id: logLevelField - // placeholderTextColor: "#999999" - // placeholderText: "Enter the log level to download" - // color: "black" - // // text: root.downloadCid - // onTextChanged: root.logLevel = text - // anchors.top: manifestTableContainer.bottom - // anchors.topMargin: 30 - // anchors.horizontalCenter: parent.horizontalCenter - // } - - // Button { - // id: logLevelButton - // objectName: "logLevelButton" - // text: "Log level" - // onClicked: root.backend.updateLogLevel(root.logLevel) - // anchors.top: logLevelField.bottom - // anchors.horizontalCenter: parent.horizontalCenter - // enabled: root.isRunning - // anchors.topMargin: 10 - // } - - // TextEdit { - // id: selectableText - // anchors.fill: parent - // anchors.margins: 10 - // text: "This text is selectable. You can copy it, but not edit it." - // readOnly: true // Makes the text non-editable - // selectByMouse: true // Enables selection by mouse drag (often the default for desktop) - // // Optional: Change cursor shape to IBeam when hovering - // MouseArea { - // anchors.fill: parent - // cursorShape: Qt.IBeamCursor - // acceptedButtons: Qt.NoButton // Allows TextEdit to handle mouse events - // } - // } - - // Button { - // anchors.left: parent.left - // anchors.bottom: parent.bottom - // objectName: "uploadButton" - // text: "Upload" - // anchors.bottomMargin: 80 - // onClicked: root.backend.tryUpload() - // } - - // Button { - // anchors.left: parent.left - // anchors.bottom: parent.bottom - // objectName: "finalizeButton" - // text: "Finalize" - // onClicked: root.backend.tryUploadFinalize() - // } - // Button { - // anchors.left: parent.left - // anchors.bottom: parent.bottom - // objectName: "uploadFileButton" - // text: "Upload file" - // onClicked: root.backend.tryUploadFile() - // anchors.bottomMargin: 30 - // } - FileDialog { - id: fileDialog - onAccepted: root.backend.tryUploadFile(fileDialog.selectedFile) - } - - FileDialog { - id: fileDialog2 - fileMode: FileDialog.SaveFile - onAccepted: { - root.downloadDestination = fileDialog2.selectedFile - console.log("Destination selected:", - root.backend.downloadDestination) - } - } - - FileDialog { - id: manifestSaveDialog - fileMode: FileDialog.SaveFile - onAccepted: { - if (root.pendingDownloadManifest) { - root.backend.tryDownloadFile( - root.pendingDownloadManifest["cid"], - manifestSaveDialog.selectedFile) - root.pendingDownloadManifest = null - } - } - onRejected: { - root.pendingDownloadManifest = null - } - } - - Rectangle { + DebugPanel { id: debugPanel anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom - height: 150 - color: "#111111" - visible: root.showDebug // or: visible: showDebug - - TabBar { - id: bar - width: parent.width - - TabButton { - text: qsTr("Logs") - } - - TabButton { - text: qsTr("Config") - } - } - - StackLayout { - id: stackLayout - currentIndex: bar.currentIndex - anchors.top: bar.bottom - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - - Item { - id: homeTab - - Flickable { - id: flick - anchors.fill: parent - clip: true - - contentWidth: width - contentHeight: debugText.paintedHeight - - TextEdit { - id: debugText - width: flick.width - text: root.backend.debugLogs - color: "#dddddd" - font.family: "monospace" - font.pixelSize: 12 - wrapMode: Text.WrapAnywhere - readOnly: true - - onTextChanged: Qt.callLater(function () { - flick.contentY = Math.max( - 0, flick.contentHeight - flick.height) - }) - } - } - } - Rectangle { - id: discoverTab - - ScrollView { - anchors.fill: parent - - TextArea { - id: jsonEditor - font.family: "monospace" - font.pixelSize: 12 - color: "#d4d4d4" - width: parent.width - height: parent.height - wrapMode: Text.WrapAnywhere - - background: Rectangle { - color: "#1e1e1e" - border.color: jsonEditor.isValid ? "#3a3a3a" : "#ff0000" - border.width: 1 - } - - property bool isValid: true - - Connections { - target: root.backend - - function onConfigJsonChanged() { - jsonEditor.text = root.backend.configJson - try { - const jsonData = JSON.parse(jsonEditor.text) - jsonEditor.isValid = true - } catch (e) { - jsonEditor.isValid = false - } - } - } - - Component.onCompleted: { - text = root.backend.configJson - - try { - const jsonData = JSON.parse(text) - isValid = true - } catch (e) { - isValid = false - } - } - - onTextChanged: { - - // try { - // const jsonData = JSON.parse(text) - // isValid = true - // } catch (e) { - // isValid = false - // } - } - - onEditingFinished: { - try { - const jsonData = JSON.parse(text) - root.backend.saveUserConfig(text) - isValid = true - } catch (e) { - isValid = false - } - } - } - } - } - } - Shortcut { - sequence: "Ctrl+D" - onActivated: root.showDebug = !root.showDebug - } + height: 220 + visible: root.showDebug + backend: root.backend + running: root.isRunning() } } diff --git a/src/qml/UploadIcon.qml b/src/qml/UploadIcon.qml new file mode 100644 index 0000000..dca846e --- /dev/null +++ b/src/qml/UploadIcon.qml @@ -0,0 +1,12 @@ +import QtQuick + +// Upward arrow — upload +DotIcon { + pattern: [ + 0, 0, 1, 0, 0, + 0, 1, 1, 1, 0, + 1, 1, 1, 1, 1, + 0, 0, 1, 0, 0, + 0, 0, 1, 0, 0 + ] +} diff --git a/src/qml/UploadWidget.qml b/src/qml/UploadWidget.qml new file mode 100644 index 0000000..aafef56 --- /dev/null +++ b/src/qml/UploadWidget.qml @@ -0,0 +1,96 @@ +import QtQuick +import QtQuick.Layouts +import Logos.Theme +import Logos.Controls + +ArcWidget { + id: root + + property var backend: MockBackend + property bool running: false + property real totalBytes: 0 + property real uploadedBytes: 0 + + readonly property int uploadProgress: { + if (totalBytes <= 0) { + return 0 + } + return Math.min(Math.round(uploadedBytes / totalBytes * 100), 100) + } + + signal uploadRequested + + readonly property bool isUploading: uploadProgress > 0 + && uploadProgress < 100 + readonly property bool isDone: uploadProgress >= 100 + + Connections { + target: root.backend + + function onUploadStarted(totalBytes) { + root.totalBytes = totalBytes + root.uploadedBytes = 0 + } + + function onUploadChunk(len) { + root.uploadedBytes += len + } + + function onUploadCompleted(cid) { + root.uploadedBytes = root.totalBytes // force 100% + } + } + + fraction: root.uploadProgress / 100.0 + fillColor: root.isDone ? Theme.palette.success : Theme.palette.text + + ColumnLayout { + anchors.centerIn: parent + spacing: 2 + + UploadIcon { + dotColor: Theme.palette.textSecondary + dotSize: 4 + dotSpacing: 3 + activeOpacity: 0.5 + visible: !root.isUploading + Layout.alignment: Qt.AlignHCenter + } + + // Uploading: percentage + LogosText { + text: root.uploadProgress + "%" + font.pixelSize: 22 + font.bold: true + visible: root.isUploading + Layout.alignment: Qt.AlignHCenter + } + + LogosText { + text: root.isDone ? "DONE" : "UPLOAD" + font.pixelSize: 9 + color: Theme.palette.textTertiary + font.letterSpacing: 1.2 + Layout.alignment: Qt.AlignHCenter + } + } + + HoverHandler { + id: widgetHover + } + + Rectangle { + anchors.fill: parent + radius: root.radius + color: widgetHover.hovered + && root.running ? Qt.rgba(1, 1, 1, 0.04) : "transparent" + } + + MouseArea { + anchors.fill: parent + cursorShape: root.running ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: if (root.running) { + root.uploadRequested() + } + } +} diff --git a/src/qml/Utils.js b/src/qml/Utils.js new file mode 100644 index 0000000..f461ee6 --- /dev/null +++ b/src/qml/Utils.js @@ -0,0 +1,13 @@ +.pragma library + +function formatBytes(bytes) { + if (bytes <= 0) + return "0 B" + if (bytes < 1024) + return bytes + " B" + if (bytes < 1024 * 1024) + return (bytes / 1024).toFixed(1) + " KB" + if (bytes < 1024 * 1024 * 1024) + return (bytes / (1024 * 1024)).toFixed(1) + " MB" + return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB" +} diff --git a/src/qml/Widgets.qml b/src/qml/Widgets.qml new file mode 100644 index 0000000..10a55eb --- /dev/null +++ b/src/qml/Widgets.qml @@ -0,0 +1,158 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Dialogs +import QtQuick.Layouts +import Logos.Theme +import Logos.Controls + +// qmllint disable unqualified +ColumnLayout { + id: root + + property var backend: MockBackend + property bool running: false + property string _lastUploadedCid: "" + + spacing: 0 + + TextEdit { + id: clipHelper + visible: false + function copyText(str) { + clipHelper.text = str + clipHelper.selectAll() + clipHelper.copy() + } + } + + FileDialog { + id: uploadDialog + onAccepted: root.backend.uploadFile(selectedFile) + } + + Connections { + target: root.backend + function onUploadCompleted(cid) { + root._lastUploadedCid = cid + } + + function onDownloadCompleted() {} + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.medium + + UploadWidget { + backend: root.backend + running: root.running + onUploadRequested: uploadDialog.open() + } + + DiskWidget { + backend: root.backend + } + + PeersWidget { + backend: root.backend + } + + Item { + Layout.fillWidth: true + } + } + + Item { + Layout.fillWidth: true + Layout.topMargin: 10 + Layout.bottomMargin: 10 + Layout.preferredHeight: 36 + + opacity: root._lastUploadedCid.length > 0 ? 1.0 : 0.0 + + Behavior on opacity { + NumberAnimation { + duration: 200 + } + } + + Rectangle { + height: 36 + width: cidBadgeRow.implicitWidth + 28 + radius: 6 + color: Theme.palette.backgroundSecondary + border.color: Theme.palette.borderSecondary + border.width: 1 + + RowLayout { + id: cidBadgeRow + anchors.centerIn: parent + spacing: 8 + + LogosText { + text: "CID" + font.pixelSize: 10 + color: Theme.palette.textTertiary + } + + LogosText { + text: { + var c = root._lastUploadedCid + return c.length > 20 ? c.substring(0, 8) + "…" + c.slice(-6) : c + } + font.pixelSize: 11 + font.family: "monospace" + color: Theme.palette.text + } + + LogosText { + text: "COPY" + font.pixelSize: 9 + color: Theme.palette.textTertiary + font.letterSpacing: 0.8 + } + } + + Rectangle { + id: copyFlash + anchors.fill: parent + radius: parent.radius + color: Theme.palette.success + opacity: 0 + + SequentialAnimation on opacity { + id: copyFlashAnim + running: false + NumberAnimation { + to: 0.18 + duration: 80 + } + NumberAnimation { + to: 0 + duration: 500 + } + } + } + + HoverHandler { + id: cidBadgeHover + } + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: cidBadgeHover.hovered ? Qt.rgba(1, 1, 1, + 0.04) : "transparent" + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + clipHelper.copyText(root._lastUploadedCid) + copyFlashAnim.restart() + } + } + } + } +} diff --git a/src/qml/icons/AdvancedIcon.qml b/src/qml/icons/AdvancedIcon.qml new file mode 100644 index 0000000..66b882e --- /dev/null +++ b/src/qml/icons/AdvancedIcon.qml @@ -0,0 +1,17 @@ +import QtQuick + +// X pattern — advanced / expert mode +// ● . . . ● +// . ● . ● . +// . . ● . . +// . ● . ● . +// ● . . . ● +DotIcon { + pattern: [ + 1, 0, 0, 0, 1, + 0, 1, 0, 1, 0, + 0, 0, 1, 0, 0, + 0, 1, 0, 1, 0, + 1, 0, 0, 0, 1 + ] +} diff --git a/src/qml/icons/ArcGauge.qml b/src/qml/icons/ArcGauge.qml new file mode 100644 index 0000000..0b997f0 --- /dev/null +++ b/src/qml/icons/ArcGauge.qml @@ -0,0 +1,56 @@ +import QtQuick +import Logos.Theme + +// Reusable arc gauge — same visual style as the Nothing OS ring widgets. +// +// Usage: +// ArcGauge { +// anchors.fill: parent +// fraction: 0.65 +// fillColor: Theme.palette.success +// } +Canvas { + id: root + + // 0.0 – 1.0 (clamped internally) + property real fraction: 0.0 + property color trackColor: Theme.palette.textMuted + property color fillColor: Theme.palette.text + property real arcRadius: 46 + property real arcWidth: 8 + + onFractionChanged: requestPaint() + onTrackColorChanged: requestPaint() + onFillColorChanged: requestPaint() + + Component.onCompleted: requestPaint() + + onPaint: { + var ctx = getContext("2d") + ctx.reset() + + var cx = width / 2 + var cy = height / 2 + var startRad = 130 * Math.PI / 180 + var totalRad = 280 * Math.PI / 180 + + // ── Track (full arc, muted) ─────────────────────────────────────────── + ctx.beginPath() + ctx.arc(cx, cy, root.arcRadius, startRad, startRad + totalRad) + ctx.strokeStyle = root.trackColor.toString() + ctx.lineWidth = root.arcWidth + ctx.lineCap = "round" + ctx.stroke() + + // ── Fill (proportional) ─────────────────────────────────────────────── + var f = Math.min(Math.max(root.fraction, 0.0), 1.0) + if (f > 0) { + ctx.beginPath() + ctx.arc(cx, cy, root.arcRadius, startRad, startRad + totalRad * f) + ctx.strokeStyle = root.fillColor.toString() + ctx.lineWidth = root.arcWidth + ctx.lineCap = "round" + ctx.stroke() + } + } +} diff --git a/src/qml/icons/ArcWidget.qml b/src/qml/icons/ArcWidget.qml new file mode 100644 index 0000000..0b1bc77 --- /dev/null +++ b/src/qml/icons/ArcWidget.qml @@ -0,0 +1,76 @@ +import QtQuick +import Logos.Theme + +// Reusable ring widget — Rectangle + arc canvas + content overlay. +// Usage: +// ArcWidget { +// fraction: 0.65 +// fillColor: Theme.palette.success +// ColumnLayout { anchors.centerIn: parent; ... } +// } +// Children are placed inside an overlay Item that fills the widget, +// so anchors such as `anchors.centerIn: parent` work as expected. +Rectangle { + id: root + + width: 140 + height: 140 + radius: 14 + color: Theme.palette.backgroundSecondary + border.color: Theme.palette.borderSecondary + border.width: 1 + + property real fraction: 0.0 + property color fillColor: Theme.palette.text + property color trackColor: Theme.palette.textMuted + property real arcRadius: 46 + property real arcWidth: 8 + + onFractionChanged: arc.requestPaint() + onFillColorChanged: arc.requestPaint() + onTrackColorChanged: arc.requestPaint() + + default property alias content: overlay.data + + Canvas { + id: arc + anchors.fill: parent + + Component.onCompleted: requestPaint() + + onPaint: { + var ctx = getContext("2d") + ctx.reset() + + var cx = width / 2 + var cy = height / 2 + var startRad = 130 * Math.PI / 180 + var totalRad = 280 * Math.PI / 180 + + // Track (full arc, muted) + ctx.beginPath() + ctx.arc(cx, cy, root.arcRadius, startRad, startRad + totalRad) + ctx.strokeStyle = root.trackColor.toString() + ctx.lineWidth = root.arcWidth + ctx.lineCap = "round" + ctx.stroke() + + // Fill (proportional) + var f = Math.min(Math.max(root.fraction, 0.0), 1.0) + if (f > 0) { + ctx.beginPath() + ctx.arc(cx, cy, root.arcRadius, startRad, + startRad + totalRad * f) + ctx.strokeStyle = root.fillColor.toString() + ctx.lineWidth = root.arcWidth + ctx.lineCap = "round" + ctx.stroke() + } + } + } + + Item { + id: overlay + anchors.fill: parent + } +} diff --git a/src/qml/icons/DeleteIcon.qml b/src/qml/icons/DeleteIcon.qml new file mode 100644 index 0000000..04aa56e --- /dev/null +++ b/src/qml/icons/DeleteIcon.qml @@ -0,0 +1,18 @@ +import QtQuick + +// X pattern — delete / remove +// ● . . . ● +// . ● . ● . +// . . ● . . +// . ● . ● . +// ● . . . ● +// qmllint disable unqualified +DotIcon { + pattern: [ + 1, 0, 0, 0, 1, + 0, 1, 0, 1, 0, + 0, 0, 1, 0, 0, + 0, 1, 0, 1, 0, + 1, 0, 0, 0, 1 + ] +} diff --git a/src/qml/icons/DotIcon.qml b/src/qml/icons/DotIcon.qml new file mode 100644 index 0000000..dc22a0c --- /dev/null +++ b/src/qml/icons/DotIcon.qml @@ -0,0 +1,74 @@ +import QtQuick + +// qmllint disable unqualified +Item { + id: root + + // Static pattern — flat array of 0/1, row-major + property var pattern: [] + + // Dimensions + property int columns: 5 + property int dotSize: 6 + property int dotSpacing: 4 + + // Appearance + property color dotColor: "white" + property real inactiveOpacity: 0.1 + property real activeOpacity: 0.9 + + // Animation + property bool animated: false + property int animPhase: 0 + + readonly property int rows: Math.max(1, Math.ceil(pattern.length / columns)) + readonly property int count: animated ? columns * columns : pattern.length + + implicitWidth: columns * dotSize + Math.max(0, columns - 1) * dotSpacing + implicitHeight: rows * dotSize + Math.max(0, rows - 1) * dotSpacing + width: implicitWidth + height: implicitHeight + + Timer { + interval: 140 + repeat: true + running: root.animated + onTriggered: root.animPhase = (root.animPhase + 1) % (root.columns * 2) + } + + Grid { + columns: root.columns + spacing: root.dotSpacing + + Repeater { + model: root.count + + Rectangle { + width: root.dotSize + height: root.dotSize + radius: root.dotSize * 0.3 + color: root.dotColor + + opacity: { + if (!root.animated) { + return (index < root.pattern.length + && root.pattern[index]) ? root.activeOpacity : root.inactiveOpacity + } + // Wave from center + const cx = Math.floor(root.columns / 2) + const cy = Math.floor(root.columns / 2) + const col = index % root.columns + const row = Math.floor(index / root.columns) + const d = Math.abs(col - cx) + Math.abs(row - cy) + const wave = root.animPhase % root.columns + const diff = Math.abs(d - wave) + if (diff === 0) + return root.activeOpacity + if (diff === 1) + return 0.35 + return root.inactiveOpacity + } + } + } + } +} diff --git a/src/qml/icons/DownloadIcon.qml b/src/qml/icons/DownloadIcon.qml new file mode 100644 index 0000000..558ad8b --- /dev/null +++ b/src/qml/icons/DownloadIcon.qml @@ -0,0 +1,17 @@ +import QtQuick + +// Downward arrow — download +// . . ● . . +// . . ● . . +// ● ● ● ● ● +// . ● ● ● . +// . . ● . . +DotIcon { + pattern: [ + 0, 0, 1, 0, 0, + 0, 0, 1, 0, 0, + 1, 1, 1, 1, 1, + 0, 1, 1, 1, 0, + 0, 0, 1, 0, 0 + ] +} diff --git a/src/qml/icons/GuideIcon.qml b/src/qml/icons/GuideIcon.qml new file mode 100644 index 0000000..275c37a --- /dev/null +++ b/src/qml/icons/GuideIcon.qml @@ -0,0 +1,17 @@ +import QtQuick + +// Crosshair pattern — step-by-step guide +// . . ● . . +// . . ● . . +// ● ● . ● ● +// . . ● . . +// . . ● . . +DotIcon { + pattern: [ + 0, 0, 1, 0, 0, + 0, 0, 1, 0, 0, + 1, 1, 0, 1, 1, + 0, 0, 1, 0, 0, + 0, 0, 1, 0, 0 + ] +} diff --git a/src/qml/icons/NodeStatusIcon.qml b/src/qml/icons/NodeStatusIcon.qml new file mode 100644 index 0000000..4df6fab --- /dev/null +++ b/src/qml/icons/NodeStatusIcon.qml @@ -0,0 +1,72 @@ +import QtQuick +import Logos.Theme + +// qmllint disable unqualified +Item { + id: root + + property bool starting: true + property bool success: false + property int animPhase: 0 + + readonly property int columns: 7 + readonly property int dotSize: 8 + readonly property int dotSpacing: 5 + + implicitWidth: columns * dotSize + (columns - 1) * dotSpacing + implicitHeight: columns * dotSize + (columns - 1) * dotSpacing + width: implicitWidth + height: implicitHeight + + Timer { + interval: 120 + repeat: true + running: root.starting + onTriggered: root.animPhase = (root.animPhase + 1) % 14 + } + + Grid { + columns: root.columns + spacing: root.dotSpacing + + Repeater { + model: root.columns * root.columns + + Rectangle { + width: root.dotSize + height: root.dotSize + radius: root.dotSize * 0.25 + + color: { + if (root.success) + return Theme.palette.success + if (!root.starting) + return Theme.palette.error + return Theme.palette.text + } + + opacity: { + const col = index % root.columns + const row = Math.floor(index / root.columns) + const d = Math.abs(col - 3) + Math.abs(row - 3) + + if (root.starting) { + const wave = root.animPhase % root.columns + const diff = Math.abs(d - wave) + if (diff === 0) + return 0.9 + if (diff === 1) + return 0.35 + return 0.1 + } + + if (root.success) + return 0.85 + + // Error — X pattern + return (col === row || col + row === 6) ? 0.9 : 0.1 + } + } + } + } +} diff --git a/src/qml/icons/PlayIcon.qml b/src/qml/icons/PlayIcon.qml new file mode 100644 index 0000000..af62992 --- /dev/null +++ b/src/qml/icons/PlayIcon.qml @@ -0,0 +1,17 @@ +import QtQuick + +// Right-pointing triangle — play / start +// ● . . . . +// ● ● . . . +// ● ● ● . . +// ● ● . . . +// ● . . . . +DotIcon { + pattern: [ + 1, 0, 0, 0, 0, + 1, 1, 0, 0, 0, + 1, 1, 1, 0, 0, + 1, 1, 0, 0, 0, + 1, 0, 0, 0, 0 + ] +} diff --git a/src/qml/icons/PortIcon.qml b/src/qml/icons/PortIcon.qml new file mode 100644 index 0000000..196b526 --- /dev/null +++ b/src/qml/icons/PortIcon.qml @@ -0,0 +1,17 @@ +import QtQuick + +// Right arrow pattern — manual port forwarding +// . . ● . . +// . . . ● . +// ● ● ● ● ● +// . . . ● . +// . . ● . . +DotIcon { + pattern: [ + 0, 0, 1, 0, 0, + 0, 0, 0, 1, 0, + 1, 1, 1, 1, 1, + 0, 0, 0, 1, 0, + 0, 0, 1, 0, 0 + ] +} diff --git a/src/qml/icons/SettingsIcon.qml b/src/qml/icons/SettingsIcon.qml new file mode 100644 index 0000000..b976fce --- /dev/null +++ b/src/qml/icons/SettingsIcon.qml @@ -0,0 +1,11 @@ +import QtQuick + +// Gear / cog icon — 4 cardinal teeth + ring with center hole +// . . ● . . +// . ● ● ● . +// ● ● . ● ● +// . ● ● ● . +// . . ● . . +DotIcon { + pattern: [0, 0, 1, 0, 0, 0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0] +} diff --git a/src/qml/icons/StopIcon.qml b/src/qml/icons/StopIcon.qml new file mode 100644 index 0000000..d34a081 --- /dev/null +++ b/src/qml/icons/StopIcon.qml @@ -0,0 +1,17 @@ +import QtQuick + +// Filled square — stop +// . . . . . +// . ● ● ● . +// . ● ● ● . +// . ● ● ● . +// . . . . . +DotIcon { + pattern: [ + 0, 0, 0, 0, 0, + 0, 1, 1, 1, 0, + 0, 1, 1, 1, 0, + 0, 1, 1, 1, 0, + 0, 0, 0, 0, 0 + ] +} diff --git a/src/qml/icons/StorageIcon.qml b/src/qml/icons/StorageIcon.qml new file mode 100644 index 0000000..e5eae8b --- /dev/null +++ b/src/qml/icons/StorageIcon.qml @@ -0,0 +1,19 @@ +import QtQuick + +// Ring / node pattern — used in the StorageView header +// . ● ● ● . +// ● . . . ● +// ● . ● . ● +// ● . . . ● +// . ● ● ● . +DotIcon { + pattern: [ + 0, 1, 1, 1, 0, + 1, 0, 0, 0, 1, + 1, 0, 1, 0, 1, + 1, 0, 0, 0, 1, + 0, 1, 1, 1, 0 + ] + dotSize: 7 + dotSpacing: 5 +} diff --git a/src/qml/icons/UploadIcon.qml b/src/qml/icons/UploadIcon.qml new file mode 100644 index 0000000..66375d8 --- /dev/null +++ b/src/qml/icons/UploadIcon.qml @@ -0,0 +1,17 @@ +import QtQuick + +// Upward arrow — upload +// . . ● . . +// . ● ● ● . +// ● ● ● ● ● +// . . ● . . +// . . ● . . +DotIcon { + pattern: [ + 0, 0, 1, 0, 0, + 0, 1, 1, 1, 0, + 1, 1, 1, 1, 1, + 0, 0, 1, 0, 0, + 0, 0, 1, 0, 0 + ] +} diff --git a/src/qml/icons/UpnpIcon.qml b/src/qml/icons/UpnpIcon.qml new file mode 100644 index 0000000..6aa2c72 --- /dev/null +++ b/src/qml/icons/UpnpIcon.qml @@ -0,0 +1,17 @@ +import QtQuick + +// Diamond / network pattern — UPnP automatic port forwarding +// . . ● . . +// . ● . ● . +// ● . ● . ● +// . ● . ● . +// . . ● . . +DotIcon { + pattern: [ + 0, 0, 1, 0, 0, + 0, 1, 0, 1, 0, + 1, 0, 1, 0, 1, + 0, 1, 0, 1, 0, + 0, 0, 1, 0, 0 + ] +} diff --git a/src/storage_resources.qrc b/src/storage_resources.qrc index 1ad838e..50601c5 100644 --- a/src/storage_resources.qrc +++ b/src/storage_resources.qrc @@ -12,12 +12,31 @@ qml/HealthIndicator.qml qml/ModeSelector.qml qml/AdvancedSetup.qml - qml/DotIcon.qml - qml/NodeStatusIcon.qml - qml/GuideIcon.qml - qml/AdvancedIcon.qml - qml/UpnpIcon.qml - qml/PortIcon.qml + qml/ManifestTable.qml + qml/NodeHeader.qml + qml/Widgets.qml + qml/DebugPanel.qml + qml/SettingsPopup.qml + qml/JsonEditor.qml + qml/DiskWidget.qml + qml/UploadWidget.qml + qml/PeersWidget.qml + qml/icons/DotIcon.qml + qml/icons/NodeStatusIcon.qml + qml/icons/GuideIcon.qml + qml/icons/AdvancedIcon.qml + qml/icons/UpnpIcon.qml + qml/icons/PortIcon.qml + qml/icons/StorageIcon.qml + qml/icons/PlayIcon.qml + qml/icons/StopIcon.qml + qml/icons/SettingsIcon.qml + qml/icons/UploadIcon.qml + qml/icons/DownloadIcon.qml + qml/icons/DeleteIcon.qml + qml/icons/ArcWidget.qml + qml/Utils.js + qml/MockBackend.qml icons/storage.png diff --git a/vendor/logos-design-system b/vendor/logos-design-system new file mode 160000 index 0000000..063c4b4 --- /dev/null +++ b/vendor/logos-design-system @@ -0,0 +1 @@ +Subproject commit 063c4b46accc621bc85fa8baab46b31ef65f3957