Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions public/assets/locale/en-AU.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 9 additions & 3 deletions src/app/admin/signage-plugins/signage-plugin-embed.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
);
}
Expand All @@ -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);
Expand Down
213 changes: 213 additions & 0 deletions src/app/admin/signage-plugins/signage-plugin-import-modal.component.ts
Original file line number Diff line number Diff line change
@@ -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: `
<div
class="bg-base-200 mx-2 mt-2 flex items-center justify-between rounded-sm px-4 py-2"
>
<h3 class="text-xl font-medium">
{{ 'ADMIN.SIGNAGE_PLUGINS_IMPORT' | translate }}
</h3>
<button icon matRipple mat-dialog-close>
<icon>close</icon>
</button>
</div>
<main
class="flex h-[65vh] w-lg max-w-[calc(100vw-2rem)] flex-col px-4 pt-4"
>
<mat-form-field appearance="outline" class="w-full">
<mat-select
[placeholder]="
'ADMIN.SIGNAGE_PLUGINS_IMPORT_REPOSITORY' | translate
"
[value]="repository_id()"
(valueChange)="selectRepository($event)"
>
@for (repo of repositories(); track repo.id) {
<mat-option [value]="repo.id">
{{ repo.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-progress-bar
[mode]="loading_files() ? 'indeterminate' : 'determinate'"
[value]="progress()"
[class.opacity-0]="!loading_files() && progress() >= 100"
/>
<div class="flex-1 space-y-2 overflow-auto py-2">
@for (probe of plugins(); track probe.uri) {
<button
matRipple
class="border-base-300 hover:bg-base-200 flex w-full items-center gap-4 rounded-sm border px-4 py-2 text-left"
(click)="selectPlugin(probe)"
>
<div class="min-w-0 flex-1">
<div class="truncate font-medium">
{{ probe.details.plugin.name }}
</div>
<div class="truncate font-mono text-xs opacity-60">
{{ probe.uri }}
</div>
</div>
<div class="text-xs uppercase opacity-60">
{{ probe.details.plugin.type }}
</div>
<icon class="text-xl">chevron_right</icon>
</button>
} @empty {
<div
class="bg-base-200 rounded-sm p-8 text-center text-sm opacity-60"
>
{{ empty_message() | translate }}
</div>
}
</div>
</main>
`,
styles: [``],
imports: [
IconComponent,
MatDialogModule,
MatFormFieldModule,
MatProgressBarModule,
MatRippleModule,
MatSelectModule,
TranslatePipe,
],
})
export class SignagePluginImportModalComponent
extends AsyncHandler
implements OnInit
{
private _dialog_ref =
inject<MatDialogRef<SignagePluginImportModalComponent, SignagePlugin>>(
MatDialogRef,
);

public readonly repositories = signal<PlaceRepository[]>([]);
public readonly repository_id = signal('');
public readonly loading_files = signal(false);
public readonly probes = signal<PluginProbe[]>([]);
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),
}),
);
}
}
35 changes: 33 additions & 2 deletions src/app/admin/signage-plugins/signage-plugins.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -30,6 +31,21 @@ import { SignagePluginTestModalComponent } from './signage-plugin-test-modal.com
{{ 'ADMIN.SIGNAGE_PLUGINS_HEADER' | translate }}
</div>
<div class="flex items-center space-x-2">
<button
icon
default
matRipple
class="text-xl"
(click)="importPlugin()"
[matTooltip]="
'ADMIN.SIGNAGE_PLUGINS_IMPORT' | translate
"
[attr.aria-label]="
'ADMIN.SIGNAGE_PLUGINS_IMPORT' | translate
"
>
<icon>travel_explore</icon>
</button>
<button
icon
default
Expand Down Expand Up @@ -202,10 +218,25 @@ export class AdminSignagePluginsComponent
this.loadPlugins();
}

public newPlugin(): void {
/** Open the import wizard, then the new plugin form with the selected plugin details */
public importPlugin(): void {
const ref = this._dialog.open<
SignagePluginImportModalComponent,
void,
SignagePlugin
>(SignagePluginImportModalComponent);
this.subscription(
'import_events',
ref.afterClosed().subscribe((item) => {
if (item) this.newPlugin(item);
}),
);
}

public newPlugin(item = new SignagePlugin()): void {
const ref = this._dialog.open(SignagePluginModalComponent, {
data: {
item: new SignagePlugin(),
item,
save: (item: Partial<SignagePlugin>) => addSignagePlugin(item),
},
});
Expand Down
Loading
Loading