diff --git a/eink/backend/routes.py b/eink/backend/routes.py index 2e94f928..b50326de 100644 --- a/eink/backend/routes.py +++ b/eink/backend/routes.py @@ -56,6 +56,8 @@ def setup_routes(app): static_dist = os.path.join(os.path.dirname(__file__), "static_dist") if os.path.exists(static_dist): + print(f"DEBUG: Serving static files from: {static_dist}") + # Serve index.html at the root async def index(_): # noqa: U101 return web.FileResponse(os.path.join(static_dist, "index.html")) diff --git a/eink/e2e/playwright.config.ts b/eink/e2e/playwright.config.ts index fa26f5f5..223138af 100644 --- a/eink/e2e/playwright.config.ts +++ b/eink/e2e/playwright.config.ts @@ -27,7 +27,7 @@ export default defineConfig({ timeout: 15000, }, webServer: process.env.CI ? undefined : { - command: `cd ../.. && export DATA_DIR=${DATA_DIR} && export MEDIA_DIR=${DATA_DIR}/media && export INGRESS_PORT=${INGRESS_PORT} && cd eink && PYTHONPATH=. backend/.venv/bin/python3 -m backend.main`, + command: `export DATA_DIR=${DATA_DIR} && export MEDIA_DIR=${path.join(DATA_DIR, 'media')} && export INGRESS_PORT=${INGRESS_PORT} && cd ${path.resolve(__dirname, '../..')} && cd eink && PYTHONPATH=. backend/.venv/bin/python3 -m backend.main`, url: `http://localhost:${INGRESS_PORT}/api/ping`, reuseExistingServer: true, stdout: 'pipe', diff --git a/eink/e2e/tests/scene_item_fitting.spec.ts b/eink/e2e/tests/scene_item_fitting.spec.ts index c3d76c46..b3a029fb 100644 --- a/eink/e2e/tests/scene_item_fitting.spec.ts +++ b/eink/e2e/tests/scene_item_fitting.spec.ts @@ -68,9 +68,8 @@ test.describe('Scene Item Fitting', () => { // 4. Verify auto-fit: Check that scaling factor is updated const scalingInput = dialog.locator('input[type="number"]').first(); - const scalingValue = await scalingInput.inputValue(); - - expect(parseInt(scalingValue)).not.toBe(100); + await expect(scalingInput).not.toHaveValue('100'); + const autoFittedValue = await scalingInput.inputValue(); // Check offsets are 0 const offsetXInput = dialog.locator('input[type="number"]').nth(1); @@ -87,6 +86,6 @@ test.describe('Scene Item Fitting', () => { await dialog.locator('button:has-text("FIT")').click(); // Verify it returned to the auto-fitted value - await expect(scalingInput).toHaveValue(scalingValue); + await expect(scalingInput).toHaveValue(autoFittedValue); }); }); diff --git a/eink/frontend/src/app-root.ts b/eink/frontend/src/app-root.ts index 789babcc..03d2d737 100644 --- a/eink/frontend/src/app-root.ts +++ b/eink/frontend/src/app-root.ts @@ -1,6 +1,7 @@ import { LitElement, html, css } from 'lit'; import { customElement, state, query } from 'lit/decorators.js'; import { HaStateController } from './controllers/HaStateController'; +import { NavigationController } from './controllers/NavigationController'; import './components/shell/app-header'; import './components/layout/side-bar'; @@ -21,7 +22,8 @@ import { BaseResourceView } from './components/views/base-resource-view'; @customElement('app-root') export class AppRoot extends LitElement { - private state = new HaStateController(this); + private navigation = new NavigationController(this); + private state = new HaStateController(this, this.navigation); static styles = css` :host { @@ -185,13 +187,7 @@ export class AppRoot extends LitElement { }); if (confirmed) { - this.state.updateActiveLayout({ - items: this.state.activeLayout?.items.filter(i => i.id !== e.detail.id) - }); - if (this.state.selectedItemId === e.detail.id) { - this.state.selectItem(null); - } - this.state.showMessage('Item deleted', 'success'); + this.state.deleteLayoutItem(e.detail.id); } } @@ -209,10 +205,10 @@ export class AppRoot extends LitElement { @discard-changes="${this._handleDiscard}" @delete-item="${this._onHeaderDeleteItem}" @add-item="${this._onHeaderAddItem}" - @toggle-view-mode="${() => this.state.setViewMode(this.state.viewMode === 'graphical' ? 'yaml' : 'graphical')}" + @toggle-view-mode="${() => this.navigation.setViewMode(this.state.viewMode === 'graphical' ? 'yaml' : 'graphical')}" @set-section="${async (e: CustomEvent) => { if (await this._requestNavigationConfirmation()) { - this.state.setSection(e.detail); + this.navigation.setSection(e.detail); } }}" > diff --git a/eink/frontend/src/components/dialogs/scene-item-settings-dialog.test.ts b/eink/frontend/src/components/dialogs/scene-item-settings-dialog.test.ts index dba646b5..1e09a720 100644 --- a/eink/frontend/src/components/dialogs/scene-item-settings-dialog.test.ts +++ b/eink/frontend/src/components/dialogs/scene-item-settings-dialog.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import './scene-item-settings-dialog'; import { SceneItemSettingsDialog } from './scene-item-settings-dialog'; @@ -45,11 +45,29 @@ describe('SceneItemSettingsDialog', () => { }; beforeEach(async () => { + // Mock global Image for preview loading + // @ts-ignore + global.Image = class { + onload: any = null; + onerror: any = null; + set src(_: string) { + if (this.onload) setTimeout(() => this.onload(), 0); + } + get width() { return 100; } + get height() { return 100; } + crossOrigin: string = ''; + }; + element = document.createElement('scene-item-settings-dialog') as SceneItemSettingsDialog; document.body.appendChild(element); await element.updateComplete; }); + afterEach(() => { + if (element.parentNode) document.body.removeChild(element); + vi.clearAllMocks(); + }); + it('populates initial image items', async () => { await element.show(mockItem, mockLayout as any, mockDisplayTypes as any); await element.updateComplete; diff --git a/eink/frontend/src/components/dialogs/scene-item-settings-dialog.ts b/eink/frontend/src/components/dialogs/scene-item-settings-dialog.ts index f7f062f8..43de95de 100644 --- a/eink/frontend/src/components/dialogs/scene-item-settings-dialog.ts +++ b/eink/frontend/src/components/dialogs/scene-item-settings-dialog.ts @@ -460,6 +460,13 @@ export class SceneItemSettingsDialog extends LitElement { private _cachedPreviewData: any = null; private _lastPreviewSource: string = ''; + disconnectedCallback() { + super.disconnectedCallback(); + if (this._updateTimer) { + clearTimeout(this._updateTimer); + } + } + async show(item: any, layout: Layout, displayTypes: DisplayType[]) { this.item = item; this._layout = layout; @@ -649,7 +656,7 @@ export class SceneItemSettingsDialog extends LitElement { this._offsetY = 0; // Automatically fill new image - this._fillImage(); + this._applyImageFitting('fill', image); this._isAddingImage = false; this.requestUpdate(); @@ -818,10 +825,15 @@ export class SceneItemSettingsDialog extends LitElement { this._applyImageFitting('fill'); } - private _applyImageFitting(mode: 'fit' | 'fill') { - if (!this._selectedImageId || !this.item || !this.item.displays || this.item.displays.length === 0) return; - const image = this._availableImages.find(i => i.id === this._selectedImageId); - if (!image) return; + private _applyImageFitting(mode: 'fit' | 'fill', imageToUse?: ImageMetadata) { + if (!this.item || !this.item.displays || this.item.displays.length === 0) return; + + let image = imageToUse; + if (!image && this._selectedImageId) { + image = this._availableImages.find(i => i.id === this._selectedImageId); + } + + if (!image || !image.dimensions || image.dimensions.width === 0 || image.dimensions.height === 0) return; const panelBB = this._panelBoundingBox; const firstDisplayId = this.item.displays[0]; @@ -849,7 +861,7 @@ export class SceneItemSettingsDialog extends LitElement { this._offsetY = Math.round((targetHeightPx - scaledHeight) / 2); // Update the item data - const img = this.item.images.find((i: any) => i.image_id === this._selectedImageId); + const img = this.item.images.find((i: any) => i.image_id === image!.id); if (img) { img.scaling_factor = this._scalingFactor; img.offset = { x: this._offsetX, y: this._offsetY }; diff --git a/eink/frontend/src/components/shell/app-header.ts b/eink/frontend/src/components/shell/app-header.ts index 6dd8d97b..4ab9ee4b 100644 --- a/eink/frontend/src/components/shell/app-header.ts +++ b/eink/frontend/src/components/shell/app-header.ts @@ -1,7 +1,8 @@ import { LitElement, html, css } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { commonStyles } from '../../styles/common-styles'; -import { type AppSection } from '../../controllers/HaStateController'; +import { type AppSection } from '../../controllers/NavigationController'; +import { type AppMessage } from '../../controllers/HaStateController'; @customElement('app-header') export class AppHeader extends LitElement { @@ -101,7 +102,7 @@ export class AppHeader extends LitElement { @property({ type: String }) activeSection: AppSection = 'layouts'; @property({ type: Boolean }) connected = false; - @property({ type: String }) message = ''; + @property({ type: Object }) message: string | AppMessage | null = null; @property({ type: Boolean }) isSaving = false; @property({ type: Boolean }) isDirty = false; @property({ type: Boolean }) canDelete = false; @@ -155,10 +156,14 @@ export class AppHeader extends LitElement {