Toasts and stuff

This commit is contained in:
Jan Zípek 2024-04-03 15:00:25 +02:00
parent e979a4dcb1
commit d1888d2c6c
Signed by: kamen
GPG Key ID: A17882625B33AC31
11 changed files with 241 additions and 20 deletions

55
client/src/App.tsx Normal file
View File

@ -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>
)
}

View File

@ -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 (
<QueryClientProvider client={queryClient}>
<AppContextProvider>
<ConfirmModalsContextProvider>
<Router />
</ConfirmModalsContextProvider>
</AppContextProvider>
</QueryClientProvider>
<ToastsContextProvider>
<App />
</ToastsContextProvider>
)
}

View File

@ -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;
}
}

View File

@ -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';

View File

@ -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) {

View File

@ -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
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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
}

View File

@ -270,6 +270,7 @@ export const useForm = <TValue extends BaseValue = BaseValue>({
) => {
return {
name,
id: name,
ref: handleInputRef,
onChange: (e: Event) => handleInputChange(e, options),
value: `${internalRef.current.value[name] ?? ''}`,

View File

@ -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
}