Merge pull request #12 from logos-co/feat/improve-dashboard

feat: improve dashboard
This commit is contained in:
Arnaud 2026-02-23 13:43:13 +04:00 committed by GitHub
commit 006d0b6a68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 2257 additions and 1581 deletions

4
.gitmodules vendored
View File

@ -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

View File

@ -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()

@ -1 +0,0 @@
Subproject commit 596811cbb0a0644322267368e87fab80e34203d8

File diff suppressed because it is too large Load Diff

View File

@ -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;
};

View File

@ -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<QObject*>(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;
}

View File

@ -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()
}
}

56
src/qml/ArcGauge.qml Normal file
View File

@ -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()
}
}
}

46
src/qml/ArcWidget.qml Normal file
View File

@ -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
}
}

View File

@ -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

92
src/qml/DebugPanel.qml Normal file
View File

@ -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)
})
}
}
}
}

6
src/qml/DeleteIcon.qml Normal file
View File

@ -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]
}

52
src/qml/DiskWidget.qml Normal file
View File

@ -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
}
}
}

View File

@ -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
}
}

12
src/qml/DownloadIcon.qml Normal file
View File

@ -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
]
}

View File

@ -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)
}
}

57
src/qml/JsonEditor.qml Normal file
View File

@ -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()
}
}
}

View File

@ -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

View File

@ -5,8 +5,6 @@ import Logos.Theme
Rectangle {
id: root
color: Theme.palette.background
Layout.fillWidth: true
Layout.fillHeight: true
implicitWidth: 600
implicitHeight: 400
}

View File

@ -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

View File

@ -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 ""
}
}
}

282
src/qml/ManifestTable.qml Normal file
View File

@ -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
}
}
}
}
}

47
src/qml/MockBackend.qml Normal file
View File

@ -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 "{}" }
}

View File

@ -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"

147
src/qml/NodeHeader.qml Normal file
View File

@ -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()
}
}
}

View File

@ -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

View File

@ -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() {}
}
}

42
src/qml/PeersWidget.qml Normal file
View File

@ -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
}
}
}

12
src/qml/PlayIcon.qml Normal file
View File

@ -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
]
}

View File

@ -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) {}
}
}

11
src/qml/SettingsIcon.qml Normal file
View File

@ -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]
}

75
src/qml/SettingsPopup.qml Normal file
View File

@ -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()
}
}
}
}
}

View File

@ -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() {}
}
}

12
src/qml/StopIcon.qml Normal file
View File

@ -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
]
}

14
src/qml/StorageIcon.qml Normal file
View File

@ -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
}

View File

@ -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()
}
}

12
src/qml/UploadIcon.qml Normal file
View File

@ -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
]
}

96
src/qml/UploadWidget.qml Normal file
View File

@ -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()
}
}
}

13
src/qml/Utils.js Normal file
View File

@ -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"
}

158
src/qml/Widgets.qml Normal file
View File

@ -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()
}
}
}
}
}

View File

@ -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
]
}

View File

@ -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()
}
}
}

View File

@ -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
}
}

View File

@ -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
]
}

74
src/qml/icons/DotIcon.qml Normal file
View File

@ -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
}
}
}
}
}

View File

@ -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
]
}

View File

@ -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
]
}

View File

@ -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
}
}
}
}
}

View File

@ -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
]
}

View File

@ -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
]
}

View File

@ -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]
}

View File

@ -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
]
}

View File

@ -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
}

View File

@ -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
]
}

View File

@ -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
]
}

View File

@ -12,12 +12,31 @@
<file alias="HealthIndicator.qml">qml/HealthIndicator.qml</file>
<file alias="ModeSelector.qml">qml/ModeSelector.qml</file>
<file alias="AdvancedSetup.qml">qml/AdvancedSetup.qml</file>
<file alias="DotIcon.qml">qml/DotIcon.qml</file>
<file alias="NodeStatusIcon.qml">qml/NodeStatusIcon.qml</file>
<file alias="GuideIcon.qml">qml/GuideIcon.qml</file>
<file alias="AdvancedIcon.qml">qml/AdvancedIcon.qml</file>
<file alias="UpnpIcon.qml">qml/UpnpIcon.qml</file>
<file alias="PortIcon.qml">qml/PortIcon.qml</file>
<file alias="ManifestTable.qml">qml/ManifestTable.qml</file>
<file alias="NodeHeader.qml">qml/NodeHeader.qml</file>
<file alias="Widgets.qml">qml/Widgets.qml</file>
<file alias="DebugPanel.qml">qml/DebugPanel.qml</file>
<file alias="SettingsPopup.qml">qml/SettingsPopup.qml</file>
<file alias="JsonEditor.qml">qml/JsonEditor.qml</file>
<file alias="DiskWidget.qml">qml/DiskWidget.qml</file>
<file alias="UploadWidget.qml">qml/UploadWidget.qml</file>
<file alias="PeersWidget.qml">qml/PeersWidget.qml</file>
<file alias="DotIcon.qml">qml/icons/DotIcon.qml</file>
<file alias="NodeStatusIcon.qml">qml/icons/NodeStatusIcon.qml</file>
<file alias="GuideIcon.qml">qml/icons/GuideIcon.qml</file>
<file alias="AdvancedIcon.qml">qml/icons/AdvancedIcon.qml</file>
<file alias="UpnpIcon.qml">qml/icons/UpnpIcon.qml</file>
<file alias="PortIcon.qml">qml/icons/PortIcon.qml</file>
<file alias="StorageIcon.qml">qml/icons/StorageIcon.qml</file>
<file alias="PlayIcon.qml">qml/icons/PlayIcon.qml</file>
<file alias="StopIcon.qml">qml/icons/StopIcon.qml</file>
<file alias="SettingsIcon.qml">qml/icons/SettingsIcon.qml</file>
<file alias="UploadIcon.qml">qml/icons/UploadIcon.qml</file>
<file alias="DownloadIcon.qml">qml/icons/DownloadIcon.qml</file>
<file alias="DeleteIcon.qml">qml/icons/DeleteIcon.qml</file>
<file alias="ArcWidget.qml">qml/icons/ArcWidget.qml</file>
<file alias="Utils.js">qml/Utils.js</file>
<file alias="MockBackend.qml">qml/MockBackend.qml</file>
<file>icons/storage.png</file>
</qresource>
</RCC>

1
vendor/logos-design-system vendored Submodule

@ -0,0 +1 @@
Subproject commit 063c4b46accc621bc85fa8baab46b31ef65f3957