diff --git a/.gitignore b/.gitignore index e1e4f519e..ac5c54dd4 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,9 @@ yarn-error.log /e2e/*.js /e2e/*.map +# Playwright MCP working dir +.playwright-mcp/ + # System Files .DS_Store Thumbs.db 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/e2e/image-screenshots/.gitignore b/e2e/image-screenshots/.gitignore new file mode 100644 index 000000000..6e6ec5daa --- /dev/null +++ b/e2e/image-screenshots/.gitignore @@ -0,0 +1 @@ +kip.tgz diff --git a/e2e/image-screenshots/Dockerfile b/e2e/image-screenshots/Dockerfile new file mode 100644 index 000000000..62a86cd8c --- /dev/null +++ b/e2e/image-screenshots/Dockerfile @@ -0,0 +1,10 @@ +# Reproducible Signal K harness for capturing KIP image-widget screenshots. +# Bakes the (integrated) KIP plugin + webapp and a security-OFF config into the image so the +# image library, thumbnails and on-disk cache all work without a login. No VOLUME on the official +# image's config dir, so the bake survives at runtime. +FROM signalk/signalk-server:latest +USER node +COPY --chown=node:node signalk-config/ /home/node/.signalk/ +COPY --chown=node:node kip.tgz /tmp/kip.tgz +# Installs @mxtommy/kip (webapp + plugin) and its native deps (sharp) for THIS platform/arch. +RUN cd /home/node/.signalk && npm install --no-audit --no-fund --omit=dev /tmp/kip.tgz && rm /tmp/kip.tgz diff --git a/e2e/image-screenshots/README.md b/e2e/image-screenshots/README.md new file mode 100644 index 000000000..3ae62870e --- /dev/null +++ b/e2e/image-screenshots/README.md @@ -0,0 +1,37 @@ +# KIP image-widget screenshot harness + +Reproducible, **open** (no-login) Signal K + KIP stack with the `kip` image plugin, seeded with +realistic marine diagrams, for capturing the Image widget help screenshots. + +## Why it exists +The image widget is split across three PRs/branches: +`feat/image-assets-server` (1080, the plugin), `feat/image-assets-client` (1081), +`feat/image-assets-config-ui` (1082, the webapp/config UI). No single branch runs end to end, so this +harness builds the **integrated** package and runs it in a throwaway container. + +## Build the package (kip.tgz) +From a clone of KIP, integrate the server branch with the UI branch and pack: +``` +git checkout feat/image-assets-config-ui +git checkout -b tmp/image-harness +git merge --no-ff feat/image-assets-server +npm install && npm run build:all # build:plugin + build:prod +npm pack # -> mxtommy-kip-.tgz +cp mxtommy-kip-*.tgz /kip.tgz +``` +`npm pack` ships `plugin/**` + `public/**`; the Dockerfile's `npm install` then resolves native deps +(`sharp`) for the container's platform. + +## Run +``` +./run.sh # build image, start on :3015, seed sample images + warm the cache +./run.sh --down # stop + remove +``` +- KIP webapp: http://localhost:3015/@mxtommy/kip +- Image cache: http://localhost:3015/plugins/kip/images/cache + +Security is OFF (open) so the gallery, thumbnails and cache work without a login. Do not expose it. + +## Updating screenshots as the UI evolves +Rebuild `kip.tgz` (above) and `./run.sh` again, then re-capture. Sample images live in +`sample-images/` (SVG diagrams + PNG rasters; the PNGs populate the raster cache). diff --git a/e2e/image-screenshots/docker-compose.yml b/e2e/image-screenshots/docker-compose.yml new file mode 100644 index 000000000..8c0cf3854 --- /dev/null +++ b/e2e/image-screenshots/docker-compose.yml @@ -0,0 +1,10 @@ +name: kip-image-shots +services: + kip: + build: . + container_name: kip-image-shots + # The base image's startup forces --securityenabled; run signalk-server directly for an OPEN + # local server (do not expose this). With no `security` in settings.json, the server is open. + entrypoint: ['node', '/home/node/signalk/node_modules/signalk-server/bin/signalk-server'] + ports: + - '3015:3000' diff --git a/e2e/image-screenshots/run.sh b/e2e/image-screenshots/run.sh new file mode 100755 index 000000000..68f214061 --- /dev/null +++ b/e2e/image-screenshots/run.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Brings up the KIP image-widget screenshot harness and seeds it. +# Prereq: kip.tgz built from the INTEGRATED image feature (see README) sitting next to this script. +# ./run.sh build + start + seed +# ./run.sh --down stop and remove +set -euo pipefail +cd "$(dirname "$0")" +if [[ "${1:-}" == "--down" ]]; then docker compose down -v; exit 0; fi +[[ -f kip.tgz ]] || { echo "!! kip.tgz missing — see README to build it from the integrated branches."; exit 1; } +echo "==> Building + starting (open Signal K on :3015 with KIP + the kip image plugin)" +docker compose up -d --build +echo "==> Waiting for Signal K" +for i in $(seq 1 60); do curl -fsS http://localhost:3015/signalk >/dev/null 2>&1 && break; sleep 2; done +echo "==> Seeding sample images" +./seed.sh +cat < + + + 12V DC Distribution Panel + + + + + Nav lights — 5A + Anchor light — 3A + Cabin lights — 8A + VHF radio — 5A + Chartplotter — 7A + Autopilot — 10A + Windlass — 40A + + + + Bilge pump (auto) — 8A + Fresh-water pump — 6A + Refrigeration — 12A + Inverter — 15A + USB / 12V outlets — 10A + Instruments — 5A + Spare — 15A + + + + House bank: 2 × 100Ah AGM + Start bank: 1 × 80Ah + Charging: 60A alternator · 200W solar + 12.7V + diff --git a/e2e/image-screenshots/sample-images/safety-equipment-plan.png b/e2e/image-screenshots/sample-images/safety-equipment-plan.png new file mode 100644 index 000000000..0f8123f46 Binary files /dev/null and b/e2e/image-screenshots/sample-images/safety-equipment-plan.png differ diff --git a/e2e/image-screenshots/sample-images/safety-equipment-plan.svg b/e2e/image-screenshots/sample-images/safety-equipment-plan.svg new file mode 100644 index 000000000..f5d771c1d --- /dev/null +++ b/e2e/image-screenshots/sample-images/safety-equipment-plan.svg @@ -0,0 +1,21 @@ + + + Safety Equipment Layout — S/V Test Vessel + + + + BOW + STERN + + + Fire extinguisher + Life jackets (6) + First-aid kit + Fire extinguisher + EPIRB + Flares / horn + Throwable + horseshoe + + Review at the start of every passage · Last checked: this season + diff --git a/e2e/image-screenshots/sample-images/seacock-through-hull-plan.png b/e2e/image-screenshots/sample-images/seacock-through-hull-plan.png new file mode 100644 index 000000000..f5366bb2e Binary files /dev/null and b/e2e/image-screenshots/sample-images/seacock-through-hull-plan.png differ diff --git a/e2e/image-screenshots/sample-images/seacock-through-hull-plan.svg b/e2e/image-screenshots/sample-images/seacock-through-hull-plan.svg new file mode 100644 index 000000000..bd5c14a03 --- /dev/null +++ b/e2e/image-screenshots/sample-images/seacock-through-hull-plan.svg @@ -0,0 +1,23 @@ + + + Through-Hull & Seacock Plan + + + + waterline + BOW + STERN + + + Engine raw-water + Galley sink + Head intake + Head discharge + Bilge outlet + + + Close when leaving the boat unattended + Leave open (bilge / cockpit drains) + Soft wooden plug tied at each fitting + diff --git a/e2e/image-screenshots/seed.sh b/e2e/image-screenshots/seed.sh new file mode 100755 index 000000000..52f2f314c --- /dev/null +++ b/e2e/image-screenshots/seed.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Uploads the realistic sample marine diagrams into the shared image library, then warms the cache +# (raster variants) so the Media → Image Cache card shows a real on-disk size. +set -euo pipefail +cd "$(dirname "$0")" +BASE="${KIP_SK_URL:-http://localhost:3015}" +upload() { # file mime + curl -fsS -X POST "$BASE/plugins/kip/images" -F "file=@${1};type=${2};filename=$(basename "$1")" >/dev/null \ + && echo " uploaded: $(basename "$1")" || echo " FAILED: $(basename "$1")" +} +for f in sample-images/*.svg; do [ -e "$f" ] && upload "$f" "image/svg+xml"; done +for f in sample-images/*.png; do [ -e "$f" ] && upload "$f" "image/png"; done +echo "Warming the cache (raster variants)…" +curl -fsS "$BASE/plugins/kip/images" | python3 -c "import sys,json;[print(i['id'],i['format']) for i in json.load(sys.stdin)]" | while read -r id fmt; do + [ "$fmt" = "svg" ] && continue + for w in 160 320 640; do curl -fsS -o /dev/null "$BASE/plugins/kip/images/${id}?w=${w}" || true; done +done +echo "Done. Cache: $(curl -fsS "$BASE/plugins/kip/images/cache")" diff --git a/e2e/image-screenshots/signalk-config/package.json b/e2e/image-screenshots/signalk-config/package.json new file mode 100644 index 000000000..47cb6490c --- /dev/null +++ b/e2e/image-screenshots/signalk-config/package.json @@ -0,0 +1 @@ +{ "name": "kip-screenshot-config", "version": "1.0.0", "private": true, "description": "Throwaway Signal K config for KIP image-widget screenshots" } diff --git a/e2e/image-screenshots/signalk-config/plugin-config-data/kip.json b/e2e/image-screenshots/signalk-config/plugin-config-data/kip.json new file mode 100644 index 000000000..bef401d9f --- /dev/null +++ b/e2e/image-screenshots/signalk-config/plugin-config-data/kip.json @@ -0,0 +1 @@ +{ "enabled": true, "configuration": {} } diff --git a/e2e/image-screenshots/signalk-config/settings.json b/e2e/image-screenshots/signalk-config/settings.json new file mode 100644 index 000000000..f2278bf9c --- /dev/null +++ b/e2e/image-screenshots/signalk-config/settings.json @@ -0,0 +1,5 @@ +{ + "interfaces": {}, + "pipedProviders": [], + "vessel": { "name": "Test Vessel" } +} diff --git a/src/app/core/components/options/media/media.component.html b/src/app/core/components/options/media/media.component.html new file mode 100644 index 000000000..6559ecd00 --- /dev/null +++ b/src/app/core/components/options/media/media.component.html @@ -0,0 +1,35 @@ +
+
+
+

Image Cache

+

+ KIP keeps smaller copies of your images so they load quickly. Clearing frees + space on the Signal K server — your originals are kept and the smaller copies + are recreated when needed. +

+
+ Cache size: + {{ imageCacheDisplay() }} +
+
+ + + +
+
+
+
diff --git a/src/app/core/components/options/media/media.component.scss b/src/app/core/components/options/media/media.component.scss new file mode 100644 index 000000000..54b578096 --- /dev/null +++ b/src/app/core/components/options/media/media.component.scss @@ -0,0 +1,36 @@ +:host { + display: block; + height: 100%; + width: 100%; +} + +.page-content { + width: 100%; + overflow-y: auto; + scroll-behavior: smooth; + padding: 0px 0px 10px 0px; +} + +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; +} + +// Horizontal gap between the Refresh and Purge buttons (footer is text-align:end, not flex). +.image-cache-actions button + button { + margin-left: 8px; +} diff --git a/src/app/core/components/options/media/media.component.spec.ts b/src/app/core/components/options/media/media.component.spec.ts new file mode 100644 index 000000000..946cee554 --- /dev/null +++ b/src/app/core/components/options/media/media.component.spec.ts @@ -0,0 +1,87 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SettingsMediaComponent } from './media.component'; +import { ToastService } from '../../../services/toast.service'; +import { ImageAssetService } from '../../../services/image-asset.service'; +import { DialogService } from '../../../services/dialog.service'; + +describe('SettingsMediaComponent', () => { + let component: SettingsMediaComponent; + let fixture: ComponentFixture; + let toastMock: { + show: ReturnType; + }; + let imagesMock: { + ready: boolean; + cacheStats: ReturnType; + purgeCache: ReturnType; + }; + let dialogMock: { + openConfirmationDialog: ReturnType; + }; + + beforeEach(async () => { + 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: [SettingsMediaComponent], + providers: [ + { provide: ToastService, useValue: toastMock }, + { provide: ImageAssetService, useValue: imagesMock }, + { provide: DialogService, useValue: dialogMock } + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SettingsMediaComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); + + 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('Cache cleared', 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/media/media.component.ts b/src/app/core/components/options/media/media.component.ts new file mode 100644 index 000000000..2ac18631d --- /dev/null +++ b/src/app/core/components/options/media/media.component.ts @@ -0,0 +1,88 @@ +import { Component, DestroyRef, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { HttpErrorResponse } from '@angular/common/http'; +import { MatButton } from '@angular/material/button'; +import { MatDivider } from '@angular/material/divider'; + +import { ToastService } from '../../../services/toast.service'; +import { ImageAssetService, IImageCacheStats } from '../../../services/image-asset.service'; +import { DialogService } from '../../../services/dialog.service'; + +/** + * Media settings tab. Hosts the image-cache card (on-disk size + purge of generated variants). + * Kept separate from the Configurations tab, which is for KIP config management (backup/restore). + */ +@Component({ + selector: 'settings-media', + templateUrl: './media.component.html', + styleUrls: ['./media.component.scss'], + imports: [MatButton, MatDivider] +}) +export class SettingsMediaComponent { + private toast = inject(ToastService); + private images = inject(ImageAssetService); + private dialog = inject(DialogService); + private destroyRef = inject(DestroyRef); + + 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'}`; + }); + + constructor() { + this.refreshImageCache(); + } + + /** 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().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (stats) => this.imageCacheStats.set(stats), + error: () => this.imageCacheStats.set(null) + }); + } + + /** Clear the resized image copies (originals are kept and recreated on demand). */ + public purgeImageCache(): void { + this.dialog.openConfirmationDialog({ + title: 'Clear image cache?', + message: 'Clear the resized copies of all images? Your originals are kept and recreated when needed.', + confirmBtnText: 'Clear', + cancelBtnText: 'Cancel' + }).pipe(takeUntilDestroyed(this.destroyRef)).subscribe((confirmed) => { + if (!confirmed) { + return; + } + this.imageCachePurging.set(true); + this.images.purgeCache().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: () => { + this.imageCachePurging.set(false); + this.toast.show('Cache cleared', 1000, true, 'success'); + this.refreshImageCache(); + }, + error: (error: HttpErrorResponse) => { + this.imageCachePurging.set(false); + this.toast.show("Couldn't clear the 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]}`; + } +} diff --git a/src/app/core/components/options/tabs/tabs.component.html b/src/app/core/components/options/tabs/tabs.component.html index e344321dd..1d7d09f3d 100644 --- a/src/app/core/components/options/tabs/tabs.component.html +++ b/src/app/core/components/options/tabs/tabs.component.html @@ -12,6 +12,9 @@ + + + diff --git a/src/app/core/components/options/tabs/tabs.component.ts b/src/app/core/components/options/tabs/tabs.component.ts index e7e6af17d..e9f2c8c1d 100644 --- a/src/app/core/components/options/tabs/tabs.component.ts +++ b/src/app/core/components/options/tabs/tabs.component.ts @@ -6,6 +6,7 @@ import { SettingsDisplayComponent } from '../display/display.component'; import { MatTabGroup, MatTab } from '@angular/material/tabs'; import { PageHeaderComponent } from '../../page-header/page-header.component'; import { SettingsConfigComponent } from '../configuration/config.component'; +import { SettingsMediaComponent } from '../media/media.component'; @Component({ selector: 'tabs', @@ -19,6 +20,7 @@ import { SettingsConfigComponent } from '../configuration/config.component'; SettingsUnitsComponent, SettingsDisplayComponent, SettingsNotificationsComponent, + SettingsMediaComponent, SettingsConfigComponent, PageHeaderComponent ] 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/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/services/widget.service.ts b/src/app/core/services/widget.service.ts index 6e64c6487..aa791cafa 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: 'imageWidget', + 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/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; +} 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..96eb6187a --- /dev/null +++ b/src/app/widget-config/image-source-setup/image-source-setup.component.html @@ -0,0 +1,121 @@ +
+ + +
+ + + JPG, PNG, WebP, GIF, HEIC/HEIF, SVG · max 10 MB +
+ + @if (uploading()) { + + } + @if (error(); as message) { + + } + + {{ liveStatus() }} + + +

Boat image library

+

+ Images are stored on the Signal K server and shared by every display on this boat. +

+ + @switch (galleryStatus()) { + @case ('loading') { + + } + @case ('error') { + + } + @default { + @if (gallery().length) { + + } @else { +

No images in the shared Signal K library yet. Upload one to get started.

+ } + } + } + + + + Scaling + + Fit (show whole image) + Fill (crop to fill the widget) + + + + + Description + + Read aloud by screen readers and shown if the image can't load. + + +
+ + Transparent background + + @if (!isTransparent) { + + + + {{ backgroundControl.value }} + + } + Shown behind the image — in empty space and through see-through images. +
+ + + @if (selectedId(); as sel) { +
+
+ +
+
Preview
+
+ } + +
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..c4a9cd0bc --- /dev/null +++ b/src/app/widget-config/image-source-setup/image-source-setup.component.scss @@ -0,0 +1,226 @@ +.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__heading { + margin: 0; + font-size: 0.85rem; + font-weight: 600; +} + +.image-setup__error { + color: var(--mat-sys-error, #f44336); + font-size: 0.85rem; + margin: 0; +} + +// Visually hidden, but readable by screen readers (status announcements). +.image-setup__sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.image-setup__gallery-status { + display: flex; + align-items: center; + gap: 0.75rem; + min-height: 48px; +} + +.image-setup__gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); + gap: 0.5rem; + max-height: 260px; + overflow-y: auto; +} + +.image-setup__thumb { + display: flex; + flex-direction: column; + margin: 0; + border: 2px solid transparent; + border-radius: 8px; + + &--selected { + border-color: var(--mat-sys-primary, #4f9cff); + } + + // Relative selection cue that needs no bright color: dim the unselected tiles. Works in night + // theme (where the accent resolves to a dim red on near-black) without harming dark adaptation. + &:not(&--selected) .image-setup__thumb-select { + opacity: 0.55; + } +} + +.image-setup__thumb-select { + position: relative; + width: 100%; + aspect-ratio: 1 / 1; + padding: 0; + border: none; + border-radius: 6px 6px 0 0; + overflow: hidden; + cursor: pointer; + // Theme-neutral checkerboard so both light- and dark-line transparent diagrams stay visible + // (the old --kip-widget-background token was never defined and fell back to near-black). + background-color: #808080; + background-image: + linear-gradient(45deg, rgba(0, 0, 0, 0.22) 25%, transparent 0, transparent 75%, rgba(0, 0, 0, 0.22) 0), + linear-gradient(45deg, rgba(0, 0, 0, 0.22) 25%, transparent 0, transparent 75%, rgba(0, 0, 0, 0.22) 0); + background-position: 0 0, 8px 8px; + background-size: 16px 16px; + + img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + } + + &:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--mat-sys-primary, #4f9cff); + } +} + +.image-setup__thumb-broken { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: var(--kip-contrast-dim-color, rgba(255, 255, 255, 0.6)); +} + +.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)); +} + +// Footer row: the image name + a 44px delete target, OUT of the select tap zone so a wet/gloved +// reach to select can't land on delete. +.image-setup__thumb-foot { + display: flex; + align-items: center; + gap: 4px; + margin: 0; + padding-left: 4px; +} + +.image-setup__thumb-name { + flex: 1 1 auto; + min-width: 0; + font-size: 0.72rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--kip-contrast-dim-color, rgba(255, 255, 255, 0.6)); +} + +.image-setup__delete { + flex: 0 0 auto; + width: 44px; + height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 8px; + background: none; + cursor: pointer; + color: var(--mat-sys-error, #e53935); + + &:hover, + &:focus-visible { + background: color-mix(in srgb, var(--mat-sys-error, #e53935) 16%, transparent); + } + &:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--mat-sys-error, #e53935); + } + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } +} + +.image-setup__field { + width: 100%; +} + +.image-setup__bg { + display: flex; + align-items: center; + gap: 1rem 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: 44px; + height: 44px; + padding: 2px; + border: 1px solid var(--mat-sys-outline, #888); + border-radius: 6px; + background: none; + cursor: pointer; + } +} + +.image-setup__color-hex { + font-size: 0.8rem; + font-variant-numeric: tabular-nums; + text-transform: uppercase; + color: var(--kip-contrast-dim-color, rgba(255, 255, 255, 0.6)); +} + +.image-setup__preview { + margin: 0; +} + +.image-setup__preview-stage { + width: 100%; + height: 140px; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--mat-sys-outline-variant, rgba(127, 127, 127, 0.3)); + + img { + width: 100%; + height: 100%; + display: block; + } +} 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..73a74c6ef --- /dev/null +++ b/src/app/widget-config/image-source-setup/image-source-setup.component.spec.ts @@ -0,0 +1,204 @@ +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'; +import { DialogService } from '../../core/services/dialog.service'; +import { AppService } from '../../core/services/app-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 dialogMock = { openConfirmationDialog: vi.fn(() => of(true)) }; + const appMock = { cssThemeColors: { cardColor: '#1e1e1e' } }; + + const api = () => component as unknown as { + imageGroup: UntypedFormGroup; + gallery: () => IImageAsset[]; + galleryStatus: () => 'loading' | 'loaded' | 'error'; + galleryError: () => string | null; + error: () => string | null; + uploading: () => boolean; + validateFile: (file: File) => string | null; + selectImage: (id: string | null) => void; + selectedId: () => string | null; + 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 }, + { provide: DialogService, useValue: dialogMock }, + { provide: AppService, useValue: appMock } + ] + }).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, newest first', async () => { + await buildWith(new UntypedFormGroup({})); + expect(imagesMock.list).toHaveBeenCalled(); + // Sorted by createdAt descending so a just-uploaded image is easy to find. + expect(api().gallery().map(a => a.id)).toEqual(['img-2', 'img-1']); + }); + + 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("isn't supported"); + }); + + 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, the highlight signal, 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); + expect(api().selectedId()).toBe('img-1'); + }); + + it('initializes the highlight signal from a saved imageId', async () => { + await buildWith(new UntypedFormGroup({ imageId: new UntypedFormControl('img-2') })); + expect(api().selectedId()).toBe('img-2'); + }); + + 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('Sign in'); + }); + + it('confirms before deleting, then clears the selection on success', async () => { + dialogMock.openConfirmationDialog.mockReturnValueOnce(of(true)); + await buildWith(new UntypedFormGroup({ imageId: new UntypedFormControl('img-1') })); + api().deleteImage('img-1', { stopPropagation: vi.fn() } as unknown as Event); + expect(dialogMock.openConfirmationDialog).toHaveBeenCalled(); + expect(imagesMock.delete).toHaveBeenCalledWith('img-1'); + expect(api().imageGroup.get('imageId')!.value).toBeNull(); + }); + + it('does NOT delete when the confirmation is declined', async () => { + dialogMock.openConfirmationDialog.mockReturnValueOnce(of(false)); + await buildWith(new UntypedFormGroup({ imageId: new UntypedFormControl('img-1') })); + api().deleteImage('img-1', { stopPropagation: vi.fn() } as unknown as Event); + expect(imagesMock.delete).not.toHaveBeenCalled(); + expect(api().imageGroup.get('imageId')!.value).toBe('img-1'); + }); + + it('maps a 401 delete failure to a Sign in message', async () => { + dialogMock.openConfirmationDialog.mockReturnValueOnce(of(true)); + imagesMock.delete.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 }))); + await buildWith(new UntypedFormGroup({ imageId: new UntypedFormControl('img-1') })); + api().deleteImage('img-1', { stopPropagation: vi.fn() } as unknown as Event); + expect(api().error()).toContain('Sign in'); + }); + + it('shows an error (not an empty library) when the list request fails', async () => { + imagesMock.list.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 0 }))); + await buildWith(new UntypedFormGroup({})); + expect(api().galleryStatus()).toBe('error'); + expect(api().galleryError()).toBeTruthy(); + }); + + it('toggles a transparent background on and off, seeding the opaque color from the theme', async () => { + await buildWith(new UntypedFormGroup({})); + expect(api().isTransparent).toBe(true); + api().toggleTransparent(false); + expect(api().imageGroup.get('backgroundColor')!.value).toBe('#1e1e1e'); + 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..7344a0495 --- /dev/null +++ b/src/app/widget-config/image-source-setup/image-source-setup.component.ts @@ -0,0 +1,253 @@ +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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { ImageAssetService, IImageAsset } from '../../core/services/image-asset.service'; +import { DialogService } from '../../core/services/dialog.service'; +import { AppService } from '../../core/services/app-service'; + +type TGalleryStatus = 'loading' | 'loaded' | 'error'; + +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, MatProgressSpinnerModule] +}) +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); + private readonly dialog = inject(DialogService); + private readonly app = inject(AppService); + protected readonly images = inject(ImageAssetService); + + protected imageGroup!: UntypedFormGroup; + protected readonly gallery = signal([]); + /** Distinguishes a genuinely empty shared library from a failed load (so we don't tell the + * crew the library is empty when the server was simply unreachable). */ + protected readonly galleryStatus = signal('loading'); + protected readonly galleryError = signal(null); + protected readonly uploading = signal(false); + protected readonly uploadProgress = signal(0); + protected readonly error = signal(null); + /** Screen-reader status line (upload progress / completion / deletion). */ + protected readonly liveStatus = signal(''); + /** Per-asset broken-thumbnail tracking (a variant can fail transiently). */ + protected readonly brokenThumbs = signal>(new Set()); + /** Source of truth for the gallery highlight; kept in sync with the imageId control so the + * highlight updates under zoneless change detection even when set from async callbacks. */ + protected readonly selectedId = signal(null); + private galleryRequestSeq = 0; + + 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.selectedId.set(this.imageIdControl.value ?? 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 imageFitControl(): UntypedFormControl { return this.imageGroup.get('imageFit') as UntypedFormControl; } + protected get backgroundControl(): UntypedFormControl { return this.imageGroup.get('backgroundColor') as UntypedFormControl; } + + /** 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 'This image is larger than the 10 MB limit. Choose a smaller file.'; + } + if (file.type && !ACCEPTED_TYPES.has(file.type) && !ACCEPTED_EXT.test(file.name)) { + return "That image type isn't supported. Use JPG, PNG, WebP, GIF, HEIC/HEIF, or SVG."; + } + 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.liveStatus.set('Uploading image…'); + + 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); + this.liveStatus.set('Upload complete'); + 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.describeAssetError(err, 'upload')); + this.liveStatus.set('Upload failed'); + } + }); + } + + /** Maps an upload/delete HTTP error to plain, status-specific copy. The server is authoritative; + * serverMessage (when present) is preferred for the cases the server can describe. */ + private describeAssetError(err: HttpErrorResponse, verb: 'upload' | 'delete'): string { + const serverMessage = (err?.error as { error?: string })?.error; + switch (err?.status) { + case 401: return `Sign in to the Signal K server to ${verb} images.`; + case 413: return 'This image is larger than the 10 MB limit. Choose a smaller file.'; + case 415: return serverMessage ?? "That image type isn't supported, or the file couldn't be read."; + default: return serverMessage ?? (verb === 'upload' + ? "Couldn't upload the image. Check your Signal K server connection and try again." + : 'Could not delete the image.'); + } + } + + /** Maps a library-load failure so an unreachable server isn't shown as an empty library. */ + private describeListError(err: HttpErrorResponse): string { + switch (err?.status) { + case 401: return 'Sign in to the Signal K server to load the image library.'; + case 404: return "The image library plugin isn't installed or is disabled."; + default: return "Couldn't reach the Signal K server."; + } + } + + protected selectImage(id: string | null): void { + this.imageIdControl.setValue(id); + this.imageIdControl.markAsDirty(); + this.selectedId.set(id); + } + + /** Confirms before deleting, because the image lives in the SHARED, boat-wide library — one tap + * would otherwise remove it from every display with no undo. */ + protected deleteImage(id: string, event: Event): void { + event.stopPropagation(); + const name = this.gallery().find((a) => a.id === id)?.name ?? 'this image'; + this.dialog.openConfirmationDialog({ + title: 'Delete From Shared Library?', + message: `Delete "${name}"? This removes it from every display on the boat and can't be undone.`, + confirmBtnText: 'Delete', + cancelBtnText: 'Cancel' + }).pipe(takeUntilDestroyed(this.destroyRef)).subscribe((confirmed) => { + if (!confirmed) return; + this.error.set(null); + this.images.delete(id).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: () => { + if (this.selectedId() === id) this.selectImage(null); + this.liveStatus.set('Image deleted'); + this.refreshGallery(); + }, + error: (err: HttpErrorResponse) => this.error.set(this.describeAssetError(err, 'delete')) + }); + }); + } + + protected thumbUrl(id: string): string | null { + return this.images.urlFor(id, 160); + } + + /** Marks a thumbnail variant as failed to load (tile stays selectable — it may be valid at other sizes). */ + protected markThumbBroken(id: string): void { + if (this.brokenThumbs().has(id)) return; + this.brokenThumbs.set(new Set(this.brokenThumbs()).add(id)); + } + + protected refreshGallery(): void { + const seq = ++this.galleryRequestSeq; + this.galleryStatus.set('loading'); + this.images.list().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (list) => { + if (seq !== this.galleryRequestSeq) return; + // Newest first so a just-uploaded image is easy to find. + this.gallery.set([...list].sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))); + this.brokenThumbs.set(new Set()); + this.galleryStatus.set('loaded'); + }, + error: (err: HttpErrorResponse) => { + if (seq !== this.galleryRequestSeq) return; + this.galleryError.set(this.describeListError(err)); + this.galleryStatus.set('error'); + } + }); + } + + protected get isTransparent(): boolean { + return !this.backgroundControl?.value; + } + + protected toggleTransparent(transparent: boolean): void { + this.backgroundControl.setValue(transparent ? null : this.defaultOpaqueColor()); + this.backgroundControl.markAsDirty(); + } + + /** The opaque backing defaults to the active theme's card color, not a hardcoded pure black. */ + private defaultOpaqueColor(): string { + return toHex(this.app.cssThemeColors?.cardColor ?? '') ?? '#000000'; + } + + protected setBackground(event: Event): void { + this.backgroundControl.setValue((event.target as HTMLInputElement).value); + this.backgroundControl.markAsDirty(); + } +} + +/** Normalises a CSS color (hex or rgb/rgba) to the `#rrggbb` that `` accepts. */ +function toHex(color: string): string | null { + const trimmed = color.trim(); + if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) { + return trimmed.toLowerCase(); + } + const rgb = trimmed.match(/rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/i); + if (rgb) { + const hex = (n: string) => Math.min(255, Number(n)).toString(16).padStart(2, '0'); + return `#${hex(rgb[1])}${hex(rgb[2])}${hex(rgb[3])}`; + } + return null; +} 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 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..85f1a3006 --- /dev/null +++ b/src/app/widgets/widget-image/widget-image.component.html @@ -0,0 +1,12 @@ +
+ @if (imageUrl(); as url) { + + @if (loadFailed()) { +
Image unavailable
+ } + } @else { +
No image selected. Pick one in widget settings.
+ } +
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..4a85545a0 --- /dev/null +++ b/src/app/widgets/widget-image/widget-image.component.ts @@ -0,0 +1,52 @@ +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); + /** True when the fails to load (e.g. the shared image was deleted from another display, or + * the server is briefly unreachable). Self-clears: the stays mounted, so a later successful + * (load) — including after the URL changes on resize — resets it. */ + protected readonly loadFailed = signal(false); + + 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)); + } +} diff --git a/src/assets/help-docs/image-widget.md b/src/assets/help-docs/image-widget.md new file mode 100644 index 000000000..76c1dadde --- /dev/null +++ b/src/assets/help-docs/image-widget.md @@ -0,0 +1,49 @@ +## 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 + +The Image widget in the Add Widget panel + +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 + +The Image widget options dialog + +- **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. +- **Description** is read aloud by screen readers and shown if the image cannot be displayed. Add one so the widget is usable without sight of the picture. +- **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 + +The Image Cache card on the Media settings tab + +Cached copies can be cleared at any time from **Settings → Media → Image Cache**. The card shows the current cache size and a **Clear cache** button. Clearing only removes the smaller, generated copies — your original uploads are kept, and the copies are recreated automatically the next time they are displayed. diff --git a/src/assets/help-docs/img/image-widget-add.png b/src/assets/help-docs/img/image-widget-add.png new file mode 100644 index 000000000..4e073e270 Binary files /dev/null and b/src/assets/help-docs/img/image-widget-add.png differ diff --git a/src/assets/help-docs/img/image-widget-cache.png b/src/assets/help-docs/img/image-widget-cache.png new file mode 100644 index 000000000..405f3588b Binary files /dev/null and b/src/assets/help-docs/img/image-widget-cache.png differ diff --git a/src/assets/help-docs/img/image-widget-options.png b/src/assets/help-docs/img/image-widget-options.png new file mode 100644 index 000000000..620777bf9 Binary files /dev/null and b/src/assets/help-docs/img/image-widget-options.png differ diff --git a/src/assets/help-docs/menu.json b/src/assets/help-docs/menu.json index ebac30525..04ce72a26 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": "image-widget.md" + }, { "title": "History-API Provider", "file": "history-api.md" diff --git a/src/assets/svg/icons.svg b/src/assets/svg/icons.svg index a79915375..2a2810dca 100644 --- a/src/assets/svg/icons.svg +++ b/src/assets/svg/icons.svg @@ -249,6 +249,9 @@ + + +