From 44cfc45c55f61676d8c3f9dcaab9bbac0f75f678 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 24 Sep 2026 12:10:51 +1000 Subject: [PATCH] feat(signage): import plugins from a frontend repository Add an import wizard next to manual plugin entry. It lists the HTML files in an interface repository, loads each in a sandboxed off-screen iframe, and lists the files that report as plugins. Selecting one opens the new plugin form with name, URI and type filled in. Bump @placeos/ts-client to 6.5.0 for listRepositoryFiles. Refs PPT-2814 Co-Authored-By: Claude Opus 5.5 (1M context) --- bun.lock | 4 +- package.json | 2 +- public/assets/locale/en-AU.json | 6 + .../signage-plugin-embed.component.ts | 12 +- .../signage-plugin-import-modal.component.ts | 213 ++++++++++++++++++ .../signage-plugins.component.ts | 35 ++- .../signage-plugins.utilities.ts | 73 ++++++ .../admin/signage-plugins.utilities.spec.ts | 121 +++++++++- 8 files changed, 456 insertions(+), 10 deletions(-) create mode 100644 src/app/admin/signage-plugins/signage-plugin-import-modal.component.ts diff --git a/bun.lock b/bun.lock index fce5e4f50..583c1d993 100644 --- a/bun.lock +++ b/bun.lock @@ -43,7 +43,7 @@ "@nx/web": "23.1.1", "@nx/workspace": "23.1.1", "@placeos/cloud-uploads": "^1.1.1", - "@placeos/ts-client": "^6.3.1", + "@placeos/ts-client": "^6.5.0", "@playwright/test": "^1.36.0", "@schematics/angular": "22.0.0", "@sentry/browser": "^10.25.0", @@ -876,7 +876,7 @@ "@placeos/cloud-uploads": ["@placeos/cloud-uploads@1.1.1", "", { "peerDependencies": { "rxjs": "^7.8.2", "ts-md5": "^2.0.1" } }, "sha512-krF2RiuX4u0MkHBhm+DkrxWa91pMHmiWs3M4tuFpFDEsnrpbvPVvVPDs1EwqyLib5D6d/vxS6an+TcMTGfA+tg=="], - "@placeos/ts-client": ["@placeos/ts-client@6.3.1", "", { "dependencies": { "byte-base64": "^1.1.0", "fast-sha256": "^1.3.0" }, "peerDependencies": { "date-fns": "^4.1.0", "node-fetch": "^3.2.0", "ts-md5": "^2.0.1", "websocket": "^1.0.34" } }, "sha512-rT86XaIx3JI8K6003vaZXN44mP/dGNhs5VeylEbfYQlUKC0gV/Ku12oAWFq9a5Iis+YHxb2Uh9beWS0bDVo0eg=="], + "@placeos/ts-client": ["@placeos/ts-client@6.5.0", "", { "dependencies": { "byte-base64": "^1.1.0", "fast-sha256": "^1.3.0" }, "peerDependencies": { "date-fns": "^4.1.0", "node-fetch": "^3.2.0", "ts-md5": "^2.0.1", "websocket": "^1.0.34" } }, "sha512-M6rvhk9So3vCFxlvqcvP7SpajFdrkikxICCFK323cQopx0iuApiIB1v4UNrQHl1L5lUvQzm0O09zFqpGViv+Cg=="], "@playwright/test": ["@playwright/test@1.56.1", "", { "dependencies": { "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" } }, "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg=="], diff --git a/package.json b/package.json index 978371060..d9842c8a5 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@nx/web": "23.1.1", "@nx/workspace": "23.1.1", "@placeos/cloud-uploads": "^1.1.1", - "@placeos/ts-client": "^6.3.1", + "@placeos/ts-client": "^6.5.0", "@playwright/test": "^1.36.0", "@schematics/angular": "22.0.0", "@sentry/browser": "^10.25.0", diff --git a/public/assets/locale/en-AU.json b/public/assets/locale/en-AU.json index 556b0241b..7b3f9c22b 100644 --- a/public/assets/locale/en-AU.json +++ b/public/assets/locale/en-AU.json @@ -1164,6 +1164,12 @@ "TAB_SIGNAGE_PLUGINS": "Signage Plugins", "SIGNAGE_PLUGINS_HEADER": "Signage Plugins", "SIGNAGE_PLUGINS_ADD": "Add Plugin", + "SIGNAGE_PLUGINS_IMPORT": "Import Plugin from Repository", + "SIGNAGE_PLUGINS_IMPORT_REPOSITORY": "Frontend repository", + "SIGNAGE_PLUGINS_IMPORT_SELECT_REPOSITORY": "Select a frontend repository to find its plugins.", + "SIGNAGE_PLUGINS_IMPORT_SEARCHING": "Finding plugins...", + "SIGNAGE_PLUGINS_IMPORT_EMPTY": "No plugins found in this repository.", + "SIGNAGE_PLUGINS_IMPORT_ERROR": "Error finding plugins. Error: {{ error }}", "SIGNAGE_PLUGINS_EDIT": "Edit Signage Plugin", "SIGNAGE_PLUGINS_NEW": "New Signage Plugin", "SIGNAGE_PLUGINS_REMOVE": "Remove Plugin", diff --git a/src/app/admin/signage-plugins/signage-plugin-embed.component.ts b/src/app/admin/signage-plugins/signage-plugin-embed.component.ts index 5f7f1f8d5..fbbb8839b 100644 --- a/src/app/admin/signage-plugins/signage-plugin-embed.component.ts +++ b/src/app/admin/signage-plugins/signage-plugin-embed.component.ts @@ -16,7 +16,7 @@ import { SignagePlugin } from '@placeos/ts-client'; import { AsyncHandler } from '../../common/async-handler.class'; import { SafePipe } from '../../ui/pipes/safe.pipe'; -const API_VERSION = 'signage-plugin/v1'; +export const SIGNAGE_PLUGIN_API_VERSION = 'signage-plugin/v1'; /** Resolve a plugin URI the same way as the iframe element. */ export function resolveSignagePluginUrl( @@ -49,6 +49,8 @@ export type PluginLoadedPayload = { plugin: { name: string; version: string; + /** Not sent by plugins built before plugin types were added */ + type?: SignagePlugin['plugin_type']; }; capabilities: { requires_play_signal: boolean; @@ -136,7 +138,7 @@ export class SignagePluginEmbedComponent payload: PluginConfigPayload | null = null, ) { this._plugin_el()?.nativeElement?.contentWindow.postMessage( - { api: API_VERSION, type, payload }, + { api: SIGNAGE_PLUGIN_API_VERSION, type, payload }, this.plugin_origin(), ); } @@ -155,7 +157,11 @@ export class SignagePluginEmbedComponent return; const msg = event.data; - if (!msg || msg.api !== API_VERSION || typeof msg.type !== 'string') + if ( + !msg || + msg.api !== SIGNAGE_PLUGIN_API_VERSION || + typeof msg.type !== 'string' + ) return; this.status.set(msg.type); diff --git a/src/app/admin/signage-plugins/signage-plugin-import-modal.component.ts b/src/app/admin/signage-plugins/signage-plugin-import-modal.component.ts new file mode 100644 index 000000000..53c4e65d7 --- /dev/null +++ b/src/app/admin/signage-plugins/signage-plugin-import-modal.component.ts @@ -0,0 +1,213 @@ +import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatSelectModule } from '@angular/material/select'; +import { + listRepositoryFiles, + PlaceRepository, + PlaceRepositoryType, + queryRepositories, + SignagePlugin, +} from '@placeos/ts-client'; + +import { AsyncHandler } from '../../common/async-handler.class'; +import { i18n } from '../../common/locale.service'; +import { notifyError } from '../../common/notifications'; +import { IconComponent } from '../../ui/icon.component'; +import { TranslatePipe } from '../../ui/translate.pipe'; +import { PluginLoadedPayload } from './signage-plugin-embed.component'; +import { + forEachWithLimit, + probeSignagePlugin, +} from './signage-plugins.utilities'; + +/** Number of plugin files loaded at the same time */ +const PROBE_LIMIT = 6; + +interface PluginProbe { + uri: string; + status: 'loading' | 'valid' | 'invalid'; + details?: PluginLoadedPayload; +} + +/** + * Wizard to find the plugins in an interface repository. + * Closes with the selected plugin (name, URI and type) as `SignagePlugin`, + * or `undefined` when cancelled. + */ +@Component({ + selector: 'signage-plugin-import-modal', + template: ` +
+

+ {{ 'ADMIN.SIGNAGE_PLUGINS_IMPORT' | translate }} +

+ +
+
+ + + @for (repo of repositories(); track repo.id) { + + {{ repo.name }} + + } + + + +
+ @for (probe of plugins(); track probe.uri) { + + } @empty { +
+ {{ empty_message() | translate }} +
+ } +
+
+ `, + styles: [``], + imports: [ + IconComponent, + MatDialogModule, + MatFormFieldModule, + MatProgressBarModule, + MatRippleModule, + MatSelectModule, + TranslatePipe, + ], +}) +export class SignagePluginImportModalComponent + extends AsyncHandler + implements OnInit +{ + private _dialog_ref = + inject>( + MatDialogRef, + ); + + public readonly repositories = signal([]); + public readonly repository_id = signal(''); + public readonly loading_files = signal(false); + public readonly probes = signal([]); + public readonly plugins = computed(() => + this.probes().filter((probe) => probe.status === 'valid'), + ); + public readonly progress = computed(() => { + const probes = this.probes(); + if (!probes.length) return 100; + const done = probes.filter((_) => _.status !== 'loading').length; + return (done / probes.length) * 100; + }); + public readonly empty_message = computed(() => { + if (!this.repository_id()) + return 'ADMIN.SIGNAGE_PLUGINS_IMPORT_SELECT_REPOSITORY'; + if (this.loading_files() || this.progress() < 100) + return 'ADMIN.SIGNAGE_PLUGINS_IMPORT_SEARCHING'; + return 'ADMIN.SIGNAGE_PLUGINS_IMPORT_EMPTY'; + }); + + public async ngOnInit() { + try { + const { data } = await queryRepositories({ limit: 1000 }); + this.repositories.set( + data.filter( + (repo) => repo.repo_type === PlaceRepositoryType.Interface, + ), + ); + } catch (err) { + this._notifyError(err); + } + } + + /** Find the HTML files in the repository and check which are plugins */ + public async selectRepository(id: string) { + const controller = new AbortController(); + // Replacing the subscription aborts the probes of the previous repository + this.subscription('probes', () => controller.abort()); + this.repository_id.set(id); + this.probes.set([]); + this.loading_files.set(true); + let files: string[] = []; + try { + files = await listRepositoryFiles(id, { pattern: '*.html' }); + } catch (err) { + if (!controller.signal.aborted) this._notifyError(err); + } + if (controller.signal.aborted) return; + this.loading_files.set(false); + this.probes.set(files.map((uri) => ({ uri, status: 'loading' }))); + await forEachWithLimit(files, PROBE_LIMIT, async (uri) => { + const details = await probeSignagePlugin(uri, { + signal: controller.signal, + }); + if (controller.signal.aborted) return; + this.probes.update((list) => + list.map((probe) => + probe.uri === uri + ? { + uri, + details, + status: details ? 'valid' : 'invalid', + } + : probe, + ), + ); + }); + } + + public selectPlugin(probe: PluginProbe) { + this._dialog_ref.close( + new SignagePlugin({ + name: probe.details.plugin.name, + uri: probe.uri, + plugin_type: probe.details.plugin.type, + }), + ); + } + + private _notifyError(err: { response?: unknown; message?: string }) { + notifyError( + i18n('ADMIN.SIGNAGE_PLUGINS_IMPORT_ERROR', { + error: JSON.stringify(err.response || err.message || err), + }), + ); + } +} diff --git a/src/app/admin/signage-plugins/signage-plugins.component.ts b/src/app/admin/signage-plugins/signage-plugins.component.ts index 14e144c42..159607ec4 100644 --- a/src/app/admin/signage-plugins/signage-plugins.component.ts +++ b/src/app/admin/signage-plugins/signage-plugins.component.ts @@ -18,6 +18,7 @@ import { openConfirmModal } from '../../overlays/confirm-modal.component'; import { IconComponent } from '../../ui/icon.component'; import { SimpleTableComponent } from '../../ui/simple-table.component'; import { TranslatePipe } from '../../ui/translate.pipe'; +import { SignagePluginImportModalComponent } from './signage-plugin-import-modal.component'; import { SignagePluginModalComponent } from './signage-plugin-modal.component'; import { SignagePluginTestModalComponent } from './signage-plugin-test-modal.component'; @@ -30,6 +31,21 @@ import { SignagePluginTestModalComponent } from './signage-plugin-test-modal.com {{ 'ADMIN.SIGNAGE_PLUGINS_HEADER' | translate }}
+