mirror of
https://github.com/woodchen-ink/clash-and-dashboard.git
synced 2025-07-18 05:51:56 +08:00
Feature: add log-level toggle button in log page (#94)
Co-authored-by: MetaCubeX <maze.y2b@github.com> Co-authored-by: GyDi <segydi@foxmail.com> Co-authored-by: Dreamacro <8615343+Dreamacro@users.noreply.github.com>
This commit is contained in:
parent
a928672ba1
commit
e252f85aa8
@ -1,115 +1,86 @@
|
||||
import classnames from 'classnames'
|
||||
import { useRef, useLayoutEffect, useState, useMemo, ReactElement, Children, cloneElement } from 'react'
|
||||
import { useRef, useState, useMemo, useLayoutEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { Icon } from '@components'
|
||||
import { noop } from '@lib/helper'
|
||||
import { BaseComponentProps } from '@models'
|
||||
|
||||
import './style.scss'
|
||||
|
||||
type OptionValue = string | number
|
||||
export interface SelectOptions<T extends string | number> {
|
||||
label: string
|
||||
value: T
|
||||
disabled?: boolean
|
||||
key?: React.Key
|
||||
}
|
||||
|
||||
interface SelectProps extends BaseComponentProps {
|
||||
interface SelectProps<T extends string | number> extends BaseComponentProps {
|
||||
/**
|
||||
* selected value
|
||||
* must match one of options
|
||||
*/
|
||||
value: OptionValue
|
||||
value: T
|
||||
|
||||
children?: ReactElement
|
||||
options: Array<SelectOptions<T>>
|
||||
|
||||
onSelect?: (value: OptionValue, e: React.MouseEvent<HTMLLIElement>) => void
|
||||
onSelect?: (value: T, e: React.MouseEvent<HTMLLIElement>) => void
|
||||
}
|
||||
|
||||
export function Select (props: SelectProps) {
|
||||
const { value, onSelect, children, className: cn, style } = props
|
||||
export function Select<T extends string | number> (props: SelectProps<T>) {
|
||||
const { value, options, onSelect, className: cn, style } = props
|
||||
|
||||
const portalRef = useRef<HTMLDivElement>(document.createElement('div'))
|
||||
const attachmentRef = useRef<HTMLDivElement>(null)
|
||||
const portalRef = useRef(document.createElement('div'))
|
||||
const targetRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [showDropDownList, setShowDropDownList] = useState(false)
|
||||
const [hasCreateDropList, setHasCreateDropList] = useState(false)
|
||||
const dropdownListStyles = useMemo(() => {
|
||||
if (targetRef.current != null) {
|
||||
const targetRectInfo = targetRef.current.getBoundingClientRect()
|
||||
return {
|
||||
top: Math.floor(targetRectInfo.top) - 10,
|
||||
left: Math.floor(targetRectInfo.left) - 10,
|
||||
}
|
||||
}
|
||||
return {}
|
||||
const [dropdownListStyles, setDropdownListStyles] = useState<React.CSSProperties>({})
|
||||
useLayoutEffect(() => {
|
||||
const targetRectInfo = targetRef.current!.getBoundingClientRect()
|
||||
setDropdownListStyles({
|
||||
top: Math.floor(targetRectInfo.top + targetRectInfo.height) + 6,
|
||||
left: Math.floor(targetRectInfo.left) - 10,
|
||||
})
|
||||
}, [])
|
||||
|
||||
function handleGlobalClick (e: MouseEvent) {
|
||||
const el = attachmentRef.current
|
||||
|
||||
if (el?.contains(e.target as Node)) {
|
||||
setShowDropDownList(false)
|
||||
}
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const current = portalRef.current
|
||||
document.body.appendChild(current)
|
||||
document.addEventListener('click', handleGlobalClick, true)
|
||||
return () => {
|
||||
document.addEventListener('click', handleGlobalClick, true)
|
||||
document.body.removeChild(current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
function handleShowDropList () {
|
||||
if (!hasCreateDropList) {
|
||||
setHasCreateDropList(true)
|
||||
}
|
||||
setShowDropDownList(true)
|
||||
setShowDropDownList(!showDropDownList)
|
||||
}
|
||||
|
||||
const matchChild = useMemo(() => {
|
||||
let matchChild: React.ReactElement | null = null
|
||||
|
||||
Children.forEach(children, (child) => {
|
||||
if (child?.props?.value === value) {
|
||||
matchChild = child
|
||||
}
|
||||
})
|
||||
|
||||
return matchChild as React.ReactElement | null
|
||||
}, [value, children])
|
||||
|
||||
const hookedChildren = useMemo(() => {
|
||||
return Children.map(children ?? [], child => {
|
||||
if (!child.props || !child.type) {
|
||||
return child
|
||||
}
|
||||
|
||||
// add classname for selected option
|
||||
const className = child.props.value === value
|
||||
? classnames(child.props.className, 'selected')
|
||||
: child.props.className
|
||||
|
||||
// hook element onclick event
|
||||
const rawOnClickEvent = child.props.onClick
|
||||
return cloneElement(child, Object.assign({}, child.props, {
|
||||
onClick: (e: React.MouseEvent<HTMLLIElement>) => {
|
||||
onSelect?.(child.props.value, e)
|
||||
setShowDropDownList(false)
|
||||
rawOnClickEvent?.(e)
|
||||
},
|
||||
className,
|
||||
}))
|
||||
})
|
||||
}, [children, value, onSelect])
|
||||
const matchChild = useMemo(
|
||||
() => options.find(o => o.value === value),
|
||||
[value, options],
|
||||
)
|
||||
|
||||
const dropDownList = (
|
||||
<div
|
||||
className={classnames('select-list', { 'select-list-show': showDropDownList })}
|
||||
ref={attachmentRef}
|
||||
style={dropdownListStyles}
|
||||
>
|
||||
<ul className="list">
|
||||
{ hookedChildren }
|
||||
{
|
||||
options.map(option => (
|
||||
<Option
|
||||
className={classnames({ selected: option.value === value })}
|
||||
onClick={e => {
|
||||
onSelect?.(option.value, e)
|
||||
setShowDropDownList(false)
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
key={option.key ?? option.value}
|
||||
value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
@ -122,28 +93,25 @@ export function Select (props: SelectProps) {
|
||||
ref={targetRef}
|
||||
onClick={handleShowDropList}
|
||||
>
|
||||
{matchChild?.props?.children}
|
||||
{matchChild?.label}
|
||||
<Icon type="triangle-down" />
|
||||
</div>
|
||||
{
|
||||
hasCreateDropList && createPortal(dropDownList, portalRef.current)
|
||||
}
|
||||
{createPortal(dropDownList, portalRef.current)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface OptionProps extends BaseComponentProps {
|
||||
key: React.Key
|
||||
value: OptionValue
|
||||
interface OptionProps<T> extends BaseComponentProps {
|
||||
value: T
|
||||
disabled?: boolean
|
||||
onClick?: (e: React.MouseEvent<HTMLLIElement>) => void
|
||||
}
|
||||
|
||||
export function Option (props: OptionProps) {
|
||||
const { className: cn, style, key, disabled = false, children, onClick = noop } = props
|
||||
function Option<T> (props: OptionProps<T>) {
|
||||
const { className: cn, style, disabled = false, children, onClick = noop } = props
|
||||
const className = classnames('option', { disabled }, cn)
|
||||
|
||||
return (
|
||||
<li className={className} style={style} key={key} onClick={onClick}>{children}</li>
|
||||
<li className={className} style={style} onClick={onClick}>{children}</li>
|
||||
)
|
||||
}
|
||||
|
@ -16,22 +16,16 @@
|
||||
position: absolute;
|
||||
max-width: 170px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 5px rgba($color: $color-gray-dark, $alpha: 0.5);
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
transform: scaleY(0);
|
||||
transform-origin: top;
|
||||
transition: all 200ms linear;
|
||||
transition: all 200ms ease;
|
||||
|
||||
.list {
|
||||
opacity: 0;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
background: $color-white;
|
||||
padding: 5px 0;
|
||||
transform: scaleY(2);
|
||||
transform-origin: top;
|
||||
transition: all 200ms linear;
|
||||
transition: all 200ms ease;
|
||||
|
||||
> .option {
|
||||
color: $color-primary-darken;
|
||||
@ -52,11 +46,12 @@
|
||||
}
|
||||
|
||||
.select-list-show {
|
||||
opacity: 1;
|
||||
pointer-events: visible;
|
||||
transform: scaleY(1);
|
||||
box-shadow: 0 2px 5px rgba($color: $color-gray-dark, $alpha: 0.5);
|
||||
|
||||
.list {
|
||||
opacity: 1;
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useIntersectionObserver, useSyncedRef } from '@react-hookz/web/esm'
|
||||
import { useIntersectionObserver, useSyncedRef, useUnmountEffect } from '@react-hookz/web/esm'
|
||||
import { useTableInstance, createTable, getSortedRowModelSync, getColumnFilteredRowModelSync, getCoreRowModelSync } from '@tanstack/react-table'
|
||||
import classnames from 'classnames'
|
||||
import produce from 'immer'
|
||||
@ -53,6 +53,7 @@ export default function Connections () {
|
||||
const { translation, lang } = useI18n()
|
||||
const t = useMemo(() => translation('Connections').t, [translation])
|
||||
const connStreamReader = useConnectionStreamReader()
|
||||
const readerRef = useSyncedRef(connStreamReader)
|
||||
const client = useClient()
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@ -156,9 +157,11 @@ export default function Connections () {
|
||||
connStreamReader?.subscribe('data', handleConnection)
|
||||
return () => {
|
||||
connStreamReader?.unsubscribe('data', handleConnection)
|
||||
connStreamReader?.destory()
|
||||
}
|
||||
}, [connStreamReader, feed, setTraffic])
|
||||
useUnmountEffect(() => {
|
||||
readerRef.current?.destory()
|
||||
})
|
||||
|
||||
const instance = useTableInstance(table, {
|
||||
data,
|
||||
|
@ -1,17 +1,34 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { camelCase } from 'lodash-es'
|
||||
import { useLayoutEffect, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Card, Header } from '@components'
|
||||
import { Select, Card, Header } from '@components'
|
||||
import { Log } from '@models/Log'
|
||||
import { useI18n, useLogsStreamReader } from '@stores'
|
||||
import { useConfig, useI18n, useLogsStreamReader } from '@stores'
|
||||
|
||||
import './style.scss'
|
||||
|
||||
const logLevelOptions = [
|
||||
{ label: 'Default', value: '' },
|
||||
{ label: 'Debug', value: 'debug' },
|
||||
{ label: 'Info', value: 'info' },
|
||||
{ label: 'Warn', value: 'warning' },
|
||||
{ label: 'Error', value: 'error' },
|
||||
{ label: 'Silent', value: 'silent' },
|
||||
]
|
||||
const logMap = new Map([
|
||||
['debug', 'text-teal-500'],
|
||||
['info', 'text-sky-500'],
|
||||
['warning', 'text-pink-500'],
|
||||
['error', 'text-rose-500'],
|
||||
])
|
||||
|
||||
export default function Logs () {
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
const logsRef = useRef<Log[]>([])
|
||||
const [logs, setLogs] = useState<Log[]>([])
|
||||
const { translation } = useI18n()
|
||||
const { data: { logLevel }, set: setConfig } = useConfig()
|
||||
const { t } = translation('Logs')
|
||||
const logsStreamReader = useLogsStreamReader()
|
||||
const scrollHeightRef = useRef(listRef.current?.scrollHeight ?? 0)
|
||||
@ -35,21 +52,29 @@ export default function Logs () {
|
||||
logsRef.current = logsStreamReader.buffer()
|
||||
setLogs(logsRef.current)
|
||||
}
|
||||
|
||||
return () => logsStreamReader?.unsubscribe('data', handleLog)
|
||||
}, [logsStreamReader])
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<Header title={ t('title') } />
|
||||
<Header title={ t('title') } >
|
||||
<span className="text-sm text-primary-darken mr-2">{t('levelLabel')}:</span>
|
||||
<Select
|
||||
options={logLevelOptions}
|
||||
value={camelCase(logLevel)}
|
||||
onSelect={level => setConfig(c => { c.logLevel = level })}
|
||||
/>
|
||||
</Header>
|
||||
|
||||
<Card className="flex flex-col flex-1 mt-2.5 md:mt-4">
|
||||
<ul className="logs-panel" ref={listRef}>
|
||||
{
|
||||
logs.map(
|
||||
(log, index) => (
|
||||
<li className="leading-5 inline-block" key={index}>
|
||||
<span className="mr-4 text-gray-400 text-opacity-90">{ dayjs(log.time).format('YYYY-MM-DD HH:mm:ss') }</span>
|
||||
<span>[{ log.type }] { log.payload }</span>
|
||||
<li className="leading-5 inline-block text-[11px]" key={index}>
|
||||
<span className="mr-2 text-orange-400">[{ dayjs(log.time).format('YYYY-MM-DD HH:mm:ss') }]</span>
|
||||
<span className={logMap.get(log.type)}>[{ log.type.toUpperCase() }]</span>
|
||||
<span> { log.payload }</span>
|
||||
</li>
|
||||
),
|
||||
)
|
||||
|
@ -41,6 +41,7 @@ const EN = {
|
||||
},
|
||||
Logs: {
|
||||
title: 'Logs',
|
||||
levelLabel: 'Log level',
|
||||
},
|
||||
Rules: {
|
||||
title: 'Rules',
|
||||
|
@ -41,6 +41,7 @@ const CN = {
|
||||
},
|
||||
Logs: {
|
||||
title: '日志',
|
||||
levelLabel: '日志等级',
|
||||
},
|
||||
Rules: {
|
||||
title: '规则',
|
||||
|
@ -1,11 +1,7 @@
|
||||
import EventEmitter from 'eventemitter3'
|
||||
import { ResultAsync } from 'neverthrow'
|
||||
import { SetRequired } from 'type-fest'
|
||||
|
||||
export interface Config {
|
||||
url: string
|
||||
useWebsocket: boolean
|
||||
token?: string
|
||||
bufferLength?: number
|
||||
retryInterval?: number
|
||||
}
|
||||
@ -17,30 +13,25 @@ export class StreamReader<T> {
|
||||
|
||||
protected innerBuffer: T[] = []
|
||||
|
||||
protected isClose = false
|
||||
protected url = ''
|
||||
|
||||
protected connection: WebSocket | null = null
|
||||
|
||||
constructor (config: Config) {
|
||||
this.config = Object.assign(
|
||||
{
|
||||
bufferLength: 0,
|
||||
retryInterval: 5000,
|
||||
headers: {},
|
||||
},
|
||||
config,
|
||||
)
|
||||
|
||||
this.config.useWebsocket
|
||||
? this.websocketLoop()
|
||||
: this.loop()
|
||||
}
|
||||
|
||||
protected websocketLoop () {
|
||||
const url = new URL(this.config.url)
|
||||
url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:'
|
||||
url.searchParams.set('token', this.config.token ?? '')
|
||||
protected connectWebsocket () {
|
||||
const url = new URL(this.url)
|
||||
|
||||
const connection = new WebSocket(url.toString())
|
||||
connection.addEventListener('message', msg => {
|
||||
this.connection = new WebSocket(url.toString())
|
||||
this.connection.addEventListener('message', msg => {
|
||||
const data = JSON.parse(msg.data)
|
||||
this.EE.emit('data', [data])
|
||||
if (this.config.bufferLength > 0) {
|
||||
@ -51,59 +42,20 @@ export class StreamReader<T> {
|
||||
}
|
||||
})
|
||||
|
||||
connection.addEventListener('close', () => setTimeout(this.websocketLoop, this.config.retryInterval))
|
||||
connection.addEventListener('error', err => {
|
||||
this.connection.addEventListener('error', err => {
|
||||
this.EE.emit('error', err)
|
||||
setTimeout(this.websocketLoop, this.config.retryInterval)
|
||||
this.connection?.close()
|
||||
setTimeout(this.connectWebsocket, this.config.retryInterval)
|
||||
})
|
||||
}
|
||||
|
||||
protected async loop () {
|
||||
const result = await ResultAsync.fromPromise(fetch(
|
||||
this.config.url,
|
||||
{
|
||||
mode: 'cors',
|
||||
headers: this.config.token ? { Authorization: `Bearer ${this.config.token}` } : {},
|
||||
},
|
||||
), e => e as Error)
|
||||
if (result.isErr()) {
|
||||
this.retry(result.error)
|
||||
return
|
||||
} else if (result.value.body == null) {
|
||||
this.retry(new Error('fetch body error'))
|
||||
connect (url: string) {
|
||||
if (this.url === url && this.connection) {
|
||||
return
|
||||
}
|
||||
|
||||
const reader = result.value.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
while (true) {
|
||||
if (this.isClose) {
|
||||
break
|
||||
}
|
||||
|
||||
const result = await ResultAsync.fromPromise(reader?.read(), e => e as Error)
|
||||
if (result.isErr()) {
|
||||
this.retry(result.error)
|
||||
break
|
||||
}
|
||||
|
||||
const lines = decoder.decode(result.value.value).trim().split('\n')
|
||||
const data = lines.map(l => JSON.parse(l))
|
||||
this.EE.emit('data', data)
|
||||
if (this.config.bufferLength > 0) {
|
||||
this.innerBuffer.push(...data)
|
||||
if (this.innerBuffer.length > this.config.bufferLength) {
|
||||
this.innerBuffer.splice(0, this.innerBuffer.length - this.config.bufferLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected retry (err: Error) {
|
||||
if (!this.isClose) {
|
||||
this.EE.emit('error', err)
|
||||
window.setTimeout(() => { this.loop() }, this.config.retryInterval)
|
||||
}
|
||||
this.url = url
|
||||
this.connection?.close()
|
||||
this.connectWebsocket()
|
||||
}
|
||||
|
||||
subscribe (event: string, callback: (data: T[]) => void) {
|
||||
@ -120,6 +72,7 @@ export class StreamReader<T> {
|
||||
|
||||
destory () {
|
||||
this.EE.removeAllListeners()
|
||||
this.isClose = true
|
||||
this.connection?.close()
|
||||
this.connection = null
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { usePreviousDistinct, useSyncedRef } from '@react-hookz/web/esm'
|
||||
import { AxiosError } from 'axios'
|
||||
import produce from 'immer'
|
||||
import { atom, useAtom } from 'jotai'
|
||||
import { atom, useAtom, useAtomValue } from 'jotai'
|
||||
import { atomWithImmer } from 'jotai/immer'
|
||||
import { atomWithStorage, useUpdateAtom } from 'jotai/utils'
|
||||
import { get } from 'lodash-es'
|
||||
import { ResultAsync } from 'neverthrow'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { Get } from 'type-fest'
|
||||
|
||||
@ -86,6 +87,7 @@ export function useRuleProviders () {
|
||||
|
||||
export const configAtom = atomWithStorage('profile', {
|
||||
breakConnections: false,
|
||||
logLevel: '',
|
||||
})
|
||||
|
||||
export function useConfig () {
|
||||
@ -244,49 +246,44 @@ export function useRule () {
|
||||
return { rules: data, update }
|
||||
}
|
||||
|
||||
const logsAtom = atom({
|
||||
key: '',
|
||||
instance: null as StreamReader<Log> | null,
|
||||
})
|
||||
const logsAtom = atom(new StreamReader<Log>({ bufferLength: 200 }))
|
||||
|
||||
export function useLogsStreamReader () {
|
||||
const apiInfo = useAPIInfo()
|
||||
const { general } = useGeneral()
|
||||
const version = useVersion()
|
||||
const [item, setItem] = useAtom(logsAtom)
|
||||
const { data: { logLevel } } = useConfig()
|
||||
const item = useAtomValue(logsAtom)
|
||||
|
||||
if (!version.version || !general.logLevel) {
|
||||
return null
|
||||
}
|
||||
const level = logLevel || general.logLevel
|
||||
const previousKey = usePreviousDistinct(
|
||||
`${apiInfo.protocol}//${apiInfo.hostname}:${apiInfo.port}/logs?level=${level}&secret=${apiInfo.secret}`,
|
||||
)
|
||||
|
||||
const useWebsocket = !!version.version || true
|
||||
const key = `${apiInfo.protocol}//${apiInfo.hostname}:${apiInfo.port}/logs?level=${general.logLevel ?? ''}&useWebsocket=${useWebsocket}&secret=${apiInfo.secret}`
|
||||
if (item.key === key) {
|
||||
return item.instance!
|
||||
}
|
||||
const apiInfoRef = useSyncedRef(apiInfo)
|
||||
|
||||
const oldInstance = item.instance
|
||||
useEffect(() => {
|
||||
if (level) {
|
||||
const apiInfo = apiInfoRef.current
|
||||
const protocol = apiInfo.protocol === 'http:' ? 'ws:' : 'wss:'
|
||||
const logUrl = `${protocol}//${apiInfo.hostname}:${apiInfo.port}/logs?level=${level}&token=${apiInfo.secret}`
|
||||
item.connect(logUrl)
|
||||
}
|
||||
}, [apiInfoRef, item, level, previousKey])
|
||||
|
||||
const logUrl = `${apiInfo.protocol}//${apiInfo.hostname}:${apiInfo.port}/logs?level=${general.logLevel ?? ''}`
|
||||
const instance = new StreamReader<Log>({ url: logUrl, bufferLength: 200, token: apiInfo.secret, useWebsocket })
|
||||
setItem({ key, instance })
|
||||
|
||||
if (oldInstance != null) {
|
||||
oldInstance.destory()
|
||||
}
|
||||
|
||||
return instance
|
||||
return item
|
||||
}
|
||||
|
||||
export function useConnectionStreamReader () {
|
||||
const apiInfo = useAPIInfo()
|
||||
const version = useVersion()
|
||||
|
||||
const useWebsocket = !!version.version || true
|
||||
const connection = useRef(new StreamReader<Snapshot>({ bufferLength: 200 }))
|
||||
|
||||
const url = `${apiInfo.protocol}//${apiInfo.hostname}:${apiInfo.port}/connections`
|
||||
return useMemo(
|
||||
() => version.version ? new StreamReader<Snapshot>({ url, bufferLength: 200, token: apiInfo.secret, useWebsocket }) : null,
|
||||
[apiInfo.secret, url, useWebsocket, version.version],
|
||||
)
|
||||
const protocol = apiInfo.protocol === 'http:' ? 'ws:' : 'wss:'
|
||||
const url = `${protocol}//${apiInfo.hostname}:${apiInfo.port}/connections?token=${apiInfo.secret}`
|
||||
|
||||
useEffect(() => {
|
||||
connection.current.connect(url)
|
||||
}, [url])
|
||||
|
||||
return connection.current
|
||||
}
|
||||
|
@ -11,6 +11,11 @@ export default defineConfig({
|
||||
red: '#f56c6c',
|
||||
green: '#67c23a',
|
||||
},
|
||||
textColor: {
|
||||
primary: {
|
||||
darken: '#54759a',
|
||||
},
|
||||
},
|
||||
textShadow: {
|
||||
primary: '0 0 6px rgb(44 138 248 / 40%)',
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user