new toast impl, wip
This commit is contained in:
@@ -43,6 +43,7 @@ export class Controller {
|
|||||||
controller.tankView.setTankInfo(tankinfo)
|
controller.tankView.setTankInfo(tankinfo)
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
toast.error(`Failed to load tank info: ${error}`);
|
||||||
console.log(error);
|
console.log(error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -64,7 +65,9 @@ export class Controller {
|
|||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
const logs = json as LogArray;
|
const logs = json as LogArray;
|
||||||
controller.logView.setLog(logs);
|
controller.logView.setLog(logs);
|
||||||
|
toast.info("Log loaded successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
toast.error(`Failed to load log: ${error}`);
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,6 +90,7 @@ export class Controller {
|
|||||||
const timezones = json as string[];
|
const timezones = json as string[];
|
||||||
controller.timeView.timezones(timezones);
|
controller.timeView.timezones(timezones);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
toast.error(`Error fetching timezones: ${error}`);
|
||||||
return console.error('Error fetching timezones:', error);
|
return console.error('Error fetching timezones:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,6 +102,7 @@ export class Controller {
|
|||||||
const saves = json as SaveInfo[];
|
const saves = json as SaveInfo[];
|
||||||
controller.fileview.setSaveList(saves, PUBLIC_URL);
|
controller.fileview.setSaveList(saves, PUBLIC_URL);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
toast.error(`Failed to update save list: ${error}`);
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,16 +114,21 @@ export class Controller {
|
|||||||
ajax.send();
|
ajax.send();
|
||||||
ajax.addEventListener("error", () => {
|
ajax.addEventListener("error", () => {
|
||||||
controller.progressview.removeProgress("slot_delete");
|
controller.progressview.removeProgress("slot_delete");
|
||||||
alert("Error deleting slot");
|
toast.error(`Failed to delete slot ${idx}`);
|
||||||
controller.updateSaveList();
|
controller.updateSaveList();
|
||||||
}, false);
|
}, false);
|
||||||
ajax.addEventListener("abort", () => {
|
ajax.addEventListener("abort", () => {
|
||||||
controller.progressview.removeProgress("slot_delete");
|
controller.progressview.removeProgress("slot_delete");
|
||||||
alert("Aborted deleting slot");
|
toast.warning(`Slot deletion aborted`);
|
||||||
controller.updateSaveList();
|
controller.updateSaveList();
|
||||||
}, false);
|
}, false);
|
||||||
ajax.addEventListener("load", () => {
|
ajax.addEventListener("load", () => {
|
||||||
controller.progressview.removeProgress("slot_delete");
|
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();
|
controller.updateSaveList();
|
||||||
}, false);
|
}, false);
|
||||||
}
|
}
|
||||||
@@ -131,6 +141,7 @@ export class Controller {
|
|||||||
controller.timeView.update(time.native, time.rtc);
|
controller.timeView.update(time.native, time.rtc);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
controller.timeView.update("n/a", "n/a");
|
controller.timeView.update("n/a", "n/a");
|
||||||
|
toast.error(`Failed to update RTC data: ${error}`);
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,6 +154,7 @@ export class Controller {
|
|||||||
controller.batteryView.update(battery);
|
controller.batteryView.update(battery);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
controller.batteryView.update(null);
|
controller.batteryView.update(null);
|
||||||
|
toast.error(`Failed to update battery data: ${error}`);
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,6 +167,7 @@ export class Controller {
|
|||||||
controller.solarView.update(solar);
|
controller.solarView.update(solar);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
controller.solarView.update(null);
|
controller.solarView.update(null);
|
||||||
|
toast.error(`Failed to update solar data: ${error}`);
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,6 +186,7 @@ export class Controller {
|
|||||||
controller.progressview.removeProgress("ota_upload")
|
controller.progressview.removeProgress("ota_upload")
|
||||||
const status = ajax.status;
|
const status = ajax.status;
|
||||||
if (status >= 200 && status < 300) {
|
if (status >= 200 && status < 300) {
|
||||||
|
toast.success("OTA firmware upload successful");
|
||||||
controller.reboot();
|
controller.reboot();
|
||||||
} else {
|
} else {
|
||||||
const statusText = ajax.statusText || "";
|
const statusText = ajax.statusText || "";
|
||||||
@@ -199,6 +213,7 @@ export class Controller {
|
|||||||
const versionInfo = json as VersionInfo;
|
const versionInfo = json as VersionInfo;
|
||||||
controller.progressview.removeProgress("version");
|
controller.progressview.removeProgress("version");
|
||||||
controller.firmWareView.setVersion(versionInfo);
|
controller.firmWareView.setVersion(versionInfo);
|
||||||
|
toast.info("Firmware version information updated");
|
||||||
}
|
}
|
||||||
|
|
||||||
getBackupConfig() {
|
getBackupConfig() {
|
||||||
@@ -241,6 +256,7 @@ export class Controller {
|
|||||||
.then(status => {
|
.then(status => {
|
||||||
controller.progressview.removeProgress("set_config");
|
controller.progressview.removeProgress("set_config");
|
||||||
if (status == 200) {
|
if (status == 200) {
|
||||||
|
toast.success("Configuration saved successfully");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
controller.downloadConfig().then(() => {
|
controller.downloadConfig().then(() => {
|
||||||
controller.updateSaveList().then(() => {
|
controller.updateSaveList().then(() => {
|
||||||
@@ -268,7 +284,14 @@ export class Controller {
|
|||||||
fetch(PUBLIC_URL + "/time", {
|
fetch(PUBLIC_URL + "/time", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: pretty
|
body: pretty
|
||||||
}).then(
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.error(`Failed to sync RTC: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.then(
|
||||||
_ => controller.progressview.removeProgress("write_rtc")
|
_ => controller.progressview.removeProgress("write_rtc")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -288,9 +311,23 @@ export class Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selfTest() {
|
selfTest() {
|
||||||
|
controller.progressview.addIndeterminate("self_test", "Running board test")
|
||||||
fetch(PUBLIC_URL + "/boardtest", {
|
fetch(PUBLIC_URL + "/boardtest", {
|
||||||
method: "POST"
|
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) {
|
testNightLamp(active: boolean) {
|
||||||
@@ -298,16 +335,44 @@ export class Controller {
|
|||||||
active: active
|
active: active
|
||||||
};
|
};
|
||||||
var pretty = JSON.stringify(body, undefined, 1);
|
var pretty = JSON.stringify(body, undefined, 1);
|
||||||
|
controller.progressview.addIndeterminate("night_lamp_test", "Testing night lamp")
|
||||||
fetch(PUBLIC_URL + "/lamptest", {
|
fetch(PUBLIC_URL + "/lamptest", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: pretty
|
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() {
|
testFertilizerPump() {
|
||||||
|
controller.progressview.addIndeterminate("fert_test", "Testing fertilizer pump")
|
||||||
fetch(PUBLIC_URL + "/fertilizerpumptest", {
|
fetch(PUBLIC_URL + "/fertilizerpumptest", {
|
||||||
method: "POST"
|
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) {
|
testPlant(plantId: number) {
|
||||||
@@ -344,6 +409,11 @@ export class Controller {
|
|||||||
controller.plantViews.setPumpTestCurrent(plantId, response);
|
controller.plantViews.setPumpTestCurrent(plantId, response);
|
||||||
clearTimeout(timerId);
|
clearTimeout(timerId);
|
||||||
controller.progressview.removeProgress("test_pump");
|
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) {
|
if (ajax.readyState === 4) {
|
||||||
clearTimeout(timerId);
|
clearTimeout(timerId);
|
||||||
controller.progressview.removeProgress("scan_ssid");
|
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 = (_) => {
|
ajax.onerror = (_) => {
|
||||||
clearTimeout(timerId);
|
clearTimeout(timerId);
|
||||||
controller.progressview.removeProgress("scan_ssid");
|
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.open("POST", PUBLIC_URL + "/wifiscan");
|
||||||
ajax.send();
|
ajax.send();
|
||||||
@@ -487,6 +562,9 @@ export class Controller {
|
|||||||
if (!silent) {
|
if (!silent) {
|
||||||
controller.progressview.removeProgress("measure_moisture");
|
controller.progressview.removeProgress("measure_moisture");
|
||||||
}
|
}
|
||||||
|
if (!silent) {
|
||||||
|
toast.success("Moisture measurement completed");
|
||||||
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@@ -632,6 +710,7 @@ export class Controller {
|
|||||||
};
|
};
|
||||||
await this.detectSensors(detection, true);
|
await this.detectSensors(detection, true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
toast.error(`Auto-refresh error: ${e}`);
|
||||||
console.error("Auto-refresh error", e);
|
console.error("Auto-refresh error", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,6 +748,7 @@ async function executeTasksSequentially() {
|
|||||||
try {
|
try {
|
||||||
await task();
|
await task();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
toast.error(`Error executing task '${displayString}': ${error}`);
|
||||||
console.error(`Error executing task '${displayString}':`, error);
|
console.error(`Error executing task '${displayString}':`, error);
|
||||||
// Optionally, you can decide whether to continue or break on errors
|
// Optionally, you can decide whether to continue or break on errors
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {Controller} from "./main";
|
import {Controller} from "./main";
|
||||||
import {BackupHeader} from "./api";
|
import {BackupHeader} from "./api";
|
||||||
|
import {toast} from "./toast";
|
||||||
|
|
||||||
export class SubmitView {
|
export class SubmitView {
|
||||||
json: HTMLDivElement;
|
json: HTMLDivElement;
|
||||||
@@ -28,11 +29,9 @@ export class SubmitView {
|
|||||||
controller.uploadConfig(this.json.textContent as string, (status: string) => {
|
controller.uploadConfig(this.json.textContent as string, (status: string) => {
|
||||||
if (status != "OK") {
|
if (status != "OK") {
|
||||||
// Show error toast (click to dismiss only)
|
// Show error toast (click to dismiss only)
|
||||||
const {toast} = require('./toast');
|
|
||||||
toast.error(status);
|
toast.error(status);
|
||||||
} else {
|
} else {
|
||||||
// Show info toast (auto hides after 5s, or click to dismiss sooner)
|
// Show info toast (auto hides after 5s, or click to dismiss sooner)
|
||||||
const {toast} = require('./toast');
|
|
||||||
toast.info('Config uploaded successfully');
|
toast.info('Config uploaded successfully');
|
||||||
}
|
}
|
||||||
this.submit_status.innerHTML = status;
|
this.submit_status.innerHTML = status;
|
||||||
@@ -41,10 +40,21 @@ export class SubmitView {
|
|||||||
this.backupBtn.onclick = () => {
|
this.backupBtn.onclick = () => {
|
||||||
controller.progressview.addIndeterminate("backup", "Backup to EEPROM running")
|
controller.progressview.addIndeterminate("backup", "Backup to EEPROM running")
|
||||||
controller.backupConfig(this.json.textContent as string).then(saveStatus => {
|
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.getBackupInfo().then(r => {
|
||||||
controller.progressview.removeProgress("backup")
|
controller.progressview.removeProgress("backup")
|
||||||
this.submit_status.innerHTML = saveStatus;
|
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 = () => {
|
this.restoreBackupBtn.onclick = () => {
|
||||||
|
|||||||
@@ -1,94 +1,432 @@
|
|||||||
class ToastService {
|
/**
|
||||||
private container: HTMLElement;
|
* Toast notification service for PlantCtrl embedded web interface
|
||||||
private stylesInjected = false;
|
* Provides non-blocking notifications with auto-dismiss and click-to-close functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TOAST_container_ID = 'toast-container';
|
||||||
|
const TOAST_STYLES_KEY = 'toast-styles-injected';
|
||||||
|
|
||||||
|
interface ToastOptions {
|
||||||
|
duration?: number;
|
||||||
|
dismissible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastData {
|
||||||
|
id: string;
|
||||||
|
type: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
message: string;
|
||||||
|
createdAt: number;
|
||||||
|
element?: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast service for displaying notifications
|
||||||
|
*/
|
||||||
|
export class ToastService {
|
||||||
|
private container: HTMLElement | null = null;
|
||||||
|
private activeToasts: Map<string, ToastData> = 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() {
|
constructor() {
|
||||||
this.container = this.ensureContainer();
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the toast container and inject styles
|
||||||
|
*/
|
||||||
|
private init(): void {
|
||||||
|
this.ensureContainer();
|
||||||
this.injectStyles();
|
this.injectStyles();
|
||||||
}
|
}
|
||||||
|
|
||||||
info(message: string, timeoutMs: number = 5000) {
|
/**
|
||||||
const el = this.createToast(message, 'info');
|
* Get or create the toast container element
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
private ensureContainer(): HTMLElement {
|
||||||
let container = document.getElementById('toast-container');
|
if (this.container) return this.container;
|
||||||
|
|
||||||
|
let container = document.getElementById(TOAST_container_ID);
|
||||||
if (!container) {
|
if (!container) {
|
||||||
container = document.createElement('div');
|
container = document.createElement('div');
|
||||||
container.id = 'toast-container';
|
container.id = TOAST_container_ID;
|
||||||
|
container.setAttribute('role', 'region');
|
||||||
|
container.setAttribute('aria-label', 'Notifications');
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.container = container;
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
private injectStyles() {
|
/**
|
||||||
if (this.stylesInjected) return;
|
* 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');
|
const style = document.createElement('style');
|
||||||
|
style.setAttribute('data-id', TOAST_STYLES_KEY);
|
||||||
style.textContent = `
|
style.textContent = `
|
||||||
#toast-container {
|
#toast-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 12px;
|
top: 16px;
|
||||||
right: 12px;
|
right: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
|
max-width: 400px;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
max-width: 320px;
|
background: #fff;
|
||||||
padding: 10px 12px;
|
border-left: 4px solid transparent;
|
||||||
border-radius: 6px;
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
padding: 12px 16px;
|
||||||
user-select: none;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
font-family: sans-serif;
|
|
||||||
font-size: 14px;
|
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 {
|
.toast.info {
|
||||||
background-color: #d4edda; /* green-ish */
|
border-color: #3b82f6;
|
||||||
color: #155724;
|
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||||
border-left: 4px solid #28a745;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
.toast.error {
|
||||||
background-color: #f8d7da; /* red-ish */
|
border-color: #ef4444;
|
||||||
color: #721c24;
|
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
|
||||||
border-left: 4px solid #dc3545;
|
|
||||||
}
|
}
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
.toast:hover {
|
||||||
this.stylesInjected = true;
|
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 = `
|
||||||
|
<span class="toast-icon">${icon}</span>
|
||||||
|
<span class="toast-message">${this.escapeHtml(message)}</span>
|
||||||
|
<button class="toast-close-btn" aria-label="Dismiss notification">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get icon based on toast type
|
||||||
|
*/
|
||||||
|
private getIconForType(type: 'info' | 'success' | 'warning' | 'error'): string {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
info: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>',
|
||||||
|
success: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>',
|
||||||
|
warning: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>',
|
||||||
|
error: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>'
|
||||||
|
};
|
||||||
|
return icons[type] || icons.info;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML to prevent XSS
|
||||||
|
*/
|
||||||
|
private escapeHtml(text: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
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();
|
export const toast = new ToastService();
|
||||||
|
|||||||
Reference in New Issue
Block a user