|
| 1 | +import QtQuick |
| 2 | +import QtQuick.Controls |
| 3 | +import QtQuick.Layouts |
| 4 | +import qs.Commons |
| 5 | +import qs.Services.Noctalia |
| 6 | +import qs.Services.UI |
| 7 | +import qs.Widgets |
| 8 | + |
| 9 | +ColumnLayout { |
| 10 | + id: root |
| 11 | + spacing: Style.marginL |
| 12 | + Layout.fillWidth: true |
| 13 | + implicitWidth: 0 |
| 14 | + |
| 15 | + property var pluginApi: null |
| 16 | + property string pluginSearchText: "" |
| 17 | + property string selectedTag: "" |
| 18 | + property int tagsRefreshCounter: 0 |
| 19 | + |
| 20 | + property string selectedPluginId: "" |
| 21 | + signal pluginSelected(string pluginId, string sourceUrl) |
| 22 | + property int availablePluginsRefreshCounter: 0 |
| 23 | + |
| 24 | + // Pseudo tags for filtering |
| 25 | + readonly property var pseudoTags: ["official", "downloaded", "notDownloaded"] |
| 26 | + |
| 27 | + readonly property var availableTags: { |
| 28 | + void (root.tagsRefreshCounter); |
| 29 | + var tags = {}; |
| 30 | + var plugins = PluginService.availablePlugins || []; |
| 31 | + for (var i = 0; i < plugins.length; i++) { |
| 32 | + var pluginTags = plugins[i].tags || []; |
| 33 | + for (var j = 0; j < pluginTags.length; j++) { |
| 34 | + tags[pluginTags[j]] = true; |
| 35 | + } |
| 36 | + } |
| 37 | + return Object.keys(tags).sort(); |
| 38 | + } |
| 39 | + |
| 40 | + function stripAuthorEmail(author) { |
| 41 | + if (!author) return ""; |
| 42 | + var lastBracket = author.lastIndexOf("<"); |
| 43 | + if (lastBracket >= 0) { |
| 44 | + return author.substring(0, lastBracket).trim(); |
| 45 | + } |
| 46 | + return author; |
| 47 | + } |
| 48 | + |
| 49 | + // Timer to check for updates after refresh starts |
| 50 | + Timer { |
| 51 | + id: checkUpdatesTimer |
| 52 | + interval: 100 |
| 53 | + onTriggered: { |
| 54 | + PluginService.checkForUpdates(); |
| 55 | + } |
| 56 | + } |
| 57 | + |
| 58 | + Component.onDestruction: { |
| 59 | + checkUpdatesTimer.stop(); |
| 60 | + } |
| 61 | + |
| 62 | + function installPlugin(pluginMetadata) { |
| 63 | + var title = pluginApi?.tr("panel.title") ?? "" |
| 64 | + var msg = pluginApi?.tr("panel.installing") ?? "" |
| 65 | + msg = msg.replace("{plugin}", pluginMetadata.name) |
| 66 | + ToastService.showNotice(title, msg); |
| 67 | + |
| 68 | + PluginService.installPlugin(pluginMetadata, false, function (success, error, registeredKey) { |
| 69 | + if (success) { |
| 70 | + var successMsg = pluginApi?.tr("panel.install-success") ?? "" |
| 71 | + successMsg = successMsg.replace("{plugin}", pluginMetadata.name) |
| 72 | + ToastService.showNotice(title, successMsg); |
| 73 | + PluginService.enablePlugin(registeredKey); |
| 74 | + } else { |
| 75 | + var errorMsg = pluginApi?.tr("panel.install-error") ?? "" |
| 76 | + errorMsg = errorMsg.replace("{error}", error || (pluginApi?.tr("panel.unknown-error") ?? "")) |
| 77 | + ToastService.showError(title, errorMsg); |
| 78 | + } |
| 79 | + }); |
| 80 | + } |
| 81 | + |
| 82 | + // Listen to plugin service signals |
| 83 | + Connections { |
| 84 | + target: PluginService |
| 85 | + |
| 86 | + function onAvailablePluginsUpdated() { |
| 87 | + root.tagsRefreshCounter++; |
| 88 | + root.availablePluginsRefreshCounter++; |
| 89 | + |
| 90 | + Qt.callLater(function () { |
| 91 | + PluginService.checkForUpdates(); |
| 92 | + }); |
| 93 | + } |
| 94 | + } |
| 95 | + |
| 96 | + // Tag filter chips — wrapped in Item to prevent Flow's large implicitWidth |
| 97 | + Item { |
| 98 | + Layout.fillWidth: true |
| 99 | + implicitHeight: tagFilter.implicitHeight |
| 100 | + clip: true |
| 101 | + |
| 102 | + NTagFilter { |
| 103 | + id: tagFilter |
| 104 | + width: parent.width |
| 105 | + tags: root.pseudoTags.concat(root.availableTags) |
| 106 | + selectedTag: root.selectedTag |
| 107 | + onSelectedTagChanged: root.selectedTag = selectedTag |
| 108 | + label: pluginApi?.tr("panel.filter-tags-label") |
| 109 | + description: pluginApi?.tr("panel.filter-tags-description") |
| 110 | + expanded: true |
| 111 | + |
| 112 | + formatTag: function (tag) { |
| 113 | + if (tag === "") |
| 114 | + return pluginApi?.tr("panel.filter-all") |
| 115 | + if (tag === "official") |
| 116 | + return pluginApi?.tr("panel.official") |
| 117 | + if (tag === "downloaded") |
| 118 | + return pluginApi?.tr("panel.filter-downloaded") |
| 119 | + if (tag === "notDownloaded") |
| 120 | + return pluginApi?.tr("panel.filter-not-downloaded") |
| 121 | + return tag; |
| 122 | + } |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + // Search input with refresh button |
| 127 | + RowLayout { |
| 128 | + Layout.fillWidth: true |
| 129 | + spacing: Style.marginM |
| 130 | + |
| 131 | + NTextInput { |
| 132 | + placeholderText: I18n.tr("placeholders.search") |
| 133 | + inputIconName: "search" |
| 134 | + text: root.pluginSearchText |
| 135 | + onTextChanged: root.pluginSearchText = text |
| 136 | + Layout.fillWidth: true |
| 137 | + } |
| 138 | + |
| 139 | + NIconButton { |
| 140 | + icon: "refresh" |
| 141 | + tooltipText: pluginApi?.tr("panel.refresh") |
| 142 | + baseSize: Style.baseWidgetSize * 0.9 |
| 143 | + onClicked: { |
| 144 | + PluginService.refreshAvailablePlugins(); |
| 145 | + checkUpdatesTimer.restart(); |
| 146 | + } |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + // Available plugins list |
| 151 | + ColumnLayout { |
| 152 | + spacing: Style.marginM |
| 153 | + Layout.fillWidth: true |
| 154 | + |
| 155 | + Repeater { |
| 156 | + id: availablePluginsRepeater |
| 157 | + |
| 158 | + model: { |
| 159 | + void (root.availablePluginsRefreshCounter); |
| 160 | + |
| 161 | + var all = PluginService.availablePlugins || []; |
| 162 | + var filtered = []; |
| 163 | + |
| 164 | + for (var i = 0; i < all.length; i++) { |
| 165 | + var plugin = all[i]; |
| 166 | + var downloaded = plugin.downloaded || false; |
| 167 | + var pluginTags = plugin.tags || []; |
| 168 | + |
| 169 | + if (root.selectedTag === "") { |
| 170 | + filtered.push(plugin); |
| 171 | + } else if (root.selectedTag === "official") { |
| 172 | + if (plugin.official === true) |
| 173 | + filtered.push(plugin); |
| 174 | + } else if (root.selectedTag === "downloaded") { |
| 175 | + if (downloaded) |
| 176 | + filtered.push(plugin); |
| 177 | + } else if (root.selectedTag === "notDownloaded") { |
| 178 | + if (!downloaded) |
| 179 | + filtered.push(plugin); |
| 180 | + } else { |
| 181 | + if (pluginTags.indexOf(root.selectedTag) >= 0) { |
| 182 | + filtered.push(plugin); |
| 183 | + } |
| 184 | + } |
| 185 | + } |
| 186 | + |
| 187 | + // Apply fuzzy search |
| 188 | + var query = root.pluginSearchText.trim(); |
| 189 | + if (query !== "") { |
| 190 | + var results = FuzzySort.go(query, filtered, { |
| 191 | + "keys": ["name", "description"], |
| 192 | + "limit": 50 |
| 193 | + }); |
| 194 | + filtered = []; |
| 195 | + for (var j = 0; j < results.length; j++) { |
| 196 | + filtered.push(results[j].obj); |
| 197 | + } |
| 198 | + } else { |
| 199 | + // Sort by lastUpdated (most recent first) |
| 200 | + filtered.sort(function (a, b) { |
| 201 | + var dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0; |
| 202 | + var dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0; |
| 203 | + return dateB - dateA; |
| 204 | + }); |
| 205 | + } |
| 206 | + |
| 207 | + // Move hello-world plugin to the end |
| 208 | + var helloWorldIndex = -1; |
| 209 | + for (var h = 0; h < filtered.length; h++) { |
| 210 | + if (filtered[h].id === "hello-world") { |
| 211 | + helloWorldIndex = h; |
| 212 | + break; |
| 213 | + } |
| 214 | + } |
| 215 | + if (helloWorldIndex >= 0) { |
| 216 | + var helloWorld = filtered.splice(helloWorldIndex, 1)[0]; |
| 217 | + filtered.push(helloWorld); |
| 218 | + } |
| 219 | + |
| 220 | + return filtered; |
| 221 | + } |
| 222 | + |
| 223 | + delegate: NBox { |
| 224 | + Layout.fillWidth: true |
| 225 | + Layout.leftMargin: Style.borderS |
| 226 | + Layout.rightMargin: Style.borderS |
| 227 | + implicitHeight: Math.round(contentColumn.implicitHeight + Style.margin2L) |
| 228 | + color: modelData.id === root.selectedPluginId ? Color.mHover : Color.mSurface |
| 229 | + |
| 230 | + MouseArea { |
| 231 | + anchors.fill: parent |
| 232 | + propagateComposedEvents: true |
| 233 | + cursorShape: Qt.PointingHandCursor |
| 234 | + onClicked: mouse => { |
| 235 | + root.pluginSelected(modelData.id, modelData.source?.url || "") |
| 236 | + mouse.accepted = false |
| 237 | + } |
| 238 | + } |
| 239 | + |
| 240 | + ColumnLayout { |
| 241 | + id: contentColumn |
| 242 | + anchors.fill: parent |
| 243 | + anchors.margins: Style.marginL |
| 244 | + spacing: Style.marginS |
| 245 | + |
| 246 | + // Row 1: icon, name, badge |
| 247 | + RowLayout { |
| 248 | + spacing: Style.marginM |
| 249 | + Layout.fillWidth: true |
| 250 | + |
| 251 | + NIcon { |
| 252 | + icon: "plugin" |
| 253 | + pointSize: Style.fontSizeL |
| 254 | + color: Color.mPrimary |
| 255 | + } |
| 256 | + |
| 257 | + NText { |
| 258 | + text: modelData.name |
| 259 | + color: Color.mPrimary |
| 260 | + elide: Text.ElideRight |
| 261 | + Layout.fillWidth: true |
| 262 | + } |
| 263 | + |
| 264 | + // Official badge |
| 265 | + Rectangle { |
| 266 | + visible: modelData.official === true |
| 267 | + color: Color.mSecondary |
| 268 | + radius: Style.radiusXS |
| 269 | + implicitWidth: officialBadgeRow.implicitWidth + Style.margin2S |
| 270 | + implicitHeight: officialBadgeRow.implicitHeight + Style.margin2XS |
| 271 | + |
| 272 | + RowLayout { |
| 273 | + id: officialBadgeRow |
| 274 | + anchors.centerIn: parent |
| 275 | + spacing: Style.marginXS |
| 276 | + |
| 277 | + NIcon { |
| 278 | + icon: "official-plugin" |
| 279 | + pointSize: Style.fontSizeXXS |
| 280 | + color: Color.mOnSecondary |
| 281 | + } |
| 282 | + |
| 283 | + NText { |
| 284 | + text: pluginApi?.tr("panel.official") |
| 285 | + font.pointSize: Style.fontSizeXXS |
| 286 | + font.weight: Style.fontWeightMedium |
| 287 | + color: Color.mOnSecondary |
| 288 | + } |
| 289 | + } |
| 290 | + } |
| 291 | + } |
| 292 | + |
| 293 | + // Row 2: action buttons |
| 294 | + RowLayout { |
| 295 | + spacing: Style.marginXS |
| 296 | + Layout.fillWidth: true |
| 297 | + |
| 298 | + NIconButton { |
| 299 | + icon: "external-link" |
| 300 | + baseSize: Style.baseWidgetSize * 0.7 |
| 301 | + tooltipText: pluginApi?.tr("panel.open-plugin-page") |
| 302 | + onClicked: { |
| 303 | + var sourceUrl = modelData.source?.url || ""; |
| 304 | + Qt.openUrlExternally(sourceUrl && !PluginRegistry.isMainSource(sourceUrl) ? sourceUrl : "https://noctalia.dev/plugins/" + modelData.id + "/"); |
| 305 | + } |
| 306 | + } |
| 307 | + |
| 308 | + // Downloaded indicator |
| 309 | + NIcon { |
| 310 | + icon: "circle-check" |
| 311 | + pointSize: Style.baseWidgetSize * 0.5 |
| 312 | + color: Color.mPrimary |
| 313 | + visible: modelData.downloaded === true |
| 314 | + } |
| 315 | + |
| 316 | + // Install button |
| 317 | + NIconButton { |
| 318 | + visible: modelData.downloaded === false && !PluginService.installingPlugins[modelData.id] |
| 319 | + icon: "download" |
| 320 | + baseSize: Style.baseWidgetSize * 0.7 |
| 321 | + tooltipText: pluginApi?.tr("panel.install") |
| 322 | + onClicked: installPlugin(modelData) |
| 323 | + } |
| 324 | + |
| 325 | + // Installing spinner |
| 326 | + NBusyIndicator { |
| 327 | + visible: !modelData.downloaded && (PluginService.installingPlugins[modelData.id] === true) |
| 328 | + size: Style.baseWidgetSize * 0.5 |
| 329 | + running: visible |
| 330 | + } |
| 331 | + } |
| 332 | + |
| 333 | + // Description |
| 334 | + NText { |
| 335 | + visible: modelData.description |
| 336 | + text: modelData.description || "" |
| 337 | + font.pointSize: Style.fontSizeXS |
| 338 | + color: Color.mOnSurface |
| 339 | + wrapMode: Text.WordWrap |
| 340 | + elide: Text.ElideNone |
| 341 | + Layout.fillWidth: true |
| 342 | + } |
| 343 | + |
| 344 | + // Details: version, author |
| 345 | + RowLayout { |
| 346 | + spacing: Style.marginS |
| 347 | + Layout.fillWidth: true |
| 348 | + |
| 349 | + NText { |
| 350 | + text: "v" + modelData.version |
| 351 | + font.pointSize: Style.fontSizeXS |
| 352 | + color: Color.mOnSurfaceVariant |
| 353 | + } |
| 354 | + |
| 355 | + NText { |
| 356 | + text: "\u2022" |
| 357 | + font.pointSize: Style.fontSizeXS |
| 358 | + color: Color.mOnSurfaceVariant |
| 359 | + } |
| 360 | + |
| 361 | + NText { |
| 362 | + text: stripAuthorEmail(modelData.author) |
| 363 | + font.pointSize: Style.fontSizeXS |
| 364 | + color: Color.mOnSurfaceVariant |
| 365 | + } |
| 366 | + |
| 367 | + Item { |
| 368 | + Layout.fillWidth: true |
| 369 | + } |
| 370 | + } |
| 371 | + |
| 372 | + // Source name |
| 373 | + NText { |
| 374 | + visible: modelData.source ? true : false |
| 375 | + text: modelData.source ? modelData.source.name : "" |
| 376 | + font.pointSize: Style.fontSizeXS |
| 377 | + color: Color.mOnSurfaceVariant |
| 378 | + } |
| 379 | + |
| 380 | + // Last updated |
| 381 | + NText { |
| 382 | + visible: !!modelData.lastUpdated |
| 383 | + text: modelData.lastUpdated ? Time.formatRelativeTime(new Date(modelData.lastUpdated)) : "" |
| 384 | + font.pointSize: Style.fontSizeXS |
| 385 | + color: Color.mOnSurfaceVariant |
| 386 | + } |
| 387 | + } |
| 388 | + } |
| 389 | + } |
| 390 | + |
| 391 | + NLabel { |
| 392 | + visible: availablePluginsRepeater.count === 0 |
| 393 | + label: pluginApi?.tr("panel.no-plugins-available") |
| 394 | + Layout.fillWidth: true |
| 395 | + } |
| 396 | + } |
| 397 | +} |
0 commit comments