diff --git a/src/components/Toaster.tsx b/src/components/Toaster.tsx
new file mode 100644
index 00000000..b3337a05
--- /dev/null
+++ b/src/components/Toaster.tsx
@@ -0,0 +1,34 @@
+import { useToast } from "@core/hooks/useToast.js";
+
+import {
+ Toast,
+ ToastClose,
+ ToastDescription,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport
+} from "@components/UI/Toast.js";
+
+export function Toaster() {
+ const { toasts } = useToast();
+
+ return (
+
+ {toasts.map(function ({ id, title, description, action, ...props }) {
+ return (
+
+
+ {title && {title}}
+ {description && (
+ {description}
+ )}
+
+ {action}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/core/hooks/useToast.ts b/src/core/hooks/useToast.ts
new file mode 100644
index 00000000..2995d623
--- /dev/null
+++ b/src/core/hooks/useToast.ts
@@ -0,0 +1,187 @@
+import * as React from "react";
+
+import type { ToastActionElement, ToastProps } from "@components/UI/Toast.js";
+
+const TOAST_LIMIT = 1;
+const TOAST_REMOVE_DELAY = 1000;
+
+type ToasterToast = ToastProps & {
+ id: string;
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ action?: ToastActionElement;
+};
+
+const actionTypes = {
+ ADD_TOAST: "ADD_TOAST",
+ UPDATE_TOAST: "UPDATE_TOAST",
+ DISMISS_TOAST: "DISMISS_TOAST",
+ REMOVE_TOAST: "REMOVE_TOAST"
+} as const;
+
+let count = 0;
+
+function genId() {
+ count = (count + 1) % Number.MAX_VALUE;
+ return count.toString();
+}
+
+type ActionType = typeof actionTypes;
+
+type Action =
+ | {
+ type: ActionType["ADD_TOAST"];
+ toast: ToasterToast;
+ }
+ | {
+ type: ActionType["UPDATE_TOAST"];
+ toast: Partial
;
+ }
+ | {
+ type: ActionType["DISMISS_TOAST"];
+ toastId?: ToasterToast["id"];
+ }
+ | {
+ type: ActionType["REMOVE_TOAST"];
+ toastId?: ToasterToast["id"];
+ };
+
+interface State {
+ toasts: ToasterToast[];
+}
+
+const toastTimeouts = new Map>();
+
+const addToRemoveQueue = (toastId: string) => {
+ if (toastTimeouts.has(toastId)) {
+ return;
+ }
+
+ const timeout = setTimeout(() => {
+ toastTimeouts.delete(toastId);
+ dispatch({
+ type: "REMOVE_TOAST",
+ toastId: toastId
+ });
+ }, TOAST_REMOVE_DELAY);
+
+ toastTimeouts.set(toastId, timeout);
+};
+
+export const reducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case "ADD_TOAST":
+ return {
+ ...state,
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT)
+ };
+
+ case "UPDATE_TOAST":
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === action.toast.id ? { ...t, ...action.toast } : t
+ )
+ };
+
+ case "DISMISS_TOAST":
+ const { toastId } = action;
+
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
+ // but I'll keep it here for simplicity
+ if (toastId) {
+ addToRemoveQueue(toastId);
+ } else {
+ state.toasts.forEach((toast) => {
+ addToRemoveQueue(toast.id);
+ });
+ }
+
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === toastId || toastId === undefined
+ ? {
+ ...t,
+ open: false
+ }
+ : t
+ )
+ };
+ case "REMOVE_TOAST":
+ if (action.toastId === undefined) {
+ return {
+ ...state,
+ toasts: []
+ };
+ }
+ return {
+ ...state,
+ toasts: state.toasts.filter((t) => t.id !== action.toastId)
+ };
+ }
+};
+
+const listeners: Array<(state: State) => void> = [];
+
+let memoryState: State = { toasts: [] };
+
+function dispatch(action: Action) {
+ memoryState = reducer(memoryState, action);
+ listeners.forEach((listener) => {
+ listener(memoryState);
+ });
+}
+
+interface Toast extends Omit {}
+
+function toast({ ...props }: Toast) {
+ const id = genId();
+
+ const update = (props: ToasterToast) =>
+ dispatch({
+ type: "UPDATE_TOAST",
+ toast: { ...props, id }
+ });
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
+
+ dispatch({
+ type: "ADD_TOAST",
+ toast: {
+ ...props,
+ id,
+ open: true,
+ onOpenChange: (open) => {
+ if (!open) dismiss();
+ }
+ }
+ });
+
+ return {
+ id: id,
+ dismiss,
+ update
+ };
+}
+
+function useToast() {
+ const [state, setState] = React.useState(memoryState);
+
+ React.useEffect(() => {
+ listeners.push(setState);
+ return () => {
+ const index = listeners.indexOf(setState);
+ if (index > -1) {
+ listeners.splice(index, 1);
+ }
+ };
+ }, [state]);
+
+ return {
+ ...state,
+ toast,
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId })
+ };
+}
+
+export { useToast, toast };
diff --git a/src/pages/Config/index.tsx b/src/pages/Config/index.tsx
index ba8ebac6..d80680fd 100644
--- a/src/pages/Config/index.tsx
+++ b/src/pages/Config/index.tsx
@@ -7,12 +7,14 @@ import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.js";
import { useState } from "react";
import { useDevice } from "@app/core/stores/deviceStore.js";
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.js";
+import { useToast } from "@core/hooks/useToast.js";
export const ConfigPage = (): JSX.Element => {
const { workingConfig, workingModuleConfig, connection } = useDevice();
const [activeConfigSection, setActiveConfigSection] = useState<
"device" | "module"
>("device");
+ const { toast } = useToast();
return (
<>
@@ -42,11 +44,21 @@ export const ConfigPage = (): JSX.Element => {
async onClick() {
if (activeConfigSection === "device") {
workingConfig.map(
- async (config) => await connection?.setConfig(config)
+ async (config) =>
+ await connection?.setConfig(config).then(() =>
+ toast({
+ title: `Config ${config.payloadVariant.case} saved`
+ })
+ )
);
} else {
workingModuleConfig.map(
- async (config) => await connection?.setModuleConfig(config)
+ async (moduleConfig) =>
+ await connection?.setModuleConfig(moduleConfig).then(() =>
+ toast({
+ title: `Config ${moduleConfig.payloadVariant.case} saved`
+ })
+ )
);
}