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