main
ts 360 lines 14.8 KB
Raw
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2 // all content here is shared between client and server
3
4 import { PauseCircle, PlayCircle, Refresh, SvgIconComponent } from '@mui/icons-material'
5 import { SxProps } from '@mui/system'
6 import {
7 createElement as h, forwardRef, Fragment, ReactElement, ReactNode, useCallback, useEffect, useRef,
8 ForwardedRef, useState, useMemo, isValidElement, ElementType
9 } from 'react'
10 import { Box, BoxProps, ButtonProps, CircularProgress, IconButton, IconButtonProps, Link, LinkProps,
11 Tooltip, TooltipProps, useMediaQuery, Button } from '@mui/material'
12 import type { Breakpoint } from '@mui/material/styles'
13 import {
14 anyDialogOpen, closeDialog, formatPerc, callable, isIpLan, isIpLocalHost, prefix, WIKI_URL, with_, Functionable,
15 domOn, isMac,
16 } from './misc'
17 import { dontBotherWithKeys, restartAnimation, useBatch, useStateMounted } from '@hfs/shared'
18 import { mergeSx, Promisable, StringField } from '@hfs/mui-grid-form'
19 import { alertDialog, confirmDialog, toast } from './dialog'
20 import { Link as RouterLink, useLocation } from 'wouter'
21 import { SvgIconProps } from '@mui/material/SvgIcon/SvgIcon'
22 import _ from 'lodash'
23 import { ALL as COUNTRIES } from './countries'
24 import { apiCall } from '@hfs/shared/api'
25 import { StringFieldProps } from '@hfs/mui-grid-form/StringField'
26
27 export function spinner() {
28 return h(CircularProgress)
29 }
30
31 // return true if same size or larger
32 export function useBreakpoint(breakpoint: Breakpoint) {
33 return useMediaQuery((theme: any) => theme.breakpoints.up(breakpoint), { noSsr:true }) // without noSsr, first execution always returns false
34 }
35
36 // for debug purposes
37 export function useLogBreakpoint() {
38 const breakpoints = ['xl', 'lg', 'md', 'sm', 'xs'] as const
39 console.log('BREAKPOINT', breakpoints[_.findIndex(breakpoints.map(x => useBreakpoint(x)), x => x)])
40 }
41
42 // for debug purposes
43 export function useLogMount(name: string) {
44 useEffect(() => {
45 console.log('MOUNT', name)
46 return () => console.log('UNMOUNT', name)
47 }, [])
48 }
49
50 interface IconProgressProps {
51 icon: SvgIconComponent,
52 progress: number,
53 offset?: number,
54 sx?: SxProps,
55 title?: ReactNode
56 }
57 export function IconProgress({ icon, progress, offset, title, sx }: IconProgressProps) {
58 return h(Flex, { vert: true, center: true },
59 h(icon, { sx: { position:'absolute', ml: '4px' } }),
60 h(CircularProgress, {
61 value: progress * 100 || 0,
62 variant: 'determinate',
63 size: 32,
64 sx: { position: 'absolute' },
65 }),
66 hTooltip(title ?? (_.isNumber(progress) ? formatPerc(progress) : "Size unknown"), '',
67 h(CircularProgress, {
68 color: 'success',
69 value: (offset || 1e-7) * 100,
70 variant: 'determinate',
71 size: 32,
72 sx: mergeSx({ display: 'flex' }, sx), // workaround: without this the element has 0 width when the space is crammy (monitor/file)
73 }),
74 )
75 )
76 }
77
78 export { mergeSx }
79
80 type FlexProps = { vert?: boolean, center?: boolean, children?: ReactNode, props?: Omit<BoxProps, 'sx'>, component?: ElementType } & Record<string, any>
81 export function Flex({ vert=false, center=false, children=null, props={}, component, ...rest }: FlexProps) {
82 return h(Box as any, {
83 sx: {
84 display: 'flex',
85 gap: '.8em',
86 flexDirection: vert ? 'column' : undefined,
87 alignItems: vert ? undefined : 'center',
88 ...center && { justifyContent: 'center' },
89 ...rest,
90 } as any,
91 component,
92 ...props
93 }, children)
94 }
95
96
97 export function wikiLink(uri: string, content: ReactNode) {
98 if (Array.isArray(content))
99 content = dontBotherWithKeys(content)
100 return h(Link, { href: WIKI_URL + uri, target: 'help' }, content)
101 }
102
103 export function WildcardsSupported() {
104 return wikiLink('Wildcards', "Wildcards supported")
105 }
106
107 export function reloadBtn(onClick: any, props?: any) {
108 return h(IconBtn, { icon: Refresh, title: "Reload", onClick, ...props })
109 }
110
111 export function useCtrlShortcutButton(keys: readonly string[]) {
112 const ref = useRef<HTMLButtonElement>(null)
113 useEffect(() =>
114 domOn('keydown', ev => {
115 const key = (ev.ctrlKey || isMac && ev.metaKey) && ev.key.toLowerCase()
116 const btn = ref.current
117 if (!key || !btn || !keys.some(x => x.toLowerCase() === key)) return
118 ev.preventDefault() // capture at window level because focused widgets or body can bypass the page subtree
119 btn.click() // click the button so shortcuts reuse button loading, errors, and success animation
120 }, { capture: true })
121 , [keys.join('\n')])
122 return { ref }
123 }
124
125 // modify look to convey that a form has been modified
126 export function propsForModifiedValues(modified: boolean | undefined) {
127 return modified ? { sx: { outline: '2px solid', animation: '.5s blink 2' } } : undefined
128 }
129
130 // use ref.pass as prop
131 function useRefPass<T=unknown>(forwarded: ForwardedRef<any>) {
132 const ref = useRef<T | null>(null)
133 return Object.assign(ref, {
134 pass(el: T){
135 ref.current = el
136 if (_.isFunction(forwarded))
137 forwarded(el)
138 else if (forwarded)
139 forwarded.current = el
140 },
141 })
142 }
143
144 export interface IconBtnProps extends Omit<BtnProps, 'icon' | 'children'> { icon: SvgIconComponent }
145 export const IconBtn = forwardRef((props: IconBtnProps, ref: ForwardedRef<HTMLButtonElement>) =>
146 h(Btn, { ref, ...props }))
147
148 export interface BtnProps extends Omit<ButtonProps & IconButtonProps,'disabled'|'title'|'onClick'> {
149 icon?: SvgIconComponent | ReactElement<unknown>
150 title?: ReactNode
151 disabled?: boolean | string
152 progress?: boolean | number
153 link?: string
154 confirm?: boolean | ReactNode
155 labelIf?: Breakpoint | boolean
156 doneMessage?: boolean | string // displayed only if the result of onClick !== false
157 doneAnimation?: boolean
158 tooltipProps?: Partial<TooltipProps>
159 modified?: boolean
160 loading?: boolean | null
161 onClick?: (...args: Parameters<NonNullable<ButtonProps['onClick']>>) => Promisable<any>
162 }
163
164 export const Btn = forwardRef(({ icon, title, onClick, disabled, progress, link, tooltipProps, confirm, doneMessage,
165 doneAnimation, labelIf, children, modified, loading, ...rest }: BtnProps, forwarded: ForwardedRef<HTMLButtonElement>) => {
166 const [loadingState, setLoadingState] = useStateMounted(false)
167 if (typeof disabled === 'string')
168 title = disabled
169 disabled = progress || disabled ? true : undefined
170 if (link)
171 onClick = () => window.open(link)
172 const showLabel = useBreakpoint(_.isString(labelIf) ? labelIf : 'xs') && (_.isBoolean(labelIf) ? labelIf : true)
173 if (!showLabel && children)
174 title = children
175 const ref = useRefPass<HTMLButtonElement>(forwarded)
176 const common = _.merge(propsForModifiedValues(modified), {
177 ref: ref.pass,
178 disabled,
179 'aria-hidden': disabled,
180 async onClick(...args: any[]) {
181 if (loadingState) return
182 if (confirm && !await confirmDialog(confirm === true ? "Are you sure?" : confirm)) return
183 const ret = onClick?.apply(this, args as any)
184 if (ret instanceof Promise) {
185 setLoadingState(true)
186 ret.finally(()=> setLoadingState(false))
187 }
188 try {
189 if (await ret !== false)
190 execDoneMessage(doneMessage, doneAnimation && ref.current)
191 }
192 catch(e: any) { alertDialog(e) }
193 },
194 } as const, rest)
195 const iconElement = isValidElement(icon) ? icon : (icon && h(icon))
196 let ret: ReactElement = children && showLabel ? h(Button as any, _.merge({
197 // mui v6 moved LoadingButton behavior into Button, but current typings here still miss loading props
198 variant: 'contained',
199 startIcon: iconElement,
200 loading: Boolean(loading || loadingState || progress),
201 loadingPosition: icon && 'start',
202 loadingIndicator: typeof progress !== 'number' ? undefined
203 : h(CircularProgress, { size: '1rem', value: progress*100, variant: 'determinate' }),
204 children: showLabel && children,
205 } as const, common, (!showLabel || !children) && { sx: { minWidth: 'auto', px: 1, py: '7px', '& span': { mx:0 }, } }) as any)
206 : h(IconButton, _.merge(common, {
207 sx: { height: 'fit-content' }, TouchRippleProps: { 'aria-hidden': true },
208 // we need a direct accessible name on the actual clickable element for testing
209 'aria-label': !children || !showLabel ? rest['aria-label'] ?? (_.isString(title) ? title : undefined) : rest['aria-label'],
210 }),
211 (progress || loadingState) && progress !== false // false is also useful to inhibit behavior with loading
212 && h(CircularProgress, {
213 ...(typeof progress === 'number' ? { value: progress*100, variant: 'determinate' } : null),
214 style: { position:'absolute', top: '10%', left: '10%', width: '80%', height: '80%' }
215 }),
216 iconElement,
217 )
218
219 const aria = rest['aria-label']
220 ?? with_(_.isString(title) && title, x =>
221 x ? `${prefix('', _.isString(children) && children, '')}${x}` : undefined)
222 if (title) {
223 // Keep a stable tooltip anchor while `disabled` toggles, otherwise MUI may keep a stale anchorEl and warn.pc
224 ret = h('span', disabled ? { role: 'button', 'aria-label': aria, 'aria-disabled': true } : undefined, ret)
225 ret = hTooltip(title, aria, ret, tooltipProps)
226 }
227 return ret
228 })
229
230 export function execDoneMessage(msg: boolean | string | undefined, el?: HTMLElement | null | false) {
231 if (el)
232 restartAnimation(el, 'success .5s')
233 if (msg)
234 toast(msg === true ? "Operation completed" : msg, 'success')
235 }
236
237 export function iconTooltip(icon: SvgIconComponent, tooltip: ReactNode, sx?: SxProps, props?: SvgIconProps) {
238 return hTooltip(tooltip, undefined, h(icon, { sx, ...props }) )
239 }
240
241 // link for internal navigation
242 export function InLink({ to, ...props }: LinkProps & { to: `/${string}` }) {
243 // make links inside dialogs work correctly
244 const navigate = useLocation()[1]
245 props.onClickCapture = async ev => {
246 ev.preventDefault()
247 while (anyDialogOpen())
248 await closeDialog()?.closed
249 navigate(to)
250 }
251 return h(Link, { component: RouterLink, href: to, ...props })
252 }
253
254 export const Center = forwardRef(({ sx, ...props }: BoxProps, ref) =>
255 h(Box, {
256 ref,
257 sx: mergeSx({ display:'flex', height:'100%', width:'100%', justifyContent:'center', alignItems:'center', flexDirection: 'column' }, sx),
258 ...props
259 }))
260
261 // looks like a link, but it's a button
262 export function LinkBtn({ ...rest }: LinkProps) {
263 return h(Link, {
264 ...rest,
265 href: '',
266 sx: mergeSx({ cursor: 'pointer' }, rest.sx),
267 role: 'button',
268 onClick(ev) {
269 ev.preventDefault()
270 rest.onClick?.(ev)
271 }
272 })
273 }
274
275 export function usePauseButton(name='', def: ToggleButtonDefault=true, props?: Partial<IconBtnProps>) {
276 const [going, btn] = useToggleButton(`Pause ${name}`, `Resume ${name}`, v => ({
277 icon: v ? PauseCircle : PlayCircle,
278 sx: { rotate: v ? '180deg' : '0deg' },
279 ...props,
280 }), def)
281 return { pause: !going, pauseButton: btn }
282 }
283
284 type ToggleButtonDefault = Functionable<Promisable<boolean>>
285 export function useToggleButton(onTitle: string, offTitle: undefined | string, iconBtn: (state:boolean) => IconBtnProps, init: ToggleButtonDefault=false) {
286 const [state, setState] = useState<boolean>(init instanceof Promise || init instanceof Function ? (() => {
287 const x = callable(init)
288 if (!(x instanceof Promise))
289 return x
290 x.then(v => setState(v))
291 return false
292 }) : init)
293
294 const toggle = useCallback(() => setState(x => !x), [])
295 const props = iconBtn(state) // returned props should vary only with state
296 const el = useMemo(() => h(IconBtn, {
297 size: 'small',
298 color: state ? 'primary' : undefined,
299 title: state || offTitle === undefined ? onTitle : offTitle,
300 'aria-label': onTitle, // aria should be steady, and rely on aria-pressed
301 'aria-pressed': state,
302 ...props,
303 sx: mergeSx({ transition: 'all .5s' }, props.sx),
304 onClick(ev) {
305 props.onClick?.(ev)
306 toggle()
307 },
308 }), [state]) // memoize or tooltip flickers on mouse-over
309 return [state, el, setState] as const
310 }
311
312 export function NetmaskField({ setApi, helperText, ...props }: StringFieldProps) {
313 const warned = useRef(false)
314 setApi?.({
315 getError() {
316 return props.value && apiCall('validate_net_mask', { mask: props.value }).then(x => !x.result && "Invalid mask")
317 }
318 })
319 return h(StringField, {
320 helperText: h('span', {}, helperText, helperText && '', wikiLink('Wildcards#network-masks', "Wildcards supported")),
321 ...props,
322 onTyping(v) {
323 if (!warned.current && v?.includes('127.0.0.1') && !v.includes('::1')) {
324 warned.current = true
325 alertDialog(`Hostname "localhost" is normally translated as ::1 instead of 127.0.0.1`, 'warning')
326 }
327 return props.onTyping?.(v) ?? v
328 },
329 })
330 }
331
332 export function Country({ code, ip, def, long, short }: { code: string, ip?: string, def?: ReactNode, long?: boolean, short?: boolean }) {
333 const good = ip && !isIpLocalHost(ip) && !isIpLan(ip)
334 const { data } = useBatch(code === undefined && good && ip2countryBatch, ip, { delay: 100 }) // query if necessary
335 code ||= data || ''
336 const country = code && _.find(COUNTRIES, { code })
337 return !country ? h(Fragment, {}, def)
338 : hTooltip(long ? undefined : country.name, undefined, h('span', {},
339 h(Box as any, {
340 className: `fflag fflag-${code.toUpperCase()}`,
341 component: 'span',
342 sx: { mr: '.5em', verticalAlign: 'text-bottom' },
343 }),
344 long ? country.name + prefix(' (', short && code, ')') : code
345 ) )
346 }
347
348 async function ip2countryBatch(ips: string[]) {
349 const res = await apiCall('ip_country', { ips })
350 return res.codes as string[]
351 }
352
353 // force you to think of aria when adding a tooltip
354 export function hTooltip(title: ReactNode, ariaLabel: string | undefined, children: ReactElement, props?: Omit<TooltipProps, 'title' | 'children'> & { key?: any }) {
355 return h(Tooltip, { title, children,
356 ...(ariaLabel === '' ? { 'aria-hidden': true } : { 'aria-label': ariaLabel || _.isString(title) && title || undefined }),
357 slotProps: { popper: { sx: mergeSx({ whiteSpace: 'pre-wrap' }, props?.sx) } } as any,
358 ...props
359 })
360 }