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 {
- ${this.message ? html`${this.message}` : ''} + ${this.message + ? (typeof this.message === 'string' + ? html`${this.message}` + : html`${this.message.text}`) + : ''} ${this.activeSection !== 'images' ? html` - ` : ''} @@ -173,7 +178,8 @@ export class AppHeader extends LitElement { ` : ''} - diff --git a/eink/frontend/src/components/views/layouts-view.ts b/eink/frontend/src/components/views/layouts-view.ts index bc0eacab..e7841748 100644 --- a/eink/frontend/src/components/views/layouts-view.ts +++ b/eink/frontend/src/components/views/layouts-view.ts @@ -516,7 +516,7 @@ export class LayoutsView extends BaseResourceView { `; } diff --git a/eink/frontend/src/controllers/HaStateController.test.ts b/eink/frontend/src/controllers/HaStateController.test.ts index e1a74821..98f4819a 100644 --- a/eink/frontend/src/controllers/HaStateController.test.ts +++ b/eink/frontend/src/controllers/HaStateController.test.ts @@ -8,9 +8,33 @@ describe('HaStateController', () => { beforeEach(() => { mockHost = { addController: vi.fn(), - requestUpdate: vi.fn() + requestUpdate: vi.fn(), + dispatchEvent: vi.fn(), }; - controller = new HaStateController(mockHost); + const mockNavigation = { + activeSection: 'layouts', + viewMode: 'graphical', + selectedItemId: null, + selectedImageId: null, + selectedDisplayTypeId: null, + activeSceneId: null, + isAddingNew: false, + activeLayoutId: null, + setSection: vi.fn().mockImplementation(function(this: any, s: any) { + this.activeSection = s; + mockHost.requestUpdate(); + }), + setViewMode: vi.fn().mockImplementation(function(this: any, m: any) { + this.viewMode = m; + mockHost.requestUpdate(); + }), + selectItem: vi.fn(), + selectImage: vi.fn(), + selectDisplayType: vi.fn(), + selectScene: vi.fn(), + updateHash: vi.fn() + }; + controller = new HaStateController(mockHost, mockNavigation as any); }); it('initializes with default values', () => { diff --git a/eink/frontend/src/controllers/HaStateController.ts b/eink/frontend/src/controllers/HaStateController.ts index 3cd30c0d..330f8354 100644 --- a/eink/frontend/src/controllers/HaStateController.ts +++ b/eink/frontend/src/controllers/HaStateController.ts @@ -1,15 +1,16 @@ import { ReactiveController, ReactiveControllerHost } from 'lit'; import { api, DisplayType, Layout, LayoutItem, Image, Scene, HaDevice } from '../services/HaApiClient'; +import { NavigationController, AppSection, ViewMode } from './NavigationController'; /** * A Lit Reactive Controller to manage the application state: * - Layouts collection * - Display Types collection - * - Active Layout selection * - Backend connectivity + * Delegates navigation and selection state to NavigationController. */ -export type AppSection = 'display-types' | 'layouts' | 'images' | 'scenes'; -export type ViewMode = 'graphical' | 'yaml'; + +export interface AppMessage { text: string; type: 'success' | 'error' | 'warning' | 'info'; } export class HaStateController implements ReactiveController { public connected = false; @@ -18,20 +19,53 @@ export class HaStateController implements ReactiveController { public images: Image[] = []; public scenes: Scene[] = []; public haDevices: HaDevice[] = []; - public activeLayout: Layout | null = null; - public activeScene: Scene | null = null; - public selectedItemId: string | null = null; - public selectedImageId: string | null = null; - public selectedDisplayTypeId: string | null = null; - public isAddingNew = false; - public activeSection: AppSection = 'layouts'; - public message: string = ''; + public message: AppMessage | null = null; private _originalLayout: string | null = null; public isSaving = false; - public viewMode: ViewMode = 'graphical'; + private _messageClearTimeout: any = null; + + get activeSection() { return this.navigation.activeSection; } + set activeSection(v) { this.navigation.setSection(v); } + + get viewMode() { return this.navigation.viewMode; } + set viewMode(v) { this.navigation.setViewMode(v); } + + get selectedItemId() { return this.navigation.selectedItemId; } + set selectedItemId(v) { this.navigation.selectItem(v); } + + get selectedImageId() { return this.navigation.selectedImageId; } + set selectedImageId(v) { this.navigation.selectImage(v); } + + get selectedDisplayTypeId() { return this.navigation.selectedDisplayTypeId; } + set selectedDisplayTypeId(v) { this.navigation.selectDisplayType(v); } - constructor(private host: ReactiveControllerHost) { + get isAddingNew() { return this.navigation.isAddingNew; } + set isAddingNew(v) { this.navigation.isAddingNew = v; } + + public activeLayout: Layout | null = null; + public activeScene: Scene | null = null; + + constructor(private host: ReactiveControllerHost, public navigation: NavigationController) { this.host.addController(this); + this.navigation.onHashApplied = () => this._syncFromNavigation(); + } + + private _syncFromNavigation() { + const layout = this.layouts.find(l => l.id === this.navigation.activeLayoutId); + if (layout && this.activeLayout?.id !== layout.id) { + this.activeLayout = layout; + this._originalLayout = JSON.stringify(layout); + } else if (!this.navigation.activeLayoutId) { + this.activeLayout = null; + this._originalLayout = null; + } + + const scene = this.scenes.find(s => s.id === this.navigation.activeSceneId); + if (scene && this.activeScene?.id !== scene.id) { + this.activeScene = scene; + } else if (!this.navigation.activeSceneId) { + this.activeScene = null; + } } get isDirty() { @@ -40,7 +74,6 @@ export class HaStateController implements ReactiveController { } async hostConnected() { - window.addEventListener('hashchange', () => this._applyHash()); await this.refresh(); } @@ -95,7 +128,7 @@ export class HaStateController implements ReactiveController { this.host.requestUpdate(); console.info('[HaStateController] refresh complete - host update requested'); this._ensureSelection(); - this._applyHash(); + this.navigation.updateHash(); } /** @@ -125,6 +158,7 @@ export class HaStateController implements ReactiveController { }; const result = await api.createItem('layout', defaultLayout); this.activeLayout = result; + this.navigation.activeLayoutId = result.id; this.layouts = [result]; this._originalLayout = JSON.stringify(result); } @@ -132,8 +166,10 @@ export class HaStateController implements ReactiveController { async saveActiveLayout() { if (!this.activeLayout) return; + console.info(`[HaStateController] Saving layout... ID=${this.activeLayout.id}, isAddingNew=${this.isAddingNew}`); this.isSaving = true; this.host.requestUpdate(); + (this.host as unknown as HTMLElement).dispatchEvent(new CustomEvent('state-changed')); try { let saved: Layout; @@ -146,9 +182,10 @@ export class HaStateController implements ReactiveController { } this.activeLayout = saved; + this.navigation.activeLayoutId = saved.id; this._originalLayout = JSON.stringify(saved); + this.isAddingNew = false; await this.refresh(); - this._updateHash(); } catch (e: any) { this.showMessage(`Failed to save: ${e.message}`, 'error'); } finally { @@ -169,10 +206,9 @@ export class HaStateController implements ReactiveController { saved = await api.createItem('display_type', dt); } this.selectedDisplayTypeId = saved.id; - this._updateHash(); await this.refresh(); this.isSaving = false; - this.showMessage(`Display type "${saved.name}" saved!`, 'success'); + this.showMessage('Settings applied', 'success'); } catch (e: any) { this.showMessage(`Failed to save display type: ${e.message}`, 'error'); } finally { @@ -192,7 +228,7 @@ export class HaStateController implements ReactiveController { const oldIndex = this.displayTypes.findIndex(existing => existing.id === dt.id); await api.deleteItem('display_type', dt.id); await this.refresh(); - this.showMessage(`Display type "${dt.name}" deleted.`, 'success'); + this.showMessage('Settings applied', 'success'); if (this.selectedDisplayTypeId === dt.id) { if (this.displayTypes.length > 0) { @@ -202,7 +238,6 @@ export class HaStateController implements ReactiveController { this.selectedDisplayTypeId = null; } } - this._updateHash(); return true; } catch (e: any) { this.showMessage(`Failed to delete: ${e.message}`, 'error'); @@ -216,21 +251,22 @@ export class HaStateController implements ReactiveController { const oldIndex = this.layouts.findIndex(l => l.id === layout.id); await api.deleteItem('layout', layout.id); console.info(`[HaStateController] layout deleted successfully: ${layout.id}`); - this.showMessage(`Layout "${layout.name}" deleted.`, 'success'); + this.showMessage('Settings applied', 'success'); await this.refresh(); this.host.requestUpdate(); - if (this.activeLayout?.id === layout.id) { - if (this.layouts.length > 0) { - const newIndex = Math.min(oldIndex, this.layouts.length - 1); - this.activeLayout = this.layouts[newIndex]; - this._originalLayout = JSON.stringify(this.activeLayout); - } else { - this.activeLayout = null; - this._originalLayout = null; + if (this.activeSection === 'layouts' && this.layouts.length > 0) { + if (this.activeLayout?.id === layout.id) { + if (this.layouts.length > 0) { + const newIndex = Math.min(oldIndex, this.layouts.length - 1); + this.activeLayout = this.layouts[newIndex]; + this._originalLayout = JSON.stringify(this.activeLayout); + } else { + this.activeLayout = null; + this._originalLayout = null; + } } } - this._updateHash(); return true; } catch (e: any) { this.showMessage(`Failed to delete: ${e.message}`, 'error'); @@ -245,8 +281,7 @@ export class HaStateController implements ReactiveController { this.selectedImageId = null; } await this.refresh(); - this.showMessage(`Image "${image.name}" deleted.`, 'success'); - this._updateHash(); + this.showMessage('Settings applied', 'success'); return true; } catch (e: any) { this.showMessage(`Failed to delete image: ${e.message}`, 'error'); @@ -261,10 +296,9 @@ export class HaStateController implements ReactiveController { const newScene: Omit = { name, layout }; const result = await api.createItem('scene', newScene); this.activeScene = result; - this._updateHash(); await this.refresh(); this.isSaving = false; - this.showMessage(`Scene "${name}" created!`, 'success'); + this.showMessage('Settings applied', 'success'); return result; } catch (e: any) { this.showMessage(`Failed to create scene: ${e.message}`, 'error'); @@ -286,8 +320,7 @@ export class HaStateController implements ReactiveController { if (this.activeScene?.id === id) { this.activeScene = this.scenes.find(s => s.id === id) || this.activeScene; } - this.showMessage('Scene updated!', 'success'); - this._updateHash(); + this.showMessage('Settings applied', 'success'); } catch (e: any) { this.showMessage(`Failed to update scene: ${e.message}`, 'error'); } finally { @@ -300,6 +333,7 @@ export class HaStateController implements ReactiveController { if (!this.activeScene) return; this.activeScene = { ...this.activeScene, ...updates }; this.host.requestUpdate(); + (this.host as unknown as HTMLElement).dispatchEvent(new CustomEvent('state-changed')); } async saveActiveScene() { @@ -310,7 +344,7 @@ export class HaStateController implements ReactiveController { try { await api.updateItem('scene', this.activeScene.id, this.activeScene); await this.refresh(); - this.showMessage('Scene saved!', 'success'); + this.showMessage('Settings applied', 'success'); } catch (e: any) { this.showMessage(`Failed to save scene: ${e.message}`, 'error'); } finally { @@ -324,7 +358,7 @@ export class HaStateController implements ReactiveController { const oldIndex = this.scenes.findIndex(s => s.id === scene.id); await api.deleteItem('scene', scene.id); await this.refresh(); - this.showMessage(`Scene "${scene.name}" deleted.`, 'success'); + this.showMessage('Settings applied', 'success'); if (this.activeScene?.id === scene.id) { if (this.scenes.length > 0) { @@ -334,7 +368,6 @@ export class HaStateController implements ReactiveController { this.activeScene = null; } } - this._updateHash(); return true; } catch (e: any) { this.showMessage(`Failed to delete scene: ${e.message}`, 'error'); @@ -342,23 +375,30 @@ export class HaStateController implements ReactiveController { } } - showMessage(text: string, _type: 'info' | 'success' | 'error' = 'info') { - console.info(`[HaStateController] showMessage: ${text} (${_type})`); - this.message = text; + public showMessage(text: string, type: AppMessage['type'] = 'info') { + this.message = { text, type }; this.host.requestUpdate(); - setTimeout(() => { - this.message = ''; - this.host.requestUpdate(); - }, 3000); + (this.host as unknown as HTMLElement).dispatchEvent(new CustomEvent('state-changed')); + + if (this._messageClearTimeout) clearTimeout(this._messageClearTimeout); + this._messageClearTimeout = setTimeout(() => { + if (this.message?.text === text) { + this.message = null; + this.host.requestUpdate(); + (this.host as unknown as HTMLElement).dispatchEvent(new CustomEvent('state-changed')); + } + }, 5000); } switchLayout(layout: Layout) { this.activeLayout = layout; + this.navigation.activeLayoutId = layout.id; this._originalLayout = JSON.stringify(layout); this.selectedItemId = null; this.isAddingNew = false; this.host.requestUpdate(); - this._updateHash(); + this.navigation.updateHash(); + (this.host as unknown as HTMLElement).dispatchEvent(new CustomEvent('state-changed')); } public prepareNewLayout() { @@ -370,11 +410,13 @@ export class HaStateController implements ReactiveController { grid_snap_mm: 5, items: [] }; + this.navigation.activeLayoutId = ''; this._originalLayout = null; // No baseline for new layout this.selectedItemId = null; this.isAddingNew = true; this.host.requestUpdate(); - this._updateHash(); + this.navigation.updateHash(); + (this.host as unknown as HTMLElement).dispatchEvent(new CustomEvent('state-changed')); } switchScene(scene: Scene) { @@ -382,9 +424,8 @@ export class HaStateController implements ReactiveController { } public selectScene(id: string | null) { - this.activeScene = this.scenes.find(s => s.id === id) || null; - this.host.requestUpdate(); - this._updateHash(); + this.navigation.selectScene(id); + (this.host as unknown as HTMLElement).dispatchEvent(new CustomEvent('state-changed')); } discardChanges() { @@ -392,12 +433,28 @@ export class HaStateController implements ReactiveController { this.activeLayout = JSON.parse(this._originalLayout); this.selectedItemId = null; this.host.requestUpdate(); + (this.host as unknown as HTMLElement).dispatchEvent(new CustomEvent('state-changed')); } updateActiveLayout(updates: Partial) { if (!this.activeLayout) return; this.activeLayout = { ...this.activeLayout, ...updates }; this.host.requestUpdate(); + (this.host as unknown as HTMLElement).dispatchEvent(new CustomEvent('state-changed')); + } + + deleteLayoutItem(itemId: string) { + if (!this.activeLayout) return; + this.activeLayout = { + ...this.activeLayout, + items: this.activeLayout.items.filter(i => i.id !== itemId) + }; + if (this.selectedItemId === itemId) { + this.selectedItemId = null; + } + this.showMessage('Item deleted', 'success'); + this.host.requestUpdate(); + (this.host as unknown as HTMLElement).dispatchEvent(new CustomEvent('state-changed')); } updateItem(itemId: string, updates: Partial) { @@ -410,118 +467,25 @@ export class HaStateController implements ReactiveController { } setSection(section: AppSection) { - this.activeSection = section; - this.host.requestUpdate(); - this._updateHash(); + this.navigation.setSection(section); } public selectItem(itemId: string | null) { - this.selectedItemId = itemId; - this.host.requestUpdate(); - this._updateHash(); + this.navigation.selectItem(itemId); } public selectImage(imageId: string | null) { - this.selectedImageId = imageId; - this.host.requestUpdate(); - this._updateHash(); + this.navigation.selectImage(imageId); } public selectDisplayType(id: string | null) { - this.isAddingNew = (id === null); - this.selectedDisplayTypeId = id; - this.host.requestUpdate(); - this._updateHash(); + this.navigation.selectDisplayType(id); } public setViewMode(mode: ViewMode) { - this.viewMode = mode; - this.host.requestUpdate(); - this._updateHash(); - } - - private _updateHash() { - let hash = `#/${this.activeSection}`; - - if (this.activeSection === 'layouts' && this.activeLayout) { - hash += `/${this.activeLayout.id}`; - if (this.selectedItemId) { - hash += `/item/${this.selectedItemId}`; - } - } else if (this.activeSection === 'scenes' && this.activeScene) { - hash += `/${this.activeScene.id}`; - } else if (this.activeSection === 'images' && this.selectedImageId) { - hash += `/${this.selectedImageId}`; - } else if (this.activeSection === 'display-types' && this.selectedDisplayTypeId) { - hash += `/${this.selectedDisplayTypeId}`; - } - - if (this.viewMode === 'yaml') { - hash += '?mode=yaml'; - } - - if (window.location.hash === hash) return; - - window.location.hash = hash; - } - - private _applyHash() { - - const hash = window.location.hash || '#/layouts'; - const [pathPart, queryPart] = hash.split('?'); - const path = pathPart.substring(2); // Remove '#/' - const segments = path.split('/'); - - const params = new URLSearchParams(queryPart || ''); - const mode = params.get('mode') as ViewMode; - if (mode === 'yaml' || mode === 'graphical') { - this.viewMode = mode; - } - - const section = segments[0] as AppSection; - if (['display-types', 'layouts', 'images', 'scenes'].includes(section)) { - if (this.activeSection !== section) { - this.activeSection = section; - this.isAddingNew = false; // Reset when switching sections - } - } - - if (this.activeSection === 'layouts') { - const layoutId = segments[1]; - if (layoutId) { - const layout = this.layouts.find(l => l.id === layoutId); - if (layout && this.activeLayout?.id !== layoutId) { - this.activeLayout = layout; - this._originalLayout = JSON.stringify(layout); - } - } - const newItemId = (segments[2] === 'item' && segments[3]) ? segments[3] : null; - if (this.selectedItemId !== newItemId) this.selectedItemId = newItemId; - } else if (this.activeSection === 'scenes') { - const sceneId = segments[1] || null; - if (this.activeScene?.id !== sceneId) { - const scene = sceneId ? this.scenes.find(s => s.id === sceneId) : null; - this.activeScene = scene || null; - } - } else if (this.activeSection === 'images') { - const imageId = segments[1] || null; - if (this.selectedImageId !== imageId) this.selectedImageId = imageId; - } else if (this.activeSection === 'display-types') { - const displayTypeId = segments[1] || null; - if (this.selectedDisplayTypeId !== displayTypeId) { - this.selectedDisplayTypeId = displayTypeId; - if (displayTypeId !== null) this.isAddingNew = false; - } - } - - this._ensureSelection(); - this.host.requestUpdate(); + this.navigation.setViewMode(mode); } - /** - * Selection safety: ensures an item is selected for the current section - * if items are available and none is currently active. - */ private _ensureSelection() { if (!this.activeLayout && this.layouts.length > 0) { this.activeLayout = this.layouts[0]; diff --git a/eink/frontend/src/controllers/NavigationController.test.ts b/eink/frontend/src/controllers/NavigationController.test.ts new file mode 100644 index 00000000..2296bb34 --- /dev/null +++ b/eink/frontend/src/controllers/NavigationController.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NavigationController } from './NavigationController'; + +describe('NavigationController', () => { + let controller: NavigationController; + let mockHost: any; + + beforeEach(() => { + mockHost = { + addController: vi.fn(), + requestUpdate: vi.fn(), + dispatchEvent: vi.fn(), + }; + // Mock window.location.hash + window.location.hash = ''; + controller = new NavigationController(mockHost); + }); + + it('initialises with default values', () => { + expect(controller.activeSection).toBe('layouts'); + expect(controller.viewMode).toBe('graphical'); + }); + + it('sets section and updates hash', () => { + controller.setSection('images'); + expect(controller.activeSection).toBe('images'); + expect(window.location.hash).toBe('#/images'); + expect(mockHost.requestUpdate).toHaveBeenCalled(); + }); + + it('sets view mode and updates hash', () => { + controller.setViewMode('yaml'); + expect(controller.viewMode).toBe('yaml'); + expect(window.location.hash).toBe('#/layouts?mode=yaml'); + }); + + it('selects layout and updates hash', () => { + controller.selectLayout('l123'); + expect(controller.activeLayoutId).toBe('l123'); + expect(window.location.hash).toBe('#/layouts/l123'); + }); + + it('selects item within layout and updates hash', () => { + controller.selectLayout('l123'); + controller.selectItem('i456'); + expect(controller.selectedItemId).toBe('i456'); + expect(window.location.hash).toBe('#/layouts/l123/item/i456'); + }); + + it('applies values from hash', () => { + window.location.hash = '#/scenes/s789?mode=yaml'; + // Trigger _applyHash manually since we're bypassing event listener + (controller as any)._applyHash(); + + expect(controller.activeSection).toBe('scenes'); + expect(controller.activeSceneId).toBe('s789'); + expect(controller.viewMode).toBe('yaml'); + }); +}); diff --git a/eink/frontend/src/controllers/NavigationController.ts b/eink/frontend/src/controllers/NavigationController.ts new file mode 100644 index 00000000..f20e6970 --- /dev/null +++ b/eink/frontend/src/controllers/NavigationController.ts @@ -0,0 +1,171 @@ +import { ReactiveController, ReactiveControllerHost } from 'lit'; + +export type AppSection = 'display-types' | 'layouts' | 'images' | 'scenes'; +export type ViewMode = 'graphical' | 'yaml'; + +export class NavigationController implements ReactiveController { + public activeSection: AppSection = 'layouts'; + public viewMode: ViewMode = 'graphical'; + public activeLayoutId: string | null = null; + public selectedItemId: string | null = null; + public selectedImageId: string | null = null; + public selectedDisplayTypeId: string | null = null; + public activeSceneId: string | null = null; + public isAddingNew = false; + public onHashApplied?: () => void; + + constructor(private host: ReactiveControllerHost) { + this.host.addController(this); + } + + hostConnected() { + window.addEventListener('hashchange', this._handleHashChange); + this._applyHash(); + } + + hostDisconnected() { + window.removeEventListener('hashchange', this._handleHashChange); + } + + private _handleHashChange = () => { + this._applyHash(); + }; + + public setSection(section: AppSection) { + if (this.activeSection !== section) { + this.activeSection = section; + this.isAddingNew = false; + this.updateHash(); + this.host.requestUpdate(); + (this.host as unknown as HTMLElement).dispatchEvent(new CustomEvent('state-changed')); + } + } + + public setViewMode(mode: ViewMode) { + if (this.viewMode !== mode) { + this.viewMode = mode; + this.updateHash(); + this.host.requestUpdate(); + (this.host as unknown as HTMLElement).dispatchEvent(new CustomEvent('state-changed')); + } + } + + + public selectLayout(id: string | null) { + if (this.activeLayoutId !== id) { + this.activeLayoutId = id; + this.selectedItemId = null; + this.isAddingNew = false; + this.updateHash(); + this.host.requestUpdate(); + } + } + + public selectItem(id: string | null) { + if (this.selectedItemId !== id) { + this.selectedItemId = id; + this.updateHash(); + this.host.requestUpdate(); + } + } + + public selectImage(id: string | null) { + if (this.selectedImageId !== id) { + this.selectedImageId = id; + this.updateHash(); + this.host.requestUpdate(); + } + } + + public selectDisplayType(id: string | null) { + this.isAddingNew = (id === 'new'); + if (this.selectedDisplayTypeId !== id) { + this.selectedDisplayTypeId = id; + this.updateHash(); + this.host.requestUpdate(); + } + } + + public selectScene(id: string | null) { + if (this.activeSceneId !== id) { + this.activeSceneId = id; + this.updateHash(); + this.host.requestUpdate(); + } + } + + public prepareNew() { + this.isAddingNew = true; + if (this.activeSection === 'layouts') { + this.activeLayoutId = 'new'; + } else if (this.activeSection === 'display-types') { + this.selectedDisplayTypeId = 'new'; + } else if (this.activeSection === 'scenes') { + this.activeSceneId = 'new'; + } + this.updateHash(); + this.host.requestUpdate(); + (this.host as unknown as HTMLElement).dispatchEvent(new CustomEvent('state-changed')); + } + + public updateHash() { + let hash = `#/${this.activeSection}`; + + if (this.activeSection === 'layouts' && this.activeLayoutId !== null) { + hash += `/${this.activeLayoutId}`; + if (this.selectedItemId) { + hash += `/item/${this.selectedItemId}`; + } + } else if (this.activeSection === 'scenes' && this.activeSceneId) { + hash += `/${this.activeSceneId}`; + } else if (this.activeSection === 'images' && this.selectedImageId) { + hash += `/${this.selectedImageId}`; + } else if (this.activeSection === 'display-types' && this.selectedDisplayTypeId) { + hash += `/${this.selectedDisplayTypeId}`; + } + + if (this.viewMode === 'yaml') { + hash += '?mode=yaml'; + } + + if (window.location.hash !== hash) { + window.location.hash = hash; + } + } + + private _applyHash() { + const hash = window.location.hash || '#/layouts'; + const [pathPart, queryPart] = hash.split('?'); + const path = pathPart.substring( pathPart.startsWith('#/') ? 2 : (pathPart.startsWith('#') ? 1 : 0) ); + const segments = path.split('/'); + + const params = new URLSearchParams(queryPart || ''); + const mode = params.get('mode') as ViewMode; + if (mode === 'yaml' || mode === 'graphical') { + this.viewMode = mode; + } + + const section = segments[0] as AppSection; + if (['display-types', 'layouts', 'images', 'scenes'].includes(section)) { + this.activeSection = section; + } + + if (this.activeSection === 'layouts') { + this.activeLayoutId = segments[1] || null; + this.selectedItemId = (segments[2] === 'item' && segments[3]) ? segments[3] : null; + this.isAddingNew = (this.activeLayoutId === 'new'); + } else if (this.activeSection === 'scenes') { + this.activeSceneId = segments[1] || null; + this.isAddingNew = (this.activeSceneId === 'new'); + } else if (this.activeSection === 'images') { + this.selectedImageId = segments[1] || null; + } else if (this.activeSection === 'display-types') { + this.selectedDisplayTypeId = segments[1] || null; + this.isAddingNew = (this.selectedDisplayTypeId === 'new'); + } + + this.host.requestUpdate(); + (this.host as unknown as HTMLElement).dispatchEvent(new CustomEvent('state-changed')); + if (this.onHashApplied) this.onHashApplied(); + } +}