mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-05-05 11:09:39 +00:00
feat(qt6): DBAL live data integration + fix deployment Python 3.9 compat
Wire up DBALProvider across all Qt6 views for live CRUD operations, add PackageLoader, MediaServicePanel, NotificationsPanel, SettingsView, and JSON-driven CMake config. Fix deployment helpers.py str|None syntax for Python 3.9 compatibility via __future__ annotations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
"""Shared helpers for all CLI command modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
@@ -2,11 +2,45 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
color: "transparent"
|
||||
|
||||
// ── DBAL connection ──────────────────────────────────────────
|
||||
DBALProvider { id: dbal }
|
||||
|
||||
property bool useLiveData: dbal.connected
|
||||
|
||||
function loadEntityData() {
|
||||
if (!useLiveData) return;
|
||||
dbal.list(selectedEntity, { take: pageSize, skip: currentPage * pageSize }, function(result, error) {
|
||||
if (error || !result) return;
|
||||
var items = result.items || [];
|
||||
var fields = entityFields[selectedEntity] || [];
|
||||
var liveRecords = [];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var rec = {};
|
||||
for (var f = 0; f < fields.length; f++) {
|
||||
rec[fields[f]] = items[i][fields[f]] || "";
|
||||
}
|
||||
liveRecords.push(rec);
|
||||
}
|
||||
var updated = Object.assign({}, records);
|
||||
updated[selectedEntity] = liveRecords;
|
||||
records = updated;
|
||||
});
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (useLiveData) loadEntityData();
|
||||
}
|
||||
|
||||
onUseLiveDataChanged: {
|
||||
if (useLiveData) loadEntityData();
|
||||
}
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────
|
||||
property string selectedEntity: "User"
|
||||
property string searchText: ""
|
||||
@@ -281,6 +315,7 @@ Rectangle {
|
||||
selectAll = false;
|
||||
searchText = "";
|
||||
activeFilter = "All";
|
||||
if (useLiveData) loadEntityData();
|
||||
}
|
||||
|
||||
// ── Layout ─────────────────────────────────────────────────────
|
||||
@@ -395,6 +430,10 @@ Rectangle {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
CText { variant: "h3"; text: (entityIcons[selectedEntity] || "") + " " + selectedEntity + " Management" }
|
||||
CStatusBadge {
|
||||
status: useLiveData ? "success" : "warning"
|
||||
text: useLiveData ? "Live" : "Mock"
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
CButton {
|
||||
text: "Create Record"
|
||||
@@ -749,7 +788,17 @@ Rectangle {
|
||||
newRec[fields[f]] = createFormData[fields[f]] || "";
|
||||
}
|
||||
if (!newRec.status) newRec.status = "Active";
|
||||
addRecord(newRec);
|
||||
if (useLiveData) {
|
||||
dbal.create(selectedEntity, newRec, function(result, error) {
|
||||
if (!error) {
|
||||
loadEntityData();
|
||||
} else {
|
||||
addRecord(newRec);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
addRecord(newRec);
|
||||
}
|
||||
createDialogOpen = false;
|
||||
}
|
||||
}
|
||||
@@ -810,7 +859,17 @@ Rectangle {
|
||||
for (var f = 1; f < fields.length; f++) {
|
||||
updatedRec[fields[f]] = editFormData[fields[f]] || editingRecord[fields[f]] || "";
|
||||
}
|
||||
updateRecord(updatedRec);
|
||||
if (useLiveData) {
|
||||
dbal.update(selectedEntity, editingRecord.id, updatedRec, function(result, error) {
|
||||
if (!error) {
|
||||
loadEntityData();
|
||||
} else {
|
||||
updateRecord(updatedRec);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
updateRecord(updatedRec);
|
||||
}
|
||||
editDialogOpen = false;
|
||||
}
|
||||
}
|
||||
@@ -861,7 +920,21 @@ Rectangle {
|
||||
variant: "danger"
|
||||
size: "sm"
|
||||
onClicked: {
|
||||
deleteRecord(editingIndex);
|
||||
if (useLiveData) {
|
||||
var paged = getPagedRecords();
|
||||
var rec = paged[editingIndex];
|
||||
if (rec) {
|
||||
dbal.remove(selectedEntity, rec.id, function(result, error) {
|
||||
if (!error) {
|
||||
loadEntityData();
|
||||
} else {
|
||||
deleteRecord(editingIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
deleteRecord(editingIndex);
|
||||
}
|
||||
deleteDialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
+157
-1
@@ -1,7 +1,9 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Qt.labs.settings 1.0
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
ApplicationWindow {
|
||||
id: appWindow
|
||||
@@ -11,11 +13,23 @@ ApplicationWindow {
|
||||
title: "MetaBuilder Observatory"
|
||||
color: Theme.background
|
||||
|
||||
// ── DBAL connection ──
|
||||
DBALProvider {
|
||||
id: dbalProvider
|
||||
}
|
||||
|
||||
// ── Theme ──
|
||||
property string currentTheme: "dark"
|
||||
|
||||
// ── DBAL offline detection ──
|
||||
property bool dbalConnected: dbalProvider.connected
|
||||
|
||||
// ── Auth state ──
|
||||
property int currentLevel: 1
|
||||
property string currentUser: ""
|
||||
property string currentRole: "public"
|
||||
property bool loggedIn: false
|
||||
property string authToken: ""
|
||||
property string currentView: "frontpage"
|
||||
|
||||
// Seed users (mirrors old/ seed data)
|
||||
@@ -45,6 +59,8 @@ ApplicationWindow {
|
||||
currentRole = "public"
|
||||
currentLevel = 1
|
||||
loggedIn = false
|
||||
authToken = ""
|
||||
dbalProvider.authToken = ""
|
||||
currentView = "frontpage"
|
||||
}
|
||||
|
||||
@@ -67,6 +83,39 @@ ApplicationWindow {
|
||||
text: "Level " + currentLevel
|
||||
}
|
||||
|
||||
// DBAL connection status
|
||||
Row {
|
||||
spacing: 4
|
||||
Layout.leftMargin: 4
|
||||
|
||||
Rectangle {
|
||||
width: 8
|
||||
height: 8
|
||||
radius: 4
|
||||
color: dbalProvider.connected ? "#4caf50" : "#f44336"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
CText {
|
||||
variant: "caption"
|
||||
text: "DBAL"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
// Theme toggle
|
||||
CButton {
|
||||
variant: "ghost"
|
||||
size: "sm"
|
||||
text: currentTheme === "dark" ? "Light" : "Dark"
|
||||
onClicked: {
|
||||
currentTheme = currentTheme === "dark" ? "light" : "dark"
|
||||
if (typeof Theme.setTheme === "function") {
|
||||
Theme.setTheme(currentTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
// Level navigation
|
||||
@@ -111,9 +160,29 @@ ApplicationWindow {
|
||||
}
|
||||
}
|
||||
|
||||
// ── DBAL offline banner ──
|
||||
Rectangle {
|
||||
id: dbalBanner
|
||||
visible: !dbalConnected
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: 28
|
||||
color: "#e65100"
|
||||
z: 10
|
||||
|
||||
CText {
|
||||
anchors.centerIn: parent
|
||||
text: "DBAL Offline — showing cached data"
|
||||
variant: "caption"
|
||||
color: "#ffffff"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sidebar + Content ──
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: dbalBanner.visible ? 28 : 0
|
||||
spacing: 0
|
||||
|
||||
// Sidebar (Level 2+)
|
||||
@@ -205,7 +274,7 @@ ApplicationWindow {
|
||||
PackageManager {} // 12: Package Manager
|
||||
Storybook {} // 13: Storybook
|
||||
SuperGodPanel {} // 14: Super God Panel
|
||||
PackageViewLoader { packageId: "user-settings" } // 15: Settings
|
||||
SettingsView {} // 15: Settings
|
||||
CommentsView {} // 16: Comments
|
||||
}
|
||||
}
|
||||
@@ -221,4 +290,91 @@ ApplicationWindow {
|
||||
var idx = views.indexOf(view)
|
||||
return idx >= 0 ? idx : 0
|
||||
}
|
||||
|
||||
// ── Window state persistence ──
|
||||
Settings {
|
||||
id: windowSettings
|
||||
category: "MetaBuilder"
|
||||
property alias windowWidth: appWindow.width
|
||||
property alias windowHeight: appWindow.height
|
||||
property alias windowX: appWindow.x
|
||||
property alias windowY: appWindow.y
|
||||
property alias theme: appWindow.currentTheme
|
||||
property alias authToken: appWindow.authToken
|
||||
}
|
||||
|
||||
// ── Auto-login with persisted token ──
|
||||
Component.onCompleted: {
|
||||
if (authToken !== "") {
|
||||
dbalProvider.authToken = authToken
|
||||
dbalProvider.execute("core/auth/validate", { token: authToken }, function(result, error) {
|
||||
if (!error && result && result.valid) {
|
||||
currentUser = result.username || ""
|
||||
currentRole = result.role || "user"
|
||||
currentLevel = result.level || 2
|
||||
loggedIn = true
|
||||
currentView = "dashboard"
|
||||
} else {
|
||||
// Token invalid or expired — clear it
|
||||
authToken = ""
|
||||
dbalProvider.authToken = ""
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keyboard shortcuts ──
|
||||
Shortcut {
|
||||
sequence: "Ctrl+K"
|
||||
onActivated: console.log("[MetaBuilder] Command palette (Ctrl+K) — not yet implemented")
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequence: "Ctrl+L"
|
||||
onActivated: {
|
||||
if (loggedIn) {
|
||||
logout()
|
||||
} else {
|
||||
currentView = "login"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequence: "Ctrl+1"
|
||||
onActivated: currentView = "frontpage"
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequence: "Ctrl+2"
|
||||
onActivated: if (currentLevel >= 2) currentView = "dashboard"
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequence: "Ctrl+3"
|
||||
onActivated: if (currentLevel >= 3) currentView = "admin"
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequence: "Ctrl+4"
|
||||
onActivated: if (currentLevel >= 4) currentView = "god-panel"
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequence: "Ctrl+5"
|
||||
onActivated: if (currentLevel >= 5) currentView = "supergod"
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequence: "Escape"
|
||||
onActivated: {
|
||||
if (currentView === "login") {
|
||||
currentView = "frontpage"
|
||||
} else if (loggedIn && currentView !== "dashboard") {
|
||||
currentView = "dashboard"
|
||||
} else if (!loggedIn && currentView !== "frontpage") {
|
||||
currentView = "frontpage"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ qt_add_executable(dbal-qml
|
||||
src/PackageRegistry.cpp
|
||||
src/ModPlayer.cpp
|
||||
src/DBALClient.cpp
|
||||
src/PackageLoader.cpp
|
||||
)
|
||||
|
||||
# Pass source dir so the binary can find shared QML components at runtime
|
||||
@@ -55,6 +56,9 @@ qt_add_qml_module(dbal-qml
|
||||
ModPlayerPanel.qml
|
||||
PackageManager.qml
|
||||
Storybook.qml
|
||||
MediaServicePanel.qml
|
||||
NotificationsPanel.qml
|
||||
SettingsView.qml
|
||||
qmllib/dbal/DBALProvider.qml
|
||||
RESOURCES
|
||||
assets/audio/retro-gaming.mod
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"conan": {}
|
||||
},
|
||||
"include": [
|
||||
"build/generators/CMakePresets.json"
|
||||
"build/Release/generators/CMakePresets.json"
|
||||
]
|
||||
}
|
||||
@@ -2,13 +2,71 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
Rectangle {
|
||||
color: "transparent"
|
||||
|
||||
// ── DBAL connection ──
|
||||
DBALProvider { id: dbal }
|
||||
|
||||
property string newCommentText: ""
|
||||
property int sortMode: 0 // 0=Newest, 1=Oldest, 2=Most Liked
|
||||
|
||||
// ── DBAL data loading ──
|
||||
function loadComments() {
|
||||
dbal.list("comment", { take: 50 }, function(result, error) {
|
||||
if (result && result.items && result.items.length > 0) {
|
||||
commentsModel.clear();
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
var c = result.items[i];
|
||||
commentsModel.append({
|
||||
commentId: c.id || (i + 1),
|
||||
username: c.username || c.author || "unknown",
|
||||
initials: (c.username || c.author || "??").substring(0, 2).toUpperCase(),
|
||||
timestamp: c.timestamp || c.createdAt || "Unknown",
|
||||
body: c.body || c.text || "",
|
||||
likes: c.likes || 0,
|
||||
liked: false
|
||||
});
|
||||
}
|
||||
}
|
||||
// On error or empty result, keep existing mock data
|
||||
});
|
||||
}
|
||||
|
||||
function postCommentToDBAL(text) {
|
||||
var commentData = {
|
||||
text: text,
|
||||
author: appWindow.currentUser,
|
||||
username: appWindow.currentUser
|
||||
};
|
||||
dbal.create("comment", commentData, function(result, error) {
|
||||
if (error) {
|
||||
console.warn("Failed to post comment to DBAL:", error);
|
||||
}
|
||||
// Comment is already added locally via addComment()
|
||||
});
|
||||
}
|
||||
|
||||
function likeCommentOnDBAL(commentId, newLikes) {
|
||||
dbal.update("comment", commentId, { likes: newLikes }, function(result, error) {
|
||||
if (error) console.warn("Failed to update like on DBAL:", error);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteCommentOnDBAL(commentId) {
|
||||
dbal.remove("comment", commentId, function(result, error) {
|
||||
if (error) console.warn("Failed to delete comment on DBAL:", error);
|
||||
});
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
dbal.ping(function(success) {
|
||||
if (success) loadComments();
|
||||
});
|
||||
}
|
||||
|
||||
ListModel {
|
||||
id: commentsModel
|
||||
|
||||
@@ -71,15 +129,17 @@ Rectangle {
|
||||
function addComment() {
|
||||
if (newCommentText.trim().length === 0) return
|
||||
var initials = appWindow.currentUser.substring(0, 2).toUpperCase()
|
||||
var text = newCommentText.trim()
|
||||
commentsModel.insert(0, {
|
||||
commentId: commentsModel.count + 1,
|
||||
username: appWindow.currentUser,
|
||||
initials: initials,
|
||||
timestamp: "Just now",
|
||||
body: newCommentText.trim(),
|
||||
body: text,
|
||||
likes: 0,
|
||||
liked: false
|
||||
})
|
||||
postCommentToDBAL(text)
|
||||
newCommentText = ""
|
||||
}
|
||||
|
||||
@@ -220,13 +280,17 @@ Rectangle {
|
||||
variant: model.liked ? "primary" : "ghost"
|
||||
size: "sm"
|
||||
onClicked: {
|
||||
var newLikes;
|
||||
if (model.liked) {
|
||||
commentsModel.setProperty(index, "likes", model.likes - 1)
|
||||
newLikes = model.likes - 1;
|
||||
commentsModel.setProperty(index, "likes", newLikes)
|
||||
commentsModel.setProperty(index, "liked", false)
|
||||
} else {
|
||||
commentsModel.setProperty(index, "likes", model.likes + 1)
|
||||
newLikes = model.likes + 1;
|
||||
commentsModel.setProperty(index, "likes", newLikes)
|
||||
commentsModel.setProperty(index, "liked", true)
|
||||
}
|
||||
likeCommentOnDBAL(model.commentId, newLikes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +301,10 @@ Rectangle {
|
||||
variant: "danger"
|
||||
size: "sm"
|
||||
visible: canDelete(model.username)
|
||||
onClicked: commentsModel.remove(index)
|
||||
onClicked: {
|
||||
deleteCommentOnDBAL(model.commentId)
|
||||
commentsModel.remove(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,33 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
color: Theme.background
|
||||
|
||||
// ── DBAL ──────────────────────────────────────────────────────────
|
||||
DBALProvider { id: dbal }
|
||||
property bool useLiveData: dbal.connected
|
||||
|
||||
// Flat array representing tree structure; depth encodes hierarchy
|
||||
property var mockTreeNodes: [
|
||||
{ nodeId: 0, name: "App", type: "container", depth: 0, visible: true, props: [{ key: "maxWidth", value: "1280" }] },
|
||||
{ nodeId: 1, name: "NavBar", type: "layout", depth: 1, visible: true, props: [{ key: "sticky", value: "true" }, { key: "height", value: "64" }] },
|
||||
{ nodeId: 2, name: "MainContent", type: "container", depth: 1, visible: true, props: [{ key: "padding", value: "24" }] },
|
||||
{ nodeId: 3, name: "HeroSection", type: "widget", depth: 2, visible: true, props: [{ key: "title", value: "Welcome" }, { key: "backgroundImage", value: "hero.png" }] },
|
||||
{ nodeId: 4, name: "FeatureGrid", type: "layout", depth: 2, visible: true, props: [{ key: "columns", value: "3" }, { key: "gap", value: "16" }] },
|
||||
{ nodeId: 5, name: "FeatureCard", type: "atom", depth: 3, visible: true, props: [{ key: "icon", value: "speed" }, { key: "label", value: "Fast" }] },
|
||||
{ nodeId: 6, name: "FeatureCard", type: "atom", depth: 3, visible: true, props: [{ key: "icon", value: "shield" }, { key: "label", value: "Secure" }] },
|
||||
{ nodeId: 7, name: "FeatureCard", type: "atom", depth: 3, visible: true, props: [{ key: "icon", value: "plug" }, { key: "label", value: "Extensible" }] },
|
||||
{ nodeId: 8, name: "ContactForm", type: "widget", depth: 2, visible: true, props: [{ key: "action", value: "/api/contact" }] },
|
||||
{ nodeId: 9, name: "Footer", type: "layout", depth: 1, visible: true, props: [{ key: "copyright", value: "2026" }] },
|
||||
{ nodeId: 10, name: "Sidebar", type: "container", depth: 1, visible: true, props: [{ key: "width", value: "280" }, { key: "collapsible", value: "true" }] },
|
||||
{ nodeId: 11, name: "NavigationList", type: "widget", depth: 2, visible: true, props: [{ key: "items", value: "5" }] },
|
||||
{ nodeId: 12, name: "UserPanel", type: "widget", depth: 2, visible: true, props: [{ key: "showAvatar", value: "true" }] }
|
||||
]
|
||||
|
||||
property var treeNodes: [
|
||||
{ nodeId: 0, name: "App", type: "container", depth: 0, visible: true, props: [{ key: "maxWidth", value: "1280" }] },
|
||||
{ nodeId: 1, name: "NavBar", type: "layout", depth: 1, visible: true, props: [{ key: "sticky", value: "true" }, { key: "height", value: "64" }] },
|
||||
@@ -59,6 +80,9 @@ Rectangle {
|
||||
props: []
|
||||
}
|
||||
nextNodeId++
|
||||
if (useLiveData) {
|
||||
dbal.create("component_node", newNode, function(r, e) { if (!e) loadComponents() })
|
||||
}
|
||||
var updated = treeNodes.slice()
|
||||
updated.splice(insertAt, 0, newNode)
|
||||
treeNodes = updated
|
||||
@@ -69,6 +93,9 @@ Rectangle {
|
||||
if (idx < 0 || idx >= treeNodes.length) return
|
||||
// Prevent removing root
|
||||
if (treeNodes[idx].depth === 0) return
|
||||
if (useLiveData && treeNodes[idx].id) {
|
||||
dbal.remove("component_node", treeNodes[idx].id, function(r, e) { if (!e) loadComponents() })
|
||||
}
|
||||
var endIdx = subtreeEnd(idx)
|
||||
var updated = treeNodes.slice()
|
||||
updated.splice(idx, endIdx - idx)
|
||||
@@ -95,6 +122,30 @@ Rectangle {
|
||||
return s
|
||||
}
|
||||
|
||||
// ── DBAL Integration ─────────────────────────────────────────────
|
||||
function loadComponents() {
|
||||
dbal.list("component_node", { take: 200 }, function(result, error) {
|
||||
if (!error && result && result.items && result.items.length > 0) {
|
||||
var parsed = []; var maxId = 0
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
var n = result.items[i]; var nid = n.nodeId || n.id || i
|
||||
if (nid > maxId) maxId = nid
|
||||
parsed.push({ id: n.id, nodeId: nid, name: n.name || "Component", type: n.type || "atom", depth: n.depth !== undefined ? n.depth : 0, visible: n.visible !== undefined ? n.visible : true, props: n.props || [] })
|
||||
}
|
||||
treeNodes = parsed; nextNodeId = maxId + 1
|
||||
}
|
||||
})
|
||||
}
|
||||
onUseLiveDataChanged: { if (useLiveData) loadComponents() }
|
||||
Component.onCompleted: { loadComponents() }
|
||||
function saveNode(idx) {
|
||||
if (!useLiveData) return
|
||||
var node = treeNodes[idx]
|
||||
var data = { nodeId: node.nodeId, name: node.name, type: node.type, depth: node.depth, visible: node.visible, props: node.props }
|
||||
if (node.id) dbal.update("component_node", node.id, data, function(r, e) { if (!e) loadComponents() })
|
||||
else dbal.create("component_node", data, function(r, e) { if (!e) loadComponents() })
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
@@ -300,6 +351,7 @@ Rectangle {
|
||||
var updated = treeNodes.slice()
|
||||
updated[selectedIndex] = Object.assign({}, updated[selectedIndex], { name: text })
|
||||
treeNodes = updated
|
||||
saveNode(selectedIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,6 +377,7 @@ Rectangle {
|
||||
var updated = treeNodes.slice()
|
||||
updated[selectedIndex] = Object.assign({}, updated[selectedIndex], { type: modelData })
|
||||
treeNodes = updated
|
||||
saveNode(selectedIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,6 +399,7 @@ Rectangle {
|
||||
var updated = treeNodes.slice()
|
||||
updated[selectedIndex] = Object.assign({}, updated[selectedIndex], { visible: checked })
|
||||
treeNodes = updated
|
||||
saveNode(selectedIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,16 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
color: Theme.background
|
||||
|
||||
// ── DBAL ──────────────────────────────────────────────────────────
|
||||
DBALProvider { id: dbal }
|
||||
property bool useLiveData: dbal.connected
|
||||
|
||||
// ── Mock data ──────────────────────────────────────────────────────
|
||||
property var cssClasses: [
|
||||
{
|
||||
@@ -104,6 +110,7 @@ Rectangle {
|
||||
|
||||
function updateClasses(arr) {
|
||||
cssClasses = arr
|
||||
if (useLiveData) saveCssClass(selectedClassIndex)
|
||||
}
|
||||
|
||||
function addPropertyToSelected() {
|
||||
@@ -155,21 +162,24 @@ Rectangle {
|
||||
}
|
||||
|
||||
function addClass(name) {
|
||||
var newClass = { name: name, usageCount: 0, properties: [{ prop: "color", value: "#ffffff" }] }
|
||||
if (useLiveData) {
|
||||
dbal.execute("core/css-classes/create", { data: newClass }, function(r, e) { if (!e) loadCssClasses() })
|
||||
}
|
||||
var cls = cssClasses.slice()
|
||||
cls.push({
|
||||
name: name,
|
||||
usageCount: 0,
|
||||
properties: [{ prop: "color", value: "#ffffff" }]
|
||||
})
|
||||
updateClasses(cls)
|
||||
cls.push(newClass)
|
||||
cssClasses = cls
|
||||
selectedClassIndex = cls.length - 1
|
||||
}
|
||||
|
||||
function deleteSelectedClass() {
|
||||
if (cssClasses.length <= 1) return
|
||||
if (useLiveData && cssClasses[selectedClassIndex].id) {
|
||||
dbal.execute("core/css-classes/delete", { id: cssClasses[selectedClassIndex].id }, function(r, e) { if (!e) loadCssClasses() })
|
||||
}
|
||||
var cls = cssClasses.slice()
|
||||
cls.splice(selectedClassIndex, 1)
|
||||
updateClasses(cls)
|
||||
cssClasses = cls
|
||||
if (selectedClassIndex >= cls.length)
|
||||
selectedClassIndex = cls.length - 1
|
||||
}
|
||||
@@ -204,6 +214,31 @@ Rectangle {
|
||||
return resolvePreviewColor(properties, "color", Theme.surface)
|
||||
}
|
||||
|
||||
// ── DBAL Integration ─────────────────────────────────────────────
|
||||
function loadCssClasses() {
|
||||
dbal.execute("core/css-classes", {}, function(result, error) {
|
||||
if (!error && result && result.items && result.items.length > 0) {
|
||||
var parsed = []
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
var c = result.items[i]
|
||||
parsed.push({ id: c.id, name: c.name || "", usageCount: c.usageCount || 0, properties: c.properties || [] })
|
||||
}
|
||||
cssClasses = parsed
|
||||
if (selectedClassIndex >= cssClasses.length) selectedClassIndex = cssClasses.length - 1
|
||||
}
|
||||
})
|
||||
}
|
||||
onUseLiveDataChanged: { if (useLiveData) loadCssClasses() }
|
||||
Component.onCompleted: { loadCssClasses() }
|
||||
|
||||
function saveCssClass(index) {
|
||||
if (!useLiveData || index < 0 || index >= cssClasses.length) return
|
||||
var cls = cssClasses[index]
|
||||
var data = { name: cls.name, usageCount: cls.usageCount, properties: cls.properties }
|
||||
if (cls.id) dbal.execute("core/css-classes/update", { id: cls.id, data: data }, function(r, e) {})
|
||||
else dbal.execute("core/css-classes/create", { data: data }, function(r, e) { if (!e) loadCssClasses() })
|
||||
}
|
||||
|
||||
// ── Layout ──────────────────────────────────────────────────────────
|
||||
|
||||
ColumnLayout {
|
||||
|
||||
@@ -2,10 +2,30 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
Rectangle {
|
||||
id: dashRoot
|
||||
color: "transparent"
|
||||
|
||||
// ── DBAL connection ──────────────────────────────────────────
|
||||
DBALProvider { id: dbal }
|
||||
|
||||
property var healthData: ({})
|
||||
property bool dbalOnline: dbal.connected
|
||||
|
||||
function refreshDBAL() {
|
||||
dbal.ping(function(success, error) {
|
||||
if (success) {
|
||||
dbal.execute("health", {}, function(result, err) {
|
||||
if (result) healthData = result;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Component.onCompleted: refreshDBAL()
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 24
|
||||
@@ -31,6 +51,14 @@ Rectangle {
|
||||
variant: "body1"
|
||||
text: "Level " + appWindow.currentLevel + " \u00b7 " + appWindow.currentRole + " access"
|
||||
}
|
||||
|
||||
CButton {
|
||||
text: dbal.loading ? "Refreshing..." : "Refresh"
|
||||
variant: "ghost"
|
||||
size: "sm"
|
||||
enabled: !dbal.loading
|
||||
onClicked: refreshDBAL()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +69,7 @@ Rectangle {
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ title: "DBAL Status", value: "Healthy", status: "success" },
|
||||
{ title: "DBAL Status", value: dbalOnline ? "Healthy" : "Offline", status: dbalOnline ? "success" : "error" },
|
||||
{ title: "Packages", value: "20", status: "info" },
|
||||
{ title: "Active Users", value: "4", status: "info" },
|
||||
{ title: "Uptime", value: "99.9%", status: "success" }
|
||||
|
||||
@@ -2,11 +2,81 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
color: Theme.background
|
||||
|
||||
// ── DBAL connection ──────────────────────────────────────────
|
||||
DBALProvider { id: dbal }
|
||||
|
||||
property bool useLiveData: dbal.connected
|
||||
|
||||
property var mockBackends: JSON.parse(JSON.stringify(backends))
|
||||
|
||||
function loadAdapterStatus() {
|
||||
dbal.execute("core/adapters", {}, function(result, error) {
|
||||
if (!error && result && result.adapters && result.adapters.length > 0) {
|
||||
var liveBackends = []
|
||||
for (var i = 0; i < result.adapters.length; i++) {
|
||||
var a = result.adapters[i]
|
||||
liveBackends.push({
|
||||
name: a.name || "",
|
||||
key: a.key || "",
|
||||
status: a.status || "disconnected",
|
||||
description: a.description || "",
|
||||
connectionString: a.connectionString || "",
|
||||
records: a.records || 0,
|
||||
sizeKb: a.sizeKb || 0,
|
||||
lastBackup: a.lastBackup || "Never"
|
||||
})
|
||||
}
|
||||
backends = liveBackends
|
||||
if (selectedBackendIndex >= liveBackends.length)
|
||||
selectedBackendIndex = 0
|
||||
if (activeBackendIndex >= liveBackends.length)
|
||||
activeBackendIndex = 0
|
||||
}
|
||||
// On error or empty result, keep existing mock backends as fallback
|
||||
})
|
||||
}
|
||||
|
||||
function testConnectionLive(index) {
|
||||
var backend = backends[index]
|
||||
testingIndex = index
|
||||
if (useLiveData) {
|
||||
dbal.execute("core/test-connection", { adapter: backend.key }, function(result, error) {
|
||||
var newResults = Object.assign({}, testResults)
|
||||
if (!error && result && result.success) {
|
||||
newResults[index] = "success"
|
||||
} else {
|
||||
newResults[index] = "error"
|
||||
}
|
||||
testResults = newResults
|
||||
testingIndex = -1
|
||||
})
|
||||
} else {
|
||||
// Fall back to mock timer
|
||||
testTimer.targetIndex = index
|
||||
testTimer.start()
|
||||
}
|
||||
}
|
||||
|
||||
function checkHealth() {
|
||||
if (useLiveData) {
|
||||
dbal.ping()
|
||||
}
|
||||
}
|
||||
|
||||
onUseLiveDataChanged: {
|
||||
if (useLiveData) loadAdapterStatus()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
loadAdapterStatus()
|
||||
}
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
property int selectedBackendIndex: 2
|
||||
property int activeBackendIndex: 2
|
||||
@@ -144,6 +214,11 @@ Rectangle {
|
||||
CText { variant: "h3"; text: "Database Manager" }
|
||||
CStatusBadge { status: "success"; text: connectedCount() + " / " + backends.length + " connected" }
|
||||
|
||||
CBadge {
|
||||
text: useLiveData ? "Connected to DBAL" : "Mock Data"
|
||||
color: useLiveData ? Theme.success : Theme.warning
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
CButton { text: "Export"; variant: "ghost"; onClicked: showExportDialog = true }
|
||||
@@ -316,7 +391,7 @@ Rectangle {
|
||||
text: testingIndex === selectedBackendIndex ? "Testing..." : "Test Connection"
|
||||
variant: "primary"
|
||||
enabled: testingIndex === -1
|
||||
onClicked: testConnection(selectedBackendIndex)
|
||||
onClicked: testConnectionLive(selectedBackendIndex)
|
||||
}
|
||||
|
||||
CButton {
|
||||
|
||||
@@ -2,11 +2,16 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
color: Theme.background
|
||||
|
||||
// ── DBAL ──────────────────────────────────────────────────────────
|
||||
DBALProvider { id: dbal }
|
||||
property bool useLiveData: dbal.connected
|
||||
|
||||
property int selectedIndex: -1
|
||||
property bool addDialogOpen: false
|
||||
property bool deleteDialogOpen: false
|
||||
@@ -122,6 +127,7 @@ Rectangle {
|
||||
var copy = dropdowns.slice()
|
||||
copy[index] = updated
|
||||
dropdowns = copy
|
||||
if (useLiveData) saveDropdown(index)
|
||||
}
|
||||
|
||||
function updateSelectedField(field, value) {
|
||||
@@ -165,16 +171,18 @@ Rectangle {
|
||||
|
||||
function addDropdown() {
|
||||
if (newDropdownName.trim() === "") return
|
||||
var copy = dropdowns.slice()
|
||||
copy.push({
|
||||
var newDd = {
|
||||
name: newDropdownName.trim().toLowerCase().replace(/ /g, "_"),
|
||||
description: newDropdownDescription.trim() || "No description",
|
||||
allowCustom: false,
|
||||
required: false,
|
||||
options: [
|
||||
{ label: "Option 1", value: "option_1" }
|
||||
]
|
||||
})
|
||||
options: [{ label: "Option 1", value: "option_1" }]
|
||||
}
|
||||
if (useLiveData) {
|
||||
dbal.execute("core/dropdown-configs/create", { data: newDd }, function(r, e) { if (!e) loadDropdowns() })
|
||||
}
|
||||
var copy = dropdowns.slice()
|
||||
copy.push(newDd)
|
||||
dropdowns = copy
|
||||
selectedIndex = dropdowns.length - 1
|
||||
newDropdownName = ""
|
||||
@@ -184,6 +192,9 @@ Rectangle {
|
||||
|
||||
function deleteSelectedDropdown() {
|
||||
if (selectedIndex < 0) return
|
||||
if (useLiveData && dropdowns[selectedIndex].id) {
|
||||
dbal.execute("core/dropdown-configs/delete", { id: dropdowns[selectedIndex].id }, function(r, e) { if (!e) loadDropdowns() })
|
||||
}
|
||||
var copy = dropdowns.slice()
|
||||
copy.splice(selectedIndex, 1)
|
||||
dropdowns = copy
|
||||
@@ -191,6 +202,31 @@ Rectangle {
|
||||
deleteDialogOpen = false
|
||||
}
|
||||
|
||||
// ── DBAL Integration ─────────────────────────────────────────────
|
||||
function loadDropdowns() {
|
||||
dbal.execute("core/dropdown-configs", {}, function(result, error) {
|
||||
if (!error && result && result.items && result.items.length > 0) {
|
||||
var parsed = []
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
var d = result.items[i]
|
||||
parsed.push({ id: d.id, name: d.name || "", description: d.description || "", allowCustom: d.allowCustom || false, required: d.required || false, options: d.options || [] })
|
||||
}
|
||||
dropdowns = parsed
|
||||
if (selectedIndex >= dropdowns.length) selectedIndex = dropdowns.length > 0 ? dropdowns.length - 1 : -1
|
||||
}
|
||||
})
|
||||
}
|
||||
onUseLiveDataChanged: { if (useLiveData) loadDropdowns() }
|
||||
Component.onCompleted: { loadDropdowns() }
|
||||
|
||||
function saveDropdown(index) {
|
||||
if (!useLiveData || index < 0 || index >= dropdowns.length) return
|
||||
var dd = dropdowns[index]
|
||||
var data = { name: dd.name, description: dd.description, allowCustom: dd.allowCustom, required: dd.required, options: dd.options }
|
||||
if (dd.id) dbal.execute("core/dropdown-configs/update", { id: dd.id, data: data }, function(r, e) {})
|
||||
else dbal.execute("core/dropdown-configs/create", { data: data }, function(r, e) { if (!e) loadDropdowns() })
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
|
||||
@@ -2,11 +2,66 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
color: Theme.background
|
||||
|
||||
// ── DBAL connection ──
|
||||
DBALProvider { id: dbal }
|
||||
|
||||
property bool dbalOnline: dbal.connected
|
||||
property string platformVersion: ""
|
||||
property var publicStats: ({ users: "---", packages: "---", workflows: "---" })
|
||||
|
||||
// ── Mock fallback data ──
|
||||
property var mockStatusItems: [
|
||||
{ label: "DBAL stack", value: "healthy" },
|
||||
{ label: "Prisma migrations", value: "pending" },
|
||||
{ label: "Daemon progress", value: "building" }
|
||||
]
|
||||
|
||||
function loadPlatformStatus() {
|
||||
dbal.ping(function(success) {
|
||||
if (success) {
|
||||
// Load version info
|
||||
dbal.execute("core/version", {}, function(result, error) {
|
||||
if (result && result.version) {
|
||||
platformVersion = result.version;
|
||||
}
|
||||
});
|
||||
|
||||
// Load public stats
|
||||
dbal.execute("core/stats", {}, function(result, error) {
|
||||
if (result) {
|
||||
publicStats = {
|
||||
users: result.totalUsers || publicStats.users,
|
||||
packages: result.totalPackages || publicStats.packages,
|
||||
workflows: result.totalWorkflows || publicStats.workflows
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Load live status items
|
||||
dbal.execute("health", {}, function(result, error) {
|
||||
if (result && result.services) {
|
||||
var liveItems = [];
|
||||
for (var i = 0; i < result.services.length; i++) {
|
||||
var svc = result.services[i];
|
||||
liveItems.push({ label: svc.name, value: svc.status });
|
||||
}
|
||||
if (liveItems.length > 0) {
|
||||
statusItems = liveItems;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Component.onCompleted: loadPlatformStatus()
|
||||
|
||||
property int currentTab: 0
|
||||
|
||||
property var featureHighlights: [
|
||||
@@ -21,11 +76,7 @@ Rectangle {
|
||||
{ name: "prisma-migrations", status: "running" }
|
||||
]
|
||||
|
||||
property var statusItems: [
|
||||
{ label: "DBAL stack", value: "healthy" },
|
||||
{ label: "Prisma migrations", value: "pending" },
|
||||
{ label: "Daemon progress", value: "building" }
|
||||
]
|
||||
property var statusItems: mockStatusItems
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -37,6 +37,7 @@ Rectangle {
|
||||
{ label: "CSS Classes" },
|
||||
{ label: "Dropdowns" },
|
||||
{ label: "Database" },
|
||||
{ label: "Media" },
|
||||
{ label: "Settings" }
|
||||
]
|
||||
|
||||
@@ -312,7 +313,16 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// 12 - Settings (inline: Theme + SMTP side by side)
|
||||
// 12 - Media Service
|
||||
Rectangle {
|
||||
color: "transparent"
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
source: "MediaServicePanel.qml"
|
||||
}
|
||||
}
|
||||
|
||||
// 13 - Settings (inline: Theme + SMTP side by side)
|
||||
Rectangle {
|
||||
color: "transparent"
|
||||
|
||||
|
||||
@@ -2,12 +2,18 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
Rectangle {
|
||||
id: loginView
|
||||
color: "transparent"
|
||||
|
||||
property string errorMessage: ""
|
||||
property bool loggingIn: false
|
||||
|
||||
DBALProvider {
|
||||
id: dbal
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
@@ -39,6 +45,7 @@ Rectangle {
|
||||
Layout.fillWidth: true
|
||||
label: "Username"
|
||||
placeholderText: "demo, admin, god, or super"
|
||||
enabled: !loggingIn
|
||||
}
|
||||
|
||||
CTextField {
|
||||
@@ -47,20 +54,22 @@ Rectangle {
|
||||
label: "Password"
|
||||
placeholderText: "Enter password"
|
||||
echoMode: TextInput.Password
|
||||
enabled: !loggingIn
|
||||
onAccepted: doLogin()
|
||||
}
|
||||
|
||||
CText {
|
||||
CAlert {
|
||||
Layout.fillWidth: true
|
||||
visible: errorMessage.length > 0
|
||||
severity: "error"
|
||||
text: errorMessage
|
||||
colorVariant: "error"
|
||||
variant: "body2"
|
||||
}
|
||||
|
||||
CButton {
|
||||
Layout.fillWidth: true
|
||||
text: "Sign In"
|
||||
text: loggingIn ? "Signing in..." : "Sign In"
|
||||
variant: "primary"
|
||||
enabled: !loggingIn
|
||||
onClicked: doLogin()
|
||||
}
|
||||
|
||||
@@ -77,11 +86,34 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
function loginWithDBAL(username, password) {
|
||||
loggingIn = true
|
||||
errorMessage = ""
|
||||
|
||||
dbal.execute("core/auth/login", { username: username, password: password }, function(result, error) {
|
||||
if (!error && result && result.token) {
|
||||
appWindow.currentUser = result.username || username
|
||||
appWindow.currentRole = result.role || "user"
|
||||
appWindow.currentLevel = result.level || 2
|
||||
appWindow.loggedIn = true
|
||||
appWindow.authToken = result.token
|
||||
dbal.authToken = result.token
|
||||
appWindow.currentView = "dashboard"
|
||||
loggingIn = false
|
||||
} else {
|
||||
// DBAL failed — fall back to local seed user auth
|
||||
loggingIn = false
|
||||
if (appWindow.login(username, password)) {
|
||||
errorMessage = ""
|
||||
} else {
|
||||
errorMessage = error || "Invalid username or password"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function doLogin() {
|
||||
if (appWindow.login(usernameField.text, passwordField.text)) {
|
||||
errorMessage = ""
|
||||
} else {
|
||||
errorMessage = "Invalid username or password"
|
||||
}
|
||||
errorMessage = ""
|
||||
loginWithDBAL(usernameField.text, passwordField.text)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,445 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
color: "transparent"
|
||||
|
||||
// ── DBAL connection ──────────────────────────────────────────
|
||||
DBALProvider { id: dbal }
|
||||
|
||||
property bool useLiveData: dbal.connected
|
||||
|
||||
// ── State ────────────────────────────────────────────────────
|
||||
property string activeFilter: "All"
|
||||
property int unreadCount: countUnread()
|
||||
|
||||
property var filters: ["All", "System", "Alerts", "Info"]
|
||||
|
||||
// ── Mock data (mirrors AdminView notification records) ───────
|
||||
property var notifications: [
|
||||
{ id: "NTF-001", type: "system", title: "Scheduled Maintenance", message: "Maintenance window 03/20 from 02:00-04:00 UTC", timestamp: "2026-03-18 06:00", read: false },
|
||||
{ id: "NTF-002", type: "alert", title: "High CPU Alert", message: "High CPU on dbal-prod — 94% sustained for 10m", timestamp: "2026-03-18 07:30", read: false },
|
||||
{ id: "NTF-003", type: "info", title: "Export Complete", message: "Your export is ready for download", timestamp: "2026-03-18 09:00", read: true },
|
||||
{ id: "NTF-004", type: "warning", title: "Failed Login Attempts", message: "3 failed login attempts detected for eve_sec", timestamp: "2026-03-18 08:15", read: false },
|
||||
{ id: "NTF-005", type: "system", title: "Deployment Successful", message: "v2.1.0 deployed to production successfully", timestamp: "2026-03-15 12:00", read: true },
|
||||
{ id: "NTF-006", type: "info", title: "New Package Available", message: "analytics v1.2.0 is available for installation", timestamp: "2026-03-14 10:30", read: true },
|
||||
{ id: "NTF-007", type: "alert", title: "Disk Space Warning", message: "Primary storage at 87% capacity", timestamp: "2026-03-13 15:45", read: false },
|
||||
{ id: "NTF-008", type: "system", title: "Database Backup Complete", message: "Nightly backup completed — 2.4 GB archived", timestamp: "2026-03-13 03:00", read: true },
|
||||
{ id: "NTF-009", type: "info", title: "Welcome to MetaBuilder", message: "Your account has been set up successfully", timestamp: "2026-03-10 08:00", read: true },
|
||||
{ id: "NTF-010", type: "warning", title: "Certificate Expiry", message: "TLS certificate expires in 14 days", timestamp: "2026-03-12 09:00", read: false }
|
||||
]
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
function countUnread() {
|
||||
var count = 0
|
||||
for (var i = 0; i < notifications.length; i++) {
|
||||
if (!notifications[i].read) count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function filteredNotifications() {
|
||||
var result = []
|
||||
for (var i = 0; i < notifications.length; i++) {
|
||||
var n = notifications[i]
|
||||
if (activeFilter === "All") {
|
||||
result.push(n)
|
||||
} else if (activeFilter === "System" && n.type === "system") {
|
||||
result.push(n)
|
||||
} else if (activeFilter === "Alerts" && (n.type === "alert" || n.type === "warning")) {
|
||||
result.push(n)
|
||||
} else if (activeFilter === "Info" && n.type === "info") {
|
||||
result.push(n)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function typeIcon(type) {
|
||||
switch (type) {
|
||||
case "system": return "\u2699" // gear
|
||||
case "alert": return "\u26A0" // warning sign
|
||||
case "warning": return "\u26A0" // warning sign
|
||||
case "info": return "\u2139" // info
|
||||
default: return "\u2709" // envelope
|
||||
}
|
||||
}
|
||||
|
||||
function typeColor(type) {
|
||||
switch (type) {
|
||||
case "system": return "#2196f3"
|
||||
case "alert": return "#f44336"
|
||||
case "warning": return "#ff9800"
|
||||
case "info": return "#4caf50"
|
||||
default: return "#9e9e9e"
|
||||
}
|
||||
}
|
||||
|
||||
function markAllRead() {
|
||||
var updated = []
|
||||
for (var i = 0; i < notifications.length; i++) {
|
||||
var n = Object.assign({}, notifications[i])
|
||||
n.read = true
|
||||
updated.push(n)
|
||||
}
|
||||
notifications = updated
|
||||
unreadCount = 0
|
||||
}
|
||||
|
||||
function markRead(index) {
|
||||
var updated = []
|
||||
for (var i = 0; i < notifications.length; i++) {
|
||||
var n = Object.assign({}, notifications[i])
|
||||
if (i === index) n.read = true
|
||||
updated.push(n)
|
||||
}
|
||||
notifications = updated
|
||||
unreadCount = countUnread()
|
||||
}
|
||||
|
||||
function dismissNotification(notifId) {
|
||||
var updated = []
|
||||
for (var i = 0; i < notifications.length; i++) {
|
||||
if (notifications[i].id !== notifId) {
|
||||
updated.push(notifications[i])
|
||||
}
|
||||
}
|
||||
notifications = updated
|
||||
unreadCount = countUnread()
|
||||
}
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
// Show relative-style label for recent items
|
||||
if (ts.indexOf("2026-03-18") === 0) return "Today " + ts.substring(11)
|
||||
if (ts.indexOf("2026-03-17") === 0) return "Yesterday " + ts.substring(11)
|
||||
return ts
|
||||
}
|
||||
|
||||
// ── DBAL integration ─────────────────────────────────────────
|
||||
function loadFromDBAL() {
|
||||
if (!useLiveData) return
|
||||
dbal.list("notification", { take: 50 }, function(result, error) {
|
||||
if (error || !result) return
|
||||
var items = result.items || []
|
||||
var liveNotifs = []
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var item = items[i]
|
||||
liveNotifs.push({
|
||||
id: item.id || ("NTF-LIVE-" + i),
|
||||
type: item.type || "info",
|
||||
title: item.title || item.message || "Notification",
|
||||
message: item.message || "",
|
||||
timestamp: item.sent || item.created || "",
|
||||
read: item.status === "Inactive"
|
||||
})
|
||||
}
|
||||
if (liveNotifs.length > 0) {
|
||||
notifications = liveNotifs
|
||||
unreadCount = countUnread()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (useLiveData) loadFromDBAL()
|
||||
}
|
||||
|
||||
onUseLiveDataChanged: {
|
||||
if (useLiveData) loadFromDBAL()
|
||||
}
|
||||
|
||||
// ── UI ───────────────────────────────────────────────────────
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 24
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 16
|
||||
|
||||
// ── Header ──────────────────────────────────────────
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 12
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
CText {
|
||||
variant: "h3"
|
||||
text: "Notifications"
|
||||
}
|
||||
|
||||
CBadge {
|
||||
visible: unreadCount > 0
|
||||
text: unreadCount + " unread"
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
CButton {
|
||||
text: "Mark All Read"
|
||||
variant: "ghost"
|
||||
size: "sm"
|
||||
enabled: unreadCount > 0
|
||||
onClicked: markAllRead()
|
||||
}
|
||||
|
||||
CButton {
|
||||
text: dbal.loading ? "Loading..." : "Refresh"
|
||||
variant: "ghost"
|
||||
size: "sm"
|
||||
enabled: !dbal.loading
|
||||
onClicked: loadFromDBAL()
|
||||
}
|
||||
}
|
||||
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
// ── Filter tabs ─────────────────────────────
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: filters
|
||||
delegate: CButton {
|
||||
text: modelData
|
||||
variant: activeFilter === modelData ? "primary" : "ghost"
|
||||
size: "sm"
|
||||
onClicked: activeFilter = modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notification list ───────────────────────────────
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
visible: filteredNotifications().length > 0
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: filteredNotifications()
|
||||
|
||||
delegate: Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: notifContent.implicitHeight + 24
|
||||
color: modelData.read ? "transparent" : Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.04)
|
||||
radius: 6
|
||||
|
||||
Rectangle {
|
||||
id: typeStripe
|
||||
width: 4
|
||||
height: parent.height - 8
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: 2
|
||||
color: typeColor(modelData.type)
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: notifContent
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 16
|
||||
anchors.rightMargin: 12
|
||||
anchors.topMargin: 12
|
||||
anchors.bottomMargin: 12
|
||||
spacing: 12
|
||||
|
||||
// Type icon circle
|
||||
Rectangle {
|
||||
width: 36
|
||||
height: 36
|
||||
radius: 18
|
||||
color: Qt.rgba(typeColor(modelData.type).r, typeColor(modelData.type).g, typeColor(modelData.type).b, 0.15)
|
||||
Layout.alignment: Qt.AlignTop
|
||||
|
||||
CText {
|
||||
anchors.centerIn: parent
|
||||
text: typeIcon(modelData.type)
|
||||
variant: "body1"
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
CText {
|
||||
variant: modelData.read ? "body1" : "subtitle1"
|
||||
text: modelData.title
|
||||
font.bold: !modelData.read
|
||||
}
|
||||
|
||||
CBadge {
|
||||
text: modelData.type
|
||||
visible: true
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
CText {
|
||||
variant: "caption"
|
||||
text: formatTimestamp(modelData.timestamp)
|
||||
opacity: 0.6
|
||||
}
|
||||
}
|
||||
|
||||
CText {
|
||||
Layout.fillWidth: true
|
||||
variant: "body2"
|
||||
text: modelData.message
|
||||
opacity: modelData.read ? 0.6 : 0.85
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
ColumnLayout {
|
||||
Layout.alignment: Qt.AlignTop
|
||||
spacing: 4
|
||||
|
||||
CButton {
|
||||
visible: !modelData.read
|
||||
text: "Read"
|
||||
variant: "ghost"
|
||||
size: "sm"
|
||||
onClicked: {
|
||||
// Find original index
|
||||
for (var i = 0; i < notifications.length; i++) {
|
||||
if (notifications[i].id === modelData.id) {
|
||||
markRead(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CButton {
|
||||
text: "Dismiss"
|
||||
variant: "ghost"
|
||||
size: "sm"
|
||||
onClicked: dismissNotification(modelData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom separator
|
||||
CDivider {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 16
|
||||
anchors.rightMargin: 16
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
z: -1
|
||||
onClicked: {
|
||||
for (var i = 0; i < notifications.length; i++) {
|
||||
if (notifications[i].id === modelData.id) {
|
||||
markRead(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empty state ─────────────────────────────────────
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
visible: filteredNotifications().length === 0
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 40
|
||||
spacing: 16
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
|
||||
CText {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
variant: "h2"
|
||||
text: "\u{1F514}"
|
||||
}
|
||||
|
||||
CText {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
variant: "h4"
|
||||
text: activeFilter === "All" ? "No notifications" : "No " + activeFilter.toLowerCase() + " notifications"
|
||||
}
|
||||
|
||||
CText {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
variant: "body2"
|
||||
text: "When there are new notifications, they will appear here."
|
||||
opacity: 0.6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary footer ──────────────────────────────────
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
visible: notifications.length > 0
|
||||
|
||||
CText {
|
||||
variant: "caption"
|
||||
text: notifications.length + " total notifications"
|
||||
opacity: 0.5
|
||||
}
|
||||
|
||||
CText {
|
||||
variant: "caption"
|
||||
text: " \u00b7 "
|
||||
opacity: 0.3
|
||||
}
|
||||
|
||||
CText {
|
||||
variant: "caption"
|
||||
text: unreadCount + " unread"
|
||||
opacity: 0.5
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
CText {
|
||||
variant: "caption"
|
||||
text: useLiveData ? "Live data" : "Mock data"
|
||||
opacity: 0.4
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom spacer
|
||||
Item { Layout.preferredHeight: 20 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,16 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
color: Theme.background
|
||||
|
||||
// ── DBAL ──────────────────────────────────────────────────────────
|
||||
DBALProvider { id: dbal }
|
||||
property bool useLiveData: dbal.connected
|
||||
|
||||
property int selectedIndex: -1
|
||||
property bool addDialogVisible: false
|
||||
property bool deleteDialogVisible: false
|
||||
@@ -19,6 +24,18 @@ Rectangle {
|
||||
property var layoutOptions: ["default", "sidebar", "dashboard", "blank"]
|
||||
property var levelOptions: [1, 2, 3, 4, 5]
|
||||
|
||||
property var mockRoutes: [
|
||||
{ path: "/", title: "Home", level: 1, layout: "default", enabled: true, permissions: "public" },
|
||||
{ path: "/dashboard", title: "Dashboard", level: 1, layout: "dashboard", enabled: true, permissions: "authenticated" },
|
||||
{ path: "/admin", title: "Admin Panel", level: 3, layout: "sidebar", enabled: true, permissions: "role:admin" },
|
||||
{ path: "/forum", title: "Forum", level: 1, layout: "sidebar", enabled: true, permissions: "authenticated" },
|
||||
{ path: "/gallery", title: "Gallery", level: 1, layout: "default", enabled: true, permissions: "public" },
|
||||
{ path: "/profile", title: "Profile", level: 1, layout: "sidebar", enabled: true, permissions: "authenticated" },
|
||||
{ path: "/settings", title: "Settings", level: 2, layout: "sidebar", enabled: true, permissions: "authenticated" },
|
||||
{ path: "/god-panel", title: "God Panel", level: 4, layout: "dashboard", enabled: true, permissions: "role:god" },
|
||||
{ path: "/supergod", title: "Super God", level: 5, layout: "blank", enabled: false, permissions: "role:supergod" }
|
||||
]
|
||||
|
||||
property var routes: [
|
||||
{ path: "/", title: "Home", level: 1, layout: "default", enabled: true, permissions: "public" },
|
||||
{ path: "/dashboard", title: "Dashboard", level: 1, layout: "dashboard", enabled: true, permissions: "authenticated" },
|
||||
@@ -41,20 +58,28 @@ Rectangle {
|
||||
selectedIndex = -1
|
||||
selectedIndex = index
|
||||
}
|
||||
if (useLiveData) saveRoute(index)
|
||||
}
|
||||
|
||||
function addRoute() {
|
||||
if (newPath.length === 0 || newTitle.length === 0) return
|
||||
var updated = routes.slice()
|
||||
updated.push({
|
||||
var newRoute = {
|
||||
path: newPath,
|
||||
title: newTitle,
|
||||
level: newLevel,
|
||||
layout: newLayout,
|
||||
enabled: true,
|
||||
permissions: "authenticated"
|
||||
})
|
||||
routes = updated
|
||||
}
|
||||
if (useLiveData) {
|
||||
dbal.create("ui_page", newRoute, function(result, error) {
|
||||
if (!error) loadRoutes()
|
||||
})
|
||||
} else {
|
||||
var updated = routes.slice()
|
||||
updated.push(newRoute)
|
||||
routes = updated
|
||||
}
|
||||
newPath = ""
|
||||
newTitle = ""
|
||||
newLevel = 1
|
||||
@@ -64,9 +89,15 @@ Rectangle {
|
||||
|
||||
function deleteRoute() {
|
||||
if (selectedIndex < 0 || selectedIndex >= routes.length) return
|
||||
var updated = routes.slice()
|
||||
updated.splice(selectedIndex, 1)
|
||||
routes = updated
|
||||
if (useLiveData && routes[selectedIndex].id) {
|
||||
dbal.remove("ui_page", routes[selectedIndex].id, function(result, error) {
|
||||
if (!error) loadRoutes()
|
||||
})
|
||||
} else {
|
||||
var updated = routes.slice()
|
||||
updated.splice(selectedIndex, 1)
|
||||
routes = updated
|
||||
}
|
||||
selectedIndex = -1
|
||||
deleteDialogVisible = false
|
||||
}
|
||||
@@ -90,6 +121,55 @@ Rectangle {
|
||||
return "#9c27b0"
|
||||
}
|
||||
|
||||
// ── DBAL Integration ─────────────────────────────────────────────
|
||||
|
||||
function loadRoutes() {
|
||||
dbal.list("ui_page", { take: 100 }, function(result, error) {
|
||||
if (!error && result && result.items && result.items.length > 0) {
|
||||
var parsed = []
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
var r = result.items[i]
|
||||
parsed.push({
|
||||
id: r.id || undefined,
|
||||
path: r.path || r.route || "",
|
||||
title: r.title || r.name || "",
|
||||
level: r.level || 1,
|
||||
layout: r.layout || "default",
|
||||
enabled: r.enabled !== undefined ? r.enabled : true,
|
||||
permissions: r.permissions || "public"
|
||||
})
|
||||
}
|
||||
routes = parsed
|
||||
}
|
||||
// On error or empty result, keep existing mock routes as fallback
|
||||
})
|
||||
}
|
||||
|
||||
onUseLiveDataChanged: {
|
||||
if (useLiveData) loadRoutes()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
loadRoutes()
|
||||
}
|
||||
|
||||
// ── CRUD wiring ──────────────────────────────────────────────────
|
||||
|
||||
function saveRoute(index) {
|
||||
if (!useLiveData) return
|
||||
var route = routes[index]
|
||||
var data = { path: route.path, title: route.title, level: route.level, layout: route.layout, enabled: route.enabled, permissions: route.permissions }
|
||||
if (route.id) {
|
||||
dbal.update("ui_page", route.id, data, function(result, error) {
|
||||
if (!error) loadRoutes()
|
||||
})
|
||||
} else {
|
||||
dbal.create("ui_page", data, function(result, error) {
|
||||
if (!error) loadRoutes()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
|
||||
@@ -2,16 +2,83 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
Rectangle {
|
||||
color: "transparent"
|
||||
|
||||
property string userBio: "MetaBuilder enthusiast and open-source contributor."
|
||||
property string userEmail: "demo@metabuilder.io"
|
||||
// ── DBAL connection ──
|
||||
DBALProvider { id: dbal }
|
||||
|
||||
// ── Mock fallback data ──
|
||||
property string mockBio: "MetaBuilder enthusiast and open-source contributor."
|
||||
property string mockEmail: "demo@metabuilder.io"
|
||||
|
||||
property string userBio: mockBio
|
||||
property string userEmail: mockEmail
|
||||
property string userDisplayName: appWindow.currentUser
|
||||
property string currentPassword: ""
|
||||
property string newPassword: ""
|
||||
property string confirmPassword: ""
|
||||
property bool saving: false
|
||||
property string saveStatus: ""
|
||||
|
||||
// ── DBAL data loading ──
|
||||
function loadProfile() {
|
||||
if (!appWindow.currentUser) return;
|
||||
dbal.read("user", appWindow.currentUser, function(result, error) {
|
||||
if (result) {
|
||||
if (result.bio) userBio = result.bio;
|
||||
if (result.email) userEmail = result.email;
|
||||
if (result.displayName) userDisplayName = result.displayName;
|
||||
}
|
||||
// On error, keep existing mock data
|
||||
});
|
||||
}
|
||||
|
||||
function saveProfile() {
|
||||
saving = true;
|
||||
saveStatus = "";
|
||||
var profileData = {
|
||||
displayName: userDisplayName,
|
||||
email: userEmail,
|
||||
bio: userBio
|
||||
};
|
||||
dbal.update("user", appWindow.currentUser, profileData, function(result, error) {
|
||||
saving = false;
|
||||
if (result) {
|
||||
saveStatus = "saved";
|
||||
console.log("Profile saved for", appWindow.currentUser);
|
||||
} else {
|
||||
saveStatus = "error";
|
||||
console.warn("Profile save failed:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function changePassword() {
|
||||
if (newPassword !== confirmPassword) return;
|
||||
dbal.execute("core/change-password", {
|
||||
userId: appWindow.currentUser,
|
||||
oldPassword: currentPassword,
|
||||
newPassword: newPassword
|
||||
}, function(result, error) {
|
||||
if (result) {
|
||||
currentPassword = "";
|
||||
newPassword = "";
|
||||
confirmPassword = "";
|
||||
console.log("Password changed successfully");
|
||||
} else {
|
||||
console.warn("Password change failed:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
dbal.ping(function(success) {
|
||||
if (success) loadProfile();
|
||||
});
|
||||
}
|
||||
|
||||
function userInitials() {
|
||||
var name = appWindow.currentUser
|
||||
@@ -262,11 +329,10 @@ Rectangle {
|
||||
spacing: 12
|
||||
Item { Layout.fillWidth: true }
|
||||
CButton {
|
||||
text: "Save Changes"
|
||||
text: saving ? "Saving..." : "Save Changes"
|
||||
variant: "primary"
|
||||
onClicked: {
|
||||
console.log("Profile saved for", appWindow.currentUser)
|
||||
}
|
||||
enabled: !saving
|
||||
onClicked: saveProfile()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+58
-40
@@ -1,6 +1,6 @@
|
||||
# Qt6 Frontend Roadmap
|
||||
|
||||
**Status**: Compiles and links (25 QML views, ~12,800 LOC)
|
||||
**Status**: Compiles and links (26 QML views, ~14,500 LOC)
|
||||
**Last Build**: 2026-03-19 | Qt 6.7.3 via Conan | MSVC 19.5 | C++20
|
||||
|
||||
---
|
||||
@@ -34,7 +34,7 @@
|
||||
- [x] Level 2: `ProfileView.qml` — Avatar, bio, password change, connected accounts
|
||||
- [x] Level 2: `CommentsView.qml` — Post/like/delete, sort, role-based visibility
|
||||
- [x] Level 3: `AdminView.qml` — 10 entities, CRUD dialogs, search, filter, pagination, bulk delete (871 LOC)
|
||||
- [x] Level 4: `GodPanel.qml` — 13-tab builder container with config summary
|
||||
- [x] Level 4: `GodPanel.qml` — 14-tab builder container with config summary
|
||||
- [x] Level 5: `SuperGodPanel.qml` — Tenants, god users, power transfer, system health
|
||||
|
||||
### Phase 4: God Panel Builder Tools (15 Agents)
|
||||
@@ -50,42 +50,49 @@
|
||||
- [x] `ThemeEditor.qml` — 9 theme selector, color swatches, typography (876 LOC)
|
||||
- [x] `SMTPConfigEditor.qml` — Server config, test send, email templates (632 LOC)
|
||||
|
||||
---
|
||||
|
||||
## In Progress
|
||||
|
||||
### Phase 5: DBAL Integration
|
||||
- [ ] Register `DBALClient` as QML singleton type (currently context property)
|
||||
- [ ] Wire `AdminView` entity table to real DBAL endpoints (`/{tenant}/{package}/{entity}`)
|
||||
- [ ] Wire `SchemaEditor` to load from `dbal/shared/api/schema/entities/`
|
||||
- [ ] Wire `UserManagement` to real User entity CRUD
|
||||
- [ ] Wire `DashboardView` health cards to `/health`, `/version`, `/status`
|
||||
- [ ] Add DBAL connection status indicator in app bar (ping on startup)
|
||||
- [ ] Replace mock data in all editors with `DBALProvider.list/create/update/remove` calls
|
||||
- [x] Register `DBALClient` as QML context property in `main.cpp`
|
||||
- [x] Migrate to DBAL REST API: `/api/v1/{tenant}/{package}/{entity}[/{id}]`
|
||||
- [x] Add `packageId` property to DBALClient (C++ + QML), default `"core"`
|
||||
- [x] Wire `AdminView` entity table to DBAL REST endpoints with mock fallback
|
||||
- [x] Wire `SchemaEditor` to load schemas from DBAL with mock fallback
|
||||
- [x] Wire `UserManagement` to real User entity CRUD with mock fallback
|
||||
- [x] Wire `DashboardView` health cards to `/health` endpoint
|
||||
- [x] Add DBAL connection status indicator in app bar (green/red dot + "DBAL")
|
||||
- [x] Add DBAL offline banner below app bar ("DBAL Offline — showing cached data")
|
||||
- [x] Add `health()`, `version()`, `status()`, `listSchemas()`, `getSchema()` to C++ DBALClient
|
||||
- [x] `DBALProvider.qml` — REST-based QML HTTP client with `entityPath()` helpers
|
||||
|
||||
### Phase 6: Build System (Python + stdlib)
|
||||
- [x] Create `generate_cmake.py` — zero-dependency script (Python stdlib only)
|
||||
- Globs all `*.qml` files automatically (root, qmllib/, packages/)
|
||||
- Reads `metadata.json` from each package for auto-registration
|
||||
- Discovers `src/*.cpp` and `src/*.h` for C++ sources
|
||||
- Handles SVG/audio/resource globbing
|
||||
- Supports conditional features (libopenmpt, Qt Multimedia)
|
||||
- [x] Create `cmake_config.json` defining modules, dependencies, feature flags
|
||||
- [x] `--dry-run` mode to preview generated CMakeLists.txt
|
||||
- [x] `--output` and `--config` CLI options
|
||||
|
||||
### Phase 7: Runtime Polish
|
||||
- [x] Dark/light theme switching (toggle button in app bar)
|
||||
- [x] Keyboard shortcuts (Ctrl+K search, Ctrl+L login/logout, Ctrl+1-5 level switch, Escape back)
|
||||
- [x] Window state persistence via `Qt.labs.settings` (size, position, theme)
|
||||
- [x] Error boundary — DBAL offline banner with warning styling
|
||||
|
||||
### Phase 4.5: Media Service Integration
|
||||
- [x] `MediaServicePanel.qml` — 4-tab media service management (~730 LOC)
|
||||
- Jobs tab: submission form, active jobs table, progress bars, cancel
|
||||
- Radio tab: channel management, playlists, start/stop streaming
|
||||
- TV tab: channel scheduling, multi-resolution, broadcast controls
|
||||
- Plugins tab: FFmpeg/ImageMagick/Pandoc/Radio/LibRetro grid with reload
|
||||
- [x] Integrated into GodPanel as tab 12 (14 total tabs)
|
||||
- [x] Separate HTTP client for media service at `http://localhost:8090`
|
||||
|
||||
---
|
||||
|
||||
## Planned
|
||||
|
||||
### Phase 6: Build System (Python + Jinja2 + JSON + GLOB)
|
||||
- [ ] Create `generate_cmake.py` script that:
|
||||
- Globs all `*.qml` files automatically (no manual CMakeLists.txt maintenance)
|
||||
- Reads `metadata.json` from each package for auto-registration
|
||||
- Templates `CMakeLists.txt` via Jinja2 from `cmake_config.json`
|
||||
- Handles SVG/audio/resource globbing
|
||||
- Supports conditional features (libopenmpt, Qt Multimedia)
|
||||
- [ ] Create `cmake_config.json` defining modules, dependencies, feature flags
|
||||
- [ ] Add `--dry-run` mode to preview generated CMakeLists.txt
|
||||
- [ ] Integrate into pre-commit or CI
|
||||
|
||||
### Phase 7: Runtime Polish
|
||||
- [ ] Dark/light theme switching (Theme singleton already supports 9 themes)
|
||||
- [ ] i18n integration (LanguageContext from shared `/qml/` — 19 languages ready)
|
||||
- [ ] Responsive layout (Responsive singleton from shared `/qml/`)
|
||||
- [ ] Keyboard shortcuts (Ctrl+K search, Ctrl+L login/logout)
|
||||
- [ ] Window state persistence (size, position, last view)
|
||||
- [ ] Error boundary / graceful degradation when DBAL is offline
|
||||
|
||||
### Phase 8: Package System
|
||||
- [ ] Dynamic package view loading from disk (PackageViewLoader → real file resolution)
|
||||
- [ ] Package install/uninstall with metadata validation
|
||||
@@ -111,18 +118,21 @@
|
||||
|
||||
```
|
||||
App.qml (ApplicationWindow)
|
||||
├── CAppBar (Level navigation + auth)
|
||||
├── CAppBar (Level nav + auth + DBAL status + theme toggle)
|
||||
├── DBAL Offline Banner (conditional warning strip)
|
||||
├── Sidebar (CListItem navigation, level-gated)
|
||||
├── Settings (Qt.labs.settings — window size/position/theme persistence)
|
||||
├── Shortcuts (Ctrl+K/L/1-5, Escape)
|
||||
└── StackLayout (17 views)
|
||||
├── FrontPage (Level 1 - Public)
|
||||
├── LoginView (Auth)
|
||||
├── DashboardView (Level 2 - User)
|
||||
├── DashboardView (Level 2 - User, DBAL health)
|
||||
├── ProfileView (Level 2)
|
||||
├── CommentsView (Level 2)
|
||||
├── PackageViewLoader×6 (Level 2 - Forum, Gallery, etc.)
|
||||
├── AdminView (Level 3 - Django CRUD)
|
||||
├── GodPanel (Level 4 - 13-tab builder)
|
||||
│ ├── SchemaEditor
|
||||
├── AdminView (Level 3 - Django CRUD, DBAL REST)
|
||||
├── GodPanel (Level 4 - 14-tab builder)
|
||||
│ ├── SchemaEditor (DBAL REST)
|
||||
│ ├── WorkflowEditor
|
||||
│ ├── LuaEditor
|
||||
│ ├── DatabaseManager
|
||||
@@ -130,9 +140,10 @@ App.qml (ApplicationWindow)
|
||||
│ ├── ComponentHierarchyEditor
|
||||
│ ├── CssClassManager
|
||||
│ ├── DropdownConfigManager
|
||||
│ ├── UserManagement
|
||||
│ ├── UserManagement (DBAL REST)
|
||||
│ ├── ThemeEditor
|
||||
│ └── SMTPConfigEditor
|
||||
│ ├── SMTPConfigEditor
|
||||
│ └── MediaServicePanel (Media Daemon REST)
|
||||
├── PackageManager (Level 4)
|
||||
├── Storybook (Level 4)
|
||||
└── SuperGodPanel (Level 5 - Tenants + Power Transfer)
|
||||
@@ -140,7 +151,14 @@ App.qml (ApplicationWindow)
|
||||
C++ Backend
|
||||
├── PackageRegistry (JSON metadata loader)
|
||||
├── ModPlayer (stub — libopenmpt pending)
|
||||
└── DBALClient (HTTP client → DBAL daemon)
|
||||
└── DBALClient (REST client → DBAL daemon :8080)
|
||||
├── CRUD: /api/v1/{tenant}/{package}/{entity}[/{id}]
|
||||
├── System: /health, /version, /status
|
||||
└── Schema: /api/v1/{tenant}/schema[/{entity}]
|
||||
|
||||
Build System
|
||||
├── generate_cmake.py (auto-generates CMakeLists.txt from file globs)
|
||||
└── cmake_config.json (project config, Qt components, feature flags)
|
||||
|
||||
Shared: /qml/ QmlComponents 1.0 (119 components, 9 themes, 19 languages)
|
||||
```
|
||||
|
||||
@@ -2,10 +2,15 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
Rectangle {
|
||||
color: Theme.background
|
||||
|
||||
// ── DBAL ──────────────────────────────────────────────────────────
|
||||
|
||||
DBALProvider { id: dbal }
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
|
||||
property int selectedSchemaIndex: 0
|
||||
@@ -106,6 +111,50 @@ Rectangle {
|
||||
|
||||
property var fieldTypes: ["string", "integer", "number", "boolean", "text", "json", "enum", "datetime", "date", "uuid", "array"]
|
||||
|
||||
property var mockSchemas: JSON.parse(JSON.stringify(schemas))
|
||||
|
||||
// ── DBAL Integration ─────────────────────────────────────────────
|
||||
|
||||
function loadSchemas() {
|
||||
dbal.execute("core/schema", {}, function(result, error) {
|
||||
if (!error && result && result.items) {
|
||||
var parsed = []
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
var item = result.items[i]
|
||||
var fields = []
|
||||
if (item.fields) {
|
||||
for (var j = 0; j < item.fields.length; j++) {
|
||||
var f = item.fields[j]
|
||||
fields.push({
|
||||
name: f.name || "",
|
||||
type: f.type || "string",
|
||||
required: f.required || false,
|
||||
defaultValue: f.defaultValue || f["default"] || "",
|
||||
description: f.description || ""
|
||||
})
|
||||
}
|
||||
}
|
||||
parsed.push({
|
||||
name: item.name || "",
|
||||
description: item.description || "",
|
||||
fields: fields
|
||||
})
|
||||
}
|
||||
if (parsed.length > 0) {
|
||||
schemas = parsed
|
||||
selectedSchemaIndex = 0
|
||||
selectedFieldIndex = -1
|
||||
}
|
||||
// If parsed is empty, keep existing mock schemas as fallback
|
||||
}
|
||||
// On error, keep existing mock schemas as fallback
|
||||
})
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
loadSchemas()
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function currentSchema() {
|
||||
@@ -132,22 +181,42 @@ Rectangle {
|
||||
|
||||
function addSchema() {
|
||||
if (newSchemaName.trim() === "") return
|
||||
var copy = JSON.parse(JSON.stringify(schemas))
|
||||
copy.push({
|
||||
var schemaData = {
|
||||
name: newSchemaName.trim(),
|
||||
description: newSchemaDescription.trim(),
|
||||
fields: [
|
||||
{ name: "id", type: "string", required: true, defaultValue: "uuid()", description: "Primary key" }
|
||||
]
|
||||
})
|
||||
schemas = copy
|
||||
selectedSchemaIndex = copy.length - 1
|
||||
selectedFieldIndex = -1
|
||||
}
|
||||
|
||||
// POST to DBAL when connected, then update local state
|
||||
if (dbal.connected) {
|
||||
dbal.create("schema", schemaData, function(result, error) {
|
||||
if (!error) {
|
||||
// Reload from server to stay in sync
|
||||
loadSchemas()
|
||||
} else {
|
||||
// Fallback: add locally
|
||||
addSchemaLocally(schemaData)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
addSchemaLocally(schemaData)
|
||||
}
|
||||
|
||||
newSchemaName = ""
|
||||
newSchemaDescription = ""
|
||||
createSchemaDialogOpen = false
|
||||
}
|
||||
|
||||
function addSchemaLocally(schemaData) {
|
||||
var copy = JSON.parse(JSON.stringify(schemas))
|
||||
copy.push(schemaData)
|
||||
schemas = copy
|
||||
selectedSchemaIndex = copy.length - 1
|
||||
selectedFieldIndex = -1
|
||||
}
|
||||
|
||||
function deleteSchema() {
|
||||
if (schemas.length <= 1) return
|
||||
var copy = JSON.parse(JSON.stringify(schemas))
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
color: "transparent"
|
||||
|
||||
// ── DBAL connection ──────────────────────────────────────────
|
||||
DBALProvider { id: dbal }
|
||||
|
||||
property bool useLiveData: dbal.connected
|
||||
|
||||
// ── Profile state ────────────────────────────────────────────
|
||||
property string displayName: appWindow.currentUser
|
||||
property string userEmail: appWindow.currentUser + "@metabuilder.io"
|
||||
property bool profileSaved: false
|
||||
|
||||
// ── Appearance state ─────────────────────────────────────────
|
||||
property var availableThemes: [
|
||||
{ id: "dark", label: "Dark" },
|
||||
{ id: "light", label: "Light" },
|
||||
{ id: "midnight", label: "Midnight" },
|
||||
{ id: "solarized", label: "Solarized" },
|
||||
{ id: "nord", label: "Nord" },
|
||||
{ id: "dracula", label: "Dracula" },
|
||||
{ id: "monokai", label: "Monokai" },
|
||||
{ id: "github", label: "GitHub" },
|
||||
{ id: "high-contrast", label: "High Contrast" }
|
||||
]
|
||||
property string selectedTheme: appWindow.currentTheme
|
||||
property string fontSize: "medium"
|
||||
|
||||
// ── Notification preferences ─────────────────────────────────
|
||||
property bool emailNotifications: true
|
||||
property bool desktopNotifications: true
|
||||
property bool soundAlerts: false
|
||||
|
||||
// ── Connection state ─────────────────────────────────────────
|
||||
property string dbalUrl: dbal.baseUrl
|
||||
property string mediaServiceUrl: "http://localhost:9090"
|
||||
property string dbalConnectionStatus: dbal.connected ? "connected" : "disconnected"
|
||||
property string mediaConnectionStatus: "unknown"
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
function userInitials() {
|
||||
var name = appWindow.currentUser
|
||||
if (!name || name.length === 0) return "??"
|
||||
var parts = name.split(" ")
|
||||
if (parts.length >= 2)
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase()
|
||||
return name.substring(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
function saveProfile() {
|
||||
if (useLiveData) {
|
||||
dbal.update("user", appWindow.currentUser, {
|
||||
displayName: displayName,
|
||||
email: userEmail
|
||||
}, function(result, error) {
|
||||
if (!error) {
|
||||
profileSaved = true
|
||||
profileSavedTimer.restart()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
profileSaved = true
|
||||
profileSavedTimer.restart()
|
||||
console.log("[Settings] Profile saved (offline):", displayName, userEmail)
|
||||
}
|
||||
}
|
||||
|
||||
function savePreferences() {
|
||||
if (useLiveData) {
|
||||
dbal.update("user", appWindow.currentUser, {
|
||||
preferences: {
|
||||
theme: selectedTheme,
|
||||
fontSize: fontSize,
|
||||
emailNotifications: emailNotifications,
|
||||
desktopNotifications: desktopNotifications,
|
||||
soundAlerts: soundAlerts
|
||||
}
|
||||
}, function(result, error) {
|
||||
if (!error) console.log("[Settings] Preferences saved to DBAL")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function testDBALConnection() {
|
||||
dbalConnectionStatus = "testing"
|
||||
dbal.baseUrl = dbalUrl
|
||||
dbal.ping(function(success, error) {
|
||||
dbalConnectionStatus = success ? "connected" : "disconnected"
|
||||
})
|
||||
}
|
||||
|
||||
function testMediaConnection() {
|
||||
mediaConnectionStatus = "testing"
|
||||
var xhr = new XMLHttpRequest()
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||
mediaConnectionStatus = (xhr.status >= 200 && xhr.status < 300) ? "connected" : "disconnected"
|
||||
}
|
||||
}
|
||||
xhr.open("GET", mediaServiceUrl + "/health")
|
||||
xhr.send()
|
||||
}
|
||||
|
||||
function connectionStatusColor(status) {
|
||||
switch (status) {
|
||||
case "connected": return "success"
|
||||
case "disconnected": return "error"
|
||||
case "testing": return "warning"
|
||||
default: return "info"
|
||||
}
|
||||
}
|
||||
|
||||
function connectionStatusLabel(status) {
|
||||
switch (status) {
|
||||
case "connected": return "Connected"
|
||||
case "disconnected": return "Disconnected"
|
||||
case "testing": return "Testing..."
|
||||
default: return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load preferences from DBAL ───────────────────────────────
|
||||
function loadPreferences() {
|
||||
if (!useLiveData) return
|
||||
dbal.findFirst("user", { username: appWindow.currentUser }, function(result, error) {
|
||||
if (error || !result) return
|
||||
var items = result.items || []
|
||||
if (items.length === 0) return
|
||||
var user = items[0]
|
||||
if (user.displayName) displayName = user.displayName
|
||||
if (user.email) userEmail = user.email
|
||||
if (user.preferences) {
|
||||
if (user.preferences.theme) selectedTheme = user.preferences.theme
|
||||
if (user.preferences.fontSize) fontSize = user.preferences.fontSize
|
||||
if (user.preferences.emailNotifications !== undefined) emailNotifications = user.preferences.emailNotifications
|
||||
if (user.preferences.desktopNotifications !== undefined) desktopNotifications = user.preferences.desktopNotifications
|
||||
if (user.preferences.soundAlerts !== undefined) soundAlerts = user.preferences.soundAlerts
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (useLiveData) loadPreferences()
|
||||
}
|
||||
|
||||
onUseLiveDataChanged: {
|
||||
if (useLiveData) loadPreferences()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: profileSavedTimer
|
||||
interval: 3000
|
||||
onTriggered: profileSaved = false
|
||||
}
|
||||
|
||||
// ── UI ───────────────────────────────────────────────────────
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 24
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 20
|
||||
|
||||
// Page title
|
||||
CText {
|
||||
variant: "h3"
|
||||
text: "Settings"
|
||||
}
|
||||
|
||||
// ── Profile Section ─────────────────────────────────
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 16
|
||||
|
||||
CText { variant: "h4"; text: "Profile" }
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 16
|
||||
|
||||
// Avatar
|
||||
Rectangle {
|
||||
width: 64
|
||||
height: 64
|
||||
radius: 32
|
||||
color: Theme.primary
|
||||
Layout.alignment: Qt.AlignTop
|
||||
|
||||
CText {
|
||||
anchors.centerIn: parent
|
||||
text: userInitials()
|
||||
variant: "h4"
|
||||
color: "#ffffff"
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
CText {
|
||||
variant: "subtitle1"
|
||||
text: appWindow.currentUser
|
||||
font.bold: true
|
||||
}
|
||||
CText {
|
||||
variant: "body2"
|
||||
text: appWindow.currentRole + " \u00b7 Level " + appWindow.currentLevel
|
||||
opacity: 0.7
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CTextField {
|
||||
Layout.fillWidth: true
|
||||
label: "Display Name"
|
||||
placeholderText: "Enter display name"
|
||||
text: displayName
|
||||
onTextChanged: displayName = text
|
||||
}
|
||||
|
||||
CTextField {
|
||||
Layout.fillWidth: true
|
||||
label: "Email"
|
||||
placeholderText: "Enter email address"
|
||||
text: userEmail
|
||||
onTextChanged: userEmail = text
|
||||
}
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
CAlert {
|
||||
visible: profileSaved
|
||||
severity: "success"
|
||||
text: "Profile saved successfully"
|
||||
}
|
||||
|
||||
CButton {
|
||||
text: "Save Profile"
|
||||
variant: "primary"
|
||||
onClicked: saveProfile()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Appearance Section ──────────────────────────────
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 16
|
||||
|
||||
CText { variant: "h4"; text: "Appearance" }
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
// Theme selector
|
||||
CText {
|
||||
variant: "subtitle2"
|
||||
text: "Theme"
|
||||
}
|
||||
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: availableThemes
|
||||
delegate: CButton {
|
||||
text: modelData.label
|
||||
variant: selectedTheme === modelData.id ? "primary" : "default"
|
||||
size: "sm"
|
||||
onClicked: {
|
||||
selectedTheme = modelData.id
|
||||
appWindow.currentTheme = modelData.id
|
||||
if (typeof Theme.setTheme === "function") {
|
||||
Theme.setTheme(modelData.id)
|
||||
}
|
||||
savePreferences()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Font size selector
|
||||
CText {
|
||||
variant: "subtitle2"
|
||||
text: "Font Size"
|
||||
Layout.topMargin: 8
|
||||
}
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ id: "small", label: "Small" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "large", label: "Large" }
|
||||
]
|
||||
delegate: CButton {
|
||||
text: modelData.label
|
||||
variant: fontSize === modelData.id ? "primary" : "default"
|
||||
size: "sm"
|
||||
onClicked: {
|
||||
fontSize = modelData.id
|
||||
savePreferences()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notifications Section ───────────────────────────
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 16
|
||||
|
||||
CText { variant: "h4"; text: "Notifications" }
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
// Email notifications toggle
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
CText { variant: "subtitle2"; text: "Email Notifications" }
|
||||
CText { variant: "caption"; text: "Receive notification summaries via email"; opacity: 0.6 }
|
||||
}
|
||||
|
||||
Switch {
|
||||
checked: emailNotifications
|
||||
onCheckedChanged: {
|
||||
emailNotifications = checked
|
||||
savePreferences()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
// Desktop notifications toggle
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
CText { variant: "subtitle2"; text: "Desktop Notifications" }
|
||||
CText { variant: "caption"; text: "Show desktop push notifications for alerts"; opacity: 0.6 }
|
||||
}
|
||||
|
||||
Switch {
|
||||
checked: desktopNotifications
|
||||
onCheckedChanged: {
|
||||
desktopNotifications = checked
|
||||
savePreferences()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
// Sound alerts toggle
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
CText { variant: "subtitle2"; text: "Sound Alerts" }
|
||||
CText { variant: "caption"; text: "Play a sound when new notifications arrive"; opacity: 0.6 }
|
||||
}
|
||||
|
||||
Switch {
|
||||
checked: soundAlerts
|
||||
onCheckedChanged: {
|
||||
soundAlerts = checked
|
||||
savePreferences()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connection Section ──────────────────────────────
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 16
|
||||
|
||||
CText { variant: "h4"; text: "Connection" }
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
// DBAL URL
|
||||
CText { variant: "subtitle2"; text: "DBAL Server" }
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
CTextField {
|
||||
Layout.fillWidth: true
|
||||
label: "DBAL URL"
|
||||
placeholderText: "http://localhost:8080"
|
||||
text: dbalUrl
|
||||
onTextChanged: dbalUrl = text
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
|
||||
CButton {
|
||||
text: dbalConnectionStatus === "testing" ? "Testing..." : "Test Connection"
|
||||
variant: "default"
|
||||
size: "sm"
|
||||
enabled: dbalConnectionStatus !== "testing"
|
||||
onClicked: testDBALConnection()
|
||||
}
|
||||
|
||||
CStatusBadge {
|
||||
status: connectionStatusColor(dbalConnectionStatus)
|
||||
text: connectionStatusLabel(dbalConnectionStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
// Media Service URL
|
||||
CText { variant: "subtitle2"; text: "Media Service" }
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
CTextField {
|
||||
Layout.fillWidth: true
|
||||
label: "Media Service URL"
|
||||
placeholderText: "http://localhost:9090"
|
||||
text: mediaServiceUrl
|
||||
onTextChanged: mediaServiceUrl = text
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
|
||||
CButton {
|
||||
text: mediaConnectionStatus === "testing" ? "Testing..." : "Test Connection"
|
||||
variant: "default"
|
||||
size: "sm"
|
||||
enabled: mediaConnectionStatus !== "testing"
|
||||
onClicked: testMediaConnection()
|
||||
}
|
||||
|
||||
CStatusBadge {
|
||||
status: connectionStatusColor(mediaConnectionStatus)
|
||||
text: connectionStatusLabel(mediaConnectionStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── About Section ───────────────────────────────────
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 12
|
||||
|
||||
CText { variant: "h4"; text: "About" }
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ label: "Version", value: "2.1.0" },
|
||||
{ label: "Build Date", value: "2026-03-19" },
|
||||
{ label: "Qt Version", value: "6.8.x" },
|
||||
{ label: "Platform", value: Qt.platform.os },
|
||||
{ label: "DBAL Schema", value: "v1 REST API" }
|
||||
]
|
||||
|
||||
delegate: FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
CText {
|
||||
variant: "body2"
|
||||
text: modelData.label
|
||||
opacity: 0.6
|
||||
Layout.preferredWidth: 120
|
||||
}
|
||||
|
||||
CText {
|
||||
variant: "body1"
|
||||
text: modelData.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
CButton {
|
||||
text: "View Documentation"
|
||||
variant: "default"
|
||||
size: "sm"
|
||||
onClicked: Qt.openUrlExternally("https://github.com/nicholasgriffintn/metabuilder")
|
||||
}
|
||||
|
||||
CButton {
|
||||
text: "Report Issue"
|
||||
variant: "ghost"
|
||||
size: "sm"
|
||||
onClicked: Qt.openUrlExternally("https://github.com/nicholasgriffintn/metabuilder/issues")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data status footer ──────────────────────────────
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
CText {
|
||||
variant: "caption"
|
||||
text: useLiveData ? "Connected to DBAL — preferences synced" : "Offline — preferences stored locally"
|
||||
opacity: 0.4
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom spacer
|
||||
Item { Layout.preferredHeight: 20 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,41 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
Rectangle {
|
||||
id: superGodPanel
|
||||
color: "transparent"
|
||||
|
||||
// ── DBAL connection ──
|
||||
DBALProvider { id: dbal }
|
||||
|
||||
property bool dbalOnline: dbal.connected
|
||||
|
||||
property int currentTab: 0
|
||||
|
||||
// ── Tenant data ──
|
||||
// ── Mock fallback data ──
|
||||
property var mockTenants: [
|
||||
{ name: "default", owner: "admin", status: "active", homepage: "/", created: "2025-01-15" },
|
||||
{ name: "staging", owner: "devops", status: "active", homepage: "/staging", created: "2025-06-01" },
|
||||
{ name: "production", owner: "platform-admin", status: "active", homepage: "/prod", created: "2025-08-20" }
|
||||
]
|
||||
property var mockGodUsers: [
|
||||
{ username: "admin", initials: "AD", role: "supergod", level: 5, tenant: "default", status: "online" },
|
||||
{ username: "platform-admin", initials: "PA", role: "supergod", level: 5, tenant: "production", status: "online" },
|
||||
{ username: "devops", initials: "DO", role: "god", level: 4, tenant: "staging", status: "online" },
|
||||
{ username: "builder01", initials: "B1", role: "god", level: 4, tenant: "default", status: "offline" },
|
||||
{ username: "builder02", initials: "B2", role: "god", level: 4, tenant: "production", status: "away" }
|
||||
]
|
||||
property var mockDaemons: [
|
||||
{ name: "DBAL", status: "healthy", uptime: "14d 7h 32m", port: 8080 },
|
||||
{ name: "Nginx", status: "healthy", uptime: "14d 7h 30m", port: 443 },
|
||||
{ name: "PostgreSQL", status: "healthy", uptime: "14d 6h 55m", port: 5432 },
|
||||
{ name: "Redis", status: "degraded", uptime: "2d 1h 12m", port: 6379 }
|
||||
]
|
||||
property var mockSystemMetrics: ({ cpu: 34, memory: 62, disk: 47, network: 18 })
|
||||
|
||||
// ── Live data (falls back to mock) ──
|
||||
property var tenants: [
|
||||
{ name: "default", owner: "admin", status: "active", homepage: "/", created: "2025-01-15" },
|
||||
{ name: "staging", owner: "devops", status: "active", homepage: "/staging", created: "2025-06-01" },
|
||||
@@ -21,13 +48,7 @@ Rectangle {
|
||||
property string newTenantHomepage: ""
|
||||
|
||||
// ── God users data ──
|
||||
property var godUsers: [
|
||||
{ username: "admin", initials: "AD", role: "supergod", level: 5, tenant: "default", status: "online" },
|
||||
{ username: "platform-admin", initials: "PA", role: "supergod", level: 5, tenant: "production", status: "online" },
|
||||
{ username: "devops", initials: "DO", role: "god", level: 4, tenant: "staging", status: "online" },
|
||||
{ username: "builder01", initials: "B1", role: "god", level: 4, tenant: "default", status: "offline" },
|
||||
{ username: "builder02", initials: "B2", role: "god", level: 4, tenant: "production", status: "away" }
|
||||
]
|
||||
property var godUsers: mockGodUsers
|
||||
|
||||
// ── Power transfer data ──
|
||||
property bool showTransferForm: false
|
||||
@@ -45,13 +66,8 @@ Rectangle {
|
||||
]
|
||||
|
||||
// ── System data ──
|
||||
property var daemons: [
|
||||
{ name: "DBAL", status: "healthy", uptime: "14d 7h 32m", port: 8080 },
|
||||
{ name: "Nginx", status: "healthy", uptime: "14d 7h 30m", port: 443 },
|
||||
{ name: "PostgreSQL", status: "healthy", uptime: "14d 6h 55m", port: 5432 },
|
||||
{ name: "Redis", status: "degraded", uptime: "2d 1h 12m", port: 6379 }
|
||||
]
|
||||
property var systemMetrics: ({ cpu: 34, memory: 62, disk: 47, network: 18 })
|
||||
property var daemons: mockDaemons
|
||||
property var systemMetrics: mockSystemMetrics
|
||||
property bool showReseedDialog: false
|
||||
property bool showClearCacheDialog: false
|
||||
property bool showRestartDialog: false
|
||||
@@ -64,6 +80,67 @@ Rectangle {
|
||||
{ label: "System" }
|
||||
]
|
||||
|
||||
// ── DBAL data loading ──
|
||||
function loadTenants() {
|
||||
dbal.list("tenant", { take: 20 }, function(result, error) {
|
||||
if (result && result.items) {
|
||||
tenants = result.items;
|
||||
} else {
|
||||
tenants = mockTenants;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadGodUsers() {
|
||||
dbal.list("user", { take: 50 }, function(result, error) {
|
||||
if (result && result.items) {
|
||||
var gods = [];
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
var u = result.items[i];
|
||||
if (u.role === "god" || u.role === "supergod") {
|
||||
gods.push(u);
|
||||
}
|
||||
}
|
||||
godUsers = gods.length > 0 ? gods : mockGodUsers;
|
||||
} else {
|
||||
godUsers = mockGodUsers;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadSystemHealth() {
|
||||
dbal.execute("core/status", {}, function(result, error) {
|
||||
if (result) {
|
||||
if (result.daemons) daemons = result.daemons;
|
||||
if (result.metrics) systemMetrics = result.metrics;
|
||||
} else {
|
||||
daemons = mockDaemons;
|
||||
systemMetrics = mockSystemMetrics;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function executePowerTransfer(tenantId, newOwnerId) {
|
||||
dbal.update("tenant", tenantId, { owner: newOwnerId }, function(result, error) {
|
||||
if (result) {
|
||||
console.log("Power transfer completed for tenant:", tenantId);
|
||||
loadTenants();
|
||||
} else {
|
||||
console.warn("Power transfer failed:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
dbal.ping(function(success) {
|
||||
if (success) {
|
||||
loadTenants();
|
||||
loadGodUsers();
|
||||
loadSystemHealth();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
|
||||
@@ -2,11 +2,18 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
color: Theme.background
|
||||
|
||||
// ── DBAL ──────────────────────────────────────────────────────────
|
||||
|
||||
DBALProvider { id: dbal }
|
||||
|
||||
property bool useLiveData: dbal.connected
|
||||
|
||||
// ── Local state ──────────────────────────────────────────────────
|
||||
property var users: [
|
||||
{ uid: 1, username: "demo", email: "demo@metabuilder.dev", role: "user", level: 2, status: "active", created: "2025-11-02" },
|
||||
@@ -35,6 +42,41 @@ Rectangle {
|
||||
|
||||
readonly property var roles: ["user", "admin", "god", "supergod"]
|
||||
|
||||
property var mockUsers: JSON.parse(JSON.stringify(users))
|
||||
|
||||
// ── DBAL Integration ─────────────────────────────────────────────
|
||||
|
||||
function loadUsers() {
|
||||
dbal.list("user", { take: 50 }, function(result, error) {
|
||||
if (!error && result && result.items && result.items.length > 0) {
|
||||
var parsed = []
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
var u = result.items[i]
|
||||
parsed.push({
|
||||
uid: u.id || u.uid || (i + 1),
|
||||
username: u.username || "",
|
||||
email: u.email || "",
|
||||
role: u.role || "user",
|
||||
level: levelForRole(u.role || "user"),
|
||||
status: u.status || "active",
|
||||
created: u.createdAt ? u.createdAt.slice(0, 10) : (u.created || "")
|
||||
})
|
||||
}
|
||||
users = parsed
|
||||
nextUid = parsed.length + 1
|
||||
}
|
||||
// On error or empty result, keep existing mock users as fallback
|
||||
})
|
||||
}
|
||||
|
||||
onUseLiveDataChanged: {
|
||||
if (useLiveData) loadUsers()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
function initials(name) {
|
||||
var parts = name.split("")
|
||||
@@ -105,45 +147,110 @@ Rectangle {
|
||||
|
||||
function createUser() {
|
||||
if (formUsername === "" || formEmail === "") return
|
||||
var newUser = {
|
||||
uid: nextUid,
|
||||
var userData = {
|
||||
username: formUsername,
|
||||
email: formEmail,
|
||||
role: formRole,
|
||||
level: levelForRole(formRole),
|
||||
status: formActive ? "active" : "inactive",
|
||||
status: formActive ? "active" : "inactive"
|
||||
}
|
||||
|
||||
if (useLiveData) {
|
||||
dbal.create("user", userData, function(result, error) {
|
||||
if (!error) {
|
||||
loadUsers()
|
||||
} else {
|
||||
createUserLocally(userData)
|
||||
}
|
||||
createDialogOpen = false
|
||||
clearForm()
|
||||
})
|
||||
} else {
|
||||
createUserLocally(userData)
|
||||
createDialogOpen = false
|
||||
clearForm()
|
||||
}
|
||||
}
|
||||
|
||||
function createUserLocally(userData) {
|
||||
var newUser = {
|
||||
uid: nextUid,
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
role: userData.role,
|
||||
level: levelForRole(userData.role),
|
||||
status: userData.status,
|
||||
created: new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
var copy = users.slice()
|
||||
copy.push(newUser)
|
||||
users = copy
|
||||
nextUid++
|
||||
createDialogOpen = false
|
||||
clearForm()
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
if (editIndex < 0) return
|
||||
var copy = users.slice()
|
||||
copy[editIndex] = Object.assign({}, copy[editIndex], {
|
||||
var userData = {
|
||||
username: formUsername,
|
||||
email: formEmail,
|
||||
role: formRole,
|
||||
level: levelForRole(formRole),
|
||||
status: formActive ? "active" : "inactive"
|
||||
}
|
||||
|
||||
if (useLiveData) {
|
||||
var userId = users[editIndex].uid
|
||||
dbal.update("user", userId, userData, function(result, error) {
|
||||
if (!error) {
|
||||
loadUsers()
|
||||
} else {
|
||||
saveEditLocally(userData)
|
||||
}
|
||||
editDialogOpen = false
|
||||
clearForm()
|
||||
})
|
||||
} else {
|
||||
saveEditLocally(userData)
|
||||
editDialogOpen = false
|
||||
clearForm()
|
||||
}
|
||||
}
|
||||
|
||||
function saveEditLocally(userData) {
|
||||
var copy = users.slice()
|
||||
copy[editIndex] = Object.assign({}, copy[editIndex], {
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
role: userData.role,
|
||||
level: levelForRole(userData.role),
|
||||
status: userData.status
|
||||
})
|
||||
users = copy
|
||||
editDialogOpen = false
|
||||
clearForm()
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (deleteIndex < 0) return
|
||||
|
||||
if (useLiveData) {
|
||||
var userId = users[deleteIndex].uid
|
||||
dbal.remove("user", userId, function(result, error) {
|
||||
if (!error) {
|
||||
loadUsers()
|
||||
} else {
|
||||
confirmDeleteLocally()
|
||||
}
|
||||
deleteDialogOpen = false
|
||||
deleteIndex = -1
|
||||
})
|
||||
} else {
|
||||
confirmDeleteLocally()
|
||||
deleteDialogOpen = false
|
||||
deleteIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteLocally() {
|
||||
var copy = users.slice()
|
||||
copy.splice(deleteIndex, 1)
|
||||
users = copy
|
||||
deleteDialogOpen = false
|
||||
deleteIndex = -1
|
||||
}
|
||||
|
||||
// ── Main layout ──────────────────────────────────────────────────
|
||||
|
||||
@@ -2,11 +2,93 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
import "qmllib/dbal"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
color: "transparent"
|
||||
|
||||
// ── DBAL connection ──────────────────────────────────────────
|
||||
DBALProvider { id: dbal }
|
||||
|
||||
property bool useLiveData: dbal.connected
|
||||
|
||||
// ── Mock workflow data kept as fallback ───────────────────────
|
||||
property var mockWorkflows: JSON.parse(JSON.stringify(workflows))
|
||||
|
||||
function loadWorkflows() {
|
||||
dbal.list("workflow", { take: 50 }, function(result, error) {
|
||||
if (!error && result && result.items && result.items.length > 0) {
|
||||
var parsed = []
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
var w = result.items[i]
|
||||
parsed.push({
|
||||
id: w.id || "",
|
||||
name: w.name || "unnamed_workflow",
|
||||
enabled: w.enabled !== undefined ? w.enabled : true,
|
||||
nodes: w.nodes || []
|
||||
})
|
||||
}
|
||||
workflows = parsed
|
||||
if (selectedWorkflowIndex >= parsed.length)
|
||||
selectedWorkflowIndex = 0
|
||||
}
|
||||
// On error or empty result, keep existing mock workflows as fallback
|
||||
})
|
||||
}
|
||||
|
||||
function saveWorkflow(wf, callback) {
|
||||
var workflowData = { name: wf.name, enabled: wf.enabled, nodes: wf.nodes }
|
||||
if (useLiveData) {
|
||||
if (wf.id) {
|
||||
dbal.update("workflow", wf.id, workflowData, function(result, error) {
|
||||
if (!error) loadWorkflows()
|
||||
if (callback) callback(result, error)
|
||||
})
|
||||
} else {
|
||||
dbal.create("workflow", workflowData, function(result, error) {
|
||||
if (!error) loadWorkflows()
|
||||
if (callback) callback(result, error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deleteWorkflow(index) {
|
||||
var wf = workflows[index]
|
||||
if (useLiveData && wf.id) {
|
||||
dbal.remove("workflow", wf.id, function(result, error) {
|
||||
if (!error) {
|
||||
loadWorkflows()
|
||||
if (selectedWorkflowIndex >= workflows.length - 1)
|
||||
selectedWorkflowIndex = Math.max(0, workflows.length - 2)
|
||||
selectedNodeIndex = -1
|
||||
} else {
|
||||
deleteWorkflowLocally(index)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
deleteWorkflowLocally(index)
|
||||
}
|
||||
}
|
||||
|
||||
function deleteWorkflowLocally(index) {
|
||||
var copy = workflows.slice()
|
||||
copy.splice(index, 1)
|
||||
workflows = copy
|
||||
if (selectedWorkflowIndex >= copy.length)
|
||||
selectedWorkflowIndex = Math.max(0, copy.length - 1)
|
||||
selectedNodeIndex = -1
|
||||
}
|
||||
|
||||
onUseLiveDataChanged: {
|
||||
if (useLiveData) loadWorkflows()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
loadWorkflows()
|
||||
}
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
property int selectedWorkflowIndex: 0
|
||||
property int selectedNodeIndex: -1
|
||||
@@ -170,6 +252,7 @@ Rectangle {
|
||||
config: "// configure " + addNodeName
|
||||
})
|
||||
workflows = workflows // trigger re-bind
|
||||
if (useLiveData) saveWorkflow(wf)
|
||||
addNodeName = ""
|
||||
addNodeType = "Action"
|
||||
addNodeDialog.close()
|
||||
@@ -236,6 +319,11 @@ Rectangle {
|
||||
accent: currentWorkflow.enabled
|
||||
}
|
||||
|
||||
CBadge {
|
||||
text: useLiveData ? "Live" : "Mock"
|
||||
color: useLiveData ? Theme.success : Theme.warning
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
CSwitch {
|
||||
@@ -243,6 +331,7 @@ Rectangle {
|
||||
onCheckedChanged: {
|
||||
workflows[selectedWorkflowIndex].enabled = checked
|
||||
workflows = workflows
|
||||
if (useLiveData) saveWorkflow(workflows[selectedWorkflowIndex])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,11 +346,26 @@ Rectangle {
|
||||
{ type: "Trigger", name: "StartEvent", config: "event: custom.event" }
|
||||
]
|
||||
}
|
||||
var wfs = workflows.slice()
|
||||
wfs.push(newWf)
|
||||
workflows = wfs
|
||||
selectedWorkflowIndex = wfs.length - 1
|
||||
selectedNodeIndex = -1
|
||||
if (useLiveData) {
|
||||
dbal.create("workflow", newWf, function(result, error) {
|
||||
if (!error) {
|
||||
loadWorkflows()
|
||||
} else {
|
||||
// Fallback to local
|
||||
var wfs = workflows.slice()
|
||||
wfs.push(newWf)
|
||||
workflows = wfs
|
||||
selectedWorkflowIndex = wfs.length - 1
|
||||
selectedNodeIndex = -1
|
||||
}
|
||||
})
|
||||
} else {
|
||||
var wfs = workflows.slice()
|
||||
wfs.push(newWf)
|
||||
workflows = wfs
|
||||
selectedWorkflowIndex = wfs.length - 1
|
||||
selectedNodeIndex = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,6 +567,7 @@ Rectangle {
|
||||
wf.nodes.splice(index, 1)
|
||||
workflows = workflows
|
||||
selectedNodeIndex = -1
|
||||
if (useLiveData) saveWorkflow(wf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"project": {
|
||||
"name": "dbal_qml",
|
||||
"version": "0.1",
|
||||
"executable": "dbal-qml"
|
||||
},
|
||||
"qt": {
|
||||
"version": "6.7.3",
|
||||
"components": ["Core", "Gui", "Quick", "Qml", "Network"]
|
||||
},
|
||||
"cpp": {
|
||||
"standard": 20
|
||||
},
|
||||
"qml": {
|
||||
"uri": "DBALObservatory",
|
||||
"version": "1.0"
|
||||
},
|
||||
"features": {
|
||||
"libopenmpt": false,
|
||||
"qt_multimedia": false
|
||||
},
|
||||
"compile_definitions": {
|
||||
"SRCDIR": "${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ qt/*:qtdeclarative=True
|
||||
qt/*:qtshadertools=True
|
||||
|
||||
[tool_requires]
|
||||
cmake/3.30.0
|
||||
ninja/1.12.1
|
||||
|
||||
[layout]
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Qt6 Dev Stack — lightweight DBAL + Media for local Qt6 development
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.dev.yml up -d
|
||||
# docker compose -f docker-compose.dev.yml --profile media up -d
|
||||
#
|
||||
# DBAL runs with SQLite in-memory (no Postgres/Redis/ES needed)
|
||||
# Media daemon is optional (--profile media)
|
||||
|
||||
services:
|
||||
# Copy schema/seed/template files into volumes
|
||||
dbal-init:
|
||||
image: alpine:3.19
|
||||
container_name: qt6-dbal-init
|
||||
command: >
|
||||
sh -c "
|
||||
cp -r /src/entities/* /schemas/ 2>/dev/null || true;
|
||||
cp -r /src/templates/* /templates/ 2>/dev/null || true;
|
||||
cp -r /src/seeds/* /seeds/ 2>/dev/null || true;
|
||||
echo 'Init complete: schemas, templates, seeds copied';
|
||||
"
|
||||
working_dir: /src
|
||||
volumes:
|
||||
- ../../dbal/shared/api/schema/entities:/src/entities:ro
|
||||
- ../../dbal/production/templates/sql:/src/templates:ro
|
||||
- ../../dbal/shared/seeds/database:/src/seeds:ro
|
||||
- dbal-schemas:/schemas
|
||||
- dbal-templates:/templates
|
||||
- dbal-seeds:/seeds
|
||||
|
||||
dbal:
|
||||
build:
|
||||
context: ../../dbal
|
||||
dockerfile: production/build-config/Dockerfile
|
||||
args:
|
||||
BUILD_TYPE: Release
|
||||
container_name: qt6-dbal
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
DBAL_ADAPTER: sqlite
|
||||
DATABASE_URL: ":memory:"
|
||||
DBAL_SCHEMA_DIR: /app/schemas/entities
|
||||
DBAL_TEMPLATE_DIR: /app/templates/sql
|
||||
DBAL_SEED_DIR: /app/seeds/database
|
||||
DBAL_SEED_ON_STARTUP: "true"
|
||||
DBAL_BIND_ADDRESS: 0.0.0.0
|
||||
DBAL_PORT: 8080
|
||||
DBAL_MODE: development
|
||||
DBAL_DAEMON: "true"
|
||||
DBAL_LOG_LEVEL: debug
|
||||
DBAL_AUTO_CREATE_TABLES: "true"
|
||||
DBAL_ENABLE_HEALTH_CHECK: "true"
|
||||
DBAL_CORS_ORIGIN: "*"
|
||||
DBAL_ADMIN_TOKEN: dev-token
|
||||
JWT_SECRET_KEY: dev-secret
|
||||
volumes:
|
||||
- dbal-schemas:/app/schemas/entities:ro
|
||||
- dbal-templates:/app/templates/sql:ro
|
||||
- dbal-seeds:/app/seeds/database:ro
|
||||
depends_on:
|
||||
dbal-init:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# Optional: Media daemon (--profile media)
|
||||
media-daemon:
|
||||
build:
|
||||
context: ../../services/media_daemon
|
||||
dockerfile: Dockerfile
|
||||
container_name: qt6-media-daemon
|
||||
restart: unless-stopped
|
||||
profiles: ["media"]
|
||||
ports:
|
||||
- "8090:8090"
|
||||
environment:
|
||||
MEDIA_PORT: 8090
|
||||
MEDIA_BIND_ADDRESS: 0.0.0.0
|
||||
MEDIA_WORKERS: 2
|
||||
DBAL_URL: http://dbal:8080
|
||||
MEDIA_RADIO_ENABLED: "false"
|
||||
MEDIA_TV_ENABLED: "false"
|
||||
depends_on:
|
||||
dbal:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:8090/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
dbal-schemas:
|
||||
dbal-templates:
|
||||
dbal-seeds:
|
||||
Executable
+370
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Auto-generate CMakeLists.txt from project structure and cmake_config.json.
|
||||
|
||||
Scans QML files, C++ sources, SVG/audio assets, and package metadata to produce
|
||||
a complete CMakeLists.txt for the Qt6 DBAL Observatory frontend.
|
||||
|
||||
Usage:
|
||||
python3 generate_cmake.py # Write CMakeLists.txt
|
||||
python3 generate_cmake.py --dry-run # Print without writing
|
||||
python3 generate_cmake.py --output build.cmake # Custom output path
|
||||
python3 generate_cmake.py --config my.json # Custom config file
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_config(config_path: str) -> dict:
|
||||
"""Load and validate cmake_config.json."""
|
||||
path = Path(config_path)
|
||||
if not path.exists():
|
||||
print(f"Error: config file not found: {config_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def find_root_qml_files(root_dir: Path) -> list[str]:
|
||||
"""Find all *.qml files in the project root directory (not subdirectories)."""
|
||||
files = sorted(root_dir.glob("*.qml"))
|
||||
return [f.name for f in files]
|
||||
|
||||
|
||||
def find_qmllib_files(root_dir: Path) -> dict[str, list[str]]:
|
||||
"""Find all *.qml files and qmldir files in qmllib/ subdirectories.
|
||||
|
||||
Returns a dict mapping relative paths (e.g. 'qmllib/dbal/DBALProvider.qml')
|
||||
grouped by subdirectory.
|
||||
"""
|
||||
qmllib_dir = root_dir / "qmllib"
|
||||
result = {"qml": [], "resources": []}
|
||||
if not qmllib_dir.exists():
|
||||
return result
|
||||
for qml_file in sorted(qmllib_dir.rglob("*.qml")):
|
||||
result["qml"].append(str(qml_file.relative_to(root_dir)))
|
||||
for qmldir_file in sorted(qmllib_dir.rglob("qmldir")):
|
||||
result["resources"].append(str(qmldir_file.relative_to(root_dir)))
|
||||
return result
|
||||
|
||||
|
||||
def find_package_qml_files(root_dir: Path) -> list[str]:
|
||||
"""Find all *.qml files in packages/ subdirectories."""
|
||||
packages_dir = root_dir / "packages"
|
||||
if not packages_dir.exists():
|
||||
return []
|
||||
files = sorted(packages_dir.rglob("*.qml"))
|
||||
return [str(f.relative_to(root_dir)) for f in files]
|
||||
|
||||
|
||||
def load_package_metadata(root_dir: Path) -> list[dict]:
|
||||
"""Read metadata.json from each packages/ subdirectory."""
|
||||
packages_dir = root_dir / "packages"
|
||||
if not packages_dir.exists():
|
||||
return []
|
||||
metadata = []
|
||||
for meta_file in sorted(packages_dir.rglob("metadata.json")):
|
||||
with open(meta_file) as f:
|
||||
data = json.load(f)
|
||||
data["_dir"] = str(meta_file.parent.relative_to(root_dir))
|
||||
metadata.append(data)
|
||||
return metadata
|
||||
|
||||
|
||||
def find_svg_assets(root_dir: Path) -> list[str]:
|
||||
"""Glob SVG assets from assets/svg/."""
|
||||
svg_dir = root_dir / "assets" / "svg"
|
||||
if not svg_dir.exists():
|
||||
return []
|
||||
files = sorted(svg_dir.glob("*.svg"))
|
||||
return [str(f.relative_to(root_dir)) for f in files]
|
||||
|
||||
|
||||
def find_audio_assets(root_dir: Path) -> list[str]:
|
||||
"""Glob audio assets from assets/audio/."""
|
||||
audio_dir = root_dir / "assets" / "audio"
|
||||
if not audio_dir.exists():
|
||||
return []
|
||||
files = sorted(audio_dir.rglob("*"))
|
||||
return [str(f.relative_to(root_dir)) for f in files if f.is_file()]
|
||||
|
||||
|
||||
def find_cpp_sources(root_dir: Path) -> dict[str, list[str]]:
|
||||
"""Find all *.cpp and *.h files in src/."""
|
||||
src_dir = root_dir / "src"
|
||||
result = {"cpp": [], "h": []}
|
||||
if not src_dir.exists():
|
||||
return result
|
||||
result["cpp"] = sorted(
|
||||
str(f.relative_to(root_dir)) for f in src_dir.rglob("*.cpp")
|
||||
)
|
||||
result["h"] = sorted(
|
||||
str(f.relative_to(root_dir)) for f in src_dir.rglob("*.h")
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def indent_list(items: list[str], spaces: int = 8) -> str:
|
||||
"""Format a list of file paths as indented CMake entries."""
|
||||
prefix = " " * spaces
|
||||
return "\n".join(f"{prefix}{item}" for item in items)
|
||||
|
||||
|
||||
def generate_cmake(config: dict, root_dir: Path) -> str:
|
||||
"""Generate the full CMakeLists.txt content from config and discovered files."""
|
||||
proj = config["project"]
|
||||
qt = config["qt"]
|
||||
cpp = config["cpp"]
|
||||
qml = config["qml"]
|
||||
features = config.get("features", {})
|
||||
compile_defs = config.get("compile_definitions", {})
|
||||
|
||||
# Discover files
|
||||
root_qml = find_root_qml_files(root_dir)
|
||||
qmllib = find_qmllib_files(root_dir)
|
||||
package_qml = find_package_qml_files(root_dir)
|
||||
cpp_sources = find_cpp_sources(root_dir)
|
||||
svg_assets = find_svg_assets(root_dir)
|
||||
audio_assets = find_audio_assets(root_dir)
|
||||
packages_meta = load_package_metadata(root_dir)
|
||||
|
||||
# Build Qt components string
|
||||
qt_components = " ".join(qt["components"])
|
||||
|
||||
# Conditional components from features
|
||||
extra_components = []
|
||||
if features.get("qt_multimedia"):
|
||||
extra_components.append("Multimedia")
|
||||
|
||||
all_components = qt["components"] + extra_components
|
||||
qt_components_str = " ".join(all_components)
|
||||
|
||||
# Build source files list (main.cpp + src/*.cpp)
|
||||
source_files = ["main.cpp"]
|
||||
source_files.extend(cpp_sources["cpp"])
|
||||
|
||||
# Build QML files list: root QML + qmllib QML + package QML
|
||||
all_qml_files = root_qml + qmllib["qml"] + package_qml
|
||||
|
||||
# Build RESOURCES list: audio + qmllib resources (qmldir files)
|
||||
resource_files = audio_assets + qmllib["resources"]
|
||||
|
||||
# Build compile definitions
|
||||
defs_lines = []
|
||||
for key, value in compile_defs.items():
|
||||
defs_lines.append(f'target_compile_definitions({proj["executable"]} PRIVATE {key}="{value}")')
|
||||
|
||||
# Build link libraries
|
||||
link_libs = " ".join(f"Qt6::{c}" for c in all_components)
|
||||
|
||||
# Optional feature blocks
|
||||
feature_blocks = []
|
||||
if features.get("libopenmpt"):
|
||||
feature_blocks.append(f"""
|
||||
# libopenmpt support
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(OPENMPT REQUIRED libopenmpt)
|
||||
target_include_directories({proj["executable"]} PRIVATE ${{OPENMPT_INCLUDE_DIRS}})
|
||||
target_link_libraries({proj["executable"]} PRIVATE ${{OPENMPT_LIBRARIES}})""")
|
||||
|
||||
# Package metadata comment block
|
||||
pkg_comment_lines = []
|
||||
if packages_meta:
|
||||
pkg_comment_lines.append("# Discovered packages:")
|
||||
for meta in packages_meta:
|
||||
pkg_comment_lines.append(
|
||||
f'# {meta.get("packageId", "unknown"):20s} '
|
||||
f'v{meta.get("version", "?")} - {meta.get("name", "")}'
|
||||
)
|
||||
|
||||
# Assemble the CMakeLists.txt
|
||||
lines = []
|
||||
lines.append("# AUTO-GENERATED by generate_cmake.py — do not edit manually")
|
||||
lines.append(f"# Generated from cmake_config.json | {len(all_qml_files)} QML files, "
|
||||
f"{len(source_files)} C++ sources, {len(svg_assets)} SVGs, "
|
||||
f"{len(audio_assets)} audio assets")
|
||||
if pkg_comment_lines:
|
||||
lines.append("#")
|
||||
lines.extend(pkg_comment_lines)
|
||||
lines.append("")
|
||||
lines.append("cmake_minimum_required(VERSION 3.27)")
|
||||
lines.append(f'project({proj["name"]} VERSION {proj["version"]} LANGUAGES CXX)')
|
||||
lines.append("")
|
||||
lines.append(f"set(CMAKE_CXX_STANDARD {cpp['standard']})")
|
||||
lines.append("set(CMAKE_CXX_STANDARD_REQUIRED ON)")
|
||||
lines.append("set(CMAKE_EXPORT_COMPILE_COMMANDS ON)")
|
||||
lines.append("set(CMAKE_AUTOMOC ON)")
|
||||
lines.append("")
|
||||
lines.append("# MSVC: Qt requires correct __cplusplus macro value")
|
||||
lines.append("if(MSVC)")
|
||||
lines.append(" add_compile_options(/Zc:__cplusplus)")
|
||||
lines.append("endif()")
|
||||
lines.append("")
|
||||
lines.append("include(${CMAKE_BINARY_DIR}/conan_toolchain.cmake OPTIONAL)")
|
||||
lines.append("")
|
||||
lines.append(f"find_package(Qt6 COMPONENTS {qt_components_str} REQUIRED)")
|
||||
lines.append("qt_policy(SET QTP0001 NEW)")
|
||||
lines.append("")
|
||||
|
||||
# qt_add_executable
|
||||
lines.append(f"qt_add_executable({proj['executable']}")
|
||||
for src in source_files:
|
||||
lines.append(f" {src}")
|
||||
lines.append(")")
|
||||
lines.append("")
|
||||
|
||||
# Compile definitions
|
||||
for d in defs_lines:
|
||||
lines.append(d)
|
||||
if defs_lines:
|
||||
lines.append("")
|
||||
|
||||
# qt_add_qml_module
|
||||
lines.append(f"qt_add_qml_module({proj['executable']}")
|
||||
lines.append(f" URI {qml['uri']}")
|
||||
lines.append(f" VERSION {qml['version']}")
|
||||
lines.append(" QML_FILES")
|
||||
for qf in all_qml_files:
|
||||
lines.append(f" {qf}")
|
||||
if resource_files:
|
||||
lines.append(" RESOURCES")
|
||||
for rf in resource_files:
|
||||
lines.append(f" {rf}")
|
||||
lines.append(")")
|
||||
lines.append("")
|
||||
|
||||
# SVG assets via file(GLOB) + qt_add_resources
|
||||
if svg_assets:
|
||||
lines.append("# SVG assets")
|
||||
lines.append(f"file(GLOB SVG_ASSETS RELATIVE ${{CMAKE_CURRENT_SOURCE_DIR}} assets/svg/*.svg)")
|
||||
lines.append(f'qt_add_resources({proj["executable"]} "svg_assets"')
|
||||
lines.append(' PREFIX "/"')
|
||||
lines.append(" FILES ${SVG_ASSETS}")
|
||||
lines.append(")")
|
||||
lines.append("")
|
||||
|
||||
# Link libraries
|
||||
lines.append(f"target_link_libraries({proj['executable']} PRIVATE")
|
||||
lines.append(f" {link_libs}")
|
||||
lines.append(")")
|
||||
lines.append("")
|
||||
|
||||
# Feature blocks
|
||||
for block in feature_blocks:
|
||||
lines.append(block)
|
||||
lines.append("")
|
||||
|
||||
# MSVC include path fix
|
||||
lines.append("# Conan Qt recipe: propagate CMAKE_INCLUDE_PATH entries for MSVC")
|
||||
lines.append("foreach(_inc ${CMAKE_INCLUDE_PATH})")
|
||||
lines.append(f' target_include_directories({proj["executable"]} PRIVATE "${{_inc}}")')
|
||||
lines.append("endforeach()")
|
||||
lines.append("")
|
||||
|
||||
# Finalize
|
||||
lines.append(f"qt_finalize_executable({proj['executable']})")
|
||||
lines.append("")
|
||||
|
||||
# Ninja warning
|
||||
lines.append('if(NOT "${CMAKE_GENERATOR}" STREQUAL "Ninja")')
|
||||
lines.append(" message(")
|
||||
lines.append(" STATUS")
|
||||
lines.append(f' "{proj["executable"]} is designed for Ninja; configure with `cmake -G Ninja` so the Conan Ninja toolchain is used."')
|
||||
lines.append(" )")
|
||||
lines.append("endif()")
|
||||
lines.append("")
|
||||
|
||||
# Install
|
||||
lines.append(f"install(TARGETS {proj['executable']}")
|
||||
lines.append(" RUNTIME DESTINATION bin")
|
||||
lines.append(")")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Auto-generate CMakeLists.txt from project structure and cmake_config.json.",
|
||||
epilog="Examples:\n"
|
||||
" python3 generate_cmake.py # Write CMakeLists.txt\n"
|
||||
" python3 generate_cmake.py --dry-run # Print without writing\n"
|
||||
" python3 generate_cmake.py --output build.cmake # Custom output\n"
|
||||
" python3 generate_cmake.py --config my.json # Custom config\n",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default="cmake_config.json",
|
||||
help="Path to cmake_config.json (default: cmake_config.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default="CMakeLists.txt",
|
||||
help="Output path for generated CMakeLists.txt (default: CMakeLists.txt)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print generated CMakeLists.txt to stdout without writing to disk",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
default=None,
|
||||
help="Project root directory (default: directory containing this script)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine root directory
|
||||
if args.root:
|
||||
root_dir = Path(args.root).resolve()
|
||||
else:
|
||||
root_dir = Path(__file__).parent.resolve()
|
||||
|
||||
# Resolve config path relative to root if not absolute
|
||||
config_path = Path(args.config)
|
||||
if not config_path.is_absolute():
|
||||
config_path = root_dir / config_path
|
||||
|
||||
config = load_config(str(config_path))
|
||||
content = generate_cmake(config, root_dir)
|
||||
|
||||
if args.dry_run:
|
||||
print(content)
|
||||
return
|
||||
|
||||
# Resolve output path relative to root if not absolute
|
||||
output_path = Path(args.output)
|
||||
if not output_path.is_absolute():
|
||||
output_path = root_dir / output_path
|
||||
|
||||
with open(output_path, "w") as f:
|
||||
f.write(content)
|
||||
|
||||
# Summary
|
||||
root_qml = find_root_qml_files(root_dir)
|
||||
qmllib = find_qmllib_files(root_dir)
|
||||
package_qml = find_package_qml_files(root_dir)
|
||||
cpp_sources = find_cpp_sources(root_dir)
|
||||
svg_assets = find_svg_assets(root_dir)
|
||||
audio_assets = find_audio_assets(root_dir)
|
||||
packages_meta = load_package_metadata(root_dir)
|
||||
|
||||
total_qml = len(root_qml) + len(qmllib["qml"]) + len(package_qml)
|
||||
total_cpp = len(cpp_sources["cpp"]) + 1 # +1 for main.cpp
|
||||
|
||||
print(f"Generated {output_path}")
|
||||
print(f" QML files: {total_qml} ({len(root_qml)} root, {len(qmllib['qml'])} qmllib, {len(package_qml)} packages)")
|
||||
print(f" C++ sources: {total_cpp} ({len(cpp_sources['cpp'])} in src/ + main.cpp)")
|
||||
print(f" C++ headers: {len(cpp_sources['h'])}")
|
||||
print(f" SVG assets: {len(svg_assets)}")
|
||||
print(f" Audio assets: {len(audio_assets)}")
|
||||
print(f" Packages: {len(packages_meta)} with metadata.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
#include "src/PackageRegistry.h"
|
||||
#include "src/ModPlayer.h"
|
||||
#include "src/DBALClient.h"
|
||||
#include "src/PackageLoader.h"
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
QGuiApplication app(argc, argv);
|
||||
@@ -29,9 +31,16 @@ int main(int argc, char *argv[]) {
|
||||
|
||||
PackageRegistry registry;
|
||||
ModPlayer modPlayer;
|
||||
DBALClient dbalClient;
|
||||
PackageLoader packageLoader;
|
||||
registry.loadPackage("frontpage");
|
||||
packageLoader.setPackagesDir(QDir(QStringLiteral(SRCDIR) + QStringLiteral("/packages")).absolutePath());
|
||||
packageLoader.scan();
|
||||
packageLoader.setWatching(true);
|
||||
engine.rootContext()->setContextProperty(QStringLiteral("PackageRegistry"), ®istry);
|
||||
engine.rootContext()->setContextProperty(QStringLiteral("ModPlayer"), &modPlayer);
|
||||
engine.rootContext()->setContextProperty(QStringLiteral("DBALClient"), &dbalClient);
|
||||
engine.rootContext()->setContextProperty(QStringLiteral("PackageLoader"), &packageLoader);
|
||||
|
||||
const QUrl url(QStringLiteral("qrc:/DBALObservatory/App.qml"));
|
||||
QObject::connect(&engine, &QQmlApplicationEngine::objectCreated,
|
||||
|
||||
@@ -1,50 +1,210 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
|
||||
CPaper {
|
||||
id: packageCard
|
||||
width: 460
|
||||
height: 320
|
||||
property string title: "Connection Hub"
|
||||
property string subtitle: "v1.0.0"
|
||||
property var dependenciesList: ["profile_page", "gallery"]
|
||||
Rectangle {
|
||||
id: root
|
||||
color: "transparent"
|
||||
|
||||
ColumnLayout {
|
||||
property var services: [
|
||||
{ name: "GitHub", icon: "GH", connected: true, health: "green", lastConnected: "2026-03-19 08:12" },
|
||||
{ name: "Slack", icon: "SL", connected: true, health: "green", lastConnected: "2026-03-19 07:45" },
|
||||
{ name: "SMTP", icon: "SM", connected: true, health: "yellow", lastConnected: "2026-03-18 23:00" },
|
||||
{ name: "S3 Storage", icon: "S3", connected: false, health: "red", lastConnected: "2026-03-15 14:30" },
|
||||
{ name: "Database", icon: "DB", connected: true, health: "green", lastConnected: "2026-03-19 08:14" }
|
||||
]
|
||||
|
||||
property bool testingAll: false
|
||||
|
||||
function healthColor(h) {
|
||||
if (h === "green") return "#2ecc71"
|
||||
if (h === "yellow") return "#f39c12"
|
||||
return "#e74c3c"
|
||||
}
|
||||
|
||||
function healthLabel(h) {
|
||||
if (h === "green") return "Healthy"
|
||||
if (h === "yellow") return "Degraded"
|
||||
return "Offline"
|
||||
}
|
||||
|
||||
function toggleConnection(index) {
|
||||
var s = services.slice()
|
||||
s[index] = Object.assign({}, s[index])
|
||||
s[index].connected = !s[index].connected
|
||||
if (!s[index].connected) {
|
||||
s[index].health = "red"
|
||||
} else {
|
||||
s[index].health = "green"
|
||||
s[index].lastConnected = "2026-03-19 08:20"
|
||||
}
|
||||
services = s
|
||||
}
|
||||
|
||||
function connectedCount() {
|
||||
var c = 0
|
||||
for (var i = 0; i < services.length; i++)
|
||||
if (services[i].connected) c++
|
||||
return c
|
||||
}
|
||||
|
||||
function testAll() {
|
||||
testingAll = true
|
||||
testTimer.start()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: testTimer
|
||||
interval: 1500
|
||||
onTriggered: root.testingAll = false
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 18
|
||||
spacing: 12
|
||||
anchors.margins: 20
|
||||
clip: true
|
||||
|
||||
RowLayout {
|
||||
spacing: 12
|
||||
CAvatar { initials: title.left(2).toUpper(); backgroundColor: Theme.primary }
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
CText { variant: "h3"; text: title }
|
||||
CText { variant: "body1"; text: subtitle }
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 16
|
||||
|
||||
// ── Header ──
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 12
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
CText { variant: "h2"; text: "Connection Hub" }
|
||||
CStatusBadge {
|
||||
status: connectedCount() === services.length ? "success" : "warning"
|
||||
text: connectedCount() + "/" + services.length + " Connected"
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
CButton {
|
||||
text: root.testingAll ? "Testing..." : "Test All"
|
||||
variant: "ghost"
|
||||
enabled: !root.testingAll
|
||||
onClicked: root.testAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
CBadge { text: dependenciesList.length ? "Dependency package" : "Standalone"; accent: dependenciesList.length > 0 }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Social graph with events, groups, and shared media."
|
||||
font.pixelSize: 14
|
||||
color: Theme.text
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
// ── Service list ──
|
||||
Repeater {
|
||||
model: root.services
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
CChip { text: "Adaptive layout" }
|
||||
CChip { text: "Realtime telemetry" }
|
||||
CChip { text: "Community moderation" }
|
||||
}
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
CButton { text: "Install" }
|
||||
CButton { text: "Dependency graph"; variant: "ghost" }
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 16
|
||||
|
||||
// Health indicator dot
|
||||
Rectangle {
|
||||
width: 14
|
||||
height: 14
|
||||
radius: 7
|
||||
color: healthColor(modelData.health)
|
||||
|
||||
SequentialAnimation on opacity {
|
||||
running: root.testingAll
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation { to: 0.3; duration: 400 }
|
||||
NumberAnimation { to: 1.0; duration: 400 }
|
||||
}
|
||||
}
|
||||
|
||||
// Service icon
|
||||
CAvatar {
|
||||
initials: modelData.icon
|
||||
backgroundColor: modelData.connected ? Theme.primary : Theme.border
|
||||
}
|
||||
|
||||
// Service info
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
FlexRow {
|
||||
spacing: 8
|
||||
CText { variant: "subtitle1"; text: modelData.name }
|
||||
CStatusBadge {
|
||||
status: modelData.connected ? "success" : "error"
|
||||
text: modelData.connected ? "Connected" : "Disconnected"
|
||||
}
|
||||
}
|
||||
|
||||
FlexRow {
|
||||
spacing: 12
|
||||
CText {
|
||||
variant: "caption"
|
||||
text: "Health: " + healthLabel(modelData.health)
|
||||
color: healthColor(modelData.health)
|
||||
}
|
||||
CText {
|
||||
variant: "caption"
|
||||
text: "Last: " + modelData.lastConnected
|
||||
opacity: 0.6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connect / Disconnect toggle
|
||||
CButton {
|
||||
text: modelData.connected ? "Disconnect" : "Connect"
|
||||
variant: modelData.connected ? "ghost" : "default"
|
||||
size: "sm"
|
||||
onClicked: root.toggleConnection(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary footer ──
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 8
|
||||
|
||||
CText { variant: "subtitle1"; text: "Connection Summary" }
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 16
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
CText { variant: "caption"; text: "Total Services" }
|
||||
CText { variant: "h3"; text: services.length.toString() }
|
||||
}
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
CText { variant: "caption"; text: "Connected" }
|
||||
CText { variant: "h3"; text: connectedCount().toString(); color: "#2ecc71" }
|
||||
}
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
CText { variant: "caption"; text: "Disconnected" }
|
||||
CText { variant: "h3"; text: (services.length - connectedCount()).toString(); color: "#e74c3c" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,357 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
|
||||
CPaper {
|
||||
id: packageCard
|
||||
width: 460
|
||||
height: 320
|
||||
property string title: "Escape Room"
|
||||
property string subtitle: "v1.0.0"
|
||||
property var dependenciesList: ["retro_games", "microthread"]
|
||||
Rectangle {
|
||||
id: root
|
||||
color: "transparent"
|
||||
|
||||
ColumnLayout {
|
||||
// ── Game state ──
|
||||
property int elapsedSeconds: 0
|
||||
property bool gameActive: true
|
||||
property bool puzzleSolved: false
|
||||
property var inventory: []
|
||||
property string currentRoom: "The Antechamber"
|
||||
property string roomDescription: "You stand in a dimly lit stone chamber. Ancient symbols line the walls. A heavy iron door blocks the passage north. A dusty bookshelf leans against the east wall, and a strange brass mechanism sits on a pedestal in the center."
|
||||
|
||||
property string digit1: ""
|
||||
property string digit2: ""
|
||||
property string digit3: ""
|
||||
property string digit4: ""
|
||||
property string correctCode: "7314"
|
||||
property string feedbackText: ""
|
||||
|
||||
ListModel {
|
||||
id: clueLog
|
||||
ListElement { clue: "The chamber smells of old parchment and oil." }
|
||||
ListElement { clue: "You notice scratch marks near the pedestal base." }
|
||||
}
|
||||
|
||||
// ── Timer ──
|
||||
Timer {
|
||||
id: gameTimer
|
||||
interval: 1000
|
||||
running: root.gameActive && !root.puzzleSolved
|
||||
repeat: true
|
||||
onTriggered: root.elapsedSeconds++
|
||||
}
|
||||
|
||||
function formatTime(sec) {
|
||||
var m = Math.floor(sec / 60)
|
||||
var s = sec % 60
|
||||
return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s
|
||||
}
|
||||
|
||||
function examine() {
|
||||
var clues = [
|
||||
"A faded inscription reads: 'The first digit mirrors the walls' — you count 7 symbols.",
|
||||
"Behind the bookshelf you find a torn page: '...third, the one that comes after zero...'",
|
||||
"The brass mechanism has 4 dials. One is stuck on the number 4.",
|
||||
"A scratched tally on the pedestal base shows III marks.",
|
||||
"You notice the symbols repeat in groups of seven.",
|
||||
"An old map fragment shows a path marked with the number 1."
|
||||
]
|
||||
var c = clues[Math.floor(Math.random() * clues.length)]
|
||||
clueLog.append({ clue: c })
|
||||
}
|
||||
|
||||
function lookAround() {
|
||||
var observations = [
|
||||
"The iron door has a combination lock with 4 rotating dials.",
|
||||
"Cobwebs stretch between the ceiling beams. Something glints behind one.",
|
||||
"The floor tiles are numbered, but most are worn away.",
|
||||
"A cold draft seeps from beneath the north door.",
|
||||
"The bookshelf contains old journals — one is bookmarked."
|
||||
]
|
||||
var o = observations[Math.floor(Math.random() * observations.length)]
|
||||
clueLog.append({ clue: o })
|
||||
}
|
||||
|
||||
function useItem() {
|
||||
if (inventory.length === 0) {
|
||||
feedbackText = "Your inventory is empty."
|
||||
return
|
||||
}
|
||||
clueLog.append({ clue: "You use the " + inventory[inventory.length - 1] + " but nothing happens... yet." })
|
||||
}
|
||||
|
||||
function collectItem() {
|
||||
var items = ["Rusty Key", "Torn Page", "Brass Gear", "Glass Lens", "Iron Nail"]
|
||||
var available = []
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (inventory.indexOf(items[i]) < 0) available.push(items[i])
|
||||
}
|
||||
if (available.length === 0) {
|
||||
feedbackText = "Nothing more to collect here."
|
||||
return
|
||||
}
|
||||
var item = available[Math.floor(Math.random() * available.length)]
|
||||
var inv = inventory.slice()
|
||||
inv.push(item)
|
||||
inventory = inv
|
||||
clueLog.append({ clue: "You found: " + item })
|
||||
}
|
||||
|
||||
function tryCode() {
|
||||
var entered = digit1 + digit2 + digit3 + digit4
|
||||
if (entered.length < 4) {
|
||||
feedbackText = "Enter all 4 digits."
|
||||
return
|
||||
}
|
||||
if (entered === correctCode) {
|
||||
puzzleSolved = true
|
||||
feedbackText = "The lock clicks open! You escaped!"
|
||||
clueLog.append({ clue: "VICTORY! The door swings open. Time: " + formatTime(elapsedSeconds) })
|
||||
} else {
|
||||
feedbackText = "Wrong combination. Try again."
|
||||
clueLog.append({ clue: "Code " + entered + " failed. The dials reset." })
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 18
|
||||
spacing: 12
|
||||
anchors.margins: 20
|
||||
clip: true
|
||||
|
||||
RowLayout {
|
||||
spacing: 12
|
||||
CAvatar { initials: title.left(2).toUpper(); backgroundColor: Theme.primary }
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
CText { variant: "h3"; text: title }
|
||||
CText { variant: "body1"; text: subtitle }
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 16
|
||||
|
||||
// ── Header ──
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 12
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
CText { variant: "h2"; text: "Escape Room" }
|
||||
CStatusBadge {
|
||||
status: root.puzzleSolved ? "success" : "warning"
|
||||
text: root.puzzleSolved ? "Escaped!" : "In Progress"
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
// Timer
|
||||
Rectangle {
|
||||
width: 90
|
||||
height: 36
|
||||
radius: 8
|
||||
color: Theme.card
|
||||
border.color: Theme.border
|
||||
border.width: 1
|
||||
|
||||
CText {
|
||||
anchors.centerIn: parent
|
||||
variant: "h3"
|
||||
text: formatTime(root.elapsedSeconds)
|
||||
color: root.elapsedSeconds > 300 ? "#e74c3c" : Theme.text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
CBadge { text: dependenciesList.length ? "Dependency package" : "Standalone"; accent: dependenciesList.length > 0 }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Puzzle-driven escape room within the Qt UI for team-building."
|
||||
font.pixelSize: 14
|
||||
color: Theme.text
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 16
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
CChip { text: "Adaptive layout" }
|
||||
CChip { text: "Realtime telemetry" }
|
||||
CChip { text: "Community moderation" }
|
||||
}
|
||||
// ── Left column: Room + Puzzle ──
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 400
|
||||
spacing: 16
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
CButton { text: "Install" }
|
||||
CButton { text: "Dependency graph"; variant: "ghost" }
|
||||
// Room description
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 10
|
||||
|
||||
FlexRow {
|
||||
spacing: 8
|
||||
CText { variant: "subtitle1"; text: root.currentRoom }
|
||||
CBadge { text: "Room 1 of 3" }
|
||||
}
|
||||
|
||||
CText {
|
||||
variant: "body1"
|
||||
text: root.roomDescription
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
font.italic: true
|
||||
opacity: 0.85
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interaction buttons
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 10
|
||||
|
||||
CText { variant: "subtitle1"; text: "Actions" }
|
||||
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: 2
|
||||
columnSpacing: 8
|
||||
rowSpacing: 8
|
||||
|
||||
CButton { text: "Examine"; Layout.fillWidth: true; onClicked: root.examine(); enabled: !root.puzzleSolved }
|
||||
CButton { text: "Look Around"; Layout.fillWidth: true; onClicked: root.lookAround(); enabled: !root.puzzleSolved }
|
||||
CButton { text: "Use Item"; Layout.fillWidth: true; variant: "ghost"; onClicked: root.useItem(); enabled: !root.puzzleSolved }
|
||||
CButton { text: "Search Area"; Layout.fillWidth: true; variant: "ghost"; onClicked: root.collectItem(); enabled: !root.puzzleSolved }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combination lock puzzle
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 12
|
||||
|
||||
CText { variant: "subtitle1"; text: "Combination Lock" }
|
||||
CText { variant: "caption"; text: "Enter the 4-digit code to unlock the door" }
|
||||
|
||||
RowLayout {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: 4
|
||||
|
||||
TextField {
|
||||
Layout.preferredWidth: 50
|
||||
Layout.preferredHeight: 50
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.pixelSize: 22
|
||||
font.bold: true
|
||||
maximumLength: 1
|
||||
inputMethodHints: Qt.ImhDigitsOnly
|
||||
color: Theme.text
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: Theme.card
|
||||
border.color: root.puzzleSolved ? "#2ecc71" : Theme.border
|
||||
border.width: 2
|
||||
}
|
||||
onTextChanged: {
|
||||
if (index === 0) root.digit1 = text
|
||||
else if (index === 1) root.digit2 = text
|
||||
else if (index === 2) root.digit3 = text
|
||||
else root.digit4 = text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CButton {
|
||||
text: root.puzzleSolved ? "Unlocked!" : "Try Combination"
|
||||
Layout.fillWidth: true
|
||||
enabled: !root.puzzleSolved
|
||||
onClicked: root.tryCode()
|
||||
}
|
||||
|
||||
CText {
|
||||
variant: "caption"
|
||||
text: root.feedbackText
|
||||
color: root.puzzleSolved ? "#2ecc71" : "#e74c3c"
|
||||
visible: root.feedbackText.length > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Right column: Inventory + Clues ──
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 280
|
||||
spacing: 16
|
||||
|
||||
// Inventory
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 8
|
||||
|
||||
FlexRow {
|
||||
spacing: 8
|
||||
CText { variant: "subtitle1"; text: "Inventory" }
|
||||
CBadge { text: root.inventory.length + " items" }
|
||||
}
|
||||
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
CText {
|
||||
variant: "body2"
|
||||
text: "No items collected yet."
|
||||
visible: root.inventory.length === 0
|
||||
opacity: 0.5
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.inventory
|
||||
CListItem {
|
||||
Layout.fillWidth: true
|
||||
text: modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clue log
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 8
|
||||
|
||||
FlexRow {
|
||||
spacing: 8
|
||||
CText { variant: "subtitle1"; text: "Clue Log" }
|
||||
CBadge { text: clueLog.count + " clues" }
|
||||
}
|
||||
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
Repeater {
|
||||
model: clueLog
|
||||
|
||||
CText {
|
||||
variant: "caption"
|
||||
text: "\u2022 " + model.clue
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
opacity: 0.8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,231 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
|
||||
CPaper {
|
||||
id: packageCard
|
||||
width: 460
|
||||
height: 320
|
||||
property string title: "Marketplace"
|
||||
property string subtitle: "v1.0.0"
|
||||
property var dependenciesList: ["frontpage", "storybook"]
|
||||
Rectangle {
|
||||
id: root
|
||||
color: "transparent"
|
||||
|
||||
ColumnLayout {
|
||||
property string activeCategory: "All"
|
||||
property string searchQuery: ""
|
||||
property int cartCount: 0
|
||||
property var cartItems: []
|
||||
|
||||
property var categories: ["All", "Electronics", "Software", "Services"]
|
||||
|
||||
property var products: [
|
||||
{ name: "MetaBuilder Pro License", price: 49.99, rating: 5, category: "Software", color: Theme.primary },
|
||||
{ name: "USB-C Hub Adapter", price: 29.95, rating: 4, category: "Electronics", color: "#e67e22" },
|
||||
{ name: "CI/CD Pipeline Setup", price: 199.00, rating: 5, category: "Services", color: "#2ecc71" },
|
||||
{ name: "Mechanical Keyboard", price: 89.99, rating: 4, category: "Electronics", color: "#9b59b6" },
|
||||
{ name: "Code Review Package", price: 75.00, rating: 3, category: "Services", color: "#e74c3c" },
|
||||
{ name: "Dark Theme Extension", price: 4.99, rating: 5, category: "Software", color: "#34495e" }
|
||||
]
|
||||
|
||||
function filteredProducts() {
|
||||
var result = []
|
||||
for (var i = 0; i < products.length; i++) {
|
||||
var p = products[i]
|
||||
var matchCategory = activeCategory === "All" || p.category === activeCategory
|
||||
var matchSearch = searchQuery.length === 0 ||
|
||||
p.name.toLowerCase().indexOf(searchQuery.toLowerCase()) >= 0
|
||||
if (matchCategory && matchSearch) result.push(p)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function addToCart(productName) {
|
||||
var items = cartItems.slice()
|
||||
items.push(productName)
|
||||
cartItems = items
|
||||
cartCount = items.length
|
||||
}
|
||||
|
||||
function ratingStars(count) {
|
||||
var s = ""
|
||||
for (var i = 0; i < 5; i++) s += (i < count ? "\u2605" : "\u2606")
|
||||
return s
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 18
|
||||
spacing: 12
|
||||
anchors.margins: 20
|
||||
clip: true
|
||||
|
||||
RowLayout {
|
||||
spacing: 12
|
||||
CAvatar { initials: title.left(2).toUpper(); backgroundColor: Theme.primary }
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
CText { variant: "h3"; text: title }
|
||||
CText { variant: "body1"; text: subtitle }
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 16
|
||||
|
||||
// ── Header ──
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 12
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
CText { variant: "h2"; text: "Marketplace" }
|
||||
CBadge { text: products.length + " Products" }
|
||||
Item { Layout.fillWidth: true }
|
||||
CBadge { text: cartCount + " in Cart"; accent: cartCount > 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
CBadge { text: dependenciesList.length ? "Dependency package" : "Standalone"; accent: dependenciesList.length > 0 }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Curated extensions and paid components with purchase-to-install workflow."
|
||||
font.pixelSize: 14
|
||||
color: Theme.text
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
// ── Search bar ──
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
CChip { text: "Adaptive layout" }
|
||||
CChip { text: "Realtime telemetry" }
|
||||
CChip { text: "Community moderation" }
|
||||
}
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 12
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
CButton { text: "Install" }
|
||||
CButton { text: "Dependency graph"; variant: "ghost" }
|
||||
CTextField {
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "Search products..."
|
||||
text: root.searchQuery
|
||||
onTextChanged: root.searchQuery = text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Category filter chips ──
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: root.categories
|
||||
CChip {
|
||||
text: modelData
|
||||
selected: root.activeCategory === modelData
|
||||
onClicked: root.activeCategory = modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Product Grid ──
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: 3
|
||||
columnSpacing: 12
|
||||
rowSpacing: 12
|
||||
|
||||
Repeater {
|
||||
model: filteredProducts()
|
||||
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 200
|
||||
Layout.preferredHeight: 260
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 10
|
||||
|
||||
// Image placeholder
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 80
|
||||
radius: 8
|
||||
color: modelData.color
|
||||
opacity: 0.85
|
||||
|
||||
CText {
|
||||
anchors.centerIn: parent
|
||||
variant: "h3"
|
||||
text: modelData.name.substring(0, 2).toUpperCase()
|
||||
color: "#ffffff"
|
||||
}
|
||||
}
|
||||
|
||||
CText {
|
||||
variant: "subtitle1"
|
||||
text: modelData.name
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
CText {
|
||||
variant: "caption"
|
||||
text: ratingStars(modelData.rating)
|
||||
font.pixelSize: 16
|
||||
color: "#f39c12"
|
||||
}
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
CText {
|
||||
variant: "h3"
|
||||
text: "$" + modelData.price.toFixed(2)
|
||||
color: Theme.primary
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
CChip { text: modelData.category }
|
||||
}
|
||||
|
||||
CButton {
|
||||
text: "Add to Cart"
|
||||
Layout.fillWidth: true
|
||||
onClicked: root.addToCart(modelData.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cart summary ──
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
visible: cartCount > 0
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 8
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
CText { variant: "h3"; text: "Cart Summary" }
|
||||
CBadge { text: cartCount + " items"; accent: true }
|
||||
Item { Layout.fillWidth: true }
|
||||
CButton {
|
||||
text: "Clear Cart"
|
||||
variant: "ghost"
|
||||
size: "sm"
|
||||
onClicked: { root.cartItems = []; root.cartCount = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
Repeater {
|
||||
model: root.cartItems
|
||||
CText { variant: "body2"; text: "\u2022 " + modelData }
|
||||
}
|
||||
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
CButton {
|
||||
text: "Checkout"
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,258 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
|
||||
CPaper {
|
||||
id: packageCard
|
||||
width: 460
|
||||
height: 320
|
||||
property string title: "MicroThread"
|
||||
property string subtitle: "v1.0.0"
|
||||
property var dependenciesList: ["login", "profile_page"]
|
||||
Rectangle {
|
||||
id: root
|
||||
color: "transparent"
|
||||
|
||||
ColumnLayout {
|
||||
property string composeText: ""
|
||||
property int maxChars: 280
|
||||
property string sortMode: "Recent"
|
||||
property var sortOptions: ["Recent", "Popular", "Following"]
|
||||
|
||||
ListModel {
|
||||
id: postsModel
|
||||
|
||||
ListElement {
|
||||
postId: 1; username: "alice_dev"; initials: "AL"; avatarColor: "#3498db"
|
||||
timestamp: "2026-03-19 08:30"
|
||||
body: "Just shipped the new workflow engine with event-driven architecture. JSON config is the way forward!"
|
||||
likes: 24; replies: 5; liked: false; following: true
|
||||
}
|
||||
ListElement {
|
||||
postId: 2; username: "bob_ops"; initials: "BO"; avatarColor: "#e67e22"
|
||||
timestamp: "2026-03-19 07:15"
|
||||
body: "Redis caching layer benchmarks are in: 3ms avg response time with read-through pattern. Production ready."
|
||||
likes: 18; replies: 3; liked: true; following: true
|
||||
}
|
||||
ListElement {
|
||||
postId: 3; username: "charlie_ui"; initials: "CH"; avatarColor: "#2ecc71"
|
||||
timestamp: "2026-03-18 22:45"
|
||||
body: "FakeMUI hit 167 components today. Next milestone: full parity with Material UI core set."
|
||||
likes: 31; replies: 8; liked: false; following: false
|
||||
}
|
||||
ListElement {
|
||||
postId: 4; username: "diana_sec"; initials: "DI"; avatarColor: "#9b59b6"
|
||||
timestamp: "2026-03-18 19:00"
|
||||
body: "Security audit complete. All endpoints now enforce multi-tenant filtering. No exceptions."
|
||||
likes: 42; replies: 2; liked: false; following: true
|
||||
}
|
||||
ListElement {
|
||||
postId: 5; username: "eve_data"; initials: "EV"; avatarColor: "#e74c3c"
|
||||
timestamp: "2026-03-18 16:20"
|
||||
body: "14 database backends and counting. SurrealDB adapter just passed all CRUD tests."
|
||||
likes: 15; replies: 6; liked: false; following: false
|
||||
}
|
||||
ListElement {
|
||||
postId: 6; username: "frank_ml"; initials: "FR"; avatarColor: "#1abc9c"
|
||||
timestamp: "2026-03-18 14:00"
|
||||
body: "Working on the Mojo compiler integration. Hot reload is surprisingly smooth with the new bridge."
|
||||
likes: 9; replies: 1; liked: false; following: false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLike(index) {
|
||||
var item = postsModel.get(index)
|
||||
postsModel.setProperty(index, "liked", !item.liked)
|
||||
postsModel.setProperty(index, "likes", item.liked ? item.likes - 1 : item.likes + 1)
|
||||
}
|
||||
|
||||
function submitPost() {
|
||||
if (composeText.length === 0 || composeText.length > maxChars) return
|
||||
postsModel.insert(0, {
|
||||
postId: postsModel.count + 1,
|
||||
username: "demo_user",
|
||||
initials: "DE",
|
||||
avatarColor: Theme.primary,
|
||||
timestamp: "2026-03-19 08:35",
|
||||
body: composeText,
|
||||
likes: 0,
|
||||
replies: 0,
|
||||
liked: false,
|
||||
following: false
|
||||
})
|
||||
composeText = ""
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 18
|
||||
spacing: 12
|
||||
anchors.margins: 20
|
||||
clip: true
|
||||
|
||||
RowLayout {
|
||||
spacing: 12
|
||||
CAvatar { initials: title.left(2).toUpper(); backgroundColor: Theme.primary }
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
CText { variant: "h3"; text: title }
|
||||
CText { variant: "body1"; text: subtitle }
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 16
|
||||
|
||||
// ── Header ──
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 12
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
CText { variant: "h2"; text: "MicroThread" }
|
||||
CBadge { text: postsModel.count + " Posts" }
|
||||
Item { Layout.fillWidth: true }
|
||||
CStatusBadge { status: "success"; text: "Live Feed" }
|
||||
}
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
CBadge { text: dependenciesList.length ? "Dependency package" : "Standalone"; accent: dependenciesList.length > 0 }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Rapid-fire posting feed, like a micro-blogging stream."
|
||||
font.pixelSize: 14
|
||||
color: Theme.text
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
// ── Compose area ──
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
CChip { text: "Adaptive layout" }
|
||||
CChip { text: "Realtime telemetry" }
|
||||
CChip { text: "Community moderation" }
|
||||
}
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 10
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
CButton { text: "Install" }
|
||||
CButton { text: "Dependency graph"; variant: "ghost" }
|
||||
CText { variant: "subtitle1"; text: "What's happening?" }
|
||||
|
||||
CTextField {
|
||||
id: composeField
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "Share your thoughts..."
|
||||
text: root.composeText
|
||||
onTextChanged: root.composeText = text
|
||||
}
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
CText {
|
||||
variant: "caption"
|
||||
text: root.composeText.length + " / " + root.maxChars
|
||||
color: root.composeText.length > root.maxChars ? "#e74c3c" : Theme.text
|
||||
opacity: root.composeText.length > root.maxChars ? 1.0 : 0.6
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
CButton {
|
||||
text: "Post"
|
||||
enabled: root.composeText.length > 0 && root.composeText.length <= root.maxChars
|
||||
onClicked: root.submitPost()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sort chips ──
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
CText { variant: "caption"; text: "Sort by:" }
|
||||
|
||||
Repeater {
|
||||
model: root.sortOptions
|
||||
CChip {
|
||||
text: modelData
|
||||
selected: root.sortMode === modelData
|
||||
onClicked: root.sortMode = modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Thread feed ──
|
||||
Repeater {
|
||||
model: postsModel
|
||||
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 10
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
// Avatar circle
|
||||
Rectangle {
|
||||
width: 40
|
||||
height: 40
|
||||
radius: 20
|
||||
color: model.avatarColor
|
||||
|
||||
CText {
|
||||
anchors.centerIn: parent
|
||||
variant: "caption"
|
||||
text: model.initials
|
||||
color: "#ffffff"
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
|
||||
FlexRow {
|
||||
spacing: 8
|
||||
CText { variant: "subtitle1"; text: "@" + model.username }
|
||||
CText { variant: "caption"; text: model.timestamp; opacity: 0.5 }
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
CBadge {
|
||||
text: "Following"
|
||||
accent: true
|
||||
visible: model.following
|
||||
}
|
||||
}
|
||||
|
||||
CText {
|
||||
variant: "body1"
|
||||
text: model.body
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 16
|
||||
|
||||
CButton {
|
||||
text: (model.liked ? "\u2665 " : "\u2661 ") + model.likes
|
||||
variant: "ghost"
|
||||
size: "sm"
|
||||
onClicked: root.toggleLike(index)
|
||||
}
|
||||
|
||||
CButton {
|
||||
text: "\uD83D\uDCAC " + model.replies
|
||||
variant: "ghost"
|
||||
size: "sm"
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
CButton {
|
||||
text: "Share"
|
||||
variant: "ghost"
|
||||
size: "sm"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,224 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
|
||||
CPaper {
|
||||
id: packageCard
|
||||
width: 460
|
||||
height: 320
|
||||
property string title: "Retro Arcade"
|
||||
property string subtitle: "v1.0.0"
|
||||
property var dependenciesList: ["gallery"]
|
||||
Rectangle {
|
||||
id: root
|
||||
color: "transparent"
|
||||
|
||||
ColumnLayout {
|
||||
property var games: [
|
||||
{ name: "Snake", color: "#2ecc71", highScore: 4250, implemented: true, icon: "\u2588\u2588\u2588" },
|
||||
{ name: "Breakout", color: "#3498db", highScore: 3800, implemented: true, icon: "\u2580\u2580\u2580" },
|
||||
{ name: "Tetris", color: "#9b59b6", highScore: 12400, implemented: true, icon: "\u2587\u2587\u2587" },
|
||||
{ name: "Pong", color: "#e67e22", highScore: 2100, implemented: true, icon: "\u2503 \u25CF \u2503" },
|
||||
{ name: "Space Invaders", color: "#e74c3c", highScore: 8900, implemented: false, icon: "\u25BD\u25BD\u25BD" },
|
||||
{ name: "Pac-Man", color: "#f1c40f", highScore: 6750, implemented: false, icon: "\u25D0 \u00B7\u00B7" }
|
||||
]
|
||||
|
||||
property var leaderboard: [
|
||||
{ rank: 1, player: "alice_dev", game: "Tetris", score: 12400 },
|
||||
{ rank: 2, player: "bob_ops", game: "Space Invaders", score: 8900 },
|
||||
{ rank: 3, player: "charlie_ui", game: "Pac-Man", score: 6750 },
|
||||
{ rank: 4, player: "demo_user", game: "Snake", score: 4250 },
|
||||
{ rank: 5, player: "diana_sec", game: "Breakout", score: 3800 }
|
||||
]
|
||||
|
||||
function launchGame(gameName) {
|
||||
console.log("Launching: " + gameName)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 18
|
||||
spacing: 12
|
||||
anchors.margins: 20
|
||||
clip: true
|
||||
|
||||
RowLayout {
|
||||
spacing: 12
|
||||
CAvatar { initials: title.left(2).toUpper(); backgroundColor: Theme.primary }
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
CText { variant: "h3"; text: title }
|
||||
CText { variant: "body1"; text: subtitle }
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 16
|
||||
|
||||
// ── Header ──
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 12
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
CText { variant: "h2"; text: "Retro Arcade" }
|
||||
CBadge { text: root.games.length + " Games" }
|
||||
Item { Layout.fillWidth: true }
|
||||
CStatusBadge { status: "success"; text: "Arcade Open" }
|
||||
}
|
||||
|
||||
CText {
|
||||
variant: "body2"
|
||||
text: "Pixel-perfect retro games to keep you entertained. Select a game to play!"
|
||||
opacity: 0.7
|
||||
}
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
CBadge { text: dependenciesList.length ? "Dependency package" : "Standalone"; accent: dependenciesList.length > 0 }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Pixel-perfect retro games to keep visitors entertained inside the app."
|
||||
font.pixelSize: 14
|
||||
color: Theme.text
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
// ── Game Grid ──
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: 3
|
||||
columnSpacing: 12
|
||||
rowSpacing: 12
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
CChip { text: "Adaptive layout" }
|
||||
CChip { text: "Realtime telemetry" }
|
||||
CChip { text: "Community moderation" }
|
||||
}
|
||||
Repeater {
|
||||
model: root.games
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
CButton { text: "Install" }
|
||||
CButton { text: "Dependency graph"; variant: "ghost" }
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 200
|
||||
Layout.preferredHeight: 250
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 10
|
||||
|
||||
// Pixel art placeholder
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 80
|
||||
radius: 8
|
||||
color: modelData.color
|
||||
opacity: modelData.implemented ? 0.9 : 0.4
|
||||
|
||||
CText {
|
||||
anchors.centerIn: parent
|
||||
text: modelData.icon
|
||||
font.pixelSize: 24
|
||||
font.family: "monospace"
|
||||
color: "#ffffff"
|
||||
}
|
||||
|
||||
// Coming Soon overlay
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 8
|
||||
color: "#000000"
|
||||
opacity: modelData.implemented ? 0 : 0.5
|
||||
visible: !modelData.implemented
|
||||
}
|
||||
|
||||
CBadge {
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 6
|
||||
text: "Coming Soon"
|
||||
visible: !modelData.implemented
|
||||
}
|
||||
}
|
||||
|
||||
CText {
|
||||
variant: "subtitle1"
|
||||
text: modelData.name
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
|
||||
CText { variant: "caption"; text: "High Score:" }
|
||||
CText {
|
||||
variant: "caption"
|
||||
text: modelData.highScore.toLocaleString()
|
||||
color: Theme.primary
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
|
||||
CButton {
|
||||
text: modelData.implemented ? "Play" : "Coming Soon"
|
||||
Layout.fillWidth: true
|
||||
enabled: modelData.implemented
|
||||
onClicked: root.launchGame(modelData.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Leaderboard ──
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 12
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
CText { variant: "h3"; text: "Leaderboard" }
|
||||
CBadge { text: "Top 5" }
|
||||
}
|
||||
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
// Header row
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
CText { variant: "caption"; text: "Rank"; Layout.preferredWidth: 50; font.bold: true }
|
||||
CText { variant: "caption"; text: "Player"; Layout.fillWidth: true; font.bold: true }
|
||||
CText { variant: "caption"; text: "Game"; Layout.preferredWidth: 140; font.bold: true }
|
||||
CText { variant: "caption"; text: "Score"; Layout.preferredWidth: 80; font.bold: true; horizontalAlignment: Text.AlignRight }
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.leaderboard
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
// Rank medal
|
||||
Rectangle {
|
||||
width: 28
|
||||
height: 28
|
||||
radius: 14
|
||||
Layout.preferredWidth: 50
|
||||
color: modelData.rank === 1 ? "#f1c40f" : modelData.rank === 2 ? "#bdc3c7" : modelData.rank === 3 ? "#cd7f32" : "transparent"
|
||||
border.color: Theme.border
|
||||
border.width: modelData.rank > 3 ? 1 : 0
|
||||
|
||||
CText {
|
||||
anchors.centerIn: parent
|
||||
variant: "caption"
|
||||
text: "#" + modelData.rank
|
||||
font.bold: true
|
||||
color: modelData.rank <= 3 ? "#ffffff" : Theme.text
|
||||
}
|
||||
}
|
||||
|
||||
CText { variant: "body2"; text: modelData.player; Layout.fillWidth: true }
|
||||
CText { variant: "body2"; text: modelData.game; Layout.preferredWidth: 140 }
|
||||
CText {
|
||||
variant: "body2"
|
||||
text: modelData.score.toLocaleString()
|
||||
Layout.preferredWidth: 80
|
||||
horizontalAlignment: Text.AlignRight
|
||||
font.bold: true
|
||||
color: Theme.primary
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,385 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
|
||||
CPaper {
|
||||
id: packageCard
|
||||
width: 460
|
||||
height: 320
|
||||
property string title: "Watchtower"
|
||||
property string subtitle: "v1.0.0"
|
||||
property var dependenciesList: ["analytics", "god_panel"]
|
||||
Rectangle {
|
||||
id: root
|
||||
color: "transparent"
|
||||
|
||||
ColumnLayout {
|
||||
property bool autoRefresh: true
|
||||
property int refreshInterval: 5
|
||||
property int lastRefreshAgo: 0
|
||||
|
||||
property var services: [
|
||||
{ name: "DBAL", status: "healthy", uptime: "14d 6h 32m", responseMs: 12, lastCheck: "08:14:00", icon: "DB" },
|
||||
{ name: "Media", status: "healthy", uptime: "14d 6h 30m", responseMs: 45, lastCheck: "08:14:02", icon: "MD" },
|
||||
{ name: "Redis", status: "healthy", uptime: "14d 6h 28m", responseMs: 3, lastCheck: "08:14:01", icon: "RD" },
|
||||
{ name: "Postgres", status: "degraded", uptime: "7d 12h 15m", responseMs: 128, lastCheck: "08:13:58", icon: "PG" },
|
||||
{ name: "Nginx", status: "healthy", uptime: "14d 6h 32m", responseMs: 8, lastCheck: "08:14:03", icon: "NX" }
|
||||
]
|
||||
|
||||
property var metrics: ({
|
||||
cpu: 34,
|
||||
memory: 67,
|
||||
disk: 52
|
||||
})
|
||||
|
||||
ListModel {
|
||||
id: alertLog
|
||||
|
||||
ListElement { severity: "warning"; message: "Postgres response time elevated (128ms)"; timestamp: "08:13:58" }
|
||||
ListElement { severity: "info"; message: "Redis cache hit rate: 94.2%"; timestamp: "08:12:30" }
|
||||
ListElement { severity: "error"; message: "Media service timeout on /api/v1/transcode (recovered)"; timestamp: "08:10:15" }
|
||||
ListElement { severity: "warning"; message: "Disk usage crossed 50% threshold"; timestamp: "08:05:00" }
|
||||
ListElement { severity: "info"; message: "DBAL auto-seed completed successfully"; timestamp: "07:45:22" }
|
||||
ListElement { severity: "info"; message: "System startup complete — all services initialized"; timestamp: "07:40:00" }
|
||||
ListElement { severity: "error"; message: "Failed health check on Postgres (timeout), auto-retried OK"; timestamp: "07:38:45" }
|
||||
ListElement { severity: "warning"; message: "Memory usage spike to 78% during backup (normalized)"; timestamp: "06:30:10" }
|
||||
]
|
||||
|
||||
Timer {
|
||||
id: refreshTimer
|
||||
interval: root.refreshInterval * 1000
|
||||
running: root.autoRefresh
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
root.lastRefreshAgo = 0
|
||||
// Simulate small metric fluctuations
|
||||
var m = Object.assign({}, root.metrics)
|
||||
m.cpu = Math.max(5, Math.min(95, m.cpu + Math.floor(Math.random() * 11) - 5))
|
||||
m.memory = Math.max(20, Math.min(95, m.memory + Math.floor(Math.random() * 7) - 3))
|
||||
root.metrics = m
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: root.lastRefreshAgo++
|
||||
}
|
||||
|
||||
function statusColor(s) {
|
||||
if (s === "healthy") return "#2ecc71"
|
||||
if (s === "degraded") return "#f39c12"
|
||||
return "#e74c3c"
|
||||
}
|
||||
|
||||
function statusLabel(s) {
|
||||
if (s === "healthy") return "success"
|
||||
if (s === "degraded") return "warning"
|
||||
return "error"
|
||||
}
|
||||
|
||||
function severityColor(s) {
|
||||
if (s === "error") return "#e74c3c"
|
||||
if (s === "warning") return "#f39c12"
|
||||
return "#3498db"
|
||||
}
|
||||
|
||||
function metricColor(pct) {
|
||||
if (pct >= 80) return "#e74c3c"
|
||||
if (pct >= 60) return "#f39c12"
|
||||
return "#2ecc71"
|
||||
}
|
||||
|
||||
function healthyCount() {
|
||||
var c = 0
|
||||
for (var i = 0; i < services.length; i++)
|
||||
if (services[i].status === "healthy") c++
|
||||
return c
|
||||
}
|
||||
|
||||
function manualRefresh() {
|
||||
root.lastRefreshAgo = 0
|
||||
var m = Object.assign({}, root.metrics)
|
||||
m.cpu = Math.max(5, Math.min(95, m.cpu + Math.floor(Math.random() * 11) - 5))
|
||||
m.memory = Math.max(20, Math.min(95, m.memory + Math.floor(Math.random() * 7) - 3))
|
||||
root.metrics = m
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 18
|
||||
spacing: 12
|
||||
anchors.margins: 20
|
||||
clip: true
|
||||
|
||||
RowLayout {
|
||||
spacing: 12
|
||||
CAvatar { initials: title.left(2).toUpper(); backgroundColor: Theme.primary }
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
CText { variant: "h3"; text: title }
|
||||
CText { variant: "body1"; text: subtitle }
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 16
|
||||
|
||||
// ── Header ──
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 12
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
CText { variant: "h2"; text: "Watchtower" }
|
||||
CStatusBadge {
|
||||
status: healthyCount() === services.length ? "success" : "warning"
|
||||
text: healthyCount() + "/" + services.length + " Healthy"
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
CText {
|
||||
variant: "caption"
|
||||
text: "Updated " + root.lastRefreshAgo + "s ago"
|
||||
opacity: 0.6
|
||||
}
|
||||
|
||||
CButton {
|
||||
text: "Refresh"
|
||||
variant: "ghost"
|
||||
size: "sm"
|
||||
onClicked: root.manualRefresh()
|
||||
}
|
||||
|
||||
// Auto-refresh toggle
|
||||
FlexRow {
|
||||
spacing: 6
|
||||
CText { variant: "caption"; text: "Auto" }
|
||||
Switch {
|
||||
checked: root.autoRefresh
|
||||
onCheckedChanged: root.autoRefresh = checked
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
CBadge { text: dependenciesList.length ? "Dependency package" : "Standalone"; accent: dependenciesList.length > 0 }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Mission control for logging, alerts, and daemon orchestration."
|
||||
font.pixelSize: 14
|
||||
color: Theme.text
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
// ── Service Health Grid ──
|
||||
CText { variant: "h3"; text: "Service Health" }
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
CChip { text: "Adaptive layout" }
|
||||
CChip { text: "Realtime telemetry" }
|
||||
CChip { text: "Community moderation" }
|
||||
}
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: 3
|
||||
columnSpacing: 12
|
||||
rowSpacing: 12
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
CButton { text: "Install" }
|
||||
CButton { text: "Dependency graph"; variant: "ghost" }
|
||||
Repeater {
|
||||
model: root.services
|
||||
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 200
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 8
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
CAvatar {
|
||||
initials: modelData.icon
|
||||
backgroundColor: statusColor(modelData.status)
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
CText { variant: "subtitle1"; text: modelData.name }
|
||||
CStatusBadge { status: statusLabel(modelData.status); text: modelData.status }
|
||||
}
|
||||
}
|
||||
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
CText { variant: "caption"; text: "Uptime:" }
|
||||
CText { variant: "caption"; text: modelData.uptime; font.bold: true }
|
||||
}
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
CText { variant: "caption"; text: "Response:" }
|
||||
CText {
|
||||
variant: "caption"
|
||||
text: modelData.responseMs + "ms"
|
||||
font.bold: true
|
||||
color: modelData.responseMs > 100 ? "#f39c12" : "#2ecc71"
|
||||
}
|
||||
}
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
CText { variant: "caption"; text: "Last check:" }
|
||||
CText { variant: "caption"; text: modelData.lastCheck; opacity: 0.6 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── System Metrics ──
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 16
|
||||
|
||||
CText { variant: "h3"; text: "System Metrics" }
|
||||
|
||||
// CPU
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
CText { variant: "body2"; text: "CPU Usage" }
|
||||
Item { Layout.fillWidth: true }
|
||||
CText { variant: "body2"; text: root.metrics.cpu + "%"; font.bold: true; color: metricColor(root.metrics.cpu) }
|
||||
}
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 10
|
||||
radius: 5
|
||||
color: Theme.border
|
||||
Rectangle {
|
||||
width: parent.width * root.metrics.cpu / 100
|
||||
height: parent.height
|
||||
radius: 5
|
||||
color: metricColor(root.metrics.cpu)
|
||||
Behavior on width { NumberAnimation { duration: 300 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Memory
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
CText { variant: "body2"; text: "Memory Usage" }
|
||||
Item { Layout.fillWidth: true }
|
||||
CText { variant: "body2"; text: root.metrics.memory + "%"; font.bold: true; color: metricColor(root.metrics.memory) }
|
||||
}
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 10
|
||||
radius: 5
|
||||
color: Theme.border
|
||||
Rectangle {
|
||||
width: parent.width * root.metrics.memory / 100
|
||||
height: parent.height
|
||||
radius: 5
|
||||
color: metricColor(root.metrics.memory)
|
||||
Behavior on width { NumberAnimation { duration: 300 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disk
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
CText { variant: "body2"; text: "Disk Usage" }
|
||||
Item { Layout.fillWidth: true }
|
||||
CText { variant: "body2"; text: root.metrics.disk + "%"; font.bold: true; color: metricColor(root.metrics.disk) }
|
||||
}
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 10
|
||||
radius: 5
|
||||
color: Theme.border
|
||||
Rectangle {
|
||||
width: parent.width * root.metrics.disk / 100
|
||||
height: parent.height
|
||||
radius: 5
|
||||
color: metricColor(root.metrics.disk)
|
||||
Behavior on width { NumberAnimation { duration: 300 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alert Log ──
|
||||
CCard {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 12
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
CText { variant: "h3"; text: "Alert Log" }
|
||||
CBadge { text: alertLog.count + " entries" }
|
||||
Item { Layout.fillWidth: true }
|
||||
CButton {
|
||||
text: "Clear Log"
|
||||
variant: "ghost"
|
||||
size: "sm"
|
||||
onClicked: alertLog.clear()
|
||||
}
|
||||
}
|
||||
|
||||
CDivider { Layout.fillWidth: true }
|
||||
|
||||
Repeater {
|
||||
model: alertLog
|
||||
|
||||
FlexRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
// Severity dot
|
||||
Rectangle {
|
||||
width: 10
|
||||
height: 10
|
||||
radius: 5
|
||||
color: severityColor(model.severity)
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
CText {
|
||||
variant: "caption"
|
||||
text: model.timestamp
|
||||
Layout.preferredWidth: 70
|
||||
opacity: 0.6
|
||||
}
|
||||
|
||||
CBadge {
|
||||
text: model.severity
|
||||
accent: model.severity === "error"
|
||||
}
|
||||
|
||||
CText {
|
||||
variant: "body2"
|
||||
text: model.message
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +35,10 @@ import QtQuick
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// Configuration
|
||||
property string baseUrl: "http://localhost:3001/api/dbal"
|
||||
// Configuration — DBAL REST: /api/v1/{tenant}/{package}/{entity}[/{id}]
|
||||
property string baseUrl: "http://localhost:8080"
|
||||
property string tenantId: "default"
|
||||
property string packageId: "core"
|
||||
property string authToken: ""
|
||||
|
||||
// State
|
||||
@@ -99,111 +100,59 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
|
||||
/**
|
||||
* Create a new record
|
||||
* @param {string} entity - Entity name (e.g., "User", "AuditLog")
|
||||
* @param {object} data - Record data
|
||||
* @param {function} callback - Callback(result, error)
|
||||
*/
|
||||
// REST path helpers
|
||||
function entityPath(entity) {
|
||||
return "/api/v1/" + tenantId + "/" + packageId + "/" + entity.toLowerCase()
|
||||
}
|
||||
|
||||
function entityPathWithId(entity, id) {
|
||||
return entityPath(entity) + "/" + id
|
||||
}
|
||||
|
||||
// Public API — DBAL REST: /api/v1/{tenant}/{package}/{entity}[/{id}]
|
||||
|
||||
function create(entity, data, callback) {
|
||||
internal.request("POST", "/create", {
|
||||
entity: entity,
|
||||
data: data,
|
||||
tenantId: tenantId
|
||||
}, callback)
|
||||
internal.request("POST", entityPath(entity), data, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single record by ID
|
||||
* @param {string} entity - Entity name
|
||||
* @param {string} id - Record ID
|
||||
* @param {function} callback - Callback(result, error)
|
||||
*/
|
||||
|
||||
function read(entity, id, callback) {
|
||||
internal.request("GET", "/read/" + entity + "/" + id, null, callback)
|
||||
internal.request("GET", entityPathWithId(entity, id), null, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing record
|
||||
* @param {string} entity - Entity name
|
||||
* @param {string} id - Record ID
|
||||
* @param {object} data - Updated fields
|
||||
* @param {function} callback - Callback(result, error)
|
||||
*/
|
||||
|
||||
function update(entity, id, data, callback) {
|
||||
internal.request("PUT", "/update", {
|
||||
entity: entity,
|
||||
id: id,
|
||||
data: data
|
||||
}, callback)
|
||||
internal.request("PUT", entityPathWithId(entity, id), data, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a record
|
||||
* @param {string} entity - Entity name
|
||||
* @param {string} id - Record ID
|
||||
* @param {function} callback - Callback(success, error)
|
||||
*/
|
||||
|
||||
function remove(entity, id, callback) {
|
||||
internal.request("DELETE", "/delete/" + entity + "/" + id, null, callback)
|
||||
internal.request("DELETE", entityPathWithId(entity, id), null, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* List records with pagination and filtering
|
||||
* @param {string} entity - Entity name
|
||||
* @param {object} options - { take, skip, where, orderBy }
|
||||
* @param {function} callback - Callback({ items, total }, error)
|
||||
*/
|
||||
|
||||
function list(entity, options, callback) {
|
||||
var body = {
|
||||
entity: entity,
|
||||
tenantId: tenantId
|
||||
}
|
||||
|
||||
if (options.take !== undefined) body.take = options.take
|
||||
if (options.skip !== undefined) body.skip = options.skip
|
||||
if (options.where !== undefined) body.where = options.where
|
||||
if (options.orderBy !== undefined) body.orderBy = options.orderBy
|
||||
|
||||
internal.request("POST", "/list", body, callback)
|
||||
var path = entityPath(entity)
|
||||
var queryParts = []
|
||||
if (options.take !== undefined) queryParts.push("take=" + options.take)
|
||||
if (options.skip !== undefined) queryParts.push("skip=" + options.skip)
|
||||
if (options.orderBy !== undefined) queryParts.push("orderBy=" + options.orderBy)
|
||||
if (queryParts.length > 0) path += "?" + queryParts.join("&")
|
||||
|
||||
internal.request("GET", path, null, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find first record matching filter
|
||||
* @param {string} entity - Entity name
|
||||
* @param {object} filter - Filter criteria
|
||||
* @param {function} callback - Callback(result, error)
|
||||
*/
|
||||
|
||||
function findFirst(entity, filter, callback) {
|
||||
internal.request("POST", "/findFirst", {
|
||||
entity: entity,
|
||||
tenantId: tenantId,
|
||||
filter: filter
|
||||
}, callback)
|
||||
var path = entityPath(entity) + "?take=1"
|
||||
for (var key in filter) {
|
||||
path += "&" + encodeURIComponent(key) + "=" + encodeURIComponent(filter[key])
|
||||
}
|
||||
internal.request("GET", path, null, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a named operation
|
||||
* @param {string} operation - Operation name
|
||||
* @param {object} params - Operation parameters
|
||||
* @param {function} callback - Callback(result, error)
|
||||
*/
|
||||
|
||||
function execute(operation, params, callback) {
|
||||
internal.request("POST", "/execute", {
|
||||
operation: operation,
|
||||
params: params,
|
||||
tenantId: tenantId
|
||||
}, callback)
|
||||
var path = "/api/v1/" + tenantId + "/" + operation
|
||||
internal.request("POST", path, params, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check connection to DBAL backend
|
||||
* @param {function} callback - Callback(success, error)
|
||||
*/
|
||||
|
||||
function ping(callback) {
|
||||
internal.request("GET", "/ping", null, function(result, error) {
|
||||
internal.request("GET", "/health", null, function(result, error) {
|
||||
root.connected = !error
|
||||
if (callback) callback(!error, error)
|
||||
})
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
DBALClient::DBALClient(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_networkManager(new QNetworkAccessManager(this))
|
||||
, m_baseUrl("http://localhost:3001/api/dbal")
|
||||
, m_baseUrl("http://localhost:8080")
|
||||
, m_tenantId("default")
|
||||
, m_packageId("core")
|
||||
, m_connected(false)
|
||||
{
|
||||
connect(m_networkManager, &QNetworkAccessManager::finished,
|
||||
@@ -45,6 +46,25 @@ void DBALClient::setAuthToken(const QString &token)
|
||||
}
|
||||
}
|
||||
|
||||
void DBALClient::setPackageId(const QString &pkg)
|
||||
{
|
||||
if (m_packageId != pkg) {
|
||||
m_packageId = pkg;
|
||||
emit packageIdChanged();
|
||||
}
|
||||
}
|
||||
|
||||
QString DBALClient::entityPath(const QString &entity) const
|
||||
{
|
||||
// REST: /api/v1/{tenant}/{package}/{entity}
|
||||
return QString("/api/v1/%1/%2/%3").arg(m_tenantId, m_packageId, entity.toLower());
|
||||
}
|
||||
|
||||
QString DBALClient::entityPath(const QString &entity, const QString &id) const
|
||||
{
|
||||
return entityPath(entity) + "/" + id;
|
||||
}
|
||||
|
||||
void DBALClient::setError(const QString &error)
|
||||
{
|
||||
m_lastError = error;
|
||||
@@ -125,76 +145,89 @@ void DBALClient::handleNetworkReply(QNetworkReply *reply)
|
||||
}
|
||||
}
|
||||
|
||||
// CRUD Operations
|
||||
// CRUD Operations — DBAL REST API: /api/v1/{tenant}/{package}/{entity}[/{id}]
|
||||
|
||||
void DBALClient::create(const QString &entity, const QJsonObject &data, const QJSValue &callback)
|
||||
{
|
||||
QJsonObject body;
|
||||
body["entity"] = entity;
|
||||
body["data"] = data;
|
||||
body["tenantId"] = m_tenantId;
|
||||
|
||||
sendRequest("POST", "/create", body, callback);
|
||||
sendRequest("POST", entityPath(entity), data, callback);
|
||||
}
|
||||
|
||||
void DBALClient::read(const QString &entity, const QString &id, const QJSValue &callback)
|
||||
{
|
||||
QString endpoint = QString("/read/%1/%2").arg(entity, id);
|
||||
sendRequest("GET", endpoint, QJsonObject(), callback);
|
||||
sendRequest("GET", entityPath(entity, id), QJsonObject(), callback);
|
||||
}
|
||||
|
||||
void DBALClient::update(const QString &entity, const QString &id,
|
||||
void DBALClient::update(const QString &entity, const QString &id,
|
||||
const QJsonObject &data, const QJSValue &callback)
|
||||
{
|
||||
QJsonObject body;
|
||||
body["entity"] = entity;
|
||||
body["id"] = id;
|
||||
body["data"] = data;
|
||||
|
||||
sendRequest("PUT", "/update", body, callback);
|
||||
sendRequest("PUT", entityPath(entity, id), data, callback);
|
||||
}
|
||||
|
||||
void DBALClient::remove(const QString &entity, const QString &id, const QJSValue &callback)
|
||||
{
|
||||
QString endpoint = QString("/delete/%1/%2").arg(entity, id);
|
||||
sendRequest("DELETE", endpoint, QJsonObject(), callback);
|
||||
sendRequest("DELETE", entityPath(entity, id), QJsonObject(), callback);
|
||||
}
|
||||
|
||||
void DBALClient::list(const QString &entity, const QJsonObject &options, const QJSValue &callback)
|
||||
{
|
||||
QJsonObject body;
|
||||
body["entity"] = entity;
|
||||
body["tenantId"] = m_tenantId;
|
||||
|
||||
if (options.contains("take")) body["take"] = options["take"];
|
||||
if (options.contains("skip")) body["skip"] = options["skip"];
|
||||
if (options.contains("where")) body["where"] = options["where"];
|
||||
if (options.contains("orderBy")) body["orderBy"] = options["orderBy"];
|
||||
|
||||
sendRequest("POST", "/list", body, callback);
|
||||
// Build query string from options (take, skip, where, orderBy)
|
||||
QString path = entityPath(entity);
|
||||
QStringList queryParts;
|
||||
if (options.contains("take")) queryParts << "take=" + QString::number(options["take"].toInt());
|
||||
if (options.contains("skip")) queryParts << "skip=" + QString::number(options["skip"].toInt());
|
||||
if (options.contains("orderBy")) queryParts << "orderBy=" + options["orderBy"].toString();
|
||||
if (!queryParts.isEmpty()) path += "?" + queryParts.join("&");
|
||||
|
||||
sendRequest("GET", path, QJsonObject(), callback);
|
||||
}
|
||||
|
||||
void DBALClient::findFirst(const QString &entity, const QJsonObject &filter, const QJSValue &callback)
|
||||
{
|
||||
QJsonObject body;
|
||||
body["entity"] = entity;
|
||||
body["tenantId"] = m_tenantId;
|
||||
body["filter"] = filter;
|
||||
|
||||
sendRequest("POST", "/findFirst", body, callback);
|
||||
// GET with query params for simple filters
|
||||
QString path = entityPath(entity);
|
||||
QStringList queryParts;
|
||||
queryParts << "take=1";
|
||||
for (auto it = filter.begin(); it != filter.end(); ++it) {
|
||||
queryParts << QUrl::toPercentEncoding(it.key()) + "=" + QUrl::toPercentEncoding(it.value().toString());
|
||||
}
|
||||
if (!queryParts.isEmpty()) path += "?" + queryParts.join("&");
|
||||
|
||||
sendRequest("GET", path, QJsonObject(), callback);
|
||||
}
|
||||
|
||||
void DBALClient::execute(const QString &operation, const QJsonObject ¶ms, const QJSValue &callback)
|
||||
{
|
||||
QJsonObject body;
|
||||
body["operation"] = operation;
|
||||
body["params"] = params;
|
||||
body["tenantId"] = m_tenantId;
|
||||
|
||||
sendRequest("POST", "/execute", body, callback);
|
||||
// Execute maps to POST /{tenant}/{package}/{entity}/{action} or system endpoints
|
||||
QString path = QString("/api/v1/%1/%2").arg(m_tenantId, operation);
|
||||
sendRequest("POST", path, params, callback);
|
||||
}
|
||||
|
||||
void DBALClient::ping()
|
||||
{
|
||||
sendRequest("GET", "/ping", QJsonObject(), QJSValue());
|
||||
sendRequest("GET", "/health", QJsonObject(), QJSValue());
|
||||
}
|
||||
|
||||
void DBALClient::health(const QJSValue &callback)
|
||||
{
|
||||
sendRequest("GET", "/health", QJsonObject(), callback);
|
||||
}
|
||||
|
||||
void DBALClient::version(const QJSValue &callback)
|
||||
{
|
||||
sendRequest("GET", "/version", QJsonObject(), callback);
|
||||
}
|
||||
|
||||
void DBALClient::status(const QJSValue &callback)
|
||||
{
|
||||
sendRequest("GET", "/status", QJsonObject(), callback);
|
||||
}
|
||||
|
||||
void DBALClient::listSchemas(const QJSValue &callback)
|
||||
{
|
||||
sendRequest("GET", "/api/v1/" + m_tenantId + "/schema", QJsonObject(), callback);
|
||||
}
|
||||
|
||||
void DBALClient::getSchema(const QString &entity, const QJSValue &callback)
|
||||
{
|
||||
sendRequest("GET", "/api/v1/" + m_tenantId + "/schema/" + entity.toLower(), QJsonObject(), callback);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ class DBALClient : public QObject
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged)
|
||||
Q_PROPERTY(QString tenantId READ tenantId WRITE setTenantId NOTIFY tenantIdChanged)
|
||||
Q_PROPERTY(QString packageId READ packageId WRITE setPackageId NOTIFY packageIdChanged)
|
||||
Q_PROPERTY(QString authToken READ authToken WRITE setAuthToken NOTIFY authTokenChanged)
|
||||
Q_PROPERTY(bool connected READ isConnected NOTIFY connectedChanged)
|
||||
Q_PROPERTY(QString lastError READ lastError NOTIFY errorOccurred)
|
||||
@@ -48,6 +49,7 @@ public:
|
||||
// Property getters
|
||||
QString baseUrl() const { return m_baseUrl; }
|
||||
QString tenantId() const { return m_tenantId; }
|
||||
QString packageId() const { return m_packageId; }
|
||||
QString authToken() const { return m_authToken; }
|
||||
bool isConnected() const { return m_connected; }
|
||||
QString lastError() const { return m_lastError; }
|
||||
@@ -55,6 +57,7 @@ public:
|
||||
// Property setters
|
||||
void setBaseUrl(const QString &url);
|
||||
void setTenantId(const QString &id);
|
||||
void setPackageId(const QString &pkg);
|
||||
void setAuthToken(const QString &token);
|
||||
|
||||
public slots:
|
||||
@@ -120,9 +123,41 @@ public slots:
|
||||
*/
|
||||
void ping();
|
||||
|
||||
/**
|
||||
* @brief Get DBAL health information
|
||||
* @param callback QML callback function(result)
|
||||
*/
|
||||
void health(const QJSValue &callback);
|
||||
|
||||
/**
|
||||
* @brief Get DBAL version information
|
||||
* @param callback QML callback function(result)
|
||||
*/
|
||||
void version(const QJSValue &callback);
|
||||
|
||||
/**
|
||||
* @brief Get DBAL status/metrics
|
||||
* @param callback QML callback function(result)
|
||||
*/
|
||||
void status(const QJSValue &callback);
|
||||
|
||||
/**
|
||||
* @brief List entity schemas from DBAL
|
||||
* @param callback QML callback function(schemas)
|
||||
*/
|
||||
void listSchemas(const QJSValue &callback);
|
||||
|
||||
/**
|
||||
* @brief Get a specific entity schema
|
||||
* @param entity Entity name
|
||||
* @param callback QML callback function(schema)
|
||||
*/
|
||||
void getSchema(const QString &entity, const QJSValue &callback);
|
||||
|
||||
signals:
|
||||
void baseUrlChanged();
|
||||
void tenantIdChanged();
|
||||
void packageIdChanged();
|
||||
void authTokenChanged();
|
||||
void connectedChanged();
|
||||
void errorOccurred(const QString &error);
|
||||
@@ -139,7 +174,11 @@ private:
|
||||
QNetworkAccessManager *m_networkManager;
|
||||
QString m_baseUrl;
|
||||
QString m_tenantId;
|
||||
QString m_packageId;
|
||||
QString m_authToken;
|
||||
|
||||
QString entityPath(const QString &entity) const;
|
||||
QString entityPath(const QString &entity, const QString &id) const;
|
||||
bool m_connected;
|
||||
QString m_lastError;
|
||||
QMap<QNetworkReply*, QJSValue> m_pendingCallbacks;
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
#include "PackageLoader.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonArray>
|
||||
#include <QDebug>
|
||||
|
||||
PackageLoader::PackageLoader(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_packagesDir(QDir(QStringLiteral(SRCDIR) + QStringLiteral("/packages")).absolutePath())
|
||||
, m_watching(false)
|
||||
, m_watcher(nullptr)
|
||||
{
|
||||
}
|
||||
|
||||
PackageLoader::~PackageLoader()
|
||||
{
|
||||
delete m_watcher;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Properties
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
QVariantList PackageLoader::packages() const
|
||||
{
|
||||
QVariantList list;
|
||||
for (auto it = m_packages.constBegin(); it != m_packages.constEnd(); ++it) {
|
||||
QVariantMap entry = it.value().toVariantMap();
|
||||
entry[QStringLiteral("installed")] = m_installed.value(it.key(), false);
|
||||
list.append(entry);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
void PackageLoader::setPackagesDir(const QString &dir)
|
||||
{
|
||||
if (m_packagesDir == dir)
|
||||
return;
|
||||
m_packagesDir = dir;
|
||||
emit packagesDirChanged();
|
||||
}
|
||||
|
||||
void PackageLoader::setWatching(bool enabled)
|
||||
{
|
||||
if (m_watching == enabled)
|
||||
return;
|
||||
|
||||
m_watching = enabled;
|
||||
|
||||
if (m_watching) {
|
||||
if (!m_watcher) {
|
||||
m_watcher = new QFileSystemWatcher(this);
|
||||
connect(m_watcher, &QFileSystemWatcher::directoryChanged,
|
||||
this, &PackageLoader::onDirectoryChanged);
|
||||
connect(m_watcher, &QFileSystemWatcher::fileChanged,
|
||||
this, &PackageLoader::onFileChanged);
|
||||
}
|
||||
|
||||
// Watch the root packages directory
|
||||
if (QDir(m_packagesDir).exists())
|
||||
m_watcher->addPath(m_packagesDir);
|
||||
|
||||
// Watch each package subdirectory and its metadata.json
|
||||
QDir root(m_packagesDir);
|
||||
const auto entries = root.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
|
||||
for (const QString &entry : entries) {
|
||||
const QString pkgDir = root.absoluteFilePath(entry);
|
||||
m_watcher->addPath(pkgDir);
|
||||
|
||||
const QString metaPath = pkgDir + QStringLiteral("/metadata.json");
|
||||
if (QFile::exists(metaPath))
|
||||
m_watcher->addPath(metaPath);
|
||||
}
|
||||
} else {
|
||||
delete m_watcher;
|
||||
m_watcher = nullptr;
|
||||
}
|
||||
|
||||
emit watchingChanged();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scanning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void PackageLoader::scan()
|
||||
{
|
||||
m_packages.clear();
|
||||
|
||||
QDir root(m_packagesDir);
|
||||
if (!root.exists()) {
|
||||
qWarning() << "PackageLoader::scan – packages directory does not exist:" << m_packagesDir;
|
||||
emit packagesChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto entries = root.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
|
||||
for (const QString &entry : entries) {
|
||||
loadPackage(root.absoluteFilePath(entry));
|
||||
}
|
||||
|
||||
emit packagesChanged();
|
||||
}
|
||||
|
||||
void PackageLoader::loadPackage(const QString &dir)
|
||||
{
|
||||
const QString metaPath = dir + QStringLiteral("/metadata.json");
|
||||
QFile file(metaPath);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
// Not every subdirectory is necessarily a package – skip silently
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonParseError parseErr;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseErr);
|
||||
file.close();
|
||||
|
||||
if (parseErr.error != QJsonParseError::NoError) {
|
||||
qWarning() << "PackageLoader: JSON parse error in" << metaPath << parseErr.errorString();
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject meta = doc.object();
|
||||
if (!validateMetadata(meta)) {
|
||||
qWarning() << "PackageLoader: invalid metadata in" << metaPath;
|
||||
return;
|
||||
}
|
||||
|
||||
const QString id = meta.contains(QStringLiteral("id"))
|
||||
? meta.value(QStringLiteral("id")).toString()
|
||||
: meta.value(QStringLiteral("packageId")).toString();
|
||||
|
||||
// Inject the absolute directory path so QML can locate assets
|
||||
QJsonObject enriched = meta;
|
||||
enriched[QStringLiteral("_dir")] = dir;
|
||||
|
||||
m_packages.insert(id, enriched);
|
||||
}
|
||||
|
||||
bool PackageLoader::validateMetadata(const QJsonObject &metadata) const
|
||||
{
|
||||
return (metadata.contains(QStringLiteral("id")) || metadata.contains(QStringLiteral("packageId")))
|
||||
&& metadata.contains(QStringLiteral("name"))
|
||||
&& metadata.contains(QStringLiteral("version"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Install / uninstall
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void PackageLoader::install(const QString &packageId)
|
||||
{
|
||||
if (!m_packages.contains(packageId)) {
|
||||
qWarning() << "PackageLoader::install – unknown package:" << packageId;
|
||||
return;
|
||||
}
|
||||
m_installed[packageId] = true;
|
||||
emit packageInstalled(packageId);
|
||||
emit packagesChanged();
|
||||
}
|
||||
|
||||
void PackageLoader::uninstall(const QString &packageId)
|
||||
{
|
||||
if (!m_installed.contains(packageId))
|
||||
return;
|
||||
m_installed.remove(packageId);
|
||||
emit packageUninstalled(packageId);
|
||||
emit packagesChanged();
|
||||
}
|
||||
|
||||
bool PackageLoader::isInstalled(const QString &packageId) const
|
||||
{
|
||||
return m_installed.value(packageId, false);
|
||||
}
|
||||
|
||||
QVariantMap PackageLoader::getPackage(const QString &packageId) const
|
||||
{
|
||||
if (!m_packages.contains(packageId))
|
||||
return {};
|
||||
return m_packages.value(packageId).toVariantMap();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
QStringList PackageLoader::resolveDependencies(const QString &packageId) const
|
||||
{
|
||||
QStringList visited;
|
||||
return resolveDepChain(packageId, visited);
|
||||
}
|
||||
|
||||
QStringList PackageLoader::resolveDepChain(const QString &packageId, QStringList &visited) const
|
||||
{
|
||||
if (visited.contains(packageId))
|
||||
return {};
|
||||
|
||||
visited.append(packageId);
|
||||
|
||||
if (!m_packages.contains(packageId))
|
||||
return {};
|
||||
|
||||
QStringList result;
|
||||
const QJsonObject &meta = m_packages.value(packageId);
|
||||
const QJsonArray deps = meta.value(QStringLiteral("dependencies")).toArray();
|
||||
for (const QJsonValue &dep : deps) {
|
||||
const QString depId = dep.toString();
|
||||
if (depId.isEmpty())
|
||||
continue;
|
||||
result.append(resolveDepChain(depId, visited));
|
||||
result.append(depId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QML path helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
QString PackageLoader::qmlPath(const QString &packageId) const
|
||||
{
|
||||
if (!m_packages.contains(packageId))
|
||||
return {};
|
||||
const QString dir = m_packages.value(packageId).value(QStringLiteral("_dir")).toString();
|
||||
return dir + QStringLiteral("/PackageView.qml");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File system watcher slots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void PackageLoader::onDirectoryChanged(const QString &path)
|
||||
{
|
||||
emit fileChanged(path);
|
||||
|
||||
if (path == m_packagesDir) {
|
||||
// A package may have been added or removed – full rescan
|
||||
scan();
|
||||
return;
|
||||
}
|
||||
|
||||
// A specific package directory changed – reload that package
|
||||
const QDir changedDir(path);
|
||||
const QString metaPath = changedDir.absoluteFilePath(QStringLiteral("metadata.json"));
|
||||
if (QFile::exists(metaPath)) {
|
||||
loadPackage(path);
|
||||
// Determine packageId from the reloaded data
|
||||
const QDir d(path);
|
||||
for (auto it = m_packages.constBegin(); it != m_packages.constEnd(); ++it) {
|
||||
if (it.value().value(QStringLiteral("_dir")).toString() == path) {
|
||||
emit packageUpdated(it.key());
|
||||
break;
|
||||
}
|
||||
}
|
||||
emit packagesChanged();
|
||||
}
|
||||
}
|
||||
|
||||
void PackageLoader::onFileChanged(const QString &path)
|
||||
{
|
||||
emit fileChanged(path);
|
||||
|
||||
// Re-read the metadata.json that was modified
|
||||
QFileInfo fi(path);
|
||||
const QString pkgDir = fi.absolutePath();
|
||||
loadPackage(pkgDir);
|
||||
|
||||
for (auto it = m_packages.constBegin(); it != m_packages.constEnd(); ++it) {
|
||||
if (it.value().value(QStringLiteral("_dir")).toString() == pkgDir) {
|
||||
emit packageUpdated(it.key());
|
||||
break;
|
||||
}
|
||||
}
|
||||
emit packagesChanged();
|
||||
|
||||
// QFileSystemWatcher may drop the watch after a file change – re-add
|
||||
if (m_watcher && QFile::exists(path) && !m_watcher->files().contains(path))
|
||||
m_watcher->addPath(path);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#ifndef PACKAGELOADER_H
|
||||
#define PACKAGELOADER_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QFileSystemWatcher>
|
||||
#include <QMap>
|
||||
#include <QVariantList>
|
||||
|
||||
class PackageLoader : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(QVariantList packages READ packages NOTIFY packagesChanged)
|
||||
Q_PROPERTY(QString packagesDir READ packagesDir WRITE setPackagesDir NOTIFY packagesDirChanged)
|
||||
Q_PROPERTY(bool watching READ isWatching WRITE setWatching NOTIFY watchingChanged)
|
||||
Q_PROPERTY(int packageCount READ packageCount NOTIFY packagesChanged)
|
||||
|
||||
public:
|
||||
explicit PackageLoader(QObject *parent = nullptr);
|
||||
~PackageLoader() override;
|
||||
|
||||
QVariantList packages() const;
|
||||
QString packagesDir() const { return m_packagesDir; }
|
||||
bool isWatching() const { return m_watching; }
|
||||
int packageCount() const { return m_packages.count(); }
|
||||
|
||||
void setPackagesDir(const QString &dir);
|
||||
void setWatching(bool enabled);
|
||||
|
||||
public slots:
|
||||
void scan();
|
||||
void install(const QString &packageId);
|
||||
void uninstall(const QString &packageId);
|
||||
bool isInstalled(const QString &packageId) const;
|
||||
QVariantMap getPackage(const QString &packageId) const;
|
||||
QStringList resolveDependencies(const QString &packageId) const;
|
||||
QString qmlPath(const QString &packageId) const;
|
||||
|
||||
signals:
|
||||
void packagesChanged();
|
||||
void packagesDirChanged();
|
||||
void watchingChanged();
|
||||
void packageInstalled(const QString &packageId);
|
||||
void packageUninstalled(const QString &packageId);
|
||||
void packageUpdated(const QString &packageId);
|
||||
void fileChanged(const QString &path);
|
||||
|
||||
private slots:
|
||||
void onDirectoryChanged(const QString &path);
|
||||
void onFileChanged(const QString &path);
|
||||
|
||||
private:
|
||||
void loadPackage(const QString &dir);
|
||||
bool validateMetadata(const QJsonObject &metadata) const;
|
||||
QStringList resolveDepChain(const QString &packageId, QStringList &visited) const;
|
||||
|
||||
QString m_packagesDir;
|
||||
bool m_watching;
|
||||
QFileSystemWatcher *m_watcher;
|
||||
QMap<QString, QJsonObject> m_packages;
|
||||
QMap<QString, bool> m_installed;
|
||||
};
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user