diff --git a/apps/signage-manager/src/app/shared/nav-footer.component.ts b/apps/signage-manager/src/app/shared/nav-footer.component.ts
index de7f5f3af6..bfe542ff3f 100644
--- a/apps/signage-manager/src/app/shared/nav-footer.component.ts
+++ b/apps/signage-manager/src/app/shared/nav-footer.component.ts
@@ -6,6 +6,7 @@ import { RouterModule } from '@angular/router';
import { i18n } from '@placeos/common';
import { IconComponent, TranslatePipe } from '@placeos/components';
import { dialogClosed, SignageService } from '../signage.service';
+import { AiImageService } from '../ai/ai-image.service';
import { GroupSelectModalComponent } from './group-select-modal.component';
import { filterManageNavItems } from './nav-items';
@@ -129,6 +130,7 @@ import { filterManageNavItems } from './nav-items';
})
export class NavFooterComponent {
private readonly _service = inject(SignageService);
+ private readonly _ai = inject(AiImageService);
private readonly _dialog = inject(MatDialog);
private readonly can_manage_groups = computed(
@@ -142,12 +144,14 @@ export class NavFooterComponent {
filterManageNavItems(
this.can_manage_groups(),
this._service.templates_enabled(),
+ this._ai.enabled(),
).filter((item) => !this.MORE_MENU_ROUTES.includes(item.route)),
);
public readonly more_nav_items = computed(() =>
filterManageNavItems(
this.can_manage_groups(),
this._service.templates_enabled(),
+ this._ai.enabled(),
).filter((item) => this.MORE_MENU_ROUTES.includes(item.route)),
);
public readonly groups = this._service.signage_groups;
diff --git a/apps/signage-manager/src/app/shared/nav-items.ts b/apps/signage-manager/src/app/shared/nav-items.ts
index 2af5256548..4b57a805f7 100644
--- a/apps/signage-manager/src/app/shared/nav-items.ts
+++ b/apps/signage-manager/src/app/shared/nav-items.ts
@@ -21,6 +21,11 @@ const NAV_ITEMS = [
icon: 'display_settings',
label: 'SIGNAGE_MANAGER.NAV_DISPLAYS',
},
+ {
+ route: '/branding',
+ icon: 'palette',
+ label: 'SIGNAGE_MANAGER.NAV_BRANDING',
+ },
{ route: '/groups', icon: 'groups', label: 'COMMON.GROUPS' },
];
@@ -29,10 +34,12 @@ export type NavItem = (typeof NAV_ITEMS)[number];
export function filterManageNavItems(
can_manage_groups: boolean,
templates_enabled = false,
+ ai_enabled = false,
): NavItem[] {
return NAV_ITEMS.filter((item) => {
if (item.route === '/groups') return can_manage_groups;
if (item.route === '/templates') return templates_enabled;
+ if (item.route === '/branding') return ai_enabled;
return true;
});
}
diff --git a/apps/signage-manager/src/app/shared/nav-sidebar.component.ts b/apps/signage-manager/src/app/shared/nav-sidebar.component.ts
index 7599e31025..fe1b893080 100644
--- a/apps/signage-manager/src/app/shared/nav-sidebar.component.ts
+++ b/apps/signage-manager/src/app/shared/nav-sidebar.component.ts
@@ -10,6 +10,7 @@ import {
TranslatePipe,
} from '@placeos/components';
import { SignageService } from '../signage.service';
+import { AiImageService } from '../ai/ai-image.service';
import { filterManageNavItems } from './nav-items';
import { SignageGroupSelectorComponent } from './signage-group-selector.component';
@@ -152,6 +153,7 @@ export class NavSidebarComponent {
private readonly _settings = inject(SettingsService);
private readonly _locale = inject(LocaleService);
private readonly _service = inject(SignageService);
+ private readonly _ai = inject(AiImageService);
public readonly locales = this._settings.signal<
{ id: string; name: string; local?: string }[]
>('locales', []);
@@ -165,6 +167,7 @@ export class NavSidebarComponent {
this._service.can_manage_all_groups() ||
!!this._service.manageable_signage_groups().length,
this._service.templates_enabled(),
+ this._ai.enabled(),
),
);
public readonly active_locale = computed(() => this._locale.locale);
diff --git a/apps/signage-manager/src/app/signage.service.ts b/apps/signage-manager/src/app/signage.service.ts
index 84ebb3d20b..69e07e4ec5 100644
--- a/apps/signage-manager/src/app/signage.service.ts
+++ b/apps/signage-manager/src/app/signage.service.ts
@@ -9,7 +9,7 @@ import {
signal,
untracked,
} from '@angular/core';
-import { MatDialog } from '@angular/material/dialog';
+import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import {
i18n,
notifyError,
@@ -22,7 +22,7 @@ import {
UploadsService,
userSignal,
} from '@placeos/common';
-import { openConfirmModal } from '@placeos/components';
+import { loadAuthenticatedImage, openConfirmModal } from '@placeos/components';
import {
addGroup,
addGroupUser,
@@ -98,6 +98,10 @@ import {
updateSystem,
updateZone,
} from '@placeos/ts-client';
+import {
+ AiImageModalComponent,
+ AiImageModalData,
+} from './ai/ai-image-modal.component';
import { DisplayEditModalComponent } from './displays/display-edit-modal.component';
import { displayZoneIds } from './displays/display-zones.util';
import {
@@ -698,14 +702,17 @@ export class SignageService {
public readonly managed_group_zones = computed(
() => this._managed_group_zones.value() || [],
);
- private readonly _api_group_id = computed(
+ /**
+ * The group a write is made in.
+ */
+ public readonly api_group_id = computed(
() => this.selected_group()?.group.id || '',
);
// Group selection fans out to six heavy list queries (media, playlists,
// displays, zones...). Debounce so clicking through the group tree doesn't
// fire a full set of refetches per click.
private readonly _api_group_id_debounced = debounced(
- this._api_group_id,
+ this.api_group_id,
300,
);
public readonly can_read = computed(() =>
@@ -732,7 +739,7 @@ export class SignageService {
);
private readonly _can_query_group_data = computed(() => {
- const group_id = this._api_group_id();
+ const group_id = this.api_group_id();
return this.is_sys_admin() || !!group_id;
});
// How many items to request per network page.
@@ -1638,7 +1645,7 @@ export class SignageService {
const ref = this._dialog.open(PlaylistEditModalComponent, {
data: {
playlist,
- group_id: this._api_group_id(),
+ group_id: this.api_group_id(),
onEdit: (id: string, data: Partial
) =>
updateSignagePlaylist(id, data),
},
@@ -1721,7 +1728,7 @@ export class SignageService {
notifyWarn(i18n('SIGNAGE_MANAGER.SVC_NO_GROUPS_FOR_PLAYLIST'));
return;
}
- const selected_group_id = this._api_group_id();
+ const selected_group_id = this.api_group_id();
group =
groups.find((item) => item.group.id === selected_group_id) ||
groups[0];
@@ -2002,7 +2009,7 @@ export class SignageService {
const ref = this._dialog.open(TemplateEditModalComponent, {
data: {
template,
- group_id: this._api_group_id(),
+ group_id: this.api_group_id(),
onEdit: (id: string, data: Partial) =>
updateSignageTemplate(id, data),
},
@@ -2117,7 +2124,7 @@ export class SignageService {
notifyWarn(i18n('SIGNAGE_MANAGER.SVC_NO_GROUPS_FOR_TEMPLATE'));
return;
}
- const selected_group_id = this._api_group_id();
+ const selected_group_id = this.api_group_id();
group =
groups.find((item) => item.group.id === selected_group_id) ||
groups[0];
@@ -2168,7 +2175,7 @@ export class SignageService {
this._dialog,
);
if (result.reason !== 'done') return;
- const group_id = this._api_group_id();
+ const group_id = this.api_group_id();
await (group_id
? del(
`${apiEndpoint()}/signage/templates/${encodeURIComponent(template.id)}?group_id=${encodeURIComponent(group_id)}`,
@@ -2256,7 +2263,7 @@ export class SignageService {
}
private _addSignageTemplate(form_data: Partial) {
- const group_id = this._api_group_id();
+ const group_id = this.api_group_id();
return addSignageTemplate(
form_data,
group_id ? { group_id } : undefined,
@@ -2492,7 +2499,7 @@ export class SignageService {
private _groupQueryParams>(
query_params: T,
- group_id = this._api_group_id(),
+ group_id = this.api_group_id(),
) {
return {
...query_params,
@@ -2502,7 +2509,7 @@ export class SignageService {
private _orgZoneQueryParams>(
query_params: T,
- group_id = this._api_group_id(),
+ group_id = this.api_group_id(),
) {
const org_zone_id = this._org.organisation?.id;
let zone_params: { group_id?: string; zone_id?: string } = {};
@@ -2518,7 +2525,7 @@ export class SignageService {
}
private async _addSignageMedia(form_data: Partial) {
- const group_id = this._api_group_id();
+ const group_id = this.api_group_id();
const result = await retryMediaRequest(() =>
group_id
? post(
@@ -2548,7 +2555,7 @@ export class SignageService {
}
private _addSignagePlaylist(form_data: Partial) {
- const group_id = this._api_group_id();
+ const group_id = this.api_group_id();
if (!group_id) return addSignagePlaylist(form_data);
return post(
`${apiEndpoint()}/signage/playlists?group_id=${encodeURIComponent(group_id)}`,
@@ -2594,7 +2601,7 @@ export class SignageService {
private async _playlistApprovalGroups(playlist: SignagePlaylist) {
const groups = this.signage_groups();
- const selected_group_id = this._api_group_id();
+ const selected_group_id = this.api_group_id();
const matching_groups: PlaceCurrentGroup[] = [];
for (const group of groups) {
if (!group.group.id) continue;
@@ -2621,7 +2628,7 @@ export class SignageService {
private async _templateApprovalGroups(template: SignageTemplate) {
const groups = this.signage_groups();
- const selected_group_id = this._api_group_id();
+ const selected_group_id = this.api_group_id();
const matching_groups: PlaceCurrentGroup[] = [];
for (const group of groups) {
if (!group.group.id) continue;
@@ -2969,7 +2976,7 @@ export class SignageService {
? await this._resolvePlugin(item.plugin_id)
: undefined;
this._dialog.open(MediaPreviewModalComponent, {
- data: { media: item, plugin, group_id: this._api_group_id() },
+ data: { media: item, plugin, group_id: this.api_group_id() },
panelClass: 'fullscreen-dialog',
});
}
@@ -3064,6 +3071,118 @@ export class SignageService {
await this.editMedia(media);
}
+ /**
+ * Create a media item from an image the backend already stored, without
+ * sending the bytes up a second time.
+ */
+ public async addMediaFromUpload(
+ upload_id: string,
+ media_item: Partial = {},
+ playlist_id = '',
+ ) {
+ if (
+ !this._requirePermission(
+ this.can_create(),
+ i18n('SIGNAGE_MANAGER.SVC_NO_CREATE_MEDIA'),
+ )
+ ) {
+ throw new Error(i18n('SIGNAGE_MANAGER.SVC_PERMISSION_DENIED'));
+ }
+ const media_url = `${
+ location.origin
+ }/api/engine/v2/uploads/${encodeURIComponent(upload_id)}/url`;
+
+ let thumbnail_id = '';
+ try {
+ const source = await loadAuthenticatedImage(
+ media_url,
+ '/api/engine/v2/uploads',
+ );
+ const response = await fetch(source);
+ const blob = await response.blob();
+ const file = new File(
+ [blob],
+ `${media_item.name || 'image'}.${blob.type.includes('png') ? 'png' : 'jpg'}`,
+ { type: blob.type || 'image/jpeg' },
+ );
+ const thumbnail = await this.generateThumbnailImage(file);
+ if (thumbnail) {
+ thumbnail_id = await this._uploadThumbnailImage(
+ thumbnail,
+ media_item.name || 'image',
+ );
+ }
+ } catch {
+ notifyWarn(i18n('SIGNAGE_MANAGER.SVC_THUMBNAIL_FAILED'));
+ }
+
+ const data = {
+ ...new SignageMedia({
+ orientation: 'landscape',
+ ...media_item,
+ media_id: upload_id,
+ media_uri: media_url,
+ media_type: 'image',
+ thumbnail_id,
+ } as any),
+ };
+ for (const key in data) {
+ if (!data[key]) delete data[key];
+ }
+ const result = await this._addSignageMedia(data);
+ if (playlist_id && result?.id) {
+ await this.addMediaToPlaylist(playlist_id, result.id);
+ }
+ return result;
+ }
+
+ /** Remove a media row when the generated upload could not be claimed. */
+ public async discardCreatedMedia(id: string) {
+ await removeSignageMedia(id);
+ this._media_items.update((items) =>
+ items.filter((item) => item.id !== id),
+ );
+ this._media_tags.reload();
+ }
+
+ /** guards against a second modal while one is open */
+ private _ai_modal_ref: MatDialogRef | null = null;
+
+ /** Open the AI image modal, either to create artwork or to change some. */
+ public async generateMediaWithAI(options: AiImageModalData = {}) {
+ if (
+ !this._requirePermission(
+ this.can_create(),
+ i18n('SIGNAGE_MANAGER.SVC_NO_CREATE_MEDIA'),
+ )
+ )
+ return;
+ if (this._ai_modal_ref) return;
+ const ref = this._dialog.open(AiImageModalComponent, {
+ data: options,
+ panelClass: 'fullscreen-dialog',
+ autoFocus: false,
+ });
+ this._ai_modal_ref = ref;
+ try {
+ const result = await dialogClosed(ref);
+ this.changed();
+ return result;
+ } finally {
+ this._ai_modal_ref = null;
+ }
+ }
+
+ public async editMediaWithAI(media: SignageMedia) {
+ if (!media?.media_id) return;
+ return this.generateMediaWithAI({
+ source_upload_id: media.media_id,
+ source_item_id: media.id,
+ source_name: media.name,
+ aspect_ratio: media.orientation === 'portrait' ? '9:16' : '16:9',
+ });
+ }
+
public async addMediaFromPlugin(plugin: SignagePlugin) {
if (plugin.plugin_type !== 'plugin') return;
if (
@@ -3132,7 +3251,7 @@ export class SignageService {
file_metadata,
file_thumbnail,
playlist_id,
- group_id: this._api_group_id(),
+ group_id: this.api_group_id(),
plugin,
tag_options: this.media_tags(),
loadPlugin: load_plugin,
@@ -3662,7 +3781,7 @@ export class SignageService {
}
private async _defaultDisplayZoneIds() {
- const group_id = this._api_group_id();
+ const group_id = this.api_group_id();
const active_zone =
this._org.building || this._org.region || this._org.organisation;
let roots = group_id
diff --git a/apps/signage-manager/src/styles.css b/apps/signage-manager/src/styles.css
index 6c2390f72f..88ec177f50 100644
--- a/apps/signage-manager/src/styles.css
+++ b/apps/signage-manager/src/styles.css
@@ -50,6 +50,19 @@ select:focus-visible,
opacity var(--transition-delay) ease-out;
}
+/*
+ * A search field standing in a row of icon buttons.
+ *
+ * The app's form fields are 48px and the icon buttons are 50, so a toolbar that
+ * mixes the two lines up on the centre and nowhere else. This gives the field
+ * the missing 2px rather than resizing a button used all over the app.
+ */
+/* the app's own rule for this is under #placeos, so this one has to be too */
+#placeos .toolbar-field .mat-mdc-form-field-infix {
+ padding-top: 13px;
+ padding-bottom: 13px;
+}
+
.fullscreen-dialog {
max-width: 100vw !important;
max-height: 100vh !important;
diff --git a/apps/signage-manager/src/tests/ai/ai-image-modal.component.spec.ts b/apps/signage-manager/src/tests/ai/ai-image-modal.component.spec.ts
new file mode 100644
index 0000000000..c7f53ab6e9
--- /dev/null
+++ b/apps/signage-manager/src/tests/ai/ai-image-modal.component.spec.ts
@@ -0,0 +1,120 @@
+import { signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+
+import { AiImageModalComponent } from '../../app/ai/ai-image-modal.component';
+import { AiImageService } from '../../app/ai/ai-image.service';
+import { AiCapabilities, AiJob } from '../../app/ai/ai.types';
+import { SignageService } from '../../app/signage.service';
+
+function capabilities(changes: Partial = {}): AiCapabilities {
+ return {
+ enabled: true,
+ providers: [
+ {
+ id: 'provider-1',
+ name: 'Provider',
+ provider: 'OPENAI',
+ default_model: 'model-1',
+ models: [
+ {
+ id: 'model-1',
+ name: 'Model',
+ generate: true,
+ edit: true,
+ enhance: false,
+ max_references: 3,
+ max_candidates: 1,
+ qualities: ['standard'],
+ aspect_ratios: ['1:1'],
+ },
+ ],
+ },
+ ],
+ default_provider_id: 'provider-1',
+ aspect_ratios: ['1:1'],
+ qualities: ['standard'],
+ max_candidates: 1,
+ logo_layer: false,
+ quota: {
+ user_remaining_today: null,
+ domain_remaining_month: null,
+ },
+ ...changes,
+ };
+}
+
+describe('AiImageModalComponent', () => {
+ async function make(data: Record = {}) {
+ const jobs = signal>({});
+ const current_capabilities = capabilities();
+ const edit = vi.fn(async (request) => {
+ const job: AiJob = {
+ id: 'job-1',
+ state: 'done',
+ kind: 'edit',
+ candidates: 1,
+ images_produced: 0,
+ version: 1,
+ images: [],
+ };
+ jobs.set({ [job.id]: job });
+ return job;
+ });
+ const ai = {
+ capabilities: signal(current_capabilities),
+ default_model: signal(current_capabilities.providers[0].models[0]),
+ brand_kit: signal(null),
+ jobs,
+ intentKey: vi.fn(() => 'intent-1'),
+ edit,
+ generate: vi.fn(),
+ loadImage: vi.fn().mockResolvedValue(''),
+ };
+ const signage = {
+ is_sys_admin: signal(false),
+ api_group_id: signal('group-1'),
+ };
+ await TestBed.configureTestingModule({
+ imports: [AiImageModalComponent],
+ providers: [
+ { provide: MAT_DIALOG_DATA, useValue: data },
+ { provide: MatDialogRef, useValue: { close: vi.fn() } },
+ { provide: AiImageService, useValue: ai },
+ { provide: SignageService, useValue: signage },
+ ],
+ })
+ .overrideComponent(AiImageModalComponent, {
+ set: { template: '' },
+ })
+ .compileComponents();
+ const component = TestBed.createComponent(
+ AiImageModalComponent,
+ ).componentInstance;
+ return { ai, component };
+ }
+
+ afterEach(() => TestBed.resetTestingModule());
+
+ it('uses supported defaults and the model reference limit', async () => {
+ const { component } = await make();
+
+ expect(component.aspect()).toBe('1:1');
+ expect(component.candidates()).toBe(1);
+ expect(component.max_references()).toBe(3);
+ });
+
+ it('does not send a synthetic aspect ratio when editing', async () => {
+ const { ai, component } = await make({
+ source_upload_id: 'source-1',
+ source_name: 'Poster',
+ });
+ component.brief.set('Make it darker');
+
+ await component.start();
+
+ expect(ai.edit).toHaveBeenCalledWith(
+ expect.not.objectContaining({ aspect_ratio: expect.anything() }),
+ );
+ });
+});
diff --git a/apps/signage-manager/src/tests/ai/ai-image.service.spec.ts b/apps/signage-manager/src/tests/ai/ai-image.service.spec.ts
new file mode 100644
index 0000000000..efc6fef735
--- /dev/null
+++ b/apps/signage-manager/src/tests/ai/ai-image.service.spec.ts
@@ -0,0 +1,98 @@
+import { TestBed } from '@angular/core/testing';
+import { UploadsService } from '@placeos/common';
+import { get, post } from '@placeos/ts-client';
+
+import { AiImageService } from '../../app/ai/ai-image.service';
+import { AiJob } from '../../app/ai/ai.types';
+
+vi.mock('@placeos/ts-client', { spy: true });
+
+describe('AiImageService', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ TestBed.configureTestingModule({
+ providers: [
+ AiImageService,
+ {
+ provide: UploadsService,
+ useValue: { uploadFileToCompletion: vi.fn() },
+ },
+ ],
+ });
+ });
+
+ afterEach(() => vi.useRealTimers());
+
+ it('reuses a key only when the complete request is unchanged', () => {
+ const service = TestBed.inject(AiImageService);
+ const first = service.intentKey('generate', {
+ prompt: 'A summer party',
+ aspect_ratio: '16:9',
+ candidates: 2,
+ group_id: 'group-1',
+ });
+
+ expect(
+ service.intentKey('generate', {
+ prompt: 'A summer party',
+ aspect_ratio: '16:9',
+ candidates: 2,
+ group_id: 'group-1',
+ }),
+ ).toBe(first);
+ expect(
+ service.intentKey('generate', {
+ prompt: 'A summer party',
+ aspect_ratio: '1:1',
+ candidates: 2,
+ group_id: 'group-1',
+ }),
+ ).not.toBe(first);
+ expect(
+ service.intentKey('generate', {
+ prompt: 'A summer party',
+ aspect_ratio: '16:9',
+ candidates: 2,
+ group_id: 'group-2',
+ }),
+ ).not.toBe(first);
+ });
+
+ it('propagates a failed claim', async () => {
+ vi.useFakeTimers();
+ vi.mocked(post).mockRejectedValue(new Error('claim failed'));
+ const service = TestBed.inject(AiImageService);
+ const claim = expect(
+ service.claim('job-1', 'upload-1', 'media-1'),
+ ).rejects.toThrow('claim failed');
+
+ await vi.runAllTimersAsync();
+
+ await claim;
+ });
+
+ it('marks a job failed when status polling exhausts its retries', async () => {
+ vi.mocked(get).mockRejectedValue(new Error('network unavailable'));
+ const service = TestBed.inject(AiImageService);
+ const job: AiJob = {
+ id: 'job-1',
+ state: 'running',
+ kind: 'generate',
+ candidates: 1,
+ images_produced: 0,
+ version: 1,
+ images: [null],
+ };
+ service.jobs.set({ [job.id]: job });
+ service.watch(job.id);
+ const test_service = service as unknown as {
+ _poll: (id: string) => Promise;
+ };
+
+ for (let attempt = 0; attempt < 10; attempt++) {
+ await test_service._poll(job.id);
+ }
+
+ expect(service.job(job.id).state).toBe('failed');
+ });
+});
diff --git a/apps/signage-manager/src/tests/ai/ai-image.util.spec.ts b/apps/signage-manager/src/tests/ai/ai-image.util.spec.ts
new file mode 100644
index 0000000000..90983dd4ee
--- /dev/null
+++ b/apps/signage-manager/src/tests/ai/ai-image.util.spec.ts
@@ -0,0 +1,28 @@
+import {
+ errorMessage,
+ errorStatus,
+ perceivedLightness,
+} from '../../app/ai/ai-image.util';
+
+describe('AI image utilities', () => {
+ it('reads nested API errors without returning an object', () => {
+ expect(
+ errorMessage(
+ { error: { error: 'Provider rejected the request' } },
+ 'Fallback',
+ ),
+ ).toBe('Provider rejected the request');
+ expect(errorMessage({ error: {} }, 'Fallback')).toBe('Fallback');
+ });
+
+ it('reads direct and wrapped HTTP status codes', () => {
+ expect(errorStatus({ status: 404 })).toBe(404);
+ expect(errorStatus({ error: { status: 403 } })).toBe(403);
+ expect(errorStatus(new Error('offline'))).toBeUndefined();
+ });
+
+ it('uses one luminance calculation for black and white', () => {
+ expect(perceivedLightness(0, 0, 0)).toBe(0);
+ expect(perceivedLightness(255, 255, 255)).toBe(255);
+ });
+});
diff --git a/apps/signage-manager/src/tests/ai/ai-layer-controls.component.spec.ts b/apps/signage-manager/src/tests/ai/ai-layer-controls.component.spec.ts
new file mode 100644
index 0000000000..c4344f38f8
--- /dev/null
+++ b/apps/signage-manager/src/tests/ai/ai-layer-controls.component.spec.ts
@@ -0,0 +1,41 @@
+import { TestBed } from '@angular/core/testing';
+
+import {
+ AiLayerControlsComponent,
+ newTextBlock,
+} from '../../app/ai/ai-layer-controls.component';
+
+describe('AiLayerControlsComponent', () => {
+ it('updates a block colour from a typed input event', async () => {
+ await TestBed.configureTestingModule({
+ imports: [AiLayerControlsComponent],
+ })
+ .overrideComponent(AiLayerControlsComponent, {
+ set: { template: '' },
+ })
+ .compileComponents();
+ const fixture = TestBed.createComponent(AiLayerControlsComponent);
+ const block = newTextBlock('headline');
+ fixture.componentRef.setInput('state', {
+ blocks: [block],
+ logo: false,
+ logo_position: 'bottom-right',
+ logo_scale: 0.14,
+ logo_choice: 'auto',
+ });
+ const changed = vi.fn();
+ fixture.componentInstance.changed.subscribe(changed);
+ const input = document.createElement('input');
+ input.value = '#123456';
+
+ fixture.componentInstance.setBlockColour(block.id, {
+ target: input,
+ } as unknown as Event);
+
+ expect(changed).toHaveBeenCalledWith(
+ expect.objectContaining({
+ blocks: [expect.objectContaining({ colour: '#123456' })],
+ }),
+ );
+ });
+});
diff --git a/apps/signage-manager/src/tests/ai/ai-layer.component.spec.ts b/apps/signage-manager/src/tests/ai/ai-layer.component.spec.ts
new file mode 100644
index 0000000000..b8b51a3298
--- /dev/null
+++ b/apps/signage-manager/src/tests/ai/ai-layer.component.spec.ts
@@ -0,0 +1,22 @@
+import { TestBed } from '@angular/core/testing';
+
+import { AiLayerComponent } from '../../app/ai/ai-layer.component';
+
+describe('AiLayerComponent', () => {
+ it('does not export a blank canvas before the artwork loads', async () => {
+ await TestBed.configureTestingModule({ imports: [AiLayerComponent] })
+ .overrideComponent(AiLayerComponent, { set: { template: '' } })
+ .compileComponents();
+ const fixture = TestBed.createComponent(AiLayerComponent);
+ fixture.componentRef.setInput('image_url', 'blob:artwork');
+ fixture.componentRef.setInput('state', {
+ blocks: [],
+ logo: false,
+ logo_position: 'bottom-right',
+ logo_scale: 0.14,
+ logo_choice: 'auto',
+ });
+
+ await expect(fixture.componentInstance.toBlob()).resolves.toBeNull();
+ });
+});
diff --git a/apps/signage-manager/src/tests/ai/ai-references.component.spec.ts b/apps/signage-manager/src/tests/ai/ai-references.component.spec.ts
new file mode 100644
index 0000000000..c6183907b6
--- /dev/null
+++ b/apps/signage-manager/src/tests/ai/ai-references.component.spec.ts
@@ -0,0 +1,32 @@
+import { TestBed } from '@angular/core/testing';
+
+import { AiReferencesComponent } from '../../app/ai/ai-references.component';
+
+describe('AiReferencesComponent', () => {
+ it('emits only the files that fit within the model limit', async () => {
+ await TestBed.configureTestingModule({
+ imports: [AiReferencesComponent],
+ })
+ .overrideComponent(AiReferencesComponent, {
+ set: { template: '' },
+ })
+ .compileComponents();
+ const fixture = TestBed.createComponent(AiReferencesComponent);
+ fixture.componentRef.setInput('items', [
+ { id: 'one', name: 'one.png', url: 'blob:one' },
+ ]);
+ fixture.componentRef.setInput('max', 2);
+ const picked = vi.fn();
+ fixture.componentInstance.picked.subscribe(picked);
+ const input = document.createElement('input');
+ const files = [
+ new File(['one'], 'two.png', { type: 'image/png' }),
+ new File(['two'], 'three.png', { type: 'image/png' }),
+ ];
+ Object.defineProperty(input, 'files', { value: files });
+
+ fixture.componentInstance.pick({ target: input } as unknown as Event);
+
+ expect(picked).toHaveBeenCalledWith([files[0]]);
+ });
+});
diff --git a/apps/signage-manager/src/tests/app.component.spec.ts b/apps/signage-manager/src/tests/app.component.spec.ts
index 09f794faef..369bdfdb91 100644
--- a/apps/signage-manager/src/tests/app.component.spec.ts
+++ b/apps/signage-manager/src/tests/app.component.spec.ts
@@ -1,19 +1,28 @@
import { TestBed } from '@angular/core/testing';
import { PlaceOS_Service, UploadsService } from '@placeos/common';
+import { AiImageService } from '../app/ai/ai-image.service';
import { AppComponent } from '../app/app.component';
describe('AppComponent', () => {
const placeos = { init: vi.fn() };
const uploads = { init: vi.fn() };
+ const ai = {
+ enabled: vi.fn(() => true),
+ load: vi.fn(),
+ loadRecent: vi.fn(),
+ };
beforeEach(async () => {
vi.clearAllMocks();
placeos.init.mockResolvedValue(undefined);
+ ai.load.mockResolvedValue(undefined);
+ ai.loadRecent.mockResolvedValue([]);
await TestBed.configureTestingModule({
imports: [AppComponent],
providers: [
{ provide: PlaceOS_Service, useValue: placeos },
{ provide: UploadsService, useValue: uploads },
+ { provide: AiImageService, useValue: ai },
],
})
.overrideComponent(AppComponent, { set: { template: '' } })
@@ -28,6 +37,8 @@ describe('AppComponent', () => {
expect(placeos.init).toHaveBeenCalledTimes(1);
expect(uploads.init).toHaveBeenCalledTimes(1);
+ expect(ai.load).toHaveBeenCalledTimes(1);
+ expect(ai.loadRecent).toHaveBeenCalledTimes(1);
});
it('waits for PlaceOS init to resolve before starting uploads', async () => {
diff --git a/apps/signage-manager/src/tests/branding/branding.component.spec.ts b/apps/signage-manager/src/tests/branding/branding.component.spec.ts
new file mode 100644
index 0000000000..70abecf787
--- /dev/null
+++ b/apps/signage-manager/src/tests/branding/branding.component.spec.ts
@@ -0,0 +1,39 @@
+import { signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+
+import { AiImageService } from '../../app/ai/ai-image.service';
+import { BrandingComponent } from '../../app/branding/branding.component';
+import { SignageService } from '../../app/signage.service';
+
+describe('BrandingComponent', () => {
+ it('updates a colour from a typed input event', async () => {
+ await TestBed.configureTestingModule({
+ imports: [BrandingComponent],
+ providers: [
+ {
+ provide: AiImageService,
+ useValue: {
+ enabled: signal(true),
+ brand_kit: signal(null),
+ },
+ },
+ {
+ provide: SignageService,
+ useValue: { is_sys_admin: signal(true) },
+ },
+ ],
+ })
+ .overrideComponent(BrandingComponent, { set: { template: '' } })
+ .compileComponents();
+ const component =
+ TestBed.createComponent(BrandingComponent).componentInstance;
+ const input = document.createElement('input');
+ input.value = '#123456';
+
+ component.setColourFromInput(0, {
+ target: input,
+ } as unknown as Event);
+
+ expect(component.colours()).toEqual(['#123456']);
+ });
+});
diff --git a/apps/signage-manager/src/tests/media/media-list-header.component.spec.ts b/apps/signage-manager/src/tests/media/media-list-header.component.spec.ts
index 6e4d3fa091..c03ae219f7 100644
--- a/apps/signage-manager/src/tests/media/media-list-header.component.spec.ts
+++ b/apps/signage-manager/src/tests/media/media-list-header.component.spec.ts
@@ -2,6 +2,7 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { setNotifyOutlet } from '@placeos/common';
+import { AiImageService } from '../../app/ai/ai-image.service';
import { MediaListHeaderComponent } from '../../app/media/media-list-header.component';
import { MediaAddModalComponent } from '../../app/shared/media-add-modal.component';
import { SignageService } from '../../app/signage.service';
@@ -43,6 +44,10 @@ describe('MediaListHeaderComponent', () => {
providers: [
{ provide: SignageService, useValue: service_stub },
{ provide: MatDialog, useValue: { open: dialog_open } },
+ {
+ provide: AiImageService,
+ useValue: { can_generate: signal(true) },
+ },
],
})
.overrideComponent(MediaListHeaderComponent, {
diff --git a/apps/signage-manager/src/tests/media/media-list.component.spec.ts b/apps/signage-manager/src/tests/media/media-list.component.spec.ts
index ec2217b98b..3e11281423 100644
--- a/apps/signage-manager/src/tests/media/media-list.component.spec.ts
+++ b/apps/signage-manager/src/tests/media/media-list.component.spec.ts
@@ -1,5 +1,6 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
+import { AiImageService } from '../../app/ai/ai-image.service';
import { MediaListComponent } from '../../app/media/media-list.component';
import { SignageService } from '../../app/signage.service';
@@ -27,6 +28,7 @@ describe('MediaListComponent folders', () => {
is_sys_admin,
show_media_group_tabs,
can_update: signal(true),
+ can_create: signal(true),
can_delete: signal(true),
can_share: signal(true),
addMediaTags: vi.fn(),
@@ -36,7 +38,13 @@ describe('MediaListComponent folders', () => {
function make() {
TestBed.configureTestingModule({
- providers: [{ provide: SignageService, useValue: service_stub }],
+ providers: [
+ { provide: SignageService, useValue: service_stub },
+ {
+ provide: AiImageService,
+ useValue: { can_edit: signal(true) },
+ },
+ ],
});
return TestBed.createComponent(MediaListComponent).componentInstance;
}
@@ -78,6 +86,15 @@ describe('MediaListComponent folders', () => {
expect(component.can_switch_groups()).toBe(false);
});
+ it('offers AI edits only when the user can create the derived image', () => {
+ const component = make();
+ expect(component.can_edit_with_ai()).toBe(true);
+
+ service_stub.can_create.set(false);
+
+ expect(component.can_edit_with_ai()).toBe(false);
+ });
+
it('builds one folder per endpoint tag with loaded counts plus an untagged bucket', () => {
const component = make();
const folders = component.folders();
diff --git a/apps/signage-manager/src/tests/signage.service.spec.ts b/apps/signage-manager/src/tests/signage.service.spec.ts
index 93b7a91594..cf4816da58 100644
--- a/apps/signage-manager/src/tests/signage.service.spec.ts
+++ b/apps/signage-manager/src/tests/signage.service.spec.ts
@@ -129,7 +129,7 @@ describe('SignageService media uploads', () => {
}
function selectApiGroup(service: SignageService, group_id: string) {
- Object.defineProperty(service, '_api_group_id', {
+ Object.defineProperty(service, 'api_group_id', {
value: () => group_id,
});
}
diff --git a/libs/mocks/src/lib/api/signage.mock.ts b/libs/mocks/src/lib/api/signage.mock.ts
index f2d7b63790..ab996e7ca1 100644
--- a/libs/mocks/src/lib/api/signage.mock.ts
+++ b/libs/mocks/src/lib/api/signage.mock.ts
@@ -1801,6 +1801,215 @@ export function registerMockSignage() {
};
},
});
+
+ registerMockSignageAI();
+}
+
+/**
+ * Image generation, in mock mode.
+ *
+ * Jobs live in a closure so the long poll behaves the way the real one does:
+ * the first request comes back queued, and candidates land one at a time a
+ * moment later. Images point at media already in the mock library, so the
+ * modal renders something real.
+ */
+interface MockAiRequest {
+ candidates?: number;
+ parent_job_id?: string;
+ prompt?: string;
+}
+
+interface MockAiJobImage {
+ state: 'done';
+ index: number;
+ upload_id: string;
+ url: string;
+ width: number;
+ height: number;
+ mime: string;
+ item_id?: string;
+}
+
+interface MockAiJob {
+ id: string;
+ state: 'queued' | 'running' | 'done' | 'failed' | 'cancelled';
+ kind: 'generate' | 'edit';
+ provider: string;
+ model: string;
+ candidates: number;
+ images_produced: number;
+ parent_job_id?: string;
+ version: number;
+ prompt?: string;
+ images: (MockAiJobImage | null)[];
+ error_kind?: string;
+ error_message?: string;
+ created_at: number;
+ finished_at?: number;
+}
+
+function registerMockSignageAI() {
+ const AI_JOBS: Record = {};
+ const SAMPLE_IMAGES = MOCK_MEDIA.slice(0, 4).map((item) => item.id);
+
+ const now = () => Math.floor(Date.now() / 1000);
+
+ function makeJob(request: MockAiRequest, kind: 'generate' | 'edit') {
+ const count = Math.min(Math.max(request.candidates || 2, 1), 4);
+ const job: MockAiJob = {
+ id: `signage-ai-job-${Object.keys(AI_JOBS).length + 1}`,
+ state: 'queued',
+ kind,
+ provider: 'OPENAI',
+ model: 'gpt-image-2',
+ candidates: count,
+ images_produced: 0,
+ parent_job_id: request.parent_job_id,
+ version: 0,
+ prompt: request.prompt,
+ images: Array.from({ length: count }, () => null),
+ created_at: now(),
+ };
+ AI_JOBS[job.id] = job;
+
+ if (`${request.prompt}`.includes('trigger-moderation')) {
+ setTimeout(() => {
+ job.state = 'failed';
+ job.error_kind = 'moderation';
+ job.error_message =
+ 'The request was blocked by the safety system';
+ job.version += 1;
+ }, 600);
+ return job;
+ }
+
+ job.state = 'running';
+ for (let index = 0; index < count; index++) {
+ setTimeout(
+ () => {
+ const media_id =
+ SAMPLE_IMAGES[index % SAMPLE_IMAGES.length] ||
+ 'upload-1';
+ job.images[index] = {
+ state: 'done',
+ index,
+ upload_id: media_id,
+ url: `/api/engine/v2/uploads/${media_id}/url`,
+ width: 2048,
+ height: 1152,
+ mime: 'image/jpeg',
+ };
+ job.images_produced += 1;
+ job.version += 1;
+ if (job.images_produced >= count) {
+ job.state = 'done';
+ job.finished_at = now();
+ job.version += 1;
+ }
+ },
+ 800 + index * 500,
+ );
+ }
+ return job;
+ }
+
+ registerMockEndpoint({
+ path: '/api/engine/v2/signage/ai/capabilities',
+ metadata: {},
+ method: 'GET',
+ callback: () => ({
+ enabled: true,
+ providers: [
+ {
+ id: 'signage-ai-provider-1',
+ name: 'Mock provider',
+ provider: 'OPENAI',
+ default_model: 'gpt-image-2',
+ models: [
+ {
+ id: 'gpt-image-2',
+ name: 'GPT Image 2',
+ generate: true,
+ edit: true,
+ enhance: true,
+ max_references: 16,
+ max_candidates: 4,
+ qualities: ['standard', 'high'],
+ aspect_ratios: ['16:9', '9:16', '1:1', '4:3'],
+ },
+ ],
+ },
+ ],
+ default_provider_id: 'signage-ai-provider-1',
+ aspect_ratios: ['16:9', '9:16', '1:1', '4:3'],
+ qualities: ['standard', 'high'],
+ max_candidates: 4,
+ logo_layer: false,
+ quota: { user_remaining_today: 42, domain_remaining_month: 900 },
+ }),
+ });
+
+ registerMockEndpoint({
+ path: '/api/engine/v2/signage/ai/generate',
+ metadata: {},
+ method: 'POST',
+ callback: (request) => makeJob(request.body || {}, 'generate'),
+ });
+
+ registerMockEndpoint({
+ path: '/api/engine/v2/signage/ai/edit',
+ metadata: {},
+ method: 'POST',
+ callback: (request) => makeJob(request.body || {}, 'edit'),
+ });
+
+ registerMockEndpoint({
+ path: '/api/engine/v2/signage/ai/jobs',
+ metadata: {},
+ method: 'GET',
+ callback: () => Object.values(AI_JOBS),
+ });
+
+ registerMockEndpoint({
+ path: '/api/engine/v2/signage/ai/jobs/:id',
+ metadata: {},
+ method: 'GET',
+ callback: (request) => {
+ const job = AI_JOBS[request.route_params.id];
+ if (!job) throw { status: 404, message: 'No such job' };
+ return job;
+ },
+ });
+
+ registerMockEndpoint({
+ path: '/api/engine/v2/signage/ai/jobs/:id/cancel',
+ metadata: {},
+ method: 'POST',
+ callback: (request) => {
+ const job = AI_JOBS[request.route_params.id];
+ if (!job) throw { status: 404, message: 'No such job' };
+ if (job.state === 'queued' || job.state === 'running') {
+ job.state = 'cancelled';
+ job.version += 1;
+ }
+ return job;
+ },
+ });
+
+ registerMockEndpoint({
+ path: '/api/engine/v2/signage/ai/jobs/:id/claim',
+ metadata: {},
+ method: 'POST',
+ callback: (request) => {
+ const job = AI_JOBS[request.route_params.id];
+ if (!job) throw { status: 404, message: 'No such job' };
+ const entry = job.images.find(
+ (image) => image?.upload_id === request.body?.upload_id,
+ );
+ if (entry) entry.item_id = request.body?.item_id;
+ return job;
+ },
+ });
}
// Export mock data for testing
diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json
index e3ce4896ff..21bf74b064 100644
--- a/shared/assets/locale/en-AU.json
+++ b/shared/assets/locale/en-AU.json
@@ -21,6 +21,86 @@
"ADD_ZONE_TOOLTIP": "Add zone",
"ADD_ZONE_TO_PLAYLIST_ARIA": "Add zone to playlist",
"ADVANCED_SCHEDULE_WARNING": "This playlist uses an advanced schedule that cannot be edited with the simple recurrence controls. Choose one of the repeat patterns above to replace it.",
+ "AI_ADD_LOGO": "Add your logo",
+ "AI_ADD_TEXT": "Add another block of text",
+ "AI_ADD_WORDS": "Add the words",
+ "AI_ADD_WORDS_LAYER": "Add the words myself afterwards",
+ "AI_ADD_WORDS_LAYER_HINT": "The image is generated without any text, then you type the headline over it. This keeps the words sharp and spelt correctly.",
+ "AI_ALIGN_CENTRE": "Centre",
+ "AI_ALIGN_LEFT": "Left",
+ "AI_ALIGN_RIGHT": "Right",
+ "AI_BODY_TEXT": "Smaller detail",
+ "AI_BRIEF": "What should the image show?",
+ "AI_BRIEF_HINT": "A poster for our office Christmas party on Friday 10 December",
+ "AI_CHANGING_THIS": "Changing this image",
+ "AI_CREATE_IMAGE": "Create image with AI",
+ "AI_DEFAULT_NAME": "AI image",
+ "AI_EDIT_IMAGE": "Change image with AI",
+ "AI_ENGINE": "Images made by {{ model }}, via {{ provider }}",
+ "AI_GENERATE": "Generate",
+ "AI_HEADLINE": "Headline",
+ "AI_INSTRUCTION": "What should change?",
+ "AI_INSTRUCTION_HINT": "Make the background darker and move the tree to the left",
+ "AI_JOB_DONE": "Your images are ready",
+ "AI_JOB_FAILED": "The image could not be generated",
+ "AI_LAYER_PREVIEW": "Preview of the finished image",
+ "AI_IMAGE_UNREADABLE": "That image could not be loaded, so it cannot be saved. Pick another option or generate again.",
+ "AI_LEAVE_LOGO_SPACE": "Leave room for our logo",
+ "AI_LOGO_POSITION": "Logo position",
+ "AI_LOGO_AUTO": "Choose for me",
+ "AI_LOGO_VERSION": "Logo version",
+ "AI_LOGO_SAVED": "Logo saved. It will be used on future posters too.",
+ "AI_LOGO_UPLOADING": "Saving logo...",
+ "AI_NO_IMAGE": "There is no image to save",
+ "AI_NO_LOGO_YET": "No logo saved for this organisation yet.",
+ "AI_NO_LOGO_ADMIN": "No logo saved for this organisation yet. An administrator can add one on the branding page.",
+ "AI_NO_ORG_ZONE": "This domain has no organisation zone, so a logo cannot be saved",
+ "AI_OPTION": "Generated option",
+ "AI_OPTIONS_COUNT": "Options to generate",
+ "AI_PICK_ONE": "Pick one",
+ "AI_POS_BOTTOM": "Bottom",
+ "AI_POS_BOTTOM_LEFT": "Bottom left",
+ "AI_POS_BOTTOM_RIGHT": "Bottom right",
+ "AI_POS_CENTRE": "Centre",
+ "AI_POS_TOP": "Top",
+ "AI_POS_TOP_LEFT": "Top left",
+ "AI_POS_TOP_RIGHT": "Top right",
+ "AI_PREVIEW_EMPTY": "Your image will show here once it is made.",
+ "AI_QUOTA_LEFT": "{{ count }} images left today",
+ "AI_REFINE": "Ask for a change",
+ "AI_REFINE_ACTION": "Refine",
+ "AI_REFINE_HINT": "Make the background darker",
+ "AI_REFINE_NOTE": "Every version stays available above, so you can always go back to one you liked.",
+ "AI_REFERENCES": "Reference images",
+ "AI_REFERENCES_HINT": "Attach pictures to work from, then name them in your words above as image 1, image 2 and so on. Say what each is for: a style to follow, or something to include.",
+ "AI_REFERENCE_ADD": "Attach images",
+ "AI_REFERENCE_NUMBER": "Image {{ number }}",
+ "AI_REFERENCE_REMOVE": "Remove this image",
+ "AI_REFERENCE_UPLOADING": "Attaching...",
+ "AI_REMOVE_TEXT": "Remove this block",
+ "AI_REPLACE_LOGO": "Replace logo",
+ "AI_ROLE_BODY": "Detail",
+ "AI_ROLE_HEADLINE": "Headline",
+ "AI_ROLE_SUBHEADING": "Subheading",
+ "AI_SAVING": "Saving",
+ "AI_SHAPE": "Shape",
+ "AI_SHOW_LOGO": "Show our logo",
+ "AI_SUBHEADING": "Second line",
+ "AI_TEXT_ALIGN": "Text alignment",
+ "AI_TEXT_ANY_COLOUR": "Pick any colour",
+ "AI_TEXT_BRAND_FONT": "Default",
+ "AI_TEXT_DRAG_HINT": "Drag the words on the image to put them where you want them. Arrow keys nudge, hold shift to move further. Enter starts a new line.",
+ "AI_TEXT_FONT": "Font",
+ "AI_TEXT_COLOUR": "Colour",
+ "AI_TEXT_PANEL": "Shade behind the text",
+ "AI_TEXT_POSITION": "Text position",
+ "AI_TEXT_SIZE": "Size",
+ "AI_USE_BRANDING": "Use our branding",
+ "AI_USE_BRANDING_HINT": "The organisation's colours, font and tone shape the image, and its palette is offered for the words. Turn it off for a poster that is not meant to look like us.",
+ "AI_VERSIONS": "Options and versions",
+ "AI_VERSION_LABEL": "Version {{ version }}, option {{ option }}",
+ "AI_WORKING": "Making your images",
+ "AI_WORDS_AND_LOGO": "Words and logo",
"ALL_DAY": "All day",
"ALL_DAY_LOWER": "all day",
"ALL_GROUPS": "All Groups",
@@ -33,12 +113,12 @@
"ANIM_SLIDE_RIGHT": "Slide Right",
"ANIM_SLIDE_TOP": "Slide Top",
"ANYONE": "Anyone",
+ "APPLY_SCHEDULE": "Apply schedule",
+ "APPLY_TEMPLATE": "Apply template",
"APPROVER": "Approver",
"APPROVERS_NOTE_PLACEHOLDER": "Add a note for the approvers...",
"APPROVE_PLAYLIST": "Approve Playlist",
"APPROVE_PLAYLIST_TOOLTIP": "Approve playlist",
- "APPLY_SCHEDULE": "Apply schedule",
- "APPLY_TEMPLATE": "Apply template",
"APPROVE_SELECTED_PLAYLIST": "Approve selected playlist",
"APPROVE_SELECTED_TEMPLATE": "Approve selected template",
"APPROVE_TEMPLATE": "Approve Template",
@@ -51,6 +131,28 @@
"BACK_TO_PLAYLISTS": "Back to playlists list",
"BACK_TO_TEMPLATES": "Back to templates list",
"BACK_TO_ZONES": "Back to zones list",
+ "BRAND_ADD_COLOUR": "Add a colour",
+ "BRAND_AI_OFF": "Image generation is not switched on for this domain, so this is not used yet.",
+ "BRAND_COLOURS": "Brand colours",
+ "BRAND_COLOUR_INVALID": "A brand colour must be a hex value like #0E6E52.",
+ "BRAND_FONT": "Brand font",
+ "BRAND_FONT_SAMPLE": "The quick brown fox jumps over the lazy dog",
+ "BRAND_FONT_SYSTEM": "System default",
+ "BRAND_HEADER": "Branding",
+ "BRAND_HINT": "Used whenever artwork is generated for this organisation, so posters come back in your colours rather than the model's.",
+ "BRAND_LOGO": "Logo",
+ "BRAND_LOGO_DERIVED": "Made from your other logo",
+ "BRAND_LOGO_HINT": "Upload one and the other version is made from it, keeping your colours. Replace either with your own file whenever you have one.",
+ "BRAND_NOT_LOADED": "The branding could not be read, so it cannot be saved over. Reload the page and try again.",
+ "BRAND_LOGO_MADE": "Made the other version of your logo",
+ "BRAND_LOGO_MAKE_IT": "Make it from the other one",
+ "BRAND_LOGO_ON_DARK": "For dark backgrounds",
+ "BRAND_LOGO_ON_LIGHT": "For light backgrounds",
+ "BRAND_READ_ONLY": "Branding is set for the whole organisation, so only an administrator can change it.",
+ "BRAND_ORGANISATION": "Organisation name",
+ "BRAND_REMOVE_COLOUR": "Remove this colour",
+ "BRAND_SAVED": "Branding saved",
+ "BRAND_SAVE_FAILED": "The branding could not be saved",
"BULK_UPLOAD_CLOSE": "Close",
"BULK_UPLOAD_CLOSE_ARIA": "Close bulk upload",
"BULK_UPLOAD_FAILED": "{{ count }} media files failed to upload.",
@@ -87,9 +189,9 @@
"DEFAULT_ANIMATION": "Default animation",
"DEFAULT_DURATION": "Default Duration",
"DEFAULT_PERMISSIONS": "No permissions",
+ "DEFAULT_PLAY_TIME": "Default Play Time",
"DEFAULT_TEMPLATE": "Default template",
"DEFAULT_TEMPLATE_HINT": "This template plays when no scheduled template is active.",
- "DEFAULT_PLAY_TIME": "Default Play Time",
"DELETE_DISPLAY_TOOLTIP": "Delete display",
"DELETE_PLAYLIST_TOOLTIP": "Delete playlist",
"DELETE_SELECTED_DISPLAY": "Delete selected display",
@@ -121,6 +223,7 @@
"EDIT_DISPLAY_TOOLTIP": "Edit display",
"EDIT_GROUP_TOOLTIP": "Edit group",
"EDIT_PLAYLIST_TOOLTIP": "Edit playlist",
+ "EDIT_SCHEDULE": "Edit Schedule",
"EDIT_SELECTED_DISPLAY": "Edit selected display",
"EDIT_SELECTED_PLAYLIST": "Edit selected playlist",
"EDIT_SELECTED_TEMPLATE": "Edit selected template",
@@ -147,6 +250,7 @@
"HIDE_APPROVAL_CHANGES": "Hide approval changes",
"HOURS_BETWEEN_PLAYS": "Hours between plays",
"ITEM_ACTIONS": "Item Actions",
+ "ITEM_SCHEDULES": "Item Schedules",
"LOADING_PLAYLIST_ITEMS": "Loading playlist items...",
"LOADING_PLUGIN_DETAILS": "Loading plugin details...",
"LOADING_PLUGIN_PREVIEW": "Loading plugin preview...",
@@ -174,6 +278,7 @@
"MORE": "More",
"MORE_NAV_OPTIONS": "More navigation options",
"NAME_REQUIRED": "Name is required",
+ "NAV_BRANDING": "Branding",
"NAV_DISPLAYS": "Displays",
"NAV_MEDIA": "Media",
"NAV_PLAYLISTS": "Playlists",
@@ -188,6 +293,7 @@
"NEXT_WEEK": "Next week",
"NEXT_WEEK_ARIA": "Show next week",
"NOT_IN_PLAYLISTS": "Not in any playlists",
+ "NO_APPROVED_TEMPLATES": "No approved templates are available.",
"NO_DISPLAYS": "No displays found.",
"NO_DISPLAYS_AVAILABLE": "No displays available.",
"NO_DISPLAYS_USE_PLAYLIST": "No displays use this playlist.",
@@ -196,6 +302,7 @@
"NO_GROUPS_AVAILABLE": "No groups available",
"NO_MANAGEABLE_GROUPS": "No manageable signage groups",
"NO_MEDIA": "No media items found.",
+ "NO_OLDER_VERSION": "No older version",
"NO_PARENT": "No parent",
"NO_PLAYLISTS": "No playlists found.",
"NO_PLAYLISTS_DISPLAY": "No playlists assigned to this display.",
@@ -204,9 +311,9 @@
"NO_PLAYLISTS_ZONE": "No playlists assigned to this zone.",
"NO_PLAYLIST_ITEMS": "No items in this playlist.",
"NO_PLUGINS": "No plugins available.",
+ "NO_SCHEDULES": "No schedules",
"NO_SCHEDULES_MATCH": "No schedules match your search.",
"NO_TEMPLATES": "No templates found.",
- "NO_APPROVED_TEMPLATES": "No approved templates are available.",
"NO_TEMPLATE_MAPPINGS": "No templates are applied to this item.",
"NO_UPCOMING_PLAY_TIMES": "No upcoming play times found.",
"NO_USERS_ASSIGNED": "No users assigned to this group.",
@@ -273,7 +380,6 @@
"PREVIEW_MEDIA_ARIA": "Preview media",
"PREVIEW_UNAVAILABLE": "Preview not available",
"PREVIOUS_DAY": "Previous day",
- "NO_OLDER_VERSION": "No older version",
"PREVIOUS_VERSION": "Old version",
"PREV_WEEK": "Previous week",
"PREV_WEEK_ARIA": "Show previous week",
@@ -300,9 +406,6 @@
"REQUEST_TEMPLATE_APPROVAL_TOOLTIP": "Request template approval",
"SCHEDULE": "Schedule",
"SCHEDULED_TEMPLATE": "Scheduled template",
- "EDIT_SCHEDULE": "Edit Schedule",
- "ITEM_SCHEDULES": "Item Schedules",
- "NO_SCHEDULES": "No schedules",
"SCHEDULE_INTERVAL_ARIA": "Recurring schedule interval",
"SCHEDULE_NUMBER": "Schedule {{ number }}",
"SCHEDULE_TYPES": "Schedule types",
@@ -310,13 +413,14 @@
"SEARCH_DISPLAYS": "Search displays",
"SEARCH_DISPLAYS_ZONES_PLAYLISTS": "Search displays, zones or playlists",
"SEARCH_GROUPS": "Search groups",
+ "SEARCH_IN_ZONE": "Search in {{ name }}",
"SEARCH_MEDIA_ARIA": "Search media",
"SEARCH_PLAYLISTS": "Search playlists",
- "SEARCH_IN_ZONE": "Search in {{ name }}",
"SEARCH_TEMPLATES": "Search templates",
"SEARCH_USERS": "Search users",
"SEARCH_ZONES": "Search zones",
"SEARCH_ZONES_PLAYLISTS": "Search zones or playlists",
+ "SELECT_APPROVED_TEMPLATE": "Approved template",
"SELECT_GROUP": "Select Group",
"SELECT_MEDIA": "Select {{ name }}",
"SELECT_MEDIA_ITEM": "Select media item {{ name }}",
@@ -392,9 +496,9 @@
"SVC_ERR_UPDATE_ZONE": "Error updating group zone",
"SVC_GROUP_REMOVED": "Signage group removed",
"SVC_GROUP_SAVED": "Signage group saved",
- "SVC_ITEM_REMOVED": "Item removed from playlist",
"SVC_ITEMS_REMOVED": "{{ count }} items removed from playlist",
"SVC_ITEMS_REMOVED_1": "{{ count }} item removed from playlist",
+ "SVC_ITEM_REMOVED": "Item removed from playlist",
"SVC_MEDIA_ALREADY_IN": "Selected media is already in this playlist.",
"SVC_MEDIA_REMOVED": "Media removed",
"SVC_MEDIA_SHARED": "Media shared",
@@ -432,14 +536,16 @@
"SVC_PLAYLIST_REMOVED_ZONE": "Playlist removed from zone",
"SVC_PLAYLIST_SHARED": "Playlist shared",
"SVC_PLAYLIST_UPDATED": "Playlist updated",
- "SVC_REMOVE_GROUP_TITLE": "Remove signage group?",
"SVC_REMOVE_DISPLAY_TITLE": "Remove display?",
+ "SVC_REMOVE_GROUP_TITLE": "Remove signage group?",
"SVC_REMOVE_MEDIA_TITLE": "Remove media?",
"SVC_REMOVE_NAMED_FROM_GROUP": "Remove \"{{ name }}\" from this group?",
"SVC_REMOVE_PLAYLIST_ITEMS_TITLE": "Remove playlist items?",
"SVC_REMOVE_PLAYLIST_TITLE": "Remove playlist?",
"SVC_REMOVE_SELECTED_PLAYLIST_ITEMS": "Remove {{ count }} selected items from this playlist?",
"SVC_REMOVE_SELECTED_PLAYLIST_ITEMS_1": "Remove {{ count }} selected item from this playlist?",
+ "SVC_REMOVE_TEMPLATE_MAPPING_CONTENT": "Remove {{ name }} from this item?",
+ "SVC_REMOVE_TEMPLATE_MAPPING_TITLE": "Remove applied template?",
"SVC_REMOVE_TEMPLATE_TITLE": "Remove template?",
"SVC_REMOVE_USER_TITLE": "Remove group user?",
"SVC_REMOVE_ZONE_TITLE": "Remove group zone?",
@@ -447,17 +553,15 @@
"SVC_SHARE_MEDIA_TITLE": "Share media with group",
"SVC_SHARE_PLAYLIST_TITLE": "Share playlist with group",
"SVC_SHARE_TEMPLATE_TITLE": "Share template with group",
- "SVC_TEMPLATE_LAYOUTS_SAVED": "Template layout saved",
"SVC_TEMPLATE_APPROVAL_REQUESTED": "Template approval requested",
- "SVC_TEMPLATE_REMOVED": "Template removed",
- "SVC_TEMPLATE_SHARED": "Template shared",
- "SVC_TEMPLATE_SAVE_ERROR": "Error saving template layout",
- "SVC_REMOVE_TEMPLATE_MAPPING_CONTENT": "Remove {{ name }} from this item?",
- "SVC_REMOVE_TEMPLATE_MAPPING_TITLE": "Remove applied template?",
+ "SVC_TEMPLATE_LAYOUTS_SAVED": "Template layout saved",
"SVC_TEMPLATE_MAPPING_REMOVED": "Template removed from item",
"SVC_TEMPLATE_MAPPING_REMOVE_ERROR": "Error removing template from item",
"SVC_TEMPLATE_MAPPING_SAVED": "Applied template saved",
"SVC_TEMPLATE_MAPPING_SAVE_ERROR": "Error saving applied template",
+ "SVC_TEMPLATE_REMOVED": "Template removed",
+ "SVC_TEMPLATE_SAVE_ERROR": "Error saving template layout",
+ "SVC_TEMPLATE_SHARED": "Template shared",
"SVC_THUMBNAIL_FAILED": "Could not generate a thumbnail from the selected image.",
"SVC_THUMBNAIL_NOT_IMAGE": "Thumbnails must be an image file.",
"SVC_THUMBNAIL_UPLOAD_FAILED": "Media uploaded, but its thumbnail could not be saved.",
@@ -489,24 +593,24 @@
"TEMPLATE_DESCRIPTION_ARIA": "Template description",
"TEMPLATE_DISCARD": "Discard",
"TEMPLATE_EDIT": "Edit Template",
- "TEMPLATE_MAPPING_DEFAULT_HINT": "Turn this off to make the template the default for this item.",
- "TEMPLATE_MAPPING_DISPLAY": "Display",
- "TEMPLATE_MAPPING_EDIT": "Edit template schedule",
- "TEMPLATE_MAPPING_SCHEDULE": "Schedule this template",
- "TEMPLATE_MAPPING_ZONE": "Zone",
- "TEMPLATE_MAPPINGS": "Mappings",
- "TEMPLATE_MAPPINGS_LOAD_ERROR": "Unable to load template mappings.",
"TEMPLATE_FULLSCREEN_TAKEOVER": "Full screen takeover",
+ "TEMPLATE_LABEL": "Template",
"TEMPLATE_LAYOUT_COUNT": "{{ count }} layouts",
"TEMPLATE_LAYOUT_ITEMS": "Layout Items",
- "TEMPLATE_LABEL": "Template",
"TEMPLATE_LIVE_MODE": "Live",
"TEMPLATE_LIVE_MODE_HINT": "Show the template plugins and live display",
"TEMPLATE_LIVE_PREVIEW": "Live template preview",
+ "TEMPLATE_MAPPINGS": "Mappings",
+ "TEMPLATE_MAPPINGS_LOAD_ERROR": "Unable to load template mappings.",
+ "TEMPLATE_MAPPING_DEFAULT_HINT": "Turn this off to make the template the default for this item.",
+ "TEMPLATE_MAPPING_DISPLAY": "Display",
+ "TEMPLATE_MAPPING_EDIT": "Edit template schedule",
+ "TEMPLATE_MAPPING_SCHEDULE": "Schedule this template",
+ "TEMPLATE_MAPPING_ZONE": "Zone",
"TEMPLATE_NAME_ARIA": "Template name",
- "TEMPLATE_NO_LAYOUT_CHANGES": "No layout changes",
"TEMPLATE_NO_LAYOUTS": "No layout items yet. Add one to get started.",
"TEMPLATE_NO_LAYOUTS_HINT": "Add layout items to build this template.",
+ "TEMPLATE_NO_LAYOUT_CHANGES": "No layout changes",
"TEMPLATE_NO_MAPPINGS": "This template is not applied to any displays or zones.",
"TEMPLATE_NO_PLUGIN": "No plugin",
"TEMPLATE_PANEL_HEIGHT": "Height",
@@ -521,13 +625,12 @@
"TEMPLATE_POSITION_TOP": "Header",
"TEMPLATE_PREVIEW_ARIA": "Preview of {{ name }}",
"TEMPLATE_REMOVE_LAYOUT": "Remove layout item",
+ "TEMPLATE_REQUIRED": "Select a template",
"TEMPLATE_REVERTED": "Template reverted to previous version",
"TEMPLATE_REVERT_ERROR": "Error reverting template changes",
"TEMPLATE_SAVED": "Template saved",
"TEMPLATE_SAVE_ERROR": "Error saving template",
"TEMPLATE_SAVING": "Saving Template...",
- "TEMPLATE_REQUIRED": "Select a template",
- "SELECT_APPROVED_TEMPLATE": "Approved template",
"TEMPLATE_SELECT_DISPLAY": "Select display",
"TEMPLATE_X_POS": "X position",
"TEMPLATE_Y_POS": "Y position",