From 308abb9bc7294cf4578c418015632f3932922aa6 Mon Sep 17 00:00:00 2001 From: Dillan Laughlin Date: Tue, 23 Jun 2026 20:37:49 -0500 Subject: [PATCH 01/14] Add ImageAssetService + shared plugin-URL helper (image assets client) resolveKipPluginBaseUrl() extracts the /plugins/kip/ resolution (mirroring kip-series-api-client) and snapImageWidth() snaps a container width (x DPR) to the server's variant allow-list so the client requests stable, cache-friendly URLs. ImageAssetService wraps the plugin endpoints: upload (multipart FormData with progress), list, delete, cacheStats, purgeCache, and urlFor(id, cssWidth, dpr) for the widget. Requests are auto-authenticated by the JWT interceptor. 9 vitest cases green. --- .../core/services/image-asset.service.spec.ts | 64 ++++++++++++++ src/app/core/services/image-asset.service.ts | 87 +++++++++++++++++++ .../core/utils/kip-plugin-url.util.spec.ts | 37 ++++++++ src/app/core/utils/kip-plugin-url.util.ts | 47 ++++++++++ 4 files changed, 235 insertions(+) create mode 100644 src/app/core/services/image-asset.service.spec.ts create mode 100644 src/app/core/services/image-asset.service.ts create mode 100644 src/app/core/utils/kip-plugin-url.util.spec.ts create mode 100644 src/app/core/utils/kip-plugin-url.util.ts diff --git a/src/app/core/services/image-asset.service.spec.ts b/src/app/core/services/image-asset.service.spec.ts new file mode 100644 index 000000000..f4cdfb46c --- /dev/null +++ b/src/app/core/services/image-asset.service.spec.ts @@ -0,0 +1,64 @@ +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BehaviorSubject, of } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; +import { ImageAssetService } from './image-asset.service'; +import { SignalKConnectionService } from './signalk-connection.service'; + +function setup(httpServiceUrl: string | null, configuredUrl = '') { + const http = { post: vi.fn(() => of({})), get: vi.fn(() => of([])), delete: vi.fn(() => of({ ok: true })) }; + const connection = { + serverServiceEndpoint$: new BehaviorSubject<{ httpServiceUrl: string | null }>({ httpServiceUrl }), + signalKURL: { url: configuredUrl } + }; + TestBed.configureTestingModule({ + providers: [ + ImageAssetService, + { provide: HttpClient, useValue: http }, + { provide: SignalKConnectionService, useValue: connection } + ] + }); + return { service: TestBed.inject(ImageAssetService), http }; +} + +describe('ImageAssetService', () => { + beforeEach(() => TestBed.resetTestingModule()); + + it('becomes ready and builds variant URLs snapped to a container width', () => { + const { service } = setup('http://host:3000/signalk/v1/api/'); + expect(service.ready).toBe(true); + expect(service.urlFor('abc', 300, 1)).toBe('http://host:3000/plugins/kip/images/abc?w=320'); + expect(service.urlFor('abc', 320, 2)).toBe('http://host:3000/plugins/kip/images/abc?w=640'); + expect(service.urlFor(null, 300)).toBeNull(); + }); + + it('is not ready and yields null URLs before an endpoint is known', () => { + const { service } = setup(null); + expect(service.ready).toBe(false); + expect(service.urlFor('abc', 300)).toBeNull(); + }); + + it('posts an upload as multipart FormData to the images endpoint', () => { + const { service, http } = setup('http://host:3000/signalk/v1/api/'); + const file = new File([new Uint8Array([1, 2, 3])], 'map.png', { type: 'image/png' }); + service.upload(file).subscribe(); + expect(http.post).toHaveBeenCalledTimes(1); + const [url, body, opts] = http.post.mock.calls[0] as unknown[]; + expect(url).toBe('http://host:3000/plugins/kip/images'); + expect(body).toBeInstanceOf(FormData); + expect((body as FormData).get('file')).toBe(file); + expect(opts).toMatchObject({ reportProgress: true, observe: 'events' }); + }); + + it('targets the right endpoints for list/delete/cache/purge', () => { + const { service, http } = setup('http://host:3000/signalk/v1/api/'); + service.list().subscribe(); + expect(http.get).toHaveBeenCalledWith('http://host:3000/plugins/kip/images'); + service.delete('id-1').subscribe(); + expect(http.delete).toHaveBeenCalledWith('http://host:3000/plugins/kip/images/id-1'); + service.cacheStats().subscribe(); + expect(http.get).toHaveBeenCalledWith('http://host:3000/plugins/kip/images/cache'); + service.purgeCache().subscribe(); + expect(http.delete).toHaveBeenCalledWith('http://host:3000/plugins/kip/images/cache'); + }); +}); diff --git a/src/app/core/services/image-asset.service.ts b/src/app/core/services/image-asset.service.ts new file mode 100644 index 000000000..4906c2ced --- /dev/null +++ b/src/app/core/services/image-asset.service.ts @@ -0,0 +1,87 @@ +import { DestroyRef, Injectable, inject } from '@angular/core'; +import { HttpClient, HttpEvent } from '@angular/common/http'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Observable } from 'rxjs'; +import { SignalKConnectionService } from './signalk-connection.service'; +import { resolveKipPluginBaseUrl, snapImageWidth } from '../utils/kip-plugin-url.util'; + +export interface IImageAsset { + id: string; + name: string; + format: string; + width: number | null; + height: number | null; + bytes: number; + animated: boolean; + createdAt: string; + url?: string; +} + +export interface IImageCacheStats { + bytes: number; + files: number; +} + +/** + * Client for the KIP image-asset plugin endpoints (upload / list / delete / serve / cache). + * All requests are auto-authenticated by the app's authentication interceptor (JWT). + */ +@Injectable({ providedIn: 'root' }) +export class ImageAssetService { + private readonly http = inject(HttpClient); + private readonly connection = inject(SignalKConnectionService); + private readonly destroyRef = inject(DestroyRef); + private pluginBaseUrl: string | null = null; + + constructor() { + this.connection.serverServiceEndpoint$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(endpoint => { + this.pluginBaseUrl = resolveKipPluginBaseUrl(endpoint?.httpServiceUrl ?? null, this.connection.signalKURL?.url); + }); + } + + get ready(): boolean { + return this.pluginBaseUrl !== null; + } + + private imagesUrl(): string { + if (!this.pluginBaseUrl) { + throw new Error('Signal K connection is not ready'); + } + return `${this.pluginBaseUrl}images`; + } + + /** Upload a file; emits HttpEvents so callers can show progress. */ + upload(file: File): Observable> { + const form = new FormData(); + form.append('file', file); + return this.http.post(this.imagesUrl(), form, { reportProgress: true, observe: 'events' }); + } + + list(): Observable { + return this.http.get(this.imagesUrl()); + } + + delete(id: string): Observable<{ ok: boolean }> { + return this.http.delete<{ ok: boolean }>(`${this.imagesUrl()}/${encodeURIComponent(id)}`); + } + + cacheStats(): Observable { + return this.http.get(`${this.imagesUrl()}/cache`); + } + + purgeCache(): Observable<{ ok: boolean }> { + return this.http.delete<{ ok: boolean }>(`${this.imagesUrl()}/cache`); + } + + /** Build a cache-friendly variant URL matched to a container width (null if unset/not ready). */ + urlFor(id: string | null | undefined, cssWidth?: number | null, devicePixelRatio?: number): string | null { + if (!id || !this.pluginBaseUrl) { + return null; + } + const dpr = devicePixelRatio ?? (typeof window !== 'undefined' ? window.devicePixelRatio : 1); + const w = snapImageWidth(cssWidth, dpr); + return `${this.pluginBaseUrl}images/${encodeURIComponent(id)}?w=${w}`; + } +} diff --git a/src/app/core/utils/kip-plugin-url.util.spec.ts b/src/app/core/utils/kip-plugin-url.util.spec.ts new file mode 100644 index 000000000..96f2e5437 --- /dev/null +++ b/src/app/core/utils/kip-plugin-url.util.spec.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { resolveKipPluginBaseUrl, snapImageWidth, IMAGE_WIDTH_ALLOWLIST } from './kip-plugin-url.util'; + +describe('resolveKipPluginBaseUrl', () => { + it('prefers the configured URL', () => { + expect(resolveKipPluginBaseUrl('http://x/signalk/v1/api/', 'https://boat.local:3443')).toBe('https://boat.local:3443/plugins/kip/'); + expect(resolveKipPluginBaseUrl(null, 'https://boat.local/')).toBe('https://boat.local/plugins/kip/'); + }); + + it('derives the plugin base from the v1/v2 API URL by stripping the signalk suffix', () => { + expect(resolveKipPluginBaseUrl('http://host:3000/signalk/v1/api/')).toBe('http://host:3000/plugins/kip/'); + expect(resolveKipPluginBaseUrl('http://host:3000/signalk/v2/api')).toBe('http://host:3000/plugins/kip/'); + expect(resolveKipPluginBaseUrl('http://host:3000/signalk')).toBe('http://host:3000/plugins/kip/'); + }); + + it('returns null when nothing is known', () => { + expect(resolveKipPluginBaseUrl(null)).toBeNull(); + expect(resolveKipPluginBaseUrl(undefined, '')).toBeNull(); + }); +}); + +describe('snapImageWidth', () => { + const max = IMAGE_WIDTH_ALLOWLIST[IMAGE_WIDTH_ALLOWLIST.length - 1]; + + it('snaps up to the nearest allow-listed width, accounting for DPR', () => { + expect(snapImageWidth(100)).toBe(160); + expect(snapImageWidth(320)).toBe(320); + expect(snapImageWidth(330)).toBe(640); + expect(snapImageWidth(320, 2)).toBe(640); // 320 css * 2 dpr = 640 + }); + + it('uses the canonical max for unknown/zero/oversized widths', () => { + expect(snapImageWidth(undefined)).toBe(max); + expect(snapImageWidth(0)).toBe(max); + expect(snapImageWidth(99999)).toBe(max); + }); +}); diff --git a/src/app/core/utils/kip-plugin-url.util.ts b/src/app/core/utils/kip-plugin-url.util.ts new file mode 100644 index 000000000..1a38224f6 --- /dev/null +++ b/src/app/core/utils/kip-plugin-url.util.ts @@ -0,0 +1,47 @@ +/** + * Resolves the base URL of the KIP Signal K plugin (`/plugins/kip/`) from the connection + * endpoint, mirroring the logic in kip-series-api-client.service so both clients agree. + * + * @param httpServiceUrl the server's v1 API URL (e.g. `http://host:3000/signalk/v1/api/`) + * @param configuredUrl the user-configured Signal K URL, if any (takes precedence) + */ +export function resolveKipPluginBaseUrl(httpServiceUrl: string | null | undefined, configuredUrl?: string | null): string | null { + const configured = configuredUrl?.trim(); + if (configured) { + const base = configured.endsWith('/') ? configured.slice(0, -1) : configured; + return `${base}/plugins/kip/`; + } + if (!httpServiceUrl) { + return null; + } + const normalized = httpServiceUrl.endsWith('/') ? httpServiceUrl.slice(0, -1) : httpServiceUrl; + const root = normalized + .replace(/\/signalk\/v2\/api$/, '') + .replace(/\/signalk\/v1\/api$/, '') + .replace(/\/signalk\/v2$/, '') + .replace(/\/signalk\/v1$/, '') + .replace(/\/signalk$/, ''); + return `${root}/plugins/kip/`; +} + +/** + * Allowed image variant widths. Must match the server's allow-list so the client requests stable, + * cache-friendly URLs (the server snaps too, but matching avoids browser-cache misses). + */ +export const IMAGE_WIDTH_ALLOWLIST: readonly number[] = [160, 320, 640, 960, 1280, 1920, 2560]; + +/** Snap a CSS width (times device pixel ratio) up to the nearest allow-listed variant width. */ +export function snapImageWidth(cssWidth?: number | null, devicePixelRatio = 1): number { + const dpr = devicePixelRatio && devicePixelRatio > 0 ? devicePixelRatio : 1; + const target = cssWidth && cssWidth > 0 ? cssWidth * dpr : 0; + const max = IMAGE_WIDTH_ALLOWLIST[IMAGE_WIDTH_ALLOWLIST.length - 1]; + if (!target) { + return max; + } + for (const w of IMAGE_WIDTH_ALLOWLIST) { + if (w >= target) { + return w; + } + } + return max; +} From c5a140cb21eeb10b11cbba28e55ff3d90b45ef90 Mon Sep 17 00:00:00 2001 From: Dillan Laughlin Date: Tue, 23 Jun 2026 20:42:07 -0500 Subject: [PATCH 02/14] Add Image widget that displays a server-stored image asset New widget-image renders a selected image (by id) via ImageAssetService, scaled to fit the widget with object-fit (contain/cover) over a configurable background that defaults to transparent so the dashboard shows through. It tracks its container width with the existing kipResizeObserver directive and requests a server variant matched to that width (x DPR) so a small widget never downloads a full-resolution image. Shows an empty state when nothing is selected. Registered in WidgetService (component map + Component-category definition); adds the `image` config block to IWidgetSvcConfig. 3 vitest cases (empty state, render+object-fit, background default/override). --- src/app/core/interfaces/widgets-interface.ts | 9 +++ src/app/core/services/widget.service.ts | 15 ++++ .../widget-image/widget-image.component.html | 7 ++ .../widget-image/widget-image.component.scss | 28 ++++++++ .../widget-image.component.spec.ts | 69 +++++++++++++++++++ .../widget-image/widget-image.component.ts | 48 +++++++++++++ 6 files changed, 176 insertions(+) create mode 100644 src/app/widgets/widget-image/widget-image.component.html create mode 100644 src/app/widgets/widget-image/widget-image.component.scss create mode 100644 src/app/widgets/widget-image/widget-image.component.spec.ts create mode 100644 src/app/widgets/widget-image/widget-image.component.ts diff --git a/src/app/core/interfaces/widgets-interface.ts b/src/app/core/interfaces/widgets-interface.ts index 356da3945..321d1f559 100644 --- a/src/app/core/interfaces/widgets-interface.ts +++ b/src/app/core/interfaces/widgets-interface.ts @@ -386,6 +386,15 @@ export interface IWidgetSvcConfig { /** Used by IFrame widget: allow input on iframe or not */ allowInput?: boolean; + /** Used by the Image widget: selected shared image asset + display options. */ + image?: { + imageId?: string | null; + imageFit?: 'contain' | 'cover'; + altText?: string; + /** Letterbox/background fill; null = transparent (dashboard shows through). */ + backgroundColor?: string | null; + }; + /** Use by racetimer widget */ timerLength?: number; /** The next dashboard to display when the racer-timer-widget counts to 0 and the boat is not OCS*/ diff --git a/src/app/core/services/widget.service.ts b/src/app/core/services/widget.service.ts index 6e64c6487..43adde7e1 100644 --- a/src/app/core/services/widget.service.ts +++ b/src/app/core/services/widget.service.ts @@ -16,6 +16,7 @@ import { WidgetPositionComponent } from '../../widgets/widget-position/widget-po import { WidgetAisRadarComponent } from '../../widgets/widget-ais-radar/widget-ais-radar.component'; import { WidgetLabelComponent } from '../../widgets/widget-label/widget-label.component'; import { WidgetIframeComponent } from '../../widgets/widget-iframe/widget-iframe.component'; +import { WidgetImageComponent } from '../../widgets/widget-image/widget-image.component'; import { WidgetHorizonComponent } from '../../widgets/widget-horizon/widget-horizon.component'; import { WidgetHeelGaugeComponent } from '../../widgets/widget-heel-gauge/widget-heel-gauge.component'; import { WidgetSteelGaugeComponent } from '../../widgets/widget-gauge-steel/widget-gauge-steel.component'; @@ -157,6 +158,7 @@ export class WidgetService { WidgetAisRadarComponent: WidgetAisRadarComponent, WidgetLabelComponent: WidgetLabelComponent, WidgetIframeComponent: WidgetIframeComponent, + WidgetImageComponent: WidgetImageComponent, WidgetHorizonComponent: WidgetHorizonComponent, WidgetHeelGaugeComponent: WidgetHeelGaugeComponent, WidgetSteelGaugeComponent: WidgetSteelGaugeComponent, @@ -558,6 +560,19 @@ export class WidgetService { selector: 'widget-iframe', componentClassName: 'WidgetIframeComponent', }, + { + name: 'Image', + description: 'Displays an uploaded image (e.g. a diagram of where safety equipment is stowed) stored on the Signal K server, scaled to fit while preserving aspect ratio. Upload images or pick from the shared library in the widget options.', + icon: '', + minWidth: 2, + minHeight: 2, + defaultWidth: 4, + defaultHeight: 6, + category: 'Core', + requiredPlugins: [], + selector: 'widget-image', + componentClassName: 'WidgetImageComponent', + }, { name: 'Tutorial', description: 'An instructional widget that guides new users through basic navigation, gestures, and dashboard editing steps.', diff --git a/src/app/widgets/widget-image/widget-image.component.html b/src/app/widgets/widget-image/widget-image.component.html new file mode 100644 index 000000000..7a06cc5e3 --- /dev/null +++ b/src/app/widgets/widget-image/widget-image.component.html @@ -0,0 +1,7 @@ +
+ @if (imageUrl(); as url) { + + } @else { +
No image selected
+ } +
diff --git a/src/app/widgets/widget-image/widget-image.component.scss b/src/app/widgets/widget-image/widget-image.component.scss new file mode 100644 index 000000000..db3ad1bc8 --- /dev/null +++ b/src/app/widgets/widget-image/widget-image.component.scss @@ -0,0 +1,28 @@ +:host { + display: block; + width: 100%; + height: 100%; +} + +.image-widget-host { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.image-widget-img { + width: 100%; + height: 100%; + display: block; + // object-fit is set from config (contain | cover) to scale while preserving aspect ratio. +} + +.image-widget-empty { + color: var(--kip-contrast-dim-color, rgba(255, 255, 255, 0.5)); + font-size: 0.9em; + text-align: center; + padding: 0.5em; +} diff --git a/src/app/widgets/widget-image/widget-image.component.spec.ts b/src/app/widgets/widget-image/widget-image.component.spec.ts new file mode 100644 index 000000000..41e595d03 --- /dev/null +++ b/src/app/widgets/widget-image/widget-image.component.spec.ts @@ -0,0 +1,69 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { signal } from '@angular/core'; +import { WidgetImageComponent } from './widget-image.component'; +import { WidgetRuntimeDirective } from '../../core/directives/widget-runtime.directive'; +import { ImageAssetService } from '../../core/services/image-asset.service'; +import type { IWidgetSvcConfig } from '../../core/interfaces/widgets-interface'; +import type { ITheme } from '../../core/services/app-service'; + +describe('WidgetImageComponent', () => { + let fixture: ComponentFixture; + let component: WidgetImageComponent; + const options = signal(undefined); + + const runtimeMock = { options }; + const imagesMock = { + urlFor: (id: string | null | undefined, w?: number | null) => + id ? `http://host/plugins/kip/images/${id}?w=${w || 2560}` : null + }; + + beforeEach(async () => { + options.set({ image: { imageId: null, imageFit: 'contain', altText: '', backgroundColor: null } }); + await TestBed.configureTestingModule({ + imports: [WidgetImageComponent], + providers: [ + { provide: WidgetRuntimeDirective, useValue: runtimeMock }, + { provide: ImageAssetService, useValue: imagesMock } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(WidgetImageComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('id', 'w1'); + fixture.componentRef.setInput('type', 'widget-image'); + fixture.componentRef.setInput('theme', {} as ITheme); + }); + + const api = () => component as unknown as { + imageUrl: () => string | null; + background: () => string; + objectFit: () => string; + }; + + it('shows the empty state when no image is selected', () => { + fixture.detectChanges(); + expect(api().imageUrl()).toBeNull(); + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('.image-widget-empty')).toBeTruthy(); + expect(el.querySelector('img')).toBeFalsy(); + }); + + it('renders the configured image via the asset service URL with the chosen object-fit', () => { + options.set({ image: { imageId: 'img-1', imageFit: 'cover', altText: 'Safety map', backgroundColor: '#000' } }); + fixture.detectChanges(); + const img = (fixture.nativeElement as HTMLElement).querySelector('img'); + expect(img).toBeTruthy(); + expect(img!.getAttribute('src')).toContain('/plugins/kip/images/img-1?w='); + expect(img!.getAttribute('alt')).toBe('Safety map'); + expect(img!.style.objectFit).toBe('cover'); + }); + + it('defaults to a transparent background and contain fit, and reflects a configured color', () => { + fixture.detectChanges(); + expect(api().background()).toBe('transparent'); + expect(api().objectFit()).toBe('contain'); + options.set({ image: { imageId: 'x', backgroundColor: '#123456' } }); + expect(api().background()).toBe('#123456'); + }); +}); diff --git a/src/app/widgets/widget-image/widget-image.component.ts b/src/app/widgets/widget-image/widget-image.component.ts new file mode 100644 index 000000000..c75b916d9 --- /dev/null +++ b/src/app/widgets/widget-image/widget-image.component.ts @@ -0,0 +1,48 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core'; +import { IWidgetSvcConfig } from '../../core/interfaces/widgets-interface'; +import { WidgetRuntimeDirective } from '../../core/directives/widget-runtime.directive'; +import { KipResizeObserverDirective, IKipResizeEvent } from '../../core/directives/kip-resize-observer.directive'; +import { ImageAssetService } from '../../core/services/image-asset.service'; +import { ITheme } from '../../core/services/app-service'; + +/** + * Displays a user-uploaded image asset (stored on the Signal K server) scaled to fit the widget + * while preserving aspect ratio (object-fit), over a configurable background (transparent by default). + * Requests a variant matched to the container width so a small widget doesn't fetch a full-res image. + */ +@Component({ + selector: 'widget-image', + templateUrl: './widget-image.component.html', + styleUrls: ['./widget-image.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [KipResizeObserverDirective] +}) +export class WidgetImageComponent { + public id = input.required(); + public type = input.required(); + public theme = input.required(); + + protected readonly runtime = inject(WidgetRuntimeDirective); + private readonly images = inject(ImageAssetService); + + public static readonly DEFAULT_CONFIG: IWidgetSvcConfig = { + image: { imageId: null, imageFit: 'contain', altText: '', backgroundColor: null } + }; + + private readonly containerWidth = signal(0); + + protected readonly imageConfig = computed(() => this.runtime.options()?.image ?? null); + protected readonly altText = computed(() => this.imageConfig()?.altText ?? ''); + protected readonly objectFit = computed(() => this.imageConfig()?.imageFit ?? 'contain'); + protected readonly background = computed(() => this.imageConfig()?.backgroundColor ?? 'transparent'); + + protected readonly imageUrl = computed(() => { + const id = this.imageConfig()?.imageId; + if (!id) return null; + return this.images.urlFor(id, this.containerWidth()); + }); + + protected onResize(event: IKipResizeEvent): void { + this.containerWidth.set(Math.round(event.width)); + } +} From 378bacb9bafcd7f3e2716d66c6b9e7f1adab9733 Mon Sep 17 00:00:00 2001 From: Dillan Laughlin Date: Tue, 23 Jun 2026 21:03:12 -0500 Subject: [PATCH 03/14] Add image-source-setup widget config (upload, gallery, fit/background) Lets users upload an image (client-side 10 MB + type guard, progress), pick or delete from the shared library, and set the scaling, alt text, and a background color or transparent background for the Image widget. Wired into the widget config Display tab for widgets that expose an image config group. --- .../image-source-setup.component.html | 70 ++++++++ .../image-source-setup.component.scss | 106 +++++++++++ .../image-source-setup.component.spec.ts | 163 +++++++++++++++++ .../image-source-setup.component.ts | 166 ++++++++++++++++++ .../root-modal-widget-config.component.html | 2 + .../root-modal-widget-config.component.ts | 3 +- 6 files changed, 509 insertions(+), 1 deletion(-) create mode 100644 src/app/widget-config/image-source-setup/image-source-setup.component.html create mode 100644 src/app/widget-config/image-source-setup/image-source-setup.component.scss create mode 100644 src/app/widget-config/image-source-setup/image-source-setup.component.spec.ts create mode 100644 src/app/widget-config/image-source-setup/image-source-setup.component.ts diff --git a/src/app/widget-config/image-source-setup/image-source-setup.component.html b/src/app/widget-config/image-source-setup/image-source-setup.component.html new file mode 100644 index 000000000..e813f93eb --- /dev/null +++ b/src/app/widget-config/image-source-setup/image-source-setup.component.html @@ -0,0 +1,70 @@ +
+ + +
+ + + JPG, PNG, WebP, GIF, HEIC/HEIF, SVG · max 10 MB +
+ + @if (uploading()) { + + } + @if (error(); as message) { +

{{ message }}

+ } + + + @if (gallery().length) { + + } @else { +

No images uploaded yet.

+ } + + + + Scaling + + Fit (show whole image) + Fill (crop to container) + + + + + Alt text + + + +
+ + Transparent background + + @if (!isTransparent) { + + } +
+ +
diff --git a/src/app/widget-config/image-source-setup/image-source-setup.component.scss b/src/app/widget-config/image-source-setup/image-source-setup.component.scss new file mode 100644 index 000000000..b0cb0695b --- /dev/null +++ b/src/app/widget-config/image-source-setup/image-source-setup.component.scss @@ -0,0 +1,106 @@ +.image-setup { + display: flex; + flex-direction: column; + gap: 1rem; + padding-top: 0.5rem; +} + +.image-setup__upload { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.image-setup__hint { + color: var(--kip-contrast-dim-color, rgba(255, 255, 255, 0.6)); + font-size: 0.8rem; +} + +.image-setup__error { + color: var(--mat-sys-error, #f44336); + font-size: 0.85rem; + margin: 0; +} + +.image-setup__gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); + gap: 0.5rem; + max-height: 240px; + overflow-y: auto; +} + +.image-setup__thumb { + position: relative; + padding: 0; + border: 2px solid transparent; + border-radius: 6px; + overflow: hidden; + cursor: pointer; + background: var(--kip-widget-background, rgba(0, 0, 0, 0.2)); + aspect-ratio: 1 / 1; + + img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + } + + &--selected { + border-color: var(--mat-sys-primary, #4f9cff); + } +} + +.image-setup__check { + position: absolute; + top: 2px; + left: 2px; + color: var(--mat-sys-primary, #4f9cff); + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8)); +} + +.image-setup__delete { + position: absolute; + top: 2px; + right: 2px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #fff; + background: rgba(0, 0, 0, 0.55); + border-radius: 50%; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +.image-setup__field { + width: 100%; +} + +.image-setup__bg { + display: flex; + align-items: center; + gap: 1.25rem; + flex-wrap: wrap; +} + +.image-setup__color { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + + input[type='color'] { + width: 40px; + height: 28px; + border: none; + background: none; + cursor: pointer; + } +} diff --git a/src/app/widget-config/image-source-setup/image-source-setup.component.spec.ts b/src/app/widget-config/image-source-setup/image-source-setup.component.spec.ts new file mode 100644 index 000000000..56dfedeb9 --- /dev/null +++ b/src/app/widget-config/image-source-setup/image-source-setup.component.spec.ts @@ -0,0 +1,163 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { UntypedFormControl, UntypedFormGroup, FormGroupDirective } from '@angular/forms'; +import { HttpEventType, HttpErrorResponse } from '@angular/common/http'; +import { of, throwError } from 'rxjs'; +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { ImageSourceSetupComponent } from './image-source-setup.component'; +import { ImageAssetService, IImageAsset } from '../../core/services/image-asset.service'; + +describe('ImageSourceSetupComponent', () => { + let fixture: ComponentFixture; + let component: ImageSourceSetupComponent; + let formGroup: UntypedFormGroup; + + const sampleList: IImageAsset[] = [ + { id: 'img-1', name: 'a.png', format: 'png', width: 100, height: 80, bytes: 1234, animated: false, createdAt: '2026-01-01T00:00:00Z' }, + { id: 'img-2', name: 'b.svg', format: 'svg', width: 0, height: 0, bytes: 500, animated: false, createdAt: '2026-01-02T00:00:00Z' } + ]; + + const imagesMock = { + list: vi.fn(() => of(sampleList)), + upload: vi.fn(() => of({ type: HttpEventType.Response, body: { id: 'uploaded-1' } })), + delete: vi.fn(() => of(undefined)), + urlFor: vi.fn((id: string, w?: number) => `http://host/plugins/kip/images/${id}?w=${w}`) + }; + + const api = () => component as unknown as { + imageGroup: UntypedFormGroup; + gallery: () => IImageAsset[]; + error: () => string | null; + uploading: () => boolean; + validateFile: (file: File) => string | null; + selectImage: (id: string | null) => void; + deleteImage: (id: string, event: Event) => void; + onFileSelected: (event: Event) => void; + toggleTransparent: (v: boolean) => void; + isTransparent: boolean; + }; + + const fileOf = (name: string, type: string, size: number): File => { + const file = new File(['x'], name, { type }); + Object.defineProperty(file, 'size', { value: size }); + return file; + }; + + const buildWith = async (imageGroup: UntypedFormGroup) => { + formGroup = new UntypedFormGroup({ image: imageGroup }); + await TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [ImageSourceSetupComponent], + providers: [ + { provide: FormGroupDirective, useValue: { control: formGroup } }, + { provide: ImageAssetService, useValue: imagesMock } + ] + }).compileComponents(); + fixture = TestBed.createComponent(ImageSourceSetupComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('formGroupName', 'image'); + fixture.detectChanges(); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('ensures the imageId/imageFit/altText/backgroundColor controls exist when the group is empty', async () => { + await buildWith(new UntypedFormGroup({})); + const group = api().imageGroup; + expect(group.get('imageId')).toBeTruthy(); + expect(group.get('imageFit')!.value).toBe('contain'); + expect(group.get('altText')!.value).toBe(''); + expect(group.get('backgroundColor')!.value).toBeNull(); + }); + + it('preserves saved control values when reopening', async () => { + await buildWith(new UntypedFormGroup({ + imageId: new UntypedFormControl('img-2'), + imageFit: new UntypedFormControl('cover'), + altText: new UntypedFormControl('Map'), + backgroundColor: new UntypedFormControl('#112233') + })); + const group = api().imageGroup; + expect(group.get('imageId')!.value).toBe('img-2'); + expect(group.get('imageFit')!.value).toBe('cover'); + expect(group.get('backgroundColor')!.value).toBe('#112233'); + }); + + it('loads the shared library into the gallery on init', async () => { + await buildWith(new UntypedFormGroup({})); + expect(imagesMock.list).toHaveBeenCalled(); + expect(api().gallery().map(a => a.id)).toEqual(['img-1', 'img-2']); + }); + + it('rejects files over the 10 MB limit', async () => { + await buildWith(new UntypedFormGroup({})); + const big = fileOf('big.png', 'image/png', 10 * 1024 * 1024 + 1); + expect(api().validateFile(big)).toContain('10 MB'); + }); + + it('rejects unsupported types that also lack a known extension', async () => { + await buildWith(new UntypedFormGroup({})); + const bad = fileOf('note.txt', 'text/plain', 100); + expect(api().validateFile(bad)).toContain('Unsupported'); + }); + + it('accepts a valid image within limits', async () => { + await buildWith(new UntypedFormGroup({})); + const ok = fileOf('photo.webp', 'image/webp', 2 * 1024 * 1024); + expect(api().validateFile(ok)).toBeNull(); + }); + + it('accepts a HEIC file by extension even when the browser reports no type', async () => { + await buildWith(new UntypedFormGroup({})); + const heic = fileOf('IMG_0001.HEIC', '', 1024); + expect(api().validateFile(heic)).toBeNull(); + }); + + it('selecting a gallery image sets the imageId control and marks it dirty', async () => { + await buildWith(new UntypedFormGroup({})); + api().selectImage('img-1'); + const control = api().imageGroup.get('imageId')!; + expect(control.value).toBe('img-1'); + expect(control.dirty).toBe(true); + }); + + it('sets the selected image id from a successful upload response', async () => { + await buildWith(new UntypedFormGroup({})); + const input = document.createElement('input'); + input.type = 'file'; + const file = fileOf('new.png', 'image/png', 1024); + Object.defineProperty(input, 'files', { value: [file], configurable: true }); + api().onFileSelected({ target: input } as unknown as Event); + expect(imagesMock.upload).toHaveBeenCalledWith(file); + expect(api().imageGroup.get('imageId')!.value).toBe('uploaded-1'); + expect(api().uploading()).toBe(false); + }); + + it('surfaces a login error message when the server returns 401', async () => { + imagesMock.upload.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 }))); + await buildWith(new UntypedFormGroup({})); + const input = document.createElement('input'); + input.type = 'file'; + const file = fileOf('new.png', 'image/png', 1024); + Object.defineProperty(input, 'files', { value: [file], configurable: true }); + api().onFileSelected({ target: input } as unknown as Event); + expect(api().error()).toContain('logged in'); + }); + + it('clears the selection when the currently-selected image is deleted', async () => { + await buildWith(new UntypedFormGroup({ imageId: new UntypedFormControl('img-1') })); + api().deleteImage('img-1', { stopPropagation: vi.fn() } as unknown as Event); + expect(imagesMock.delete).toHaveBeenCalledWith('img-1'); + expect(api().imageGroup.get('imageId')!.value).toBeNull(); + }); + + it('toggles a transparent background on and off', async () => { + await buildWith(new UntypedFormGroup({})); + expect(api().isTransparent).toBe(true); + api().toggleTransparent(false); + expect(api().imageGroup.get('backgroundColor')!.value).toBe('#000000'); + api().toggleTransparent(true); + expect(api().imageGroup.get('backgroundColor')!.value).toBeNull(); + }); +}); diff --git a/src/app/widget-config/image-source-setup/image-source-setup.component.ts b/src/app/widget-config/image-source-setup/image-source-setup.component.ts new file mode 100644 index 000000000..cbc8d566a --- /dev/null +++ b/src/app/widget-config/image-source-setup/image-source-setup.component.ts @@ -0,0 +1,166 @@ +import { Component, DestroyRef, OnInit, inject, input, signal } from '@angular/core'; +import { FormGroupDirective, ReactiveFormsModule, UntypedFormControl, UntypedFormGroup } from '@angular/forms'; +import { HttpErrorResponse, HttpEventType } from '@angular/common/http'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { ImageAssetService, IImageAsset } from '../../core/services/image-asset.service'; + +const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; +const ACCEPTED_EXT = /\.(png|jpe?g|webp|gif|heic|heif|svg)$/i; +const ACCEPTED_TYPES = new Set([ + 'image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/heic', 'image/heif', 'image/svg+xml' +]); + +/** + * Widget config sub-component for the Image widget: upload a new image (with client-side size/type + * guards + progress), pick/delete from the shared library, and set fit / alt text / background. + * Binds to the widget config's nested `image` FormGroup (created if absent). + */ +@Component({ + selector: 'image-source-setup', + templateUrl: './image-source-setup.component.html', + styleUrls: ['./image-source-setup.component.scss'], + imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatButtonModule, MatIconModule, MatCheckboxModule, MatProgressBarModule] +}) +export class ImageSourceSetupComponent implements OnInit { + readonly formGroupName = input.required(); + readonly accept = 'image/png,image/jpeg,image/webp,image/gif,image/heic,image/heif,image/svg+xml'; + + private readonly rootFormGroup = inject(FormGroupDirective); + private readonly destroyRef = inject(DestroyRef); + protected readonly images = inject(ImageAssetService); + + protected imageGroup!: UntypedFormGroup; + protected readonly gallery = signal([]); + protected readonly uploading = signal(false); + protected readonly uploadProgress = signal(0); + protected readonly error = signal(null); + + ngOnInit(): void { + const existing = this.rootFormGroup.control.get(this.formGroupName()); + if (existing instanceof UntypedFormGroup) { + this.imageGroup = existing; + } else { + this.imageGroup = new UntypedFormGroup({}); + this.rootFormGroup.control.addControl(this.formGroupName(), this.imageGroup); + } + this.ensureControl('imageId', null); + this.ensureControl('imageFit', 'contain'); + this.ensureControl('altText', ''); + this.ensureControl('backgroundColor', null); + this.refreshGallery(); + } + + private ensureControl(name: string, defaultValue: unknown): void { + if (!this.imageGroup.get(name)) { + this.imageGroup.addControl(name, new UntypedFormControl(defaultValue)); + } + } + + protected get imageIdControl(): UntypedFormControl { return this.imageGroup.get('imageId') as UntypedFormControl; } + protected get backgroundControl(): UntypedFormControl { return this.imageGroup.get('backgroundColor') as UntypedFormControl; } + protected get selectedId(): string | null { return this.imageIdControl?.value ?? null; } + + /** Client-side pre-check (the server is authoritative). Returns an error message or null. */ + validateFile(file: File): string | null { + if (file.size > MAX_UPLOAD_BYTES) { + return 'File exceeds the 10 MB limit'; + } + if (file.type && !ACCEPTED_TYPES.has(file.type) && !ACCEPTED_EXT.test(file.name)) { + return 'Unsupported image type'; + } + return null; + } + + protected onFileSelected(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; // allow re-selecting the same file later + if (!file) return; + + const validationError = this.validateFile(file); + if (validationError) { + this.error.set(validationError); + return; + } + this.error.set(null); + this.uploading.set(true); + this.uploadProgress.set(0); + + this.images.upload(file).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (httpEvent) => { + if (httpEvent.type === HttpEventType.UploadProgress && httpEvent.total) { + this.uploadProgress.set(Math.round((httpEvent.loaded / httpEvent.total) * 100)); + } else if (httpEvent.type === HttpEventType.Response) { + this.uploading.set(false); + const meta = httpEvent.body as IImageAsset | null; + if (meta?.id) { + this.selectImage(meta.id); + this.refreshGallery(); + } + } + }, + error: (err: HttpErrorResponse) => { + this.uploading.set(false); + this.error.set(this.describeUploadError(err)); + } + }); + } + + private describeUploadError(err: HttpErrorResponse): string { + const serverMessage = (err?.error as { error?: string })?.error; + switch (err?.status) { + case 401: return 'You must be logged in to the Signal K server to upload images.'; + case 413: return 'File exceeds the 10 MB limit'; + case 415: return serverMessage ?? 'Unsupported or unreadable image'; + default: return serverMessage ?? 'Upload failed'; + } + } + + protected selectImage(id: string | null): void { + this.imageIdControl.setValue(id); + this.imageIdControl.markAsDirty(); + } + + protected deleteImage(id: string, event: Event): void { + event.stopPropagation(); + this.images.delete(id).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: () => { + if (this.selectedId === id) this.selectImage(null); + this.refreshGallery(); + }, + error: () => this.error.set('Failed to delete image') + }); + } + + protected thumbUrl(id: string): string | null { + return this.images.urlFor(id, 160); + } + + protected refreshGallery(): void { + this.images.list().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (list) => this.gallery.set(list), + error: () => { /* listing is best-effort */ } + }); + } + + protected get isTransparent(): boolean { + return !this.backgroundControl?.value; + } + + protected toggleTransparent(transparent: boolean): void { + this.backgroundControl.setValue(transparent ? null : '#000000'); + this.backgroundControl.markAsDirty(); + } + + protected setBackground(event: Event): void { + this.backgroundControl.setValue((event.target as HTMLInputElement).value); + this.backgroundControl.markAsDirty(); + } +} diff --git a/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.html b/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.html index 5dddc77b6..c97b62f0d 100644 --- a/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.html +++ b/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.html @@ -32,6 +32,8 @@
{{ titleDialog }}
[color]="colorToControl" /> } @else if (widgetConfig?.autopilot) { + } @else if (widgetConfig?.image) { + } @else {
diff --git a/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.ts b/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.ts index 0c47829f0..3146a3350 100644 --- a/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.ts +++ b/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.ts @@ -25,13 +25,14 @@ import { BmsBankSetupComponent } from '../bms-bank-setup/bms-bank-setup.componen import { AisTargetOptionsComponent } from '../ais-target-options/ais-target-options.component'; import { SolarChargerSetupComponent } from '../solar-charger-setup/solar-charger-setup.component'; import { ElectricalFamilySetupComponent } from '../electrical-family-setup/electrical-family-setup.component'; +import { ImageSourceSetupComponent } from '../image-source-setup/image-source-setup.component'; import { MatTabsModule } from '@angular/material/tabs'; @Component({ selector: 'modal-widget-config', templateUrl: './root-modal-widget-config.component.html', styleUrls: ['./root-modal-widget-config.component.scss'], - imports: [FormsModule, ReactiveFormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatTabsModule, MatCheckboxModule, MatSelectModule, MatDividerModule, MatButtonModule, DisplayDatetimeComponent, DisplayChartOptionsComponent, DatasetChartOptionsComponent, BooleanMultiControlOptionsComponent, PathsOptionsComponent, SelectAutopilotComponent, AisTargetOptionsComponent, BmsBankSetupComponent, SolarChargerSetupComponent, ElectricalFamilySetupComponent] + imports: [FormsModule, ReactiveFormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatTabsModule, MatCheckboxModule, MatSelectModule, MatDividerModule, MatButtonModule, DisplayDatetimeComponent, DisplayChartOptionsComponent, DatasetChartOptionsComponent, BooleanMultiControlOptionsComponent, PathsOptionsComponent, SelectAutopilotComponent, AisTargetOptionsComponent, BmsBankSetupComponent, SolarChargerSetupComponent, ElectricalFamilySetupComponent, ImageSourceSetupComponent] }) export class RootModalWidgetConfigComponent implements OnInit { // Property name constants to avoid magic strings From 0fb8196ddda1d45236f55a356ec2eee57391b6ae Mon Sep 17 00:00:00 2001 From: Dillan Laughlin Date: Tue, 23 Jun 2026 21:05:50 -0500 Subject: [PATCH 04/14] Add image cache card to settings (size + purge) Shows the on-disk size of generated image variants and a Purge button (with confirmation). Purging keeps originals; variants regenerate on demand. Refresh re-reads the size after viewing images. --- .../configuration/config.component.html | 31 +++++++++ .../configuration/config.component.scss | 15 +++++ .../configuration/config.component.spec.ts | 54 ++++++++++++++- .../options/configuration/config.component.ts | 66 +++++++++++++++++++ 4 files changed, 164 insertions(+), 2 deletions(-) diff --git a/src/app/core/components/options/configuration/config.component.html b/src/app/core/components/options/configuration/config.component.html index d2dc51f63..bf76589fa 100644 --- a/src/app/core/components/options/configuration/config.component.html +++ b/src/app/core/components/options/configuration/config.component.html @@ -150,6 +150,37 @@

Restore

+
+

Image Cache

+

+ Uploaded images are stored on the Signal K server and resized copies are + cached on disk. Purging removes the generated copies; originals are kept + and copies regenerate on demand. +

+
+ On-disk cache: + {{ imageCacheDisplay() }} +
+
+ + + +
+

Advanced

diff --git a/src/app/core/components/options/configuration/config.component.scss b/src/app/core/components/options/configuration/config.component.scss index 6dea32f63..6b5acb487 100644 --- a/src/app/core/components/options/configuration/config.component.scss +++ b/src/app/core/components/options/configuration/config.component.scss @@ -27,6 +27,21 @@ h3 { margin-top: 5px; } +.image-cache-size { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 8px; +} + +.image-cache-label { + opacity: 0.7; +} + +.image-cache-value { + font-weight: 600; +} + a { font-size: 14px; } diff --git a/src/app/core/components/options/configuration/config.component.spec.ts b/src/app/core/components/options/configuration/config.component.spec.ts index e0c7d8982..a9a1bd193 100644 --- a/src/app/core/components/options/configuration/config.component.spec.ts +++ b/src/app/core/components/options/configuration/config.component.spec.ts @@ -1,11 +1,13 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SettingsConfigComponent } from './config.component'; import { AuthenticationService, IAuthorizationToken } from '../../../services/authentication.service'; import { StorageService } from '../../../services/storage.service'; import { ToastService } from '../../../services/toast.service'; import { SettingsService } from '../../../services/settings.service'; +import { ImageAssetService } from '../../../services/image-asset.service'; +import { DialogService } from '../../../services/dialog.service'; const createToken = (overrides: Partial = {}): IAuthorizationToken => ({ token: 'token', @@ -28,6 +30,14 @@ describe('SettingsConfigComponent', () => { let toastMock: { show: ReturnType; }; + let imagesMock: { + ready: boolean; + cacheStats: ReturnType; + purgeCache: ReturnType; + }; + let dialogMock: { + openConfirmationDialog: ReturnType; + }; beforeEach(async () => { authTokenSubject = new BehaviorSubject(null); @@ -45,6 +55,14 @@ describe('SettingsConfigComponent', () => { toastMock = { show: vi.fn() }; + imagesMock = { + ready: true, + cacheStats: vi.fn(() => of({ bytes: 1048576, files: 3 })), + purgeCache: vi.fn(() => of({ ok: true })) + }; + dialogMock = { + openConfirmationDialog: vi.fn(() => of(true)) + }; await TestBed.configureTestingModule({ imports: [SettingsConfigComponent], @@ -71,7 +89,9 @@ describe('SettingsConfigComponent', () => { resetConnection: vi.fn(), loadDemoConfig: vi.fn() } - } + }, + { provide: ImageAssetService, useValue: imagesMock }, + { provide: DialogService, useValue: dialogMock } ] }) .compileComponents(); @@ -117,4 +137,34 @@ describe('SettingsConfigComponent', () => { expect(toastMock.show).toHaveBeenCalledWith('Please select a valid configuration to delete.', 0, false, 'error'); expect(storageMock.removeItem).not.toHaveBeenCalled(); }); + + it('loads and formats the image cache size on init', () => { + expect(imagesMock.cacheStats).toHaveBeenCalled(); + const api = component as unknown as { imageCacheDisplay: () => string }; + expect(api.imageCacheDisplay()).toBe('1.0 MB · 3 files'); + }); + + it('shows Unavailable when the image service is not ready', () => { + imagesMock.ready = false; + component.refreshImageCache(); + const api = component as unknown as { imageCacheDisplay: () => string }; + expect(api.imageCacheDisplay()).toBe('Unavailable'); + }); + + it('purges the image cache after confirmation and refreshes', () => { + imagesMock.cacheStats.mockClear(); + component.purgeImageCache(); + + expect(dialogMock.openConfirmationDialog).toHaveBeenCalled(); + expect(imagesMock.purgeCache).toHaveBeenCalled(); + expect(imagesMock.cacheStats).toHaveBeenCalled(); + expect(toastMock.show).toHaveBeenCalledWith('Image cache purged', 1000, true, 'success'); + }); + + it('does not purge when the confirmation is declined', () => { + dialogMock.openConfirmationDialog.mockReturnValueOnce(of(false)); + component.purgeImageCache(); + + expect(imagesMock.purgeCache).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/core/components/options/configuration/config.component.ts b/src/app/core/components/options/configuration/config.component.ts index 40515a8ad..eddcb87f3 100644 --- a/src/app/core/components/options/configuration/config.component.ts +++ b/src/app/core/components/options/configuration/config.component.ts @@ -7,6 +7,8 @@ import { ToastService } from '../../../services/toast.service'; import { SettingsService } from '../../../services/settings.service'; import { IConfig } from '../../../interfaces/app-settings.interfaces'; import { StorageService } from '../../../services/storage.service'; +import { ImageAssetService, IImageCacheStats } from '../../../services/image-asset.service'; +import { DialogService } from '../../../services/dialog.service'; import { HttpErrorResponse } from '@angular/common/http'; import { MatInput, MatInputModule } from '@angular/material/input'; import { MatOption } from '@angular/material/core'; @@ -37,6 +39,18 @@ export class SettingsConfigComponent { private toast = inject(ToastService); private auth = inject(AuthenticationService); private fb = inject(UntypedFormBuilder); + private images = inject(ImageAssetService); + private dialog = inject(DialogService); + + protected readonly imageCacheStats = signal(null); + protected readonly imageCachePurging = signal(false); + protected readonly imageCacheDisplay = computed(() => { + const stats = this.imageCacheStats(); + if (!stats) { + return 'Unavailable'; + } + return `${this.formatBytes(stats.bytes)} · ${stats.files} file${stats.files === 1 ? '' : 's'}`; + }); private authToken = toSignal(this.auth.authToken$, { initialValue: null }); private serverConfigListSignal = signal([]); @@ -72,6 +86,10 @@ export class SettingsConfigComponent { public deleteConfigKey: string = null; public jsonData: IConfig = null; + constructor() { + this.refreshImageCache(); + } + private readonly authStateEffect = effect(() => { if (!this.supportApplicationData) { return; @@ -253,6 +271,54 @@ export class SettingsConfigComponent { window.URL.revokeObjectURL(downloadURL); // Cleanup memory } + /** Refresh the on-disk image-cache size shown in the settings card. */ + public refreshImageCache(): void { + if (!this.images.ready) { + this.imageCacheStats.set(null); + return; + } + this.images.cacheStats().subscribe({ + next: (stats) => this.imageCacheStats.set(stats), + error: () => this.imageCacheStats.set(null) + }); + } + + /** Purge generated image variants (originals are kept and regenerate on demand). */ + public purgeImageCache(): void { + this.dialog.openConfirmationDialog({ + title: 'Purge Image Cache', + message: 'Delete all generated image variants? Originals are kept and variants regenerate on demand.', + confirmBtnText: 'Purge', + cancelBtnText: 'Cancel' + }).subscribe((confirmed) => { + if (!confirmed) { + return; + } + this.imageCachePurging.set(true); + this.images.purgeCache().subscribe({ + next: () => { + this.imageCachePurging.set(false); + this.toast.show('Image cache purged', 1000, true, 'success'); + this.refreshImageCache(); + }, + error: (error: HttpErrorResponse) => { + this.imageCachePurging.set(false); + this.toast.show('Could not purge image cache: ' + (error?.statusText ?? error), 0, false, 'error'); + } + }); + }); + } + + private formatBytes(bytes: number): string { + if (!bytes) { + return '0 B'; + } + const units = ['B', 'KB', 'MB', 'GB']; + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / Math.pow(1024, exponent); + return `${value.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`; + } + public uploadJsonConfig(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; From 663af9aea5820cb943f6b3d07ad87df2fe9dca1c Mon Sep 17 00:00:00 2001 From: Dillan Laughlin Date: Tue, 23 Jun 2026 21:06:55 -0500 Subject: [PATCH 05/14] Add Image widget help doc and changelog entry --- CHANGELOG.md | 3 ++ src/assets/help-docs/imagewidget.md | 43 +++++++++++++++++++++++++++++ src/assets/help-docs/menu.json | 4 +++ 3 files changed, 50 insertions(+) create mode 100644 src/assets/help-docs/imagewidget.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dfb99975..79957e9cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# v4.9.0 +## New Features +* Image Widget: Display a picture from a shared, boat-wide library on your dashboards — a safety-equipment diagram, electrical panel layout, or boat plan. Upload JPG, PNG, WebP, GIF (including animated), HEIC/HEIF, or SVG (up to 10 MB) directly from the KIP UI; images are stored on the Signal K server and shared across all displays. Pictures scale to fit or fill their widget while preserving aspect ratio, over a solid or transparent background. Uploaded content is validated, raster images are re-encoded to WebP and SVGs are sanitized for safety, and resized copies are generated on demand and cached on disk. A new **Image Cache** card in Settings shows the on-disk cache size and lets you purge generated copies. Uploading requires logging in to the Signal K server. # v4.8.0 ## New Features * Solar Charger Widget: Get instant clarity on your solar system with a compact, purpose-built Solar Charger Widget. Track individual panels or full arrays in real time, including State of Charge, remaining capacity, remaining time, voltage, current, power flow, and temperature. Device discovery is automatic, and Zones support keeps warnings and alarms state highly visible. diff --git a/src/assets/help-docs/imagewidget.md b/src/assets/help-docs/imagewidget.md new file mode 100644 index 000000000..d6f43e43f --- /dev/null +++ b/src/assets/help-docs/imagewidget.md @@ -0,0 +1,43 @@ +## Using the Image Widget + +The Image widget displays a picture you upload to your Signal K server — for example a diagram showing where safety equipment is stowed, an electrical panel layout, or a boat plan. Images are stored on the server in a shared, boat-wide library, so every crew display can show the same picture. + +## Uploading an image + +1. Add an **Image** widget to a dashboard and open its options. +2. Click **Upload image** and choose a file. Supported formats are **JPG, PNG, WebP, GIF (including animated), HEIC/HEIF, and SVG**. +3. Each upload is limited to **10 MB**. Larger files are rejected. +4. You must be **logged in to the Signal K server** to upload, delete, or purge images. Viewing images only requires the normal KIP connection. + +Uploaded pictures are added to the shared library. The thumbnail gallery in the widget options lets you reuse the same image across widgets and dashboards, or delete images you no longer need. + +## Choosing how the image is displayed + +- **Scaling** + - **Fit** scales the whole image to fit inside the widget while preserving its aspect ratio. Any leftover space around the image shows the background. + - **Fill** scales the image to cover the widget, cropping the edges as needed, while preserving aspect ratio. +- **Alt text** is a short description used by assistive technology and shown if the image cannot be displayed. +- **Background** can be a solid color or **transparent** (the dashboard shows through the area around a "Fit" image). + +## How images are stored and served + +To keep displays fast and the server responsive, the plugin does the following: + +- **Originals are stored once.** Resized copies (variants) are created **on demand** the first time a widget of a given size requests the image, then cached on disk. +- **Raster images are re-encoded to WebP** at a size matched to the widget, so a small widget never downloads a full-resolution photo. +- **SVG drawings are kept as vector** and stay crisp at any size. +- **Animated GIFs** are converted to animated WebP and keep animating. +- Image resizing runs in background worker threads so the server stays responsive. + +## Security + +Uploaded content is treated as untrusted and is checked before it is stored: + +- Files are validated by their actual content, not their file name. +- Raster images are re-encoded, which neutralizes files that try to hide other content inside them. +- SVG files are sanitized to remove scripts and other active content, and are only ever shown inside an image element where scripts do not run. +- Restrictive response headers prevent the browser from treating images as anything other than images. + +## Managing the image cache + +Cached variants can be cleared at any time from **Settings → Configurations → Image Cache**. The card shows the current on-disk cache size and a **Purge** button. Purging only removes the generated copies — your original uploads are kept, and variants are regenerated automatically the next time they are displayed. diff --git a/src/assets/help-docs/menu.json b/src/assets/help-docs/menu.json index ebac30525..5b161359c 100644 --- a/src/assets/help-docs/menu.json +++ b/src/assets/help-docs/menu.json @@ -18,6 +18,10 @@ "title": "The Embed Page Viewer", "file": "embedwidget.md" }, + { + "title": "The Image Widget", + "file": "imagewidget.md" + }, { "title": "History-API Provider", "file": "history-api.md" From 2260b12750bfa025067735f26267018111885e18 Mon Sep 17 00:00:00 2001 From: Dillan Laughlin Date: Tue, 23 Jun 2026 21:08:27 -0500 Subject: [PATCH 06/14] Make gallery thumbnail select/delete proper buttons (a11y) Splits the thumbnail into a select button and a sibling delete button so both are keyboard-focusable and avoid an invalid nested-button structure. --- .../image-source-setup.component.html | 31 +++++++++++-------- .../image-source-setup.component.scss | 23 ++++++++++---- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/app/widget-config/image-source-setup/image-source-setup.component.html b/src/app/widget-config/image-source-setup/image-source-setup.component.html index e813f93eb..1075ac0aa 100644 --- a/src/app/widget-config/image-source-setup/image-source-setup.component.html +++ b/src/app/widget-config/image-source-setup/image-source-setup.component.html @@ -21,20 +21,25 @@ @if (gallery().length) { }
} @else { diff --git a/src/app/widget-config/image-source-setup/image-source-setup.component.scss b/src/app/widget-config/image-source-setup/image-source-setup.component.scss index b0cb0695b..be221e080 100644 --- a/src/app/widget-config/image-source-setup/image-source-setup.component.scss +++ b/src/app/widget-config/image-source-setup/image-source-setup.component.scss @@ -33,24 +33,32 @@ .image-setup__thumb { position: relative; - padding: 0; border: 2px solid transparent; border-radius: 6px; overflow: hidden; - cursor: pointer; background: var(--kip-widget-background, rgba(0, 0, 0, 0.2)); aspect-ratio: 1 / 1; + &--selected { + border-color: var(--mat-sys-primary, #4f9cff); + } +} + +.image-setup__thumb-select { + display: block; + width: 100%; + height: 100%; + padding: 0; + border: none; + background: none; + cursor: pointer; + img { width: 100%; height: 100%; object-fit: contain; display: block; } - - &--selected { - border-color: var(--mat-sys-primary, #4f9cff); - } } .image-setup__check { @@ -68,9 +76,12 @@ display: inline-flex; align-items: center; justify-content: center; + padding: 2px; color: #fff; background: rgba(0, 0, 0, 0.55); + border: none; border-radius: 50%; + cursor: pointer; mat-icon { font-size: 18px; From d838ab1b3391b6c981e40c8061933945ed93d776 Mon Sep 17 00:00:00 2001 From: Dillan Laughlin Date: Tue, 23 Jun 2026 21:12:30 -0500 Subject: [PATCH 07/14] Harden image config reactivity (signal highlight + refresh race guard) Make the gallery selection a signal so the highlight updates under zoneless change detection even when set from async upload/delete callbacks, instead of relying on an incidental signal write. Add a sequence guard so a slow image list() response can't overwrite a newer one. --- .../image-source-setup.component.html | 4 ++-- .../image-source-setup.component.spec.ts | 9 ++++++++- .../image-source-setup.component.ts | 16 +++++++++++++--- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/app/widget-config/image-source-setup/image-source-setup.component.html b/src/app/widget-config/image-source-setup/image-source-setup.component.html index 1075ac0aa..ac62c4a18 100644 --- a/src/app/widget-config/image-source-setup/image-source-setup.component.html +++ b/src/app/widget-config/image-source-setup/image-source-setup.component.html @@ -21,14 +21,14 @@ @if (gallery().length) {