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 cbbbff970..b1c7d0948 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. The images are served by the **SK Image** Signal K plugin (Node.js 22.13+), which KIP recommends and the App Store offers to install. Uploading requires a Signal K account with write access. # v4.8.5 ## Improvements * New Widgets help documentation section and AIS Radar Widget help documentation. 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..a8bff5d52 --- /dev/null +++ b/e2e/image-screenshots/Dockerfile @@ -0,0 +1,18 @@ +# Reproducible Signal K harness for capturing KIP image-widget screenshots. +# +# Installs the STANDALONE sk-image plugin plus the KIP webapp into a security-OFF Signal K server, so +# the image library, thumbnails and on-disk cache all work without a login. No VOLUME on the config +# dir, so the bake survives at runtime. +# +# sk-image requires Node.js 22.13+. Recent signalk-server images run Node 22+, so `latest` normally +# satisfies this; if a pinned/older base image lags below 22.13, install onto a node:22 (or newer) +# base with signalk-server instead. +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 +# Install the standalone image plugin (sk-image) and the KIP webapp (this repo, packed to kip.tgz). +# sk-image ships a prebuilt sharp; --omit=dev keeps the image lean. +RUN cd /home/node/.signalk \ + && npm install --no-audit --no-fund --omit=dev sk-image /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..8492f808a --- /dev/null +++ b/e2e/image-screenshots/README.md @@ -0,0 +1,66 @@ +# KIP image-widget screenshot harness + +A reproducible Signal K + KIP stack for capturing the Image widget help screenshots and for +verifying the widget against the **real, standalone** image plugin. + +## Architecture (post-split) + +Image storage/processing is no longer part of KIP. It lives in the standalone **`sk-image`** Signal K +plugin (published on npm, in the App Store). KIP ships only the display widget + config UI, which talk +to the plugin's crew-reachable REST API at **`/signalk/v1/api/sk-image`**. So this harness installs two +separate packages into a Signal K server: + +- **`sk-image`** — the image plugin. **Requires Node.js 22.13+** on the server. +- **`@mxtommy/kip`** — the webapp (this repo), served at `/@mxtommy/kip`. + +## Build the KIP webapp package (kip.tgz) + +From a clone of KIP: + +``` +npm install && npm run build:prod # -> public/ +npm pack # -> mxtommy-kip-.tgz (ships public/**) +cp mxtommy-kip-*.tgz e2e/image-screenshots/kip.tgz +``` + +`npm pack` ships `public/**`; the container installs `sk-image` alongside it and resolves the plugin's +native dep (`sharp`) for the container's platform. + +## Run (open server — screenshots) + +``` +./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 library (public read): http://localhost:3015/signalk/v1/api/sk-image/images +- Image cache: http://localhost:3015/signalk/v1/api/sk-image/images/cache + +Security is OFF (open), so the gallery, thumbnails, upload and cache all work without a login. Do not +expose it. Sample images live in `sample-images/` (SVG diagrams + PNG rasters; the PNGs populate the +raster cache). + +## Verify on a SECURED server (role behavior) + +The widget's important behavior only shows with server security ON. To check it, enable security on the +server (`signalk-server` admin UI → Security, or add a `security` block to `settings.json`) and create +two accounts — one **admin/read-write**, one **read-only** — then confirm, in KIP pointed at the server: + +- **Anonymous / read-only crew can VIEW** images (the widget renders; the config gallery lists them), + because reads on `/signalk/v1/api/sk-image` are public. This is the reason KIP must target that mount + and not the admin-gated `/plugins/sk-image` alias. +- A **read-only** account gets a clear "your account is read-only" message on upload/delete (HTTP 403), + not a "check your connection" error. +- An **admin / read-write** account can upload, delete, and purge the cache. +- Adding the Image widget as non-admin crew does **not** dead-end with a false "plugin not installed" + prompt (the plugin-state API is admin-only, so KIP treats an unreadable state as "can't verify"). + +> Note: this secured, multi-role flow is a manual verification. The `run.sh`/compose here automate only +> the open (screenshot) server; a fully automated secured e2e with seeded accounts is a possible +> follow-up. + +## Updating screenshots as the UI evolves + +Rebuild `kip.tgz` (above) and `./run.sh` again, then re-capture the Image widget's Add panel, options +dialog, and the Settings → Media → Image Cache card into `src/assets/help-docs/img/`. 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..69752083e --- /dev/null +++ b/e2e/image-screenshots/run.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Brings up the KIP image-widget screenshot harness and seeds it. +# Prereq: kip.tgz (the packed KIP webapp — `npm run build:prod && npm pack`, see README) next to +# this script. The Dockerfile installs the standalone sk-image plugin from npm alongside it. +# ./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 pack the KIP webapp."; exit 1; } +echo "==> Building + starting (open Signal K on :3015 with the KIP webapp + the standalone sk-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..509fc3267 --- /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/signalk/v1/api/sk-image/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/signalk/v1/api/sk-image/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/signalk/v1/api/sk-image/images/${id}?w=${w}" || true; done +done +echo "Done. Cache: $(curl -fsS "$BASE/signalk/v1/api/sk-image/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/plugin-config-data/sk-image.json b/e2e/image-screenshots/signalk-config/plugin-config-data/sk-image.json new file mode 100644 index 000000000..bef401d9f --- /dev/null +++ b/e2e/image-screenshots/signalk-config/plugin-config-data/sk-image.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/package.json b/package.json index 33a1ebd03..ca5a09e37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mxtommy/kip", - "version": "4.8.5", + "version": "4.9.0", "description": "An advanced and versatile marine instrumentation package to display Signal K data.", "license": "MIT", "author": { @@ -59,7 +59,8 @@ "signalk-tides", "@meri-imperiumi/signalk-autostate", "signalk-venus-plugin", - "signalk-victron-battery-monitor" + "signalk-victron-battery-monitor", + "sk-image" ] }, "main": "plugin/index.js", diff --git a/src/app/core/components/dashboard/dashboard.component.spec.ts b/src/app/core/components/dashboard/dashboard.component.spec.ts index 74ece52e0..df293089c 100644 --- a/src/app/core/components/dashboard/dashboard.component.spec.ts +++ b/src/app/core/components/dashboard/dashboard.component.spec.ts @@ -18,6 +18,7 @@ interface DashboardComponentPrivateApi { nextDashboard: () => void; previousDashboard: () => void; loadDashboard: (dashboardId: number) => void; + getDisabledRequiredPlugins: (requiredPlugins: string[]) => Promise; _gridstack: () => { grid: { save: (saveContent: boolean, saveGridOpt: boolean) => unknown; @@ -134,6 +135,22 @@ describe('DashboardComponent', () => { expect(component).toBeTruthy(); }); + it('does not treat a required plugin as disabled when its state cannot be read (secured, non-admin)', async () => { + const pluginConfig = TestBed.inject(PluginConfigClientService) as unknown as { getPlugin: Mock }; + pluginConfig.getPlugin.mockResolvedValue({ ok: false, error: { reason: 'forbidden' } }); + + // A 401/403 from the admin-only plugin-state API must not block the add — ordinary crew + // would be dead-ended with an "enable" prompt they have no permission to satisfy. + expect(await privateApi.getDisabledRequiredPlugins(['sk-image'])).toEqual([]); + }); + + it('treats an installed-but-disabled required plugin as disabled', async () => { + const pluginConfig = TestBed.inject(PluginConfigClientService) as unknown as { getPlugin: Mock }; + pluginConfig.getPlugin.mockResolvedValue({ ok: true, data: { state: { enabled: false } } }); + + expect(await privateApi.getDisabledRequiredPlugins(['sk-image'])).toEqual(['sk-image']); + }); + it('should save dashboard configuration', () => { privateApi.saveDashboard(); diff --git a/src/app/core/components/dashboard/dashboard.component.ts b/src/app/core/components/dashboard/dashboard.component.ts index 9e85956a0..3772abef3 100644 --- a/src/app/core/components/dashboard/dashboard.component.ts +++ b/src/app/core/components/dashboard/dashboard.component.ts @@ -396,15 +396,22 @@ export class DashboardComponent implements AfterViewInit, OnDestroy { const statusList = await Promise.all( uniqueRequiredPlugins.map(async pluginId => { const result = await this._pluginConfig.getPlugin(pluginId); - return { - pluginId, - enabled: result.ok && result.data.state.enabled - }; + if (result.ok) { + return { pluginId, block: !result.data.state.enabled }; + } + // The plugin-state API (/plugins/{id}) is admin-only on a secured server. When the caller + // lacks the rights to read it (auth-required/forbidden) we cannot tell whether the plugin is + // installed or enabled, so we must not block the add — the widget's own endpoints are + // crew-reachable and will render (or surface an install hint) on their own. Blocking here + // would dead-end ordinary crew with an "enable" prompt they have no permission to satisfy. + const cannotVerify = + result.error.reason === 'auth-required' || result.error.reason === 'forbidden'; + return { pluginId, block: !cannotVerify }; }) ); return statusList - .filter(pluginStatus => !pluginStatus.enabled) + .filter(pluginStatus => pluginStatus.block) .map(pluginStatus => pluginStatus.pluginId); } 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 a13e9ade7..af1b723e0 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..5341faa25 --- /dev/null +++ b/src/app/core/services/image-asset.service.spec.ts @@ -0,0 +1,92 @@ +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/signalk/v1/api/sk-image/images/abc?w=320'); + expect(service.urlFor('abc', 320, 2)).toBe('http://host:3000/signalk/v1/api/sk-image/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/signalk/v1/api/sk-image/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/signalk/v1/api/sk-image/images'); + service.delete('id-1').subscribe(); + expect(http.delete).toHaveBeenCalledWith('http://host:3000/signalk/v1/api/sk-image/images/id-1'); + service.cacheStats().subscribe(); + expect(http.get).toHaveBeenCalledWith('http://host:3000/signalk/v1/api/sk-image/images/cache'); + service.purgeCache().subscribe(); + expect(http.delete).toHaveBeenCalledWith('http://host:3000/signalk/v1/api/sk-image/images/cache'); + }); + + it('discovers the width allow-list from GET config and snaps against it', () => { + const http = { + post: vi.fn(() => of({})), + get: vi.fn((url: string) => + url.endsWith('/config') ? of({ widthAllowlist: [100, 200, 400] }) : of([]) + ), + delete: vi.fn(() => of({ ok: true })) + }; + const connection = { + serverServiceEndpoint$: new BehaviorSubject<{ httpServiceUrl: string | null }>({ + httpServiceUrl: 'http://host:3000/signalk/v1/api/' + }), + signalKURL: { url: '' } + }; + TestBed.configureTestingModule({ + providers: [ + ImageAssetService, + { provide: HttpClient, useValue: http }, + { provide: SignalKConnectionService, useValue: connection } + ] + }); + const service = TestBed.inject(ImageAssetService); + expect(http.get).toHaveBeenCalledWith('http://host:3000/signalk/v1/api/sk-image/config'); + // Snaps against the discovered list, not the built-in default. + expect(service.urlFor('abc', 150, 1)).toBe('http://host:3000/signalk/v1/api/sk-image/images/abc?w=200'); + expect(service.urlFor('abc', 500, 1)).toBe('http://host:3000/signalk/v1/api/sk-image/images/abc?w=400'); + }); +}); 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..c40e421d5 --- /dev/null +++ b/src/app/core/services/image-asset.service.ts @@ -0,0 +1,119 @@ +import { DestroyRef, Injectable, inject, signal } 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 { + resolvePluginBaseUrl, + snapImageWidth, + DEFAULT_IMAGE_WIDTH_ALLOWLIST +} 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); + // Signals so consumers that build URLs inside a reactive computed (e.g. the Image widget) recompute + // when the base URL resolves or the server-discovered width list arrives — otherwise the first URL + // would lock to the built-in fallback list. + private readonly pluginBaseUrl = signal(null); + private readonly widthAllowlist = signal(DEFAULT_IMAGE_WIDTH_ALLOWLIST); + private configFetchedFor: string | null = null; + + constructor() { + this.connection.serverServiceEndpoint$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(endpoint => { + const base = resolvePluginBaseUrl(endpoint?.httpServiceUrl ?? null, this.connection.signalKURL?.url); + this.pluginBaseUrl.set(base); + if (base && base !== this.configFetchedFor) { + this.configFetchedFor = base; + this.fetchConfig(base); + } + }); + } + + /** Discover the plugin's supported variant widths so we don't hard-code a mirror of the server list. */ + private fetchConfig(base: string): void { + this.http.get<{ widthAllowlist?: number[] }>(`${base}config`).subscribe({ + next: cfg => { + const widths = (cfg?.widthAllowlist ?? []) + .filter(w => typeof w === 'number' && w > 0) + .sort((a, b) => a - b); + this.widthAllowlist.set(widths.length ? widths : DEFAULT_IMAGE_WIDTH_ALLOWLIST); + }, + error: () => { + // Older plugin or unreachable — keep the built-in default. + this.widthAllowlist.set(DEFAULT_IMAGE_WIDTH_ALLOWLIST); + } + }); + } + + get ready(): boolean { + return this.pluginBaseUrl() !== null; + } + + private imagesUrl(): string { + const base = this.pluginBaseUrl(); + if (!base) { + throw new Error('Signal K connection is not ready'); + } + return `${base}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 { + const base = this.pluginBaseUrl(); + if (!id || !base) { + return null; + } + const dpr = devicePixelRatio ?? (typeof window !== 'undefined' ? window.devicePixelRatio : 1); + const w = snapImageWidth(cssWidth, dpr, this.widthAllowlist()); + return `${base}images/${encodeURIComponent(id)}?w=${w}`; + } +} diff --git a/src/app/core/services/plugin-config-client.service.spec.ts b/src/app/core/services/plugin-config-client.service.spec.ts index 6b17ca6fa..fc039b7cb 100644 --- a/src/app/core/services/plugin-config-client.service.spec.ts +++ b/src/app/core/services/plugin-config-client.service.spec.ts @@ -63,6 +63,19 @@ describe('PluginConfigClientService', () => { expect(result.capabilities.listSupported).toBe(true); }); + it('resolves admin routes against the server root when the configured URL carries a /signalk suffix', () => { + const connection = TestBed.inject(SignalKConnectionService) as unknown as { signalKURL: { url: string } }; + connection.signalKURL.url = 'http://localhost:3000/signalk/'; + + service.listPlugins(); + + // Must strip the /signalk suffix so /plugins resolves at the server root, not + // http://localhost:3000/signalk/plugins (which would 404). + const req = httpMock.expectOne('http://localhost:3000/plugins'); + expect(req.request.method).toBe('GET'); + req.flush([]); + }); + it('should fallback to /plugins list when /plugins/{id} returns 404', async () => { const promise = service.getPlugin('autopilot'); diff --git a/src/app/core/services/plugin-config-client.service.ts b/src/app/core/services/plugin-config-client.service.ts index 95e5316c7..e3f655cc1 100644 --- a/src/app/core/services/plugin-config-client.service.ts +++ b/src/app/core/services/plugin-config-client.service.ts @@ -2,6 +2,7 @@ import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { inject, Injectable, signal } from '@angular/core'; import { lastValueFrom } from 'rxjs'; import { SignalKConnectionService } from './signalk-connection.service'; +import { stripToServerRoot } from '../utils/kip-plugin-url.util'; import { IPluginApiCapabilities, IPluginApiFailure, @@ -608,8 +609,9 @@ export class PluginConfigClientService { return path; } - const base = configuredUrl.endsWith('/') ? configuredUrl.slice(0, -1) : configuredUrl; - return `${base}${path}`; + // Strip any /signalk[/vN[/api]] suffix so server-admin routes (/plugins, /skServer) resolve + // against the server root even when the user configured a /signalk-suffixed URL. + return `${stripToServerRoot(configuredUrl)}${path}`; } private resolveBooleanConfig(configuration: Record, key: string, fallback: boolean): boolean { diff --git a/src/app/core/services/widget.service.ts b/src/app/core/services/widget.service.ts index 9b2c0d4f7..2c4e932ad 100644 --- a/src/app/core/services/widget.service.ts +++ b/src/app/core/services/widget.service.ts @@ -131,6 +131,7 @@ export class WidgetService { WidgetAisRadarComponent: () => import('../../widgets/widget-ais-radar/widget-ais-radar.component').then(m => m.WidgetAisRadarComponent), WidgetLabelComponent: () => import('../../widgets/widget-label/widget-label.component').then(m => m.WidgetLabelComponent), WidgetIframeComponent: () => import('../../widgets/widget-iframe/widget-iframe.component').then(m => m.WidgetIframeComponent), + WidgetImageComponent: () => import('../../widgets/widget-image/widget-image.component').then(m => m.WidgetImageComponent), WidgetHorizonComponent: () => import('../../widgets/widget-horizon/widget-horizon.component').then(m => m.WidgetHorizonComponent), WidgetHeelGaugeComponent: () => import('../../widgets/widget-heel-gauge/widget-heel-gauge.component').then(m => m.WidgetHeelGaugeComponent), WidgetSteelGaugeComponent: () => import('../../widgets/widget-gauge-steel/widget-gauge-steel.component').then(m => m.WidgetSteelGaugeComponent), @@ -532,6 +533,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: ['sk-image'], + 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..d50bad24b --- /dev/null +++ b/src/app/core/utils/kip-plugin-url.util.spec.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { + resolvePluginBaseUrl, + snapImageWidth, + stripToServerRoot, + DEFAULT_IMAGE_WIDTH_ALLOWLIST +} from './kip-plugin-url.util'; + +describe('stripToServerRoot', () => { + it('strips a /signalk[/vN[/api]] suffix from the path, leaving the origin', () => { + expect(stripToServerRoot('http://host:3000/signalk/v1/api/')).toBe('http://host:3000'); + expect(stripToServerRoot('http://host:3000/signalk/v2/api')).toBe('http://host:3000'); + expect(stripToServerRoot('http://host:3000/signalk')).toBe('http://host:3000'); + expect(stripToServerRoot('http://host:3000')).toBe('http://host:3000'); + expect(stripToServerRoot('http://host:3000/')).toBe('http://host:3000'); + }); + + it('does NOT eat a host that is literally named "signalk"', () => { + expect(stripToServerRoot('http://signalk')).toBe('http://signalk'); + expect(stripToServerRoot('http://signalk/')).toBe('http://signalk'); + expect(stripToServerRoot('http://signalk/signalk/v1/api')).toBe('http://signalk'); + }); + + it('preserves a reverse-proxy subpath', () => { + expect(stripToServerRoot('https://boat.local/proxy/signalk/v1/api')).toBe('https://boat.local/proxy'); + }); +}); + +describe('resolvePluginBaseUrl', () => { + // The base must target the plugin's crew-reachable /signalk/v1/api mount, NOT the /plugins/ + // alias — signalk-server admin-gates every /plugins/* route on a secured server, which would 401/403 + // ordinary crew (and the native element, which carries no auth header) even for reads. + it('prefers the configured URL and targets the v1 API mount', () => { + expect(resolvePluginBaseUrl('http://x/signalk/v1/api/', 'https://boat.local:3443')).toBe( + 'https://boat.local:3443/signalk/v1/api/sk-image/' + ); + expect(resolvePluginBaseUrl(null, 'https://boat.local/')).toBe( + 'https://boat.local/signalk/v1/api/sk-image/' + ); + }); + + it('does not double up when the configured URL already carries a /signalk suffix', () => { + expect(resolvePluginBaseUrl(null, 'https://boat.local/signalk/')).toBe( + 'https://boat.local/signalk/v1/api/sk-image/' + ); + expect(resolvePluginBaseUrl(null, 'https://boat.local/signalk/v1/api')).toBe( + 'https://boat.local/signalk/v1/api/sk-image/' + ); + }); + + it('derives the base from the v1/v2 API URL by stripping the signalk suffix', () => { + expect(resolvePluginBaseUrl('http://host:3000/signalk/v1/api/')).toBe( + 'http://host:3000/signalk/v1/api/sk-image/' + ); + expect(resolvePluginBaseUrl('http://host:3000/signalk/v2/api')).toBe( + 'http://host:3000/signalk/v1/api/sk-image/' + ); + expect(resolvePluginBaseUrl('http://host:3000/signalk')).toBe( + 'http://host:3000/signalk/v1/api/sk-image/' + ); + }); + + it('honours a custom plugin id on the v1 API mount', () => { + expect(resolvePluginBaseUrl('http://host:3000/signalk/v1/api/', null, 'some-plugin')).toBe( + 'http://host:3000/signalk/v1/api/some-plugin/' + ); + }); + + it('returns null when nothing is known', () => { + expect(resolvePluginBaseUrl(null)).toBeNull(); + expect(resolvePluginBaseUrl(undefined, '')).toBeNull(); + }); +}); + +describe('snapImageWidth', () => { + const max = DEFAULT_IMAGE_WIDTH_ALLOWLIST[DEFAULT_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); + }); + + it('snaps against a caller-supplied allow-list (the server-discovered one)', () => { + expect(snapImageWidth(150, 1, [100, 200, 400])).toBe(200); + expect(snapImageWidth(500, 1, [100, 200, 400])).toBe(400); // max of the custom list + expect(snapImageWidth(50, 1, [100, 200, 400])).toBe(100); + }); +}); 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..9adddbd5c --- /dev/null +++ b/src/app/core/utils/kip-plugin-url.util.ts @@ -0,0 +1,74 @@ +/** + * Strips a trailing slash and any `/signalk[/vN[/api]]` suffix from a URL, leaving the server root + * (e.g. `http://host:3000/signalk/v1/api/` and `http://host:3000/signalk/` both -> `http://host:3000`). + */ +export function stripToServerRoot(url: string): string { + const trimmed = url.trim(); + // Split off the authority (scheme + host[:port]) and only strip the /signalk mount from the PATH, + // so a host literally named "signalk" (e.g. http://signalk behind a reverse proxy) is never + // mistaken for the /signalk API segment and eaten. + const match = /^(https?:\/\/[^/]+)(\/.*)?$/i.exec(trimmed); + if (!match) { + // Relative or non-http input: strip a single trailing /signalk[/vN[/api]] segment defensively. + return trimmed.replace(/\/$/, '').replace(/\/signalk(\/v[12](\/api)?)?$/, ''); + } + const origin = match[1]; + const path = (match[2] ?? '') + .replace(/\/signalk(\/v[12](\/api)?)?\/?$/, '') + .replace(/\/$/, ''); + return `${origin}${path}`; +} + +/** + * Resolves the base URL of a Signal K plugin's REST API from the connection endpoint. + * + * Targets the plugin's crew-reachable `/signalk/v1/api//` mount, NOT the `/plugins/` + * alias: signalk-server admin-gates every `/plugins/*` route on a secured server, so the alias would + * 401/403 ordinary crew (and a native ``, which carries no auth header) even for reads. The + * `/signalk/v1/api` mount is public for reads and only gates writes on a read-write/admin principal. + * + * @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) + * @param pluginId the Signal K plugin id (defaults to the image plugin, `sk-image`) + */ +export function resolvePluginBaseUrl( + httpServiceUrl: string | null | undefined, + configuredUrl?: string | null, + pluginId = 'sk-image' +): string | null { + const configured = configuredUrl?.trim(); + if (configured) { + return `${stripToServerRoot(configured)}/signalk/v1/api/${pluginId}/`; + } + if (!httpServiceUrl) { + return null; + } + return `${stripToServerRoot(httpServiceUrl)}/signalk/v1/api/${pluginId}/`; +} + +/** + * Fallback image variant widths, used until the plugin's `GET /config` advertises its own list. + * Matching the server keeps client requests stable and cache-friendly. + */ +export const DEFAULT_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, + allowlist: readonly number[] = DEFAULT_IMAGE_WIDTH_ALLOWLIST +): number { + const list = allowlist.length ? allowlist : DEFAULT_IMAGE_WIDTH_ALLOWLIST; + const dpr = devicePixelRatio && devicePixelRatio > 0 ? devicePixelRatio : 1; + const target = cssWidth && cssWidth > 0 ? cssWidth * dpr : 0; + const max = list[list.length - 1]; + if (!target) { + return max; + } + for (const w of list) { + 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..7b676b608 --- /dev/null +++ b/src/app/widget-config/image-source-setup/image-source-setup.component.spec.ts @@ -0,0 +1,215 @@ +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/sk-image/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('surfaces a read-only permissions message when the server returns 403', async () => { + imagesMock.upload.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 403 }))); + 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('read-only'); + }); + + 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..7644721a6 --- /dev/null +++ b/src/app/widget-config/image-source-setup/image-source-setup.component.ts @@ -0,0 +1,255 @@ +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 403: return `Your Signal K account is read-only. Ask an administrator for read-write access 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 403: return "Your Signal K account doesn't have access to the image library."; + case 404: return "The SK Image plugin isn't installed or enabled. Install it from the Signal K App Store, or enable it in the server's plugin settings."; + 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 7ccff7076..82d90c703 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 a4febc42e..c9a0a0e8f 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..141915bd5 --- /dev/null +++ b/src/app/widgets/widget-image/widget-image.component.html @@ -0,0 +1,12 @@ +
+ @if (imageUrl(); as url) { + + @if (loadFailed()) { +
Image unavailable. It may have been removed from the shared library, or the Signal K server can't be reached.
+ } + } @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..4d2955c68 --- /dev/null +++ b/src/app/widgets/widget-image/widget-image.component.spec.ts @@ -0,0 +1,80 @@ +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/sk-image/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/sk-image/images/img-1?w='); + expect(img!.getAttribute('alt')).toBe('Safety map'); + expect(img!.style.objectFit).toBe('cover'); + }); + + it('requests a small variant before the first measurement, then the measured width', () => { + options.set({ image: { imageId: 'img-1', imageFit: 'contain', altText: '', backgroundColor: null } }); + fixture.detectChanges(); + // Unmeasured (container width 0): must not fetch the largest (2560) variant on first paint. + expect(api().imageUrl()).not.toContain('w=2560'); + + (component as unknown as { onResize: (e: { width: number; height: number }) => void }) + .onResize({ width: 640, height: 480 }); + expect(api().imageUrl()).toContain('w=640'); + }); + + 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..d3fbebb55 --- /dev/null +++ b/src/app/widgets/widget-image/widget-image.component.ts @@ -0,0 +1,56 @@ +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; + // Before the first ResizeObserver measurement the width is 0. Request the SMALLEST variant then + // (width 1 snaps up to the smallest allow-listed width) rather than letting an unknown width + // default to the largest — a full-res fetch on first paint would be immediately superseded once + // the real container width is known. The tiny first request is cheap and upgrades on resize. + return this.images.urlFor(id, this.containerWidth() || 1); + }); + + 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..e531a9ee3 --- /dev/null +++ b/src/assets/help-docs/image-widget.md @@ -0,0 +1,57 @@ +## 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. + +## Requirements + +The images are stored and served by the **SK Image** Signal K plugin (version 1.5.0 or later), a separate server-side plugin — not part of KIP itself. KIP lists it as a recommended plugin, so the Signal K App Store offers to install it when you install or update KIP. You can also install it at any time from your server's **App Store** by searching for "SK Image". + +The plugin needs **Node.js 22.13 or newer** on the Signal K server. On an older Node version the plugin will not load and the widget will report that the image library is unavailable — update the server's Node.js, or run the plugin on a server that meets the requirement. + +Viewing images only needs the normal KIP connection. Uploading, deleting, or purging images requires a Signal K account with write access. + +## 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. Uploading, deleting, and purging need a Signal K account with **write access** (read-write or admin). A read-only account can still view images, but not change them. + +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 e03d88d3d..2a408e609 100644 --- a/src/assets/help-docs/menu.json +++ b/src/assets/help-docs/menu.json @@ -33,6 +33,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 @@ + + +