From be98380ba49979d1444a64ae4aa75ecc31bc2d3a Mon Sep 17 00:00:00 2001 From: Empire Phoenix Date: Tue, 26 May 2026 13:27:35 +0200 Subject: [PATCH] new toast impl, wip --- .../MainBoard/rust/src_webpack/src/main.ts | 90 +++- .../rust/src_webpack/src/submitView.ts | 14 +- .../MainBoard/rust/src_webpack/src/toast.ts | 482 +++++++++++++++--- 3 files changed, 507 insertions(+), 79 deletions(-) diff --git a/Software/MainBoard/rust/src_webpack/src/main.ts b/Software/MainBoard/rust/src_webpack/src/main.ts index 5f86e1b..c827c48 100644 --- a/Software/MainBoard/rust/src_webpack/src/main.ts +++ b/Software/MainBoard/rust/src_webpack/src/main.ts @@ -43,6 +43,7 @@ export class Controller { controller.tankView.setTankInfo(tankinfo) }) .catch(error => { + toast.error(`Failed to load tank info: ${error}`); console.log(error); }); } @@ -64,7 +65,9 @@ export class Controller { const json = await response.json(); const logs = json as LogArray; controller.logView.setLog(logs); + toast.info("Log loaded successfully"); } catch (error) { + toast.error(`Failed to load log: ${error}`); console.log(error); } } @@ -87,6 +90,7 @@ export class Controller { const timezones = json as string[]; controller.timeView.timezones(timezones); } catch (error) { + toast.error(`Error fetching timezones: ${error}`); return console.error('Error fetching timezones:', error); } } @@ -98,6 +102,7 @@ export class Controller { const saves = json as SaveInfo[]; controller.fileview.setSaveList(saves, PUBLIC_URL); } catch (error) { + toast.error(`Failed to update save list: ${error}`); console.log(error); } } @@ -109,16 +114,21 @@ export class Controller { ajax.send(); ajax.addEventListener("error", () => { controller.progressview.removeProgress("slot_delete"); - alert("Error deleting slot"); + toast.error(`Failed to delete slot ${idx}`); controller.updateSaveList(); }, false); ajax.addEventListener("abort", () => { controller.progressview.removeProgress("slot_delete"); - alert("Aborted deleting slot"); + toast.warning(`Slot deletion aborted`); controller.updateSaveList(); }, false); ajax.addEventListener("load", () => { controller.progressview.removeProgress("slot_delete"); + if (ajax.status >= 200 && ajax.status < 300) { + toast.success("Slot deleted successfully"); + } else { + toast.error(`Failed to delete slot: ${ajax.status}`); + } controller.updateSaveList(); }, false); } @@ -131,6 +141,7 @@ export class Controller { controller.timeView.update(time.native, time.rtc); } catch (error) { controller.timeView.update("n/a", "n/a"); + toast.error(`Failed to update RTC data: ${error}`); console.log(error); } } @@ -143,6 +154,7 @@ export class Controller { controller.batteryView.update(battery); } catch (error) { controller.batteryView.update(null); + toast.error(`Failed to update battery data: ${error}`); console.log(error); } } @@ -155,6 +167,7 @@ export class Controller { controller.solarView.update(solar); } catch (error) { controller.solarView.update(null); + toast.error(`Failed to update solar data: ${error}`); console.log(error); } } @@ -173,6 +186,7 @@ export class Controller { controller.progressview.removeProgress("ota_upload") const status = ajax.status; if (status >= 200 && status < 300) { + toast.success("OTA firmware upload successful"); controller.reboot(); } else { const statusText = ajax.statusText || ""; @@ -199,6 +213,7 @@ export class Controller { const versionInfo = json as VersionInfo; controller.progressview.removeProgress("version"); controller.firmWareView.setVersion(versionInfo); + toast.info("Firmware version information updated"); } getBackupConfig() { @@ -241,6 +256,7 @@ export class Controller { .then(status => { controller.progressview.removeProgress("set_config"); if (status == 200) { + toast.success("Configuration saved successfully"); setTimeout(() => { controller.downloadConfig().then(() => { controller.updateSaveList().then(() => { @@ -268,7 +284,14 @@ export class Controller { fetch(PUBLIC_URL + "/time", { method: "POST", body: pretty - }).then( + }) + .then(response => { + if (!response.ok) { + toast.error(`Failed to sync RTC: ${response.status}`); + } + return response; + }) + .then( _ => controller.progressview.removeProgress("write_rtc") ) } @@ -288,9 +311,23 @@ export class Controller { } selfTest() { + controller.progressview.addIndeterminate("self_test", "Running board test") fetch(PUBLIC_URL + "/boardtest", { method: "POST" }) + .then(response => { + if (response.ok) { + toast.success("Board test completed"); + } else { + toast.error(`Board test failed: ${response.status}`); + } + }) + .catch(error => { + toast.error(`Board test error: ${error}`); + }) + .finally(() => { + controller.progressview.removeProgress("self_test"); + }); } testNightLamp(active: boolean) { @@ -298,16 +335,44 @@ export class Controller { active: active }; var pretty = JSON.stringify(body, undefined, 1); + controller.progressview.addIndeterminate("night_lamp_test", "Testing night lamp") fetch(PUBLIC_URL + "/lamptest", { method: "POST", body: pretty }) + .then(response => { + if (response.ok) { + toast.success(`Night lamp ${active ? "enabled" : "disabled"} successfully`); + } else { + toast.error(`Night lamp test failed: ${response.status}`); + } + }) + .catch(error => { + toast.error(`Night lamp test error: ${error}`); + }) + .finally(() => { + controller.progressview.removeProgress("night_lamp_test"); + }); } testFertilizerPump() { + controller.progressview.addIndeterminate("fert_test", "Testing fertilizer pump") fetch(PUBLIC_URL + "/fertilizerpumptest", { method: "POST" }) + .then(response => { + if (response.ok) { + toast.success("Fertilizer pump test completed"); + } else { + toast.error(`Fertilizer pump test failed: ${response.status}`); + } + }) + .catch(error => { + toast.error(`Fertilizer pump test error: ${error}`); + }) + .finally(() => { + controller.progressview.removeProgress("fert_test"); + }); } testPlant(plantId: number) { @@ -344,6 +409,11 @@ export class Controller { controller.plantViews.setPumpTestCurrent(plantId, response); clearTimeout(timerId); controller.progressview.removeProgress("test_pump"); + if (!response.error) { + toast.success(`Pump ${plantId + 1} test completed successfully`); + } else { + toast.error(`Pump ${plantId + 1} test reported an error`); + } } ) } @@ -428,13 +498,18 @@ export class Controller { if (ajax.readyState === 4) { clearTimeout(timerId); controller.progressview.removeProgress("scan_ssid"); - this.networkView.setScanResult(ajax.response as SSIDList) + if (ajax.status >= 200 && ajax.status < 300) { + this.networkView.setScanResult(ajax.response as SSIDList); + toast.success("WiFi scan completed"); + } else { + toast.error(`WiFi scan failed: ${ajax.status}`); + } } }; ajax.onerror = (_) => { clearTimeout(timerId); controller.progressview.removeProgress("scan_ssid"); - alert("Failed to start see console") + toast.error("Failed to start WiFi scan"); } ajax.open("POST", PUBLIC_URL + "/wifiscan"); ajax.send(); @@ -487,6 +562,9 @@ export class Controller { if (!silent) { controller.progressview.removeProgress("measure_moisture"); } + if (!silent) { + toast.success("Moisture measurement completed"); + } }) .catch(error => { @@ -632,6 +710,7 @@ export class Controller { }; await this.detectSensors(detection, true); } catch (e) { + toast.error(`Auto-refresh error: ${e}`); console.error("Auto-refresh error", e); } @@ -669,6 +748,7 @@ async function executeTasksSequentially() { try { await task(); } catch (error) { + toast.error(`Error executing task '${displayString}': ${error}`); console.error(`Error executing task '${displayString}':`, error); // Optionally, you can decide whether to continue or break on errors break; diff --git a/Software/MainBoard/rust/src_webpack/src/submitView.ts b/Software/MainBoard/rust/src_webpack/src/submitView.ts index 55ef5e4..1952251 100644 --- a/Software/MainBoard/rust/src_webpack/src/submitView.ts +++ b/Software/MainBoard/rust/src_webpack/src/submitView.ts @@ -1,5 +1,6 @@ import {Controller} from "./main"; import {BackupHeader} from "./api"; +import {toast} from "./toast"; export class SubmitView { json: HTMLDivElement; @@ -28,11 +29,9 @@ export class SubmitView { controller.uploadConfig(this.json.textContent as string, (status: string) => { if (status != "OK") { // Show error toast (click to dismiss only) - const {toast} = require('./toast'); toast.error(status); } else { // Show info toast (auto hides after 5s, or click to dismiss sooner) - const {toast} = require('./toast'); toast.info('Config uploaded successfully'); } this.submit_status.innerHTML = status; @@ -41,10 +40,21 @@ export class SubmitView { this.backupBtn.onclick = () => { controller.progressview.addIndeterminate("backup", "Backup to EEPROM running") controller.backupConfig(this.json.textContent as string).then(saveStatus => { + if (saveStatus === "OK") { + toast.success("Configuration backup successful"); + } else { + toast.error(`Backup failed: ${saveStatus}`); + } controller.getBackupInfo().then(r => { controller.progressview.removeProgress("backup") this.submit_status.innerHTML = saveStatus; }); + }).catch(error => { + toast.error(`Backup error: ${error}`); + controller.getBackupInfo().then(r => { + controller.progressview.removeProgress("backup") + this.submit_status.innerHTML = "Error"; + }); }); } this.restoreBackupBtn.onclick = () => { diff --git a/Software/MainBoard/rust/src_webpack/src/toast.ts b/Software/MainBoard/rust/src_webpack/src/toast.ts index bca6b71..e701170 100644 --- a/Software/MainBoard/rust/src_webpack/src/toast.ts +++ b/Software/MainBoard/rust/src_webpack/src/toast.ts @@ -1,94 +1,432 @@ -class ToastService { - private container: HTMLElement; - private stylesInjected = false; +/** + * Toast notification service for PlantCtrl embedded web interface + * Provides non-blocking notifications with auto-dismiss and click-to-close functionality + */ - constructor() { - this.container = this.ensureContainer(); - this.injectStyles(); - } +const TOAST_container_ID = 'toast-container'; +const TOAST_STYLES_KEY = 'toast-styles-injected'; - info(message: string, timeoutMs: number = 5000) { - const el = this.createToast(message, 'info'); - this.container.appendChild(el); - // Auto-dismiss after timeout - const timer = window.setTimeout(() => this.dismiss(el), timeoutMs); - // Dismiss on click immediately - el.addEventListener('click', () => { - window.clearTimeout(timer); - this.dismiss(el); - }); - } +interface ToastOptions { + duration?: number; + dismissible?: boolean; +} - error(message: string) { - console.error(message); - const el = this.createToast(message, 'error'); - this.container.appendChild(el); - // Only dismiss on click - el.addEventListener('click', () => this.dismiss(el)); - } +interface ToastData { + id: string; + type: 'info' | 'success' | 'warning' | 'error'; + message: string; + createdAt: number; + element?: HTMLElement; +} - private dismiss(el: HTMLElement) { - if (!el.parentElement) return; - el.parentElement.removeChild(el); - } - - private createToast(message: string, type: 'info' | 'error'): HTMLElement { - const div = document.createElement('div'); - div.className = `toast ${type}`; - div.textContent = message; - div.setAttribute('role', 'status'); - div.setAttribute('aria-live', 'polite'); - return div; - } - - private ensureContainer(): HTMLElement { - let container = document.getElementById('toast-container'); - if (!container) { - container = document.createElement('div'); - container.id = 'toast-container'; - document.body.appendChild(container); +/** + * Toast service for displaying notifications + */ +export class ToastService { + private container: HTMLElement | null = null; + private activeToasts: Map = new Map(); + private maxToasts: number = 5; + + // Default configuration + private defaultDuration: number = 5000; // 5 seconds for info messages + private errorDuration: number = 10000; // 10 seconds for error messages + + constructor() { + this.init(); } - return container; - } - - private injectStyles() { - if (this.stylesInjected) return; - const style = document.createElement('style'); - style.textContent = ` + + /** + * Initialize the toast container and inject styles + */ + private init(): void { + this.ensureContainer(); + this.injectStyles(); + } + + /** + * Get or create the toast container element + */ + private ensureContainer(): HTMLElement { + if (this.container) return this.container; + + let container = document.getElementById(TOAST_container_ID); + if (!container) { + container = document.createElement('div'); + container.id = TOAST_container_ID; + container.setAttribute('role', 'region'); + container.setAttribute('aria-label', 'Notifications'); + document.body.appendChild(container); + } + + this.container = container; + return container; + } + + /** + * Inject toast styles if not already injected + */ + private injectStyles(): void { + if (document.querySelector(`style[data-id="${TOAST_STYLES_KEY}"]`)) { + return; + } + + const style = document.createElement('style'); + style.setAttribute('data-id', TOAST_STYLES_KEY); + style.textContent = ` #toast-container { position: fixed; - top: 12px; - right: 12px; + top: 16px; + right: 16px; display: flex; flex-direction: column; - gap: 8px; + gap: 10px; z-index: 9999; + max-width: 400px; + pointer-events: none; } + .toast { - max-width: 320px; - padding: 10px 12px; - border-radius: 6px; - box-shadow: 0 2px 6px rgba(0,0,0,0.2); - cursor: pointer; - user-select: none; - font-family: sans-serif; + background: #fff; + border-left: 4px solid transparent; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border-radius: 8px; + padding: 12px 16px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; font-size: 14px; - line-height: 1.3; + line-height: 1.4; + color: #333; + max-width: 100%; + pointer-events: auto; + animation: toast-slide-in 0.3s cubic-bezier(0.4, 0, 0.2, 1), + toast-fade-in 0.3s ease-out; + display: flex; + align-items: center; + gap: 12px; } + .toast.info { - background-color: #d4edda; /* green-ish */ - color: #155724; - border-left: 4px solid #28a745; + border-color: #3b82f6; + background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); } + +.toast.success { + border-color: #22c55e; + background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%); +} + +.toast.warning { + border-color: #f59e0b; + background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); +} + .toast.error { - background-color: #f8d7da; /* red-ish */ - color: #721c24; - border-left: 4px solid #dc3545; + border-color: #ef4444; + background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); } -`; - document.head.appendChild(style); - this.stylesInjected = true; + +.toast:hover { + transform: translateX(-2px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); +} + +.toast-icon { + flex-shrink: 0; + font-size: 18px; +} + +.toast-message { + flex-grow: 1; + word-wrap: break-word; + overflow-wrap: anywhere; +} + +.toast-close-btn { + flex-shrink: 0; + background: none; + border: none; + cursor: pointer; + padding: 4px; + margin-left: -4px; + opacity: 0.6; + transition: opacity 0.2s; + color: inherit; +} + +.toast-close-btn:hover { + opacity: 1; +} + +@keyframes toast-slide-in { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; } } +@keyframes toast-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes toast-dismiss { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } +} +`; + document.head.appendChild(style); + } + + /** + * Create a unique ID for toast messages + */ + private generateId(): string { + return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + } + + /** + * Create a toast element + */ + private createToast(type: 'info' | 'success' | 'warning' | 'error', message: string): HTMLElement { + const div = document.createElement('div'); + div.className = 'toast'; + div.classList.add(type); + + // Add icon based on type + const icon = this.getIconForType(type); + div.innerHTML = ` + ${icon} + ${this.escapeHtml(message)} + + `; + + return div; + } + + /** + * Get icon based on toast type + */ + private getIconForType(type: 'info' | 'success' | 'warning' | 'error'): string { + const icons: Record = { + info: '', + success: '', + warning: '', + error: '' + }; + return icons[type] || icons.info; + } + + /** + * Escape HTML to prevent XSS + */ + private escapeHtml(text: string): string { + const map: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, (char) => map[char] || char); + } + + /** + * Display an info toast notification + */ + info(message: string, options?: ToastOptions): void { + const duration = options?.duration ?? this.defaultDuration; + this.showToast('info', message, duration); + } + + /** + * Display a success toast notification + */ + success(message: string, options?: ToastOptions): void { + const duration = options?.duration ?? this.defaultDuration; + this.showToast('success', message, duration); + } + + /** + * Display a warning toast notification + */ + warning(message: string, options?: ToastOptions): void { + const duration = options?.duration ?? this.defaultDuration; + this.showToast('warning', message, duration); + } + + /** + * Display an error toast notification + */ + error(message: string, options?: ToastOptions): void { + console.error(`[Toast Error] ${message}`); + const duration = options?.duration ?? this.errorDuration; + this.showToast('error', message, duration); + } + + /** + * Show a toast notification with the given type + */ + private showToast(type: 'info' | 'success' | 'warning' | 'error', message: string, duration: number): void { + // Limit the number of concurrent toasts + this.limitToasts(); + + const id = this.generateId(); + const element = this.createToast(type, message); + const container = this.ensureContainer(); + + // Add to active toasts + this.activeToasts.set(id, { id, type, message, createdAt: Date.now() }); + + // Append to container + container.appendChild(element); + + // Store reference + this.activeToasts.get(id)!.element = element; + + // Set up auto-dismiss timer + let dismissTimer: number | undefined; + + const scheduleDismiss = () => { + if (duration > 0) { + dismissTimer = window.setTimeout(() => this.dismiss(id), duration); + } + }; + + // Setup click to dismiss + const handleClick = () => { + if (dismissTimer !== undefined) { + window.clearTimeout(dismissTimer); + dismissTimer = undefined; + } + this.dismiss(id); + }; + + // Setup close button handler + const closeBtn = element.querySelector('.toast-close-btn'); + if (closeBtn) { + closeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + handleClick(); + }); + } + + // Setup click on toast to dismiss + element.addEventListener('click', handleClick); + + // Start timer + scheduleDismiss(); + } + + /** + * Dismiss a toast by ID + */ + dismiss(id: string): void { + const toastData = this.activeToasts.get(id); + if (!toastData || !toastData.element) return; + + const element = toastData.element; + + // Add dismiss animation + element.style.animation = 'toast-dismiss 0.2s ease-in forwards'; + + // Remove from DOM after animation + setTimeout(() => { + if (element.parentElement) { + element.parentElement.removeChild(element); + } + this.activeToasts.delete(id); + + // Ensure container exists before trying to append + if (this.container) { + this.moveToasts(); + } + }, 200); + } + + /** + * Remove a toast by element reference + */ + dismissElement(element: HTMLElement): void { + const entries = Array.from(this.activeToasts.entries()); + for (const [id, data] of entries) { + if (data.element === element) { + this.dismiss(id); + break; + } + } + } + + /** + * Limit the number of concurrent toasts + */ + private limitToasts(): void { + if (this.container && this.activeToasts.size >= this.maxToasts) { + // Dismiss the oldest toast + const oldestId = Array.from(this.activeToasts.keys())[0]; + if (oldestId) { + this.dismiss(oldestId); + } + } + } + + /** + * Move toasts to ensure proper stacking + */ + private moveToasts(): void { + if (!this.container) return; + + // Remove any empty container + if (this.activeToasts.size === 0) { + if (this.container.parentElement) { + this.container.parentElement.removeChild(this.container); + } + this.container = null; + } + } + + /** + * Clear all active toasts + */ + clear(): void { + const ids = Array.from(this.activeToasts.keys()); + for (const id of ids) { + this.dismiss(id); + } + } + + /** + * Get the number of active toasts + */ + getActiveCount(): number { + return this.activeToasts.size; + } + + /** + * Set the maximum number of concurrent toasts + */ + setMaxToasts(count: number): void { + this.maxToasts = count; + this.limitToasts(); + } +} + +// Export a singleton instance export const toast = new ToastService();