diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..4b0d35b --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,55 @@ +import { useMemo } from 'preact/hooks' +import { QueryClient, QueryClientProvider } from 'react-query' +import { AppContextProvider } from './contexts/AppContext' +import { ConfirmModalsContextProvider } from './contexts/ConfirmModalsContext' +import { useToasts } from './contexts/ToastsContext/ToastsContext' +import { Router } from './pages/Router' +import { RequestError } from './api/request' + +export const App = () => { + const toasts = useToasts() + + const queryClient = useMemo( + () => + new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, + mutations: { + onError: async (error) => { + if (error instanceof RequestError) { + const responseText = await error.response.text() + + try { + const response = JSON.parse(responseText) + + toasts.push({ + message: response.error ?? responseText, + variant: 'error', + }) + } catch (e) { + toasts.push({ message: responseText, variant: 'error' }) + } + + return + } + + toasts.push({ message: String(error), variant: 'error' }) + }, + }, + }, + }), + [toasts.push] + ) + + return ( + + + + + + + + ) +} diff --git a/client/src/Root.tsx b/client/src/Root.tsx index 2756746..e960028 100644 --- a/client/src/Root.tsx +++ b/client/src/Root.tsx @@ -1,24 +1,10 @@ -import { QueryClient, QueryClientProvider } from 'react-query' -import { AppContextProvider } from './contexts/AppContext' -import { ConfirmModalsContextProvider } from './contexts/ConfirmModalsContext' -import { Router } from './pages/Router' - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - }, - }, -}) +import { App } from './App' +import { ToastsContextProvider } from './contexts/ToastsContext/ToastsContext' export const Root = () => { return ( - - - - - - - + + + ) } diff --git a/client/src/assets/components/_toasts.scss b/client/src/assets/components/_toasts.scss new file mode 100644 index 0000000..e42de6e --- /dev/null +++ b/client/src/assets/components/_toasts.scss @@ -0,0 +1,56 @@ +.toasts { + position: fixed; + bottom: 2rem; + left: 50%; + transform: translate(-50%, 0); + + .toast { + background-color: var(--toast-bg-color); + color: var(--toast-fg-color); + padding: 0.25rem 0.5rem; + border-radius: var(--border-radius); + max-width: 90%; + width: 25rem; + box-shadow: var(--box-shadow); + margin-top: 1rem; + animation-name: toastIn; + animation-duration: 200ms; + animation-iteration-count: 1; + + &.variant-error { + background-color: var(--toast-error-bg-color); + color: var(--toast-error-fg-color); + } + + &.out { + animation-name: toastOut; + animation-duration: 200ms; + animation-iteration-count: 1; + animation-fill-mode: forwards; + } + } +} + +@keyframes toastIn { + from { + transform: translate(0, 100%); + opacity: 0; + } + + to { + transform: translate(0, 0); + opacity: 1; + } +} + +@keyframes toastOut { + from { + transform: translate(0, 0); + opacity: 1; + } + + to { + transform: translate(0, -100%); + opacity: 0; + } +} diff --git a/client/src/assets/style.scss b/client/src/assets/style.scss index 9e6f62e..8c850a4 100644 --- a/client/src/assets/style.scss +++ b/client/src/assets/style.scss @@ -48,6 +48,7 @@ a { @import 'components/data-table'; @import 'components/section-title'; @import 'components/empty'; +@import 'components/toasts'; @import 'pages/login-page'; @import 'pages/sensors-page'; diff --git a/client/src/assets/themes/_basic.scss b/client/src/assets/themes/_basic.scss index 281d5f1..90ab581 100644 --- a/client/src/assets/themes/_basic.scss +++ b/client/src/assets/themes/_basic.scss @@ -55,6 +55,10 @@ --input-disabled-fg-color: #000; --input-disabled-bg-color: #fff; --input-hint-color: #444; + --toast-bg-color: #eee; + --toast-fg-color: #666; + --toast-error-bg-color: rgb(153, 14, 14); + --toast-error-fg-color: #fff; } @media (prefers-color-scheme: dark) { diff --git a/client/src/contexts/ToastsContext/ToastsContext.tsx b/client/src/contexts/ToastsContext/ToastsContext.tsx new file mode 100644 index 0000000..db7a40d --- /dev/null +++ b/client/src/contexts/ToastsContext/ToastsContext.tsx @@ -0,0 +1,36 @@ +import { ComponentChildren, createContext } from 'preact' +import { useContext, useMemo, useRef } from 'preact/hooks' +import { Toasts } from './components/Toasts' +import { ToastConfig, ToastsContextType } from './types' + +type Props = { + children: ComponentChildren +} + +const ToastsContext = createContext(null) + +export const ToastsContextProvider = ({ children }: Props) => { + const pushRef = useRef(null) + + const value = useMemo( + () => ({ push: (config: ToastConfig) => pushRef.current?.(config) }), + [] + ) + + return ( + + {children} + + + ) +} + +export const useToasts = () => { + const context = useContext(ToastsContext) + + if (!context) { + throw new Error('useToasts must be used within a ToastsContextProvider') + } + + return context +} diff --git a/client/src/contexts/ToastsContext/components/Toast.tsx b/client/src/contexts/ToastsContext/components/Toast.tsx new file mode 100644 index 0000000..0e191d1 --- /dev/null +++ b/client/src/contexts/ToastsContext/components/Toast.tsx @@ -0,0 +1,21 @@ +import { cn } from '@/utils/cn' +import { ToastConfig } from '../types' + +type Props = { + config: ToastConfig + fadingOut?: boolean +} + +export const Toast = ({ config, fadingOut }: Props) => { + return ( +
+ {config.message} +
+ ) +} diff --git a/client/src/contexts/ToastsContext/components/Toasts.tsx b/client/src/contexts/ToastsContext/components/Toasts.tsx new file mode 100644 index 0000000..187d3ca --- /dev/null +++ b/client/src/contexts/ToastsContext/components/Toasts.tsx @@ -0,0 +1,46 @@ +import { useState, useCallback, MutableRef } from 'preact/hooks' +import { ToastConfigWithUniqueId, ToastConfig } from '../types' +import { Toast } from './Toast' + +type Props = { + pushRef: MutableRef<((message: ToastConfig) => void) | null> +} + +let ID_COUNTER = 1 + +export const Toasts = ({ pushRef }: Props) => { + const [toasts, setToasts] = useState([]) + const [fading, setFading] = useState>({}) + + const push = useCallback((config: ToastConfig) => { + const item = { ...config, id: ID_COUNTER++ } + const timeout = config.timeoutInMs ?? 5000 + + setToasts((prev) => [...prev, item]) + + setTimeout(() => { + setFading((prev) => ({ ...prev, [item.id]: true })) + }, Math.max(0, timeout - 500)) + + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== item.id)) + + setFading((prev) => { + const result = { ...prev } + delete result[item.id] + + return result + }) + }, timeout) + }, []) + + pushRef.current = push + + return ( +
+ {toasts.map((t) => ( + + ))} +
+ ) +} diff --git a/client/src/contexts/ToastsContext/types.ts b/client/src/contexts/ToastsContext/types.ts new file mode 100644 index 0000000..dfdd13b --- /dev/null +++ b/client/src/contexts/ToastsContext/types.ts @@ -0,0 +1,15 @@ +import { ComponentChildren } from 'preact' + +export type ToastConfig = { + message: ComponentChildren + timeoutInMs?: number + variant?: 'success' | 'info' | 'error' +} + +export type ToastConfigWithUniqueId = ToastConfig & { + id: number +} + +export type ToastsContextType = { + push: (config: ToastConfig) => void +} diff --git a/client/src/utils/hooks/useForm.tsx b/client/src/utils/hooks/useForm.tsx index af6d054..70e614a 100644 --- a/client/src/utils/hooks/useForm.tsx +++ b/client/src/utils/hooks/useForm.tsx @@ -270,6 +270,7 @@ export const useForm = ({ ) => { return { name, + id: name, ref: handleInputRef, onChange: (e: Event) => handleInputChange(e, options), value: `${internalRef.current.value[name] ?? ''}`, diff --git a/server/routes/mqtt_brokers.go b/server/routes/mqtt_brokers.go index e1279fd..6e7cff9 100644 --- a/server/routes/mqtt_brokers.go +++ b/server/routes/mqtt_brokers.go @@ -137,7 +137,7 @@ func PostMQTTBrokerPublish(s *app.Server) gin.HandlerFunc { } if err := s.Services.MQTTBrokers.PublishTopic(brokerId, body.Topic, body.Message, qos, retain); err != nil { - c.AbortWithError(http.StatusInternalServerError, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return }