diff --git a/deployment/cli/helpers.py b/deployment/cli/helpers.py index 81aae3af4..fc0f9ae00 100644 --- a/deployment/cli/helpers.py +++ b/deployment/cli/helpers.py @@ -1,5 +1,7 @@ """Shared helpers for all CLI command modules.""" +from __future__ import annotations + import os import subprocess import sys diff --git a/frontends/qt6/AdminView.qml b/frontends/qt6/AdminView.qml index 32380bd25..7aca27a84 100644 --- a/frontends/qt6/AdminView.qml +++ b/frontends/qt6/AdminView.qml @@ -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; } } diff --git a/frontends/qt6/App.qml b/frontends/qt6/App.qml index e0ab89e11..05b0bab6f 100644 --- a/frontends/qt6/App.qml +++ b/frontends/qt6/App.qml @@ -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" + } + } + } } diff --git a/frontends/qt6/CMakeLists.txt b/frontends/qt6/CMakeLists.txt index 262234b1c..1d73b0eb0 100644 --- a/frontends/qt6/CMakeLists.txt +++ b/frontends/qt6/CMakeLists.txt @@ -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 diff --git a/frontends/qt6/CMakeUserPresets.json b/frontends/qt6/CMakeUserPresets.json index 46a5650d2..71aeacec3 100644 --- a/frontends/qt6/CMakeUserPresets.json +++ b/frontends/qt6/CMakeUserPresets.json @@ -4,6 +4,6 @@ "conan": {} }, "include": [ - "build/generators/CMakePresets.json" + "build/Release/generators/CMakePresets.json" ] } \ No newline at end of file diff --git a/frontends/qt6/CommentsView.qml b/frontends/qt6/CommentsView.qml index df86f706e..51ef4fe3d 100644 --- a/frontends/qt6/CommentsView.qml +++ b/frontends/qt6/CommentsView.qml @@ -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) + } } } } diff --git a/frontends/qt6/ComponentHierarchyEditor.qml b/frontends/qt6/ComponentHierarchyEditor.qml index 012d5d20e..256ea0178 100644 --- a/frontends/qt6/ComponentHierarchyEditor.qml +++ b/frontends/qt6/ComponentHierarchyEditor.qml @@ -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) } } } diff --git a/frontends/qt6/CssClassManager.qml b/frontends/qt6/CssClassManager.qml index e965dbd5d..8c7f04537 100644 --- a/frontends/qt6/CssClassManager.qml +++ b/frontends/qt6/CssClassManager.qml @@ -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 { diff --git a/frontends/qt6/DashboardView.qml b/frontends/qt6/DashboardView.qml index 4846fcf89..9ed7b16ce 100644 --- a/frontends/qt6/DashboardView.qml +++ b/frontends/qt6/DashboardView.qml @@ -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" } diff --git a/frontends/qt6/DatabaseManager.qml b/frontends/qt6/DatabaseManager.qml index 924f0ef65..4dcd992c0 100644 --- a/frontends/qt6/DatabaseManager.qml +++ b/frontends/qt6/DatabaseManager.qml @@ -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 { diff --git a/frontends/qt6/DropdownConfigManager.qml b/frontends/qt6/DropdownConfigManager.qml index 98e8e2707..46f039f4a 100644 --- a/frontends/qt6/DropdownConfigManager.qml +++ b/frontends/qt6/DropdownConfigManager.qml @@ -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 diff --git a/frontends/qt6/FrontPage.qml b/frontends/qt6/FrontPage.qml index 61072c565..6bae26d18 100644 --- a/frontends/qt6/FrontPage.qml +++ b/frontends/qt6/FrontPage.qml @@ -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 diff --git a/frontends/qt6/GodPanel.qml b/frontends/qt6/GodPanel.qml index c02c03fa6..d82d07558 100644 --- a/frontends/qt6/GodPanel.qml +++ b/frontends/qt6/GodPanel.qml @@ -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" diff --git a/frontends/qt6/LoginView.qml b/frontends/qt6/LoginView.qml index cb71bef3c..a2c958e7f 100644 --- a/frontends/qt6/LoginView.qml +++ b/frontends/qt6/LoginView.qml @@ -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) } } diff --git a/frontends/qt6/MediaServicePanel.qml b/frontends/qt6/MediaServicePanel.qml new file mode 100644 index 000000000..193a88188 --- /dev/null +++ b/frontends/qt6/MediaServicePanel.qml @@ -0,0 +1,1130 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QmlComponents 1.0 +import "qmllib/dbal" + +Rectangle { + id: mediaPanel + color: "transparent" + + property int currentTab: 0 + + // ── Service Health ─────────────────────────────────────────────────── + property string serviceStatus: "unknown" // unknown, online, offline + property string serviceVersion: "" + property string lastHealthCheck: "" + + // ── Job Submission Form ────────────────────────────────────────────── + property int jobTypeIndex: 0 + property var jobTypes: ["video", "audio", "document", "image"] + property string jobInputPath: "" + property string jobOutputPath: "" + property int jobPriorityIndex: 2 + property var jobPriorities: ["urgent", "high", "normal", "low"] + + // ── Jobs Data ──────────────────────────────────────────────────────── + property var jobs: [ + { id: "job-001", type: "video", status: "completed", progress: 100, created: "2026-03-19 08:12:34" }, + { id: "job-002", type: "audio", status: "processing", progress: 67, created: "2026-03-19 09:45:01" }, + { id: "job-003", type: "image", status: "queued", progress: 0, created: "2026-03-19 10:02:18" }, + { id: "job-004", type: "document", status: "failed", progress: 23, created: "2026-03-19 10:15:42" }, + { id: "job-005", type: "video", status: "processing", progress: 34, created: "2026-03-19 10:30:55" } + ] + + // ── Radio Data ─────────────────────────────────────────────────────── + property int selectedRadioIndex: 0 + property var radioChannels: [ + { + name: "MetaBuilder FM", status: "live", listeners: 142, + currentTrack: "Synthwave Dreams - NeonCoder", bitrate: "320 kbps", + playlist: [ + "Synthwave Dreams - NeonCoder", + "Digital Horizon - ByteRunner", + "Midnight Protocol - CipherAce", + "Neon Streets - RetroVolt", + "Electric Soul - WaveForm" + ] + }, + { + name: "Chiptune Radio", status: "live", listeners: 87, + currentTrack: "8-Bit Adventure - PixelMaster", bitrate: "192 kbps", + playlist: [ + "8-Bit Adventure - PixelMaster", + "Game Over Theme - ChipTuner", + "Level Up! - BitCrafter", + "Boss Battle - NEStalgia" + ] + }, + { + name: "Ambient Lounge", status: "offline", listeners: 0, + currentTrack: "---", bitrate: "256 kbps", + playlist: [ + "Deep Focus - AmbientWave", + "Ocean Drift - CalmCode", + "Forest Rain - NatureByte" + ] + } + ] + + // ── TV Data ────────────────────────────────────────────────────────── + property int selectedTvIndex: 0 + property var tvChannels: [ + { + name: "MetaBuilder TV", status: "broadcasting", resolution: "1080p", + viewers: 234, uptime: "6h 14m", + schedule: [ + { time: "10:00", program: "Morning Code Review", duration: "60 min" }, + { time: "11:00", program: "Architecture Deep Dive", duration: "90 min" }, + { time: "12:30", program: "Live Build Session", duration: "120 min" }, + { time: "14:30", program: "Community Q&A", duration: "60 min" }, + { time: "15:30", program: "Plugin Showcase", duration: "45 min" } + ] + }, + { + name: "Retro Gaming Channel", status: "offline", resolution: "720p", + viewers: 0, uptime: "0m", + schedule: [ + { time: "18:00", program: "Speedrun Saturday", duration: "120 min" }, + { time: "20:00", program: "Retro Reviews", duration: "60 min" }, + { time: "21:00", program: "Chiptune Live", duration: "90 min" } + ] + } + ] + + // ── Plugins Data ───────────────────────────────────────────────────── + property var plugins: [ + { name: "FFmpeg", version: "8.0.1", status: "active", capabilities: ["H.264", "H.265", "VP9", "AV1", "AAC", "FLAC", "Opus"] }, + { name: "ImageMagick", version: "7.1.1", status: "active", capabilities: ["JPEG", "PNG", "WebP", "AVIF", "SVG", "TIFF", "Resize", "Crop"] }, + { name: "Pandoc", version: "3.6.1", status: "active", capabilities: ["Markdown", "PDF", "DOCX", "HTML", "LaTeX", "EPUB"] }, + { name: "Radio", version: "1.2.0", status: "active", capabilities: ["Icecast", "MP3 Stream", "OGG Stream", "Playlist", "Metadata"] }, + { name: "LibRetro", version: "1.19.1", status: "inactive", capabilities: ["NES", "SNES", "Genesis", "GBA", "N64", "PS1", "Recording"] } + ] + + property var tabModel: [ + { label: "Jobs" }, + { label: "Radio" }, + { label: "TV" }, + { label: "Plugins" } + ] + + // ── Media Service HTTP Client ──────────────────────────────────────── + QtObject { + id: mediaService + property string baseUrl: "http://localhost:8090" + + function request(method, endpoint, body, callback) { + var xhr = new XMLHttpRequest() + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status >= 200 && xhr.status < 300) { + try { + var result = JSON.parse(xhr.responseText) + if (callback) callback(result, null) + } catch (e) { + if (callback) callback(null, "Parse error: " + e.message) + } + } else { + if (callback) callback(null, xhr.statusText || "Request failed (" + xhr.status + ")") + } + } + } + var url = baseUrl + endpoint + xhr.open(method, url) + xhr.setRequestHeader("Content-Type", "application/json") + if (body) { + xhr.send(JSON.stringify(body)) + } else { + xhr.send() + } + } + + function healthCheck() { + serviceStatus = "unknown" + request("GET", "/health", null, function(result, error) { + if (error) { + serviceStatus = "offline" + serviceVersion = "" + } else { + serviceStatus = "online" + serviceVersion = result.version || "" + } + lastHealthCheck = Qt.formatDateTime(new Date(), "hh:mm:ss") + }) + // Fallback: if no response after 3 seconds, mark offline + healthTimeout.start() + } + + function submitJob(type, input, output, priority) { + request("POST", "/api/jobs", { + type: type, + input: input, + output: output, + priority: priority + }, function(result, error) { + if (!error && result) { + var updated = jobs.slice() + updated.unshift({ + id: result.id || ("job-" + (jobs.length + 1).toString().padStart(3, "0")), + type: type, + status: "queued", + progress: 0, + created: Qt.formatDateTime(new Date(), "yyyy-MM-dd hh:mm:ss") + }) + jobs = updated + } + }) + } + + function cancelJob(jobId) { + request("DELETE", "/api/jobs/" + jobId, null, null) + } + } + + Timer { + id: healthTimeout + interval: 3000 + repeat: false + onTriggered: { + if (serviceStatus === "unknown") { + serviceStatus = "offline" + lastHealthCheck = Qt.formatDateTime(new Date(), "hh:mm:ss") + } + } + } + + Timer { + id: healthPollTimer + interval: 30000 + repeat: true + running: true + onTriggered: mediaService.healthCheck() + } + + Component.onCompleted: { + mediaService.healthCheck() + } + + // ── Helper Functions ───────────────────────────────────────────────── + function jobStatusColor(status) { + switch (status) { + case "completed": return "success" + case "processing": return "warning" + case "queued": return "info" + case "failed": return "error" + default: return "info" + } + } + + function cancelJobById(jobId) { + mediaService.cancelJob(jobId) + var updated = jobs.map(function(j) { + return j.id === jobId ? Object.assign({}, j, { status: "failed", progress: j.progress }) : j + }) + jobs = updated + } + + function submitNewJob() { + if (jobInputPath.length === 0 || jobOutputPath.length === 0) return + var type = jobTypes[jobTypeIndex] + var priority = jobPriorities[jobPriorityIndex] + + // Try live service first + mediaService.submitJob(type, jobInputPath, jobOutputPath, priority) + + // Optimistic local update (mock fallback) + var newJob = { + id: "job-" + (jobs.length + 1).toString().padStart(3, "0"), + type: type, + status: "queued", + progress: 0, + created: Qt.formatDateTime(new Date(), "yyyy-MM-dd hh:mm:ss") + } + var updated = jobs.slice() + updated.unshift(newJob) + jobs = updated + + jobInputPath = "" + jobOutputPath = "" + } + + function toggleRadioStream(index) { + var updated = radioChannels.slice() + var ch = Object.assign({}, updated[index]) + if (ch.status === "live") { + ch.status = "offline" + ch.listeners = 0 + ch.currentTrack = "---" + } else { + ch.status = "live" + ch.listeners = Math.floor(Math.random() * 200) + 10 + ch.currentTrack = ch.playlist[0] + } + updated[index] = ch + radioChannels = updated + } + + function toggleTvBroadcast(index) { + var updated = tvChannels.slice() + var ch = Object.assign({}, updated[index]) + if (ch.status === "broadcasting") { + ch.status = "offline" + ch.viewers = 0 + ch.uptime = "0m" + } else { + ch.status = "broadcasting" + ch.viewers = Math.floor(Math.random() * 500) + 20 + ch.uptime = "0m" + } + updated[index] = ch + tvChannels = updated + } + + function resolutionColor(res) { + switch (res) { + case "1080p": return Theme.success + case "720p": return Theme.warning + case "480p": return Theme.error + default: return Theme.textSecondary + } + } + + // ── Main Layout ────────────────────────────────────────────────────── + ColumnLayout { + anchors.fill: parent + anchors.margins: 20 + 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: "Media Service" } + CBadge { text: "God Panel" } + CStatusBadge { + status: serviceStatus === "online" ? "success" + : serviceStatus === "offline" ? "error" + : "warning" + text: serviceStatus === "online" ? "Online" + : serviceStatus === "offline" ? "Offline" + : "Checking..." + } + + Item { Layout.fillWidth: true } + + CButton { + text: "Refresh" + variant: "ghost" + size: "sm" + onClicked: mediaService.healthCheck() + } + } + + CDivider { Layout.fillWidth: true } + + FlexRow { + Layout.fillWidth: true + spacing: 8 + + CChip { text: jobs.length + " Jobs" } + CChip { text: radioChannels.length + " Radio Channels" } + CChip { text: tvChannels.length + " TV Channels" } + CChip { text: plugins.length + " Plugins" } + + Item { Layout.fillWidth: true } + + CText { + visible: lastHealthCheck.length > 0 + variant: "caption" + text: "Last check: " + lastHealthCheck + color: Theme.textSecondary + } + CText { + visible: serviceVersion.length > 0 + variant: "caption" + text: "v" + serviceVersion + color: Theme.textSecondary + } + } + } + } + + // ── Tab bar ── + CTabBar { + id: tabBar + Layout.fillWidth: true + currentIndex: currentTab + onCurrentIndexChanged: currentTab = currentIndex + tabs: tabModel + } + + // ── Tab content ── + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: currentTab + + // ══════════════════════════════════════════ + // 0 - JOBS + // ══════════════════════════════════════════ + Rectangle { + color: "transparent" + + ScrollView { + anchors.fill: parent + clip: true + + ColumnLayout { + width: parent.width + spacing: 16 + + // ── Job Submission Form ── + CCard { + Layout.fillWidth: true + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + CText { variant: "h4"; text: "Submit Job" } + CDivider { Layout.fillWidth: true } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + // Type selector + ColumnLayout { + Layout.preferredWidth: 200 + spacing: 4 + + CText { variant: "caption"; text: "Type" } + + RowLayout { + spacing: 4 + + Repeater { + model: jobTypes + delegate: CButton { + text: modelData + variant: jobTypeIndex === index ? "primary" : "ghost" + size: "sm" + onClicked: jobTypeIndex = index + } + } + } + } + + CTextField { + Layout.fillWidth: true + label: "Input Path" + placeholderText: "/media/input/video.mp4" + text: jobInputPath + onTextChanged: jobInputPath = text + } + + CTextField { + Layout.fillWidth: true + label: "Output Path" + placeholderText: "/media/output/video.webm" + text: jobOutputPath + onTextChanged: jobOutputPath = text + } + } + + FlexRow { + Layout.fillWidth: true + spacing: 8 + + // Priority selector + CText { variant: "caption"; text: "Priority:" } + + Repeater { + model: jobPriorities + delegate: CButton { + text: modelData + variant: jobPriorityIndex === index ? "primary" : "ghost" + size: "sm" + onClicked: jobPriorityIndex = index + } + } + + Item { Layout.fillWidth: true } + + CButton { + text: "Submit Job" + variant: "primary" + enabled: jobInputPath.length > 0 && jobOutputPath.length > 0 + onClicked: submitNewJob() + } + } + } + } + + // ── Active Jobs Table ── + CCard { + Layout.fillWidth: true + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + FlexRow { + Layout.fillWidth: true + spacing: 12 + + CText { variant: "h4"; text: "Active Jobs" } + CText { variant: "caption"; text: jobs.length + " total"; color: Theme.textSecondary } + } + + CDivider { Layout.fillWidth: true } + + // Table header + FlexRow { + Layout.fillWidth: true + spacing: 8 + + CText { variant: "caption"; text: "ID"; Layout.preferredWidth: 100 } + CText { variant: "caption"; text: "Type"; Layout.preferredWidth: 80 } + CText { variant: "caption"; text: "Status"; Layout.preferredWidth: 100 } + CText { variant: "caption"; text: "Progress"; Layout.fillWidth: true } + CText { variant: "caption"; text: "Created"; Layout.preferredWidth: 160 } + CText { variant: "caption"; text: ""; Layout.preferredWidth: 70 } + } + + CDivider { Layout.fillWidth: true } + + // Job rows + Repeater { + model: jobs + + delegate: ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + FlexRow { + Layout.fillWidth: true + spacing: 8 + + CText { + variant: "body2" + text: modelData.id + font.family: "monospace" + Layout.preferredWidth: 100 + } + + CBadge { + text: modelData.type + Layout.preferredWidth: 80 + } + + CStatusBadge { + status: jobStatusColor(modelData.status) + text: modelData.status + Layout.preferredWidth: 100 + } + + // Progress bar area + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 20 + color: "transparent" + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: parent.width + height: 6 + radius: 3 + color: Theme.border + + Rectangle { + width: parent.width * (modelData.progress / 100) + height: parent.height + radius: 3 + color: modelData.status === "failed" ? Theme.error + : modelData.status === "completed" ? Theme.success + : Theme.primary + } + } + + CText { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + variant: "caption" + text: modelData.progress + "%" + } + } + + CText { + variant: "caption" + text: modelData.created + Layout.preferredWidth: 160 + color: Theme.textSecondary + } + + CButton { + text: "Cancel" + variant: "danger" + size: "sm" + enabled: modelData.status === "queued" || modelData.status === "processing" + visible: modelData.status !== "completed" && modelData.status !== "failed" + Layout.preferredWidth: 70 + onClicked: cancelJobById(modelData.id) + } + + // Placeholder for completed/failed jobs + Item { + visible: modelData.status === "completed" || modelData.status === "failed" + Layout.preferredWidth: 70 + } + } + + CDivider { + Layout.fillWidth: true + visible: index < jobs.length - 1 + } + } + } + } + } + + Item { Layout.preferredHeight: 8 } + } + } + } + + // ══════════════════════════════════════════ + // 1 - RADIO + // ══════════════════════════════════════════ + Rectangle { + color: "transparent" + + RowLayout { + anchors.fill: parent + spacing: 16 + + // ── Channel List ── + CCard { + Layout.preferredWidth: 320 + Layout.fillHeight: true + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + FlexRow { + Layout.fillWidth: true + spacing: 8 + + CText { variant: "h4"; text: "Radio Channels" } + CText { variant: "caption"; text: radioChannels.length + " channels"; color: Theme.textSecondary } + } + + CDivider { Layout.fillWidth: true } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + model: radioChannels + spacing: 4 + clip: true + + delegate: CListItem { + width: parent ? parent.width : 288 + title: modelData.name + subtitle: modelData.status === "live" + ? modelData.listeners + " listeners" + : "Offline" + selected: index === selectedRadioIndex + onClicked: selectedRadioIndex = index + } + } + } + } + + // ── Channel Detail ── + CCard { + Layout.fillWidth: true + Layout.fillHeight: true + + Flickable { + anchors.fill: parent + anchors.margins: 16 + contentHeight: radioDetailCol.implicitHeight + clip: true + + ColumnLayout { + id: radioDetailCol + width: parent.width + spacing: 16 + + // Channel header + FlexRow { + Layout.fillWidth: true + spacing: 12 + + CText { variant: "h3"; text: radioChannels[selectedRadioIndex].name } + CStatusBadge { + status: radioChannels[selectedRadioIndex].status === "live" ? "success" : "error" + text: radioChannels[selectedRadioIndex].status === "live" ? "Live" : "Offline" + } + + Item { Layout.fillWidth: true } + + CButton { + text: radioChannels[selectedRadioIndex].status === "live" ? "Stop Stream" : "Start Stream" + variant: radioChannels[selectedRadioIndex].status === "live" ? "danger" : "primary" + onClicked: toggleRadioStream(selectedRadioIndex) + } + } + + CDivider { Layout.fillWidth: true } + + // Stats row + FlexRow { + Layout.fillWidth: true + spacing: 12 + + CPaper { + Layout.fillWidth: true + implicitHeight: 60 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 2 + CText { variant: "caption"; text: "Listeners" } + CText { variant: "h4"; text: radioChannels[selectedRadioIndex].listeners.toString() } + } + } + + CPaper { + Layout.fillWidth: true + implicitHeight: 60 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 2 + CText { variant: "caption"; text: "Bitrate" } + CText { variant: "h4"; text: radioChannels[selectedRadioIndex].bitrate } + } + } + + CPaper { + Layout.fillWidth: true + implicitHeight: 60 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 2 + CText { variant: "caption"; text: "Now Playing" } + CText { + variant: "body2" + text: radioChannels[selectedRadioIndex].currentTrack + elide: Text.ElideRight + Layout.fillWidth: true + } + } + } + } + + CDivider { Layout.fillWidth: true } + + // Playlist + CText { variant: "subtitle1"; text: "Playlist" } + CText { + variant: "caption" + text: radioChannels[selectedRadioIndex].playlist.length + " tracks" + color: Theme.textSecondary + } + + Repeater { + model: radioChannels[selectedRadioIndex].playlist + + delegate: FlexRow { + Layout.fillWidth: true + spacing: 12 + + CText { + variant: "caption" + text: (index + 1).toString().padStart(2, " ") + "." + font.family: "monospace" + color: Theme.textSecondary + } + + CText { + variant: "body2" + text: modelData + Layout.fillWidth: true + } + + CStatusBadge { + visible: modelData === radioChannels[selectedRadioIndex].currentTrack + && radioChannels[selectedRadioIndex].status === "live" + status: "success" + text: "Playing" + } + } + } + + Item { Layout.preferredHeight: 8 } + } + } + } + } + } + + // ══════════════════════════════════════════ + // 2 - TV + // ══════════════════════════════════════════ + Rectangle { + color: "transparent" + + RowLayout { + anchors.fill: parent + spacing: 16 + + // ── Channel List ── + CCard { + Layout.preferredWidth: 320 + Layout.fillHeight: true + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + FlexRow { + Layout.fillWidth: true + spacing: 8 + + CText { variant: "h4"; text: "TV Channels" } + CText { variant: "caption"; text: tvChannels.length + " channels"; color: Theme.textSecondary } + } + + CDivider { Layout.fillWidth: true } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + model: tvChannels + spacing: 4 + clip: true + + delegate: CListItem { + width: parent ? parent.width : 288 + title: modelData.name + subtitle: modelData.status === "broadcasting" + ? modelData.viewers + " viewers" + : "Offline" + selected: index === selectedTvIndex + onClicked: selectedTvIndex = index + } + } + } + } + + // ── Channel Detail ── + CCard { + Layout.fillWidth: true + Layout.fillHeight: true + + Flickable { + anchors.fill: parent + anchors.margins: 16 + contentHeight: tvDetailCol.implicitHeight + clip: true + + ColumnLayout { + id: tvDetailCol + width: parent.width + spacing: 16 + + // Channel header + FlexRow { + Layout.fillWidth: true + spacing: 12 + + CText { variant: "h3"; text: tvChannels[selectedTvIndex].name } + CStatusBadge { + status: tvChannels[selectedTvIndex].status === "broadcasting" ? "success" : "error" + text: tvChannels[selectedTvIndex].status === "broadcasting" ? "Broadcasting" : "Offline" + } + + // Resolution badge + Rectangle { + width: resLabel.implicitWidth + 16 + height: 24 + radius: 4 + color: resolutionColor(tvChannels[selectedTvIndex].resolution) + opacity: 0.15 + + CText { + id: resLabel + anchors.centerIn: parent + variant: "caption" + text: tvChannels[selectedTvIndex].resolution + color: resolutionColor(tvChannels[selectedTvIndex].resolution) + font.bold: true + } + } + + Item { Layout.fillWidth: true } + + CButton { + text: tvChannels[selectedTvIndex].status === "broadcasting" ? "Stop Broadcast" : "Start Broadcast" + variant: tvChannels[selectedTvIndex].status === "broadcasting" ? "danger" : "primary" + onClicked: toggleTvBroadcast(selectedTvIndex) + } + } + + CDivider { Layout.fillWidth: true } + + // Stats row + FlexRow { + Layout.fillWidth: true + spacing: 12 + + CPaper { + Layout.fillWidth: true + implicitHeight: 60 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 2 + CText { variant: "caption"; text: "Viewers" } + CText { variant: "h4"; text: tvChannels[selectedTvIndex].viewers.toString() } + } + } + + CPaper { + Layout.fillWidth: true + implicitHeight: 60 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 2 + CText { variant: "caption"; text: "Resolution" } + CText { variant: "h4"; text: tvChannels[selectedTvIndex].resolution } + } + } + + CPaper { + Layout.fillWidth: true + implicitHeight: 60 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 2 + CText { variant: "caption"; text: "Uptime" } + CText { variant: "h4"; text: tvChannels[selectedTvIndex].uptime } + } + } + } + + CDivider { Layout.fillWidth: true } + + // Schedule + CText { variant: "subtitle1"; text: "Schedule" } + CText { + variant: "caption" + text: tvChannels[selectedTvIndex].schedule.length + " programs" + color: Theme.textSecondary + } + + // Schedule table header + FlexRow { + Layout.fillWidth: true + spacing: 8 + + CText { variant: "caption"; text: "Time"; Layout.preferredWidth: 80 } + CText { variant: "caption"; text: "Program"; Layout.fillWidth: true } + CText { variant: "caption"; text: "Duration"; Layout.preferredWidth: 80 } + } + + CDivider { Layout.fillWidth: true } + + Repeater { + model: tvChannels[selectedTvIndex].schedule + + delegate: ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + FlexRow { + Layout.fillWidth: true + spacing: 8 + + CText { + variant: "body2" + text: modelData.time + font.family: "monospace" + font.bold: true + Layout.preferredWidth: 80 + } + + CText { + variant: "body2" + text: modelData.program + Layout.fillWidth: true + } + + CText { + variant: "caption" + text: modelData.duration + color: Theme.textSecondary + Layout.preferredWidth: 80 + } + } + + CDivider { + Layout.fillWidth: true + visible: index < tvChannels[selectedTvIndex].schedule.length - 1 + } + } + } + + Item { Layout.preferredHeight: 8 } + } + } + } + } + } + + // ══════════════════════════════════════════ + // 3 - PLUGINS + // ══════════════════════════════════════════ + Rectangle { + color: "transparent" + + ScrollView { + anchors.fill: parent + clip: true + + ColumnLayout { + width: parent.width + spacing: 16 + + FlexRow { + Layout.fillWidth: true + spacing: 12 + + CText { variant: "h3"; text: "Installed Plugins" } + CText { variant: "caption"; text: plugins.length + " plugins"; color: Theme.textSecondary } + + Item { Layout.fillWidth: true } + + CButton { + text: "Reload All (Dev)" + variant: "ghost" + size: "sm" + onClicked: { + mediaService.request("POST", "/api/plugins/reload", null, null) + } + } + } + + CDivider { Layout.fillWidth: true } + + // Plugin grid (2 columns) + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: 16 + rowSpacing: 16 + + Repeater { + model: plugins + + delegate: CCard { + Layout.fillWidth: true + variant: "outlined" + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 10 + + FlexRow { + Layout.fillWidth: true + spacing: 8 + + CText { variant: "subtitle1"; text: modelData.name } + + Item { Layout.fillWidth: true } + + CStatusBadge { + status: modelData.status === "active" ? "success" : "warning" + text: modelData.status + } + } + + CText { + variant: "caption" + text: "v" + modelData.version + color: Theme.textSecondary + } + + CDivider { Layout.fillWidth: true } + + CText { variant: "caption"; text: "Capabilities" } + + Flow { + Layout.fillWidth: true + spacing: 6 + + Repeater { + model: modelData.capabilities + + delegate: CChip { + text: modelData + } + } + } + + FlexRow { + Layout.fillWidth: true + spacing: 8 + + Item { Layout.fillWidth: true } + + CButton { + text: "Reload" + variant: "ghost" + size: "sm" + onClicked: { + mediaService.request("POST", "/api/plugins/" + plugins[index].name.toLowerCase() + "/reload", null, null) + } + } + } + } + } + } + } + + Item { Layout.preferredHeight: 8 } + } + } + } + } + } +} diff --git a/frontends/qt6/NotificationsPanel.qml b/frontends/qt6/NotificationsPanel.qml new file mode 100644 index 000000000..d848468af --- /dev/null +++ b/frontends/qt6/NotificationsPanel.qml @@ -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 } + } + } +} diff --git a/frontends/qt6/PageRoutesManager.qml b/frontends/qt6/PageRoutesManager.qml index 3ea68b757..6b3da2d4d 100644 --- a/frontends/qt6/PageRoutesManager.qml +++ b/frontends/qt6/PageRoutesManager.qml @@ -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 diff --git a/frontends/qt6/ProfileView.qml b/frontends/qt6/ProfileView.qml index bb5e9e738..d14e09e8d 100644 --- a/frontends/qt6/ProfileView.qml +++ b/frontends/qt6/ProfileView.qml @@ -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() } } diff --git a/frontends/qt6/ROADMAP.md b/frontends/qt6/ROADMAP.md index f73051401..822cdfe24 100644 --- a/frontends/qt6/ROADMAP.md +++ b/frontends/qt6/ROADMAP.md @@ -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) ``` diff --git a/frontends/qt6/SchemaEditor.qml b/frontends/qt6/SchemaEditor.qml index c651ac9c1..8c2e055ef 100644 --- a/frontends/qt6/SchemaEditor.qml +++ b/frontends/qt6/SchemaEditor.qml @@ -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)) diff --git a/frontends/qt6/SettingsView.qml b/frontends/qt6/SettingsView.qml new file mode 100644 index 000000000..13d5cc193 --- /dev/null +++ b/frontends/qt6/SettingsView.qml @@ -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 } + } + } +} diff --git a/frontends/qt6/SuperGodPanel.qml b/frontends/qt6/SuperGodPanel.qml index 29c0882fb..814cb201c 100644 --- a/frontends/qt6/SuperGodPanel.qml +++ b/frontends/qt6/SuperGodPanel.qml @@ -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 diff --git a/frontends/qt6/UserManagement.qml b/frontends/qt6/UserManagement.qml index a089e761b..b4e04a060 100644 --- a/frontends/qt6/UserManagement.qml +++ b/frontends/qt6/UserManagement.qml @@ -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 ────────────────────────────────────────────────── diff --git a/frontends/qt6/WorkflowEditor.qml b/frontends/qt6/WorkflowEditor.qml index 18131f672..845451b13 100644 --- a/frontends/qt6/WorkflowEditor.qml +++ b/frontends/qt6/WorkflowEditor.qml @@ -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) } } } diff --git a/frontends/qt6/cmake_config.json b/frontends/qt6/cmake_config.json new file mode 100644 index 000000000..55d83954f --- /dev/null +++ b/frontends/qt6/cmake_config.json @@ -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}" + } +} diff --git a/frontends/qt6/conanfile.txt b/frontends/qt6/conanfile.txt index c1e62c067..0bf393781 100644 --- a/frontends/qt6/conanfile.txt +++ b/frontends/qt6/conanfile.txt @@ -11,7 +11,6 @@ qt/*:qtdeclarative=True qt/*:qtshadertools=True [tool_requires] -cmake/3.30.0 ninja/1.12.1 [layout] diff --git a/frontends/qt6/docker-compose.dev.yml b/frontends/qt6/docker-compose.dev.yml new file mode 100644 index 000000000..6e4bda142 --- /dev/null +++ b/frontends/qt6/docker-compose.dev.yml @@ -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: diff --git a/frontends/qt6/generate_cmake.py b/frontends/qt6/generate_cmake.py new file mode 100755 index 000000000..46eb21006 --- /dev/null +++ b/frontends/qt6/generate_cmake.py @@ -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() diff --git a/frontends/qt6/main.cpp b/frontends/qt6/main.cpp index d6cf79e48..0fc61637f 100644 --- a/frontends/qt6/main.cpp +++ b/frontends/qt6/main.cpp @@ -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, diff --git a/frontends/qt6/packages/connection-hub/PackageView.qml b/frontends/qt6/packages/connection-hub/PackageView.qml index 72827594a..08728ea99 100644 --- a/frontends/qt6/packages/connection-hub/PackageView.qml +++ b/frontends/qt6/packages/connection-hub/PackageView.qml @@ -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" } + } + } + } + } } } } diff --git a/frontends/qt6/packages/escape-room/PackageView.qml b/frontends/qt6/packages/escape-room/PackageView.qml index 29cd1bae2..9f2ea283d 100644 --- a/frontends/qt6/packages/escape-room/PackageView.qml +++ b/frontends/qt6/packages/escape-room/PackageView.qml @@ -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 + } + } + } + } + } + } } } } diff --git a/frontends/qt6/packages/marketplace/PackageView.qml b/frontends/qt6/packages/marketplace/PackageView.qml index 8c8eb43e7..93f3ea9a5 100644 --- a/frontends/qt6/packages/marketplace/PackageView.qml +++ b/frontends/qt6/packages/marketplace/PackageView.qml @@ -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 + } + } + } } } } diff --git a/frontends/qt6/packages/microthread/PackageView.qml b/frontends/qt6/packages/microthread/PackageView.qml index 9feb8fa8d..e5427038d 100644 --- a/frontends/qt6/packages/microthread/PackageView.qml +++ b/frontends/qt6/packages/microthread/PackageView.qml @@ -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" + } + } + } + } + } } } } diff --git a/frontends/qt6/packages/retro-games/PackageView.qml b/frontends/qt6/packages/retro-games/PackageView.qml index 8a33e1fd8..6f05929d3 100644 --- a/frontends/qt6/packages/retro-games/PackageView.qml +++ b/frontends/qt6/packages/retro-games/PackageView.qml @@ -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 + } + } + } + } + } } } } diff --git a/frontends/qt6/packages/watchtower/PackageView.qml b/frontends/qt6/packages/watchtower/PackageView.qml index 8d8345520..8216af021 100644 --- a/frontends/qt6/packages/watchtower/PackageView.qml +++ b/frontends/qt6/packages/watchtower/PackageView.qml @@ -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 + } + } + } + } + } } } } diff --git a/frontends/qt6/qmllib/dbal/DBALProvider.qml b/frontends/qt6/qmllib/dbal/DBALProvider.qml index 1822efe95..5b230c3a6 100644 --- a/frontends/qt6/qmllib/dbal/DBALProvider.qml +++ b/frontends/qt6/qmllib/dbal/DBALProvider.qml @@ -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) }) diff --git a/frontends/qt6/src/DBALClient.cpp b/frontends/qt6/src/DBALClient.cpp index b69ae9d76..4c90762e4 100644 --- a/frontends/qt6/src/DBALClient.cpp +++ b/frontends/qt6/src/DBALClient.cpp @@ -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); } diff --git a/frontends/qt6/src/DBALClient.h b/frontends/qt6/src/DBALClient.h index be558c393..e4d0cf2ed 100644 --- a/frontends/qt6/src/DBALClient.h +++ b/frontends/qt6/src/DBALClient.h @@ -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 m_pendingCallbacks; diff --git a/frontends/qt6/src/PackageLoader.cpp b/frontends/qt6/src/PackageLoader.cpp new file mode 100644 index 000000000..58ad1dc11 --- /dev/null +++ b/frontends/qt6/src/PackageLoader.cpp @@ -0,0 +1,281 @@ +#include "PackageLoader.h" + +#include +#include +#include +#include +#include + +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); +} diff --git a/frontends/qt6/src/PackageLoader.h b/frontends/qt6/src/PackageLoader.h new file mode 100644 index 000000000..8738f092b --- /dev/null +++ b/frontends/qt6/src/PackageLoader.h @@ -0,0 +1,67 @@ +#ifndef PACKAGELOADER_H +#define PACKAGELOADER_H + +#include +#include +#include +#include +#include +#include +#include +#include + +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 m_packages; + QMap m_installed; +}; + +#endif