Toasts and stuff
This commit is contained in:
parent
e979a4dcb1
commit
d1888d2c6c
|
|
@ -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 (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AppContextProvider>
|
||||||
|
<ConfirmModalsContextProvider>
|
||||||
|
<Router />
|
||||||
|
</ConfirmModalsContextProvider>
|
||||||
|
</AppContextProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,24 +1,10 @@
|
||||||
import { QueryClient, QueryClientProvider } from 'react-query'
|
import { App } from './App'
|
||||||
import { AppContextProvider } from './contexts/AppContext'
|
import { ToastsContextProvider } from './contexts/ToastsContext/ToastsContext'
|
||||||
import { ConfirmModalsContextProvider } from './contexts/ConfirmModalsContext'
|
|
||||||
import { Router } from './pages/Router'
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Root = () => {
|
export const Root = () => {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<ToastsContextProvider>
|
||||||
<AppContextProvider>
|
<App />
|
||||||
<ConfirmModalsContextProvider>
|
</ToastsContextProvider>
|
||||||
<Router />
|
|
||||||
</ConfirmModalsContextProvider>
|
|
||||||
</AppContextProvider>
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,7 @@ a {
|
||||||
@import 'components/data-table';
|
@import 'components/data-table';
|
||||||
@import 'components/section-title';
|
@import 'components/section-title';
|
||||||
@import 'components/empty';
|
@import 'components/empty';
|
||||||
|
@import 'components/toasts';
|
||||||
|
|
||||||
@import 'pages/login-page';
|
@import 'pages/login-page';
|
||||||
@import 'pages/sensors-page';
|
@import 'pages/sensors-page';
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,10 @@
|
||||||
--input-disabled-fg-color: #000;
|
--input-disabled-fg-color: #000;
|
||||||
--input-disabled-bg-color: #fff;
|
--input-disabled-bg-color: #fff;
|
||||||
--input-hint-color: #444;
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
|
|
||||||
|
|
@ -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<ToastsContextType | null>(null)
|
||||||
|
|
||||||
|
export const ToastsContextProvider = ({ children }: Props) => {
|
||||||
|
const pushRef = useRef<ToastsContextType['push'] | null>(null)
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ push: (config: ToastConfig) => pushRef.current?.(config) }),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<Toasts pushRef={pushRef} />
|
||||||
|
</ToastsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useToasts = () => {
|
||||||
|
const context = useContext(ToastsContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useToasts must be used within a ToastsContextProvider')
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'toast',
|
||||||
|
config.variant && `variant-${config.variant}`,
|
||||||
|
fadingOut && 'out'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{config.message}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<ToastConfigWithUniqueId[]>([])
|
||||||
|
const [fading, setFading] = useState<Record<number, boolean | undefined>>({})
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="toasts">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<Toast key={t.id} config={t} fadingOut={fading[t.id]} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -270,6 +270,7 @@ export const useForm = <TValue extends BaseValue = BaseValue>({
|
||||||
) => {
|
) => {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
|
id: name,
|
||||||
ref: handleInputRef,
|
ref: handleInputRef,
|
||||||
onChange: (e: Event) => handleInputChange(e, options),
|
onChange: (e: Event) => handleInputChange(e, options),
|
||||||
value: `${internalRef.current.value[name] ?? ''}`,
|
value: `${internalRef.current.value[name] ?? ''}`,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue