main
ts 546 lines 26.3 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
3 import { markVfsModified, prepareVfsUndo, state, useSnapState } from './state'
4 import { createElement as h, forwardRef, memo, ReactElement, ReactNode, useEffect, useMemo, useState } from 'react'
5 import { Alert, Box, Collapse, FormHelperText, Link, MenuItem, MenuList, useTheme } from '@mui/material'
6 import {
7 BoolField, DisplayField, Field, FieldProps, Form, MultiSelectField, NumberField, SelectField, StringField
8 } from '@hfs/mui-grid-form'
9 import { apiCall, UseApi, useApiEx } from './api'
10 import {
11 basename, defaultPerms, formatBytes, formatTimestamp, isWhoObject, newDialog, useRequestRender, try_, pathEncode,
12 onlyTruthy, prefix, VfsPerms, wantArray, WhoVfs, WhoObject, matches, xlate, md, Callback, copyTextToClipboard,
13 splitAt, IMAGE_FILEMASK, CFG, MASK_IN_TESTS, WHO_ANY_ACCOUNT, WHO_ADMIN, WHO_NO_ONE, WHO_ANYONE, stringBefore,
14 ipForUrl
15 } from './misc'
16 import { isModifiedConfig } from './AccountForm'
17 import { Btn, Flex, IconBtn, LinkBtn, propsForModifiedValues, useBreakpoint, wikiLink } from './mui'
18 import { deleteVfs, getInheritedPerms, id2vfsNode, reindexVfs, VfsNodeAdmin } from './VfsPage'
19 import _ from 'lodash'
20 import FileField from './FileField'
21 import { alertDialog, toast, useDialogBarColors } from './dialog'
22 import yaml from 'yaml'
23 import {
24 Check, ContentCopy, ContentCut, ContentPaste, Delete, Edit, QrCode2, Save, RestartAlt
25 } from '@mui/icons-material'
26 import { moveVfs } from './VfsTree'
27 import QrCreator from 'qr-creator'
28 import { AddVfsBtn } from './VfsMenuBar'
29 import { SYS_ICONS } from '@hfs/frontend/src/sysIcons'
30 import { TextEditorField } from './TextEditor'
31 import { account2icon } from './AccountsPage'
32 import apiAccounts from '../../src/api.accounts'
33
34 const ACCEPT_LINK = "https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept"
35
36 interface FileFormProps {
37 file: VfsNodeAdmin
38 addToBar?: ReactNode
39 statusApi: UseApi
40 accountsApi: AccountsApi
41 saved: Callback
42 isSideBreakpoint: boolean
43 }
44 export default function FileForm({ file, addToBar, statusApi, accountsApi, saved, isSideBreakpoint }: FileFormProps) {
45 const { parent, children, isRoot, byMasks, ...rest } = file
46 const [values, setValues] = useState(rest)
47 useEffect(() => {
48 setValues(Object.assign(_.mapValues(defaultPerms, () => null), rest))
49 }, [file]) //eslint-disable-line
50
51 const inheritedDefault = useMemo(() => {
52 let p = file.parent
53 while (p) {
54 if (p.default != null)
55 return p.default
56 p = p.parent
57 }
58 }, [file])
59 const { source } = file
60 const isDir = file.type === 'folder'
61 const isUnknown = !file.type && source && file.size! < 0 // the type is lost
62 const isLink = values.url !== undefined
63 const hasSource = source !== undefined // we need a boolean
64 const realFolder = hasSource && isDir
65 const xl = useBreakpoint('xl')
66 const showTimestamps = !isLink && (xl || hasSource)
67 const showSize = !isLink && xl || (hasSource && !realFolder)
68 const showAccept = file.accept! > '' || isDir && (file.can_upload ?? file.inherited?.can_upload)
69 const showWebsite = isDir
70 const barColors = useDialogBarColors()
71 const { movingFile } = useSnapState()
72
73 const needSourceWarning = !hasSource && h(Box as any, { sx: { color: 'warning.main' }, component: 'span' }, "Works only on folders with disk source! ")
74 const show: Record<keyof VfsPerms, boolean> = {
75 can_read: !isLink,
76 can_see: true,
77 can_archive: !isLink,
78 can_list: isDir,
79 can_upload: isDir,
80 can_delete: isDir,
81 }
82 const defaultIcon = !values.icon
83 const embeddedIcon = values.icon && !values.icon.includes('.')
84 const nameFromSource = source && basename(source)
85 const nameIsDerivedFromSource = nameFromSource === values.name
86 return h(Form, {
87 values,
88 set(v, k) {
89 setValues(values => {
90 // updating the source, if the name is virtual, we must update that too
91 if (k === 'source' && nameIsDerivedFromSource)
92 values.name = basename(v)
93 return { ...values, [k]: v }
94 })
95 },
96 barSx: { gap: 2, width: '100%', ...barColors },
97 stickyBar: true,
98 addToBar: [
99 isDir && !isSideBreakpoint && h(AddVfsBtn, { variant: 'outlined' }, "Add"),
100 h(IconBtn, {
101 icon: ContentCut,
102 disabled: isRoot || movingFile === file.id,
103 title: "Cut (you can also use drag & drop to move items)",
104 'aria-label': "Cut",
105 onClick() {
106 state.movingFile = file.id
107 alertDialog(h(Box, {}, "Now that this is marked for moving, click on the destination folder, and then the paste button ", h(ContentPaste)), 'info')
108 },
109 }),
110 movingFile && h(IconBtn, {
111 icon: ContentPaste,
112 disabled: file.type !== 'folder'
113 || file.id.startsWith(movingFile) // can't move below myself
114 || file.id === movingFile.replace(/[^/]+\/?$/,''), // can't move to the same parent
115 title: movingFile,
116 async onClick() {
117 if (moveVfs(movingFile, file.id))
118 state.movingFile = ''
119 },
120 }),
121 h(IconBtn, {
122 icon: Delete,
123 title: "Delete",
124 disabled: isRoot,
125 onClick() {
126 deleteVfs([file.id])
127 saved()
128 },
129 }),
130 ...wantArray(addToBar)
131 ],
132 onError: alertDialog,
133 save: {
134 ...propsForModifiedValues(isModifiedConfig(values, rest)),
135 children: "Apply",
136 startIcon: h(Check),
137 async onClick() {
138 const node = state.selectedFiles[0] || id2vfsNode.get(values.id)
139 if (!node)
140 throw Error("Selected node not found")
141 const props = _.omit(values, ['birthtime','mtime','size','id'])
142 const wasId = node.id
143 prepareVfsUndo()
144 Object.assign(node, props)
145 if (props.name !== undefined)
146 reindexVfs({ node, clearMap: false, select: [node] })
147 if (node.id !== wasId)
148 setValues(v => ({ ...v, id: node.id }))
149 markVfsModified()
150 saved()
151 }
152 },
153 fields: [
154 isRoot ? h(Alert, { severity: 'info' }, "This is the Home folder, the root of your shared files. Options set here will be applied to all files.")
155 : isDir && hasSource && h(Alert, { severity: 'info' }, `To set permissions on individual items in folder, add them by clicking Add button, and then "from disk"`),
156 {
157 k: 'name', required: true, xl: true, helperText: hasSource && "You can decide a name that's different from the one on your disk",
158 ...isRoot && { disabled: true, value: "Home folder" },
159 end: nameFromSource && !nameIsDerivedFromSource && h(Btn, {
160 icon: RestartAlt, title: "Reset to same name on disk",
161 onClick: () => setValues({ ...values, name: nameFromSource })
162 }),
163 },
164 isLink ? { k: 'url', label: "URL", lg: 12, xl: 8, required: true }
165 : { k: 'source', label: "Disk source", xl: true, comp: FileField, files: isUnknown || !isDir, folders: isUnknown || isDir,
166 placeholder: "none",
167 helperText: !values.source ? "If you enter a path here, its content will be listed. Leaving this empty, makes this folder fully virtual."
168 : isDir ? "Files from this path on disk will be listed, but you can add more" : undefined,
169 },
170 { k: 'id', comp: LinkField, statusApi, xs: 12 },
171 { k: 'order', comp: NumberField, min: -1E5, max: 1E5, label: "Priority (order in the frontend)", placeholder: 'default', sm: 4, helperText: wikiLink('Virtual-file-system#order', "To force position") },
172 {
173 k: 'iconType',
174 comp: SelectField,
175 options: ['default', 'file', 'embedded'],
176 value: !values.icon ? 'default' : embeddedIcon ? 'embedded' : 'file',
177 fromField: v => setValues({ ...values, icon: v === 'default' ? '' : v === 'file' ? 'select.a.file' : Object.keys(SYS_ICONS)[0] }),
178 xs: true,
179 sm: defaultIcon ? 8 : true,
180 },
181 !defaultIcon && { k: 'icon', xs: 8, sm: 4,
182 ...embeddedIcon ? {
183 comp: SelectField, // uniqBy to avoid same icon (with different names), but it works only on array, so first step is to convert the object
184 options: _.map(_.uniqBy(_.map(SYS_ICONS, (v,k) => [k, v[0], v[1] ?? k] as const), x => x[2]), ([k, emoji]) =>
185 ({ value: k, label: h(Flex, { gap: '.5em' }, hIcon(k), hIcon(emoji), ' ', k) }) ), // show both font-icon and emoji versions
186 helperText: "The second icon is the fallback"
187 } : {
188 label: "Icon file", placeholder: "default", comp: FileField, fileMask: IMAGE_FILEMASK,
189 }
190 },
191 perm('can_read', "Who can see but not download will be asked to log in"),
192 perm('can_archive', "Should this be included when user downloads as ZIP"),
193 perm('can_list', "Permission to request the list of a folder. The list will include only things you can see.", { contentText: "subfolders" }),
194 perm('can_delete', [needSourceWarning, "Those who can delete can also rename and cut/move"]),
195 perm('can_upload', needSourceWarning, { contentText: "subfolders" }),
196 perm('can_see', ["See this item in the list. ", wikiLink('Permissions', "More help.")]),
197 isLink && {
198 k: 'target',
199 comp: BoolField,
200 sm: true,
201 label: "Open in new browser",
202 fromField: x => x ? '_blank' : null,
203 toField: x => x > '',
204 },
205 showSize && { k: 'size', comp: DisplayField, sm: 6, lg: 4, toField: formatBytes },
206 showTimestamps && { k: 'birthtime', comp: DisplayField, sm: 6, lg: showSize && 4, label: "Created", toField: formatTimestamp },
207 showTimestamps && { k: 'mtime', comp: DisplayField, sm: 6, lg: showSize && 4, label: "Modified", toField: formatTimestamp },
208 showAccept && { k: 'accept', label: "Accept on upload", placeholder: "anything", xl: showWebsite ? 4 : 12,
209 helperText: h('span', {}, "Not enforced, just hinting the browser. ", h(Link, { href: ACCEPT_LINK, target: '_blank' }, "Example: .zip")) },
210 showWebsite && { k: 'default', comp: BoolField, xl: showAccept ? 8 : 12,
211 label: "Serve as web-page if index.html is found" + (inheritedDefault && values.default == null ? ' (inherited)' : ''),
212 value: values.default ?? inheritedDefault,
213 toField: Boolean, fromField: (v:boolean) => v && !inheritedDefault ? 'index.html' : v ? null : false,
214 helperText: md("...instead of showing list of files")
215 },
216 { k: 'comment', multiline: true, xl: true },
217 isDir && { k: 'masks', multiline: true, xl: 6,
218 toField: yaml.stringify, fromField: v => v ? yaml.parse(v) : undefined,
219 comp: TextEditorField, lang: 'yaml',
220 helperText: ["Special field, leave empty unless you know what you are doing. YAML syntax. ", wikiLink('Masks-field', "(examples)")]
221 },
222 ]
223 })
224
225 function perm(perm: keyof VfsPerms, helperText?: ReactNode, props: Partial<WhoFieldProps>={}) {
226 if (!show[perm]) return null
227 const dontShow = [perm, ...onlyTruthy(_.map(show, (v,k) => !v && k))]
228 const others = _.difference(Object.keys(defaultPerms), dontShow)
229 // a freshly created node can be selected before `inherited` is filled by a server roundtrip
230 let inherit = file.inherited?.[perm] ?? getInheritedPerms(file)?.[perm] ?? defaultPerms[perm]
231 while (typeof inherit === 'string' && _.get(show, inherit) === false) // is 'inherit' referring to another permission that is not displayed?
232 inherit = _.get(values, inherit)
233 // non-permission who values (like WHO_ANY_ACCOUNT) are not valid keys for inherited lookup
234 ?? (inherit !== WHO_ANY_ACCOUNT && inherit !== WHO_ADMIN ? getInheritedPerms(file)?.[inherit] : undefined)
235 ?? _.get(defaultPerms, inherit)! // then show its value instead
236 return {
237 comp: WhoField,
238 k: perm, sm: 6, lg: 12, xl: 4,
239 parent, accountsApi, helperText, isDir,
240 otherPerms: others.map(x => ({ value: x, label: who2desc(x) })),
241 label: "Who can " + perm2word(perm),
242 inherit,
243 byMasks: byMasks?.[perm],
244 offerInheritance: true,
245 fromField: (v?: WhoVfs) => v ?? null,
246 ...props
247 }
248 }
249
250 }
251
252 function perm2word(perm: string) {
253 return xlate(perm.split('_')[1], { read: 'download', archive: 'zip', list: 'access list' })
254 }
255
256 type AccountsApi = ReturnType<typeof useAccountsApi>
257 export function useAccountsApi() {
258 return useApiEx<typeof apiAccounts.get_accounts>('get_accounts', {}, {
259 onResponse(_res, data) {
260 if (!data) return
261 data.list = _.sortBy(data.list, 'username')
262 }
263 })
264 }
265
266 interface WhoFieldProps extends FieldProps<WhoVfs | undefined> {
267 accountsApi?: AccountsApi,
268 otherPerms?: any[],
269 isChildren?: boolean,
270 isDir: boolean
271 contentText?: string
272 }
273 export function WhoField({ value, onChange, parent, inherit, accountsApi, helperText, otherPerms, byMasks,
274 hideValues, isChildren, isDir, contentText="folder content", setApi, offerInheritance, ...rest }: WhoFieldProps): ReactElement {
275 const defaultLabel = who2desc(byMasks ?? inherit)
276 + prefix(' (', byMasks !== undefined ? "from masks" : parent !== undefined ? "as parent folder" : "default", ')')
277 const objectMode = isWhoObject(value)
278 const thisValue = objectMode ? value.this : value
279 accountsApi ??= useAccountsApi() // it's important that the "accounts" prop is stable in the truthy sense
280 const accounts = accountsApi?.data?.list
281
282 const options = useMemo(() =>
283 onlyTruthy([
284 offerInheritance && { value: null, label: defaultLabel },
285 { value: WHO_NO_ONE },
286 { value: WHO_ANY_ACCOUNT },
287 { value: WHO_ADMIN },
288 { value: WHO_ANYONE },
289 ...otherPerms || [],
290 { value: [], label: "Select accounts" },
291 ].map(x => x && !hideValues?.includes(x.value)
292 && { label: who2desc(x.value), ...x })), // default label
293 [inherit, parent, thisValue, ...wantArray(hideValues)])
294
295 const timeout = 500
296 const arrayMode = Array.isArray(thisValue)
297 // a large sideband will convey union across the fields
298 return h(Box, { sx: { borderRight: objectMode ? '8px solid #8884' : undefined, transition: `all ${timeout}ms` } },
299 h(SelectField as typeof SelectField<typeof thisValue | null>, {
300 ...rest,
301 value: arrayMode ? [] : thisValue ?? null,
302 onChange(v, { event }) {
303 onChange(objectMode ? simplify({ ...value, this: v ?? undefined }) : v ?? undefined, { was: value, event })
304 },
305 options,
306 }),
307 h(Collapse, { in: arrayMode, timeout },
308 arrayMode && h(MultiSelectField as Field<string[]>, {
309 label: accounts?.length ? "Accounts " + rest.label : "You didn't create any account yet",
310 value: thisValue,
311 onChange,
312 options: accounts?.map(a => ({ value: a.username, label: a.username, a })) || [],
313 placeholder: "none",
314 ...thisValue.length === 0 && { helperText: "Select some account", error: true },
315 // show icon only for groups, to save space inside the field (not the list)
316 renderOption: (x: any) => h('span', {}, x.a?.isGroup && account2icon(x.a), ' ', x.label),
317 }) ),
318 h(FormHelperText, {},
319 helperText,
320 !isChildren && isDir && h(LinkBtn, {
321 sx: { display: 'block', mt: -.5 },
322 onClick(event) {
323 onChange(objectMode ? thisValue : { this: thisValue, children: thisValue == null ? !inherit : undefined } , { was: value, event })
324 }
325 }, objectMode ? "Set same permission for " : "Set different permission for ", contentText)
326 ),
327 !isChildren && h(Collapse, { in: objectMode, timeout },
328 h(WhoField, {
329 label: "Permission for " + contentText,
330 parent, inherit, accountsApi, otherPerms, isDir,
331 value: objectMode ? value?.children : undefined,
332 isChildren: true,
333 hideValues: [thisValue ?? inherit, thisValue],
334 onChange(v, { event }) {
335 if (objectMode) // shut up ts
336 onChange(simplify({ ...value, children: v }), { was: value, event })
337 }
338 })
339 ),
340 )
341
342 function simplify(v: WhoObject) {
343 return v.this === v.children ? v.this : v
344 }
345 }
346
347 function who2desc(who: any) {
348 return who === false ? "No one"
349 : who === true ? "Anyone"
350 : who === WHO_ANY_ACCOUNT ? "Any logged-in account"
351 : who === WHO_ADMIN ? "Any admin"
352 : Array.isArray(who) ? who.join(', ')
353 : typeof who === 'string' ? `As "can ${perm2word(who)}"`
354 : "*UNKNOWN*" + JSON.stringify(who)
355 }
356
357 interface LinkFieldProps extends FieldProps<string> {
358 statusApi: UseApi<any> // receive status from parent, to avoid asking server at each click on a file
359 }
360 function LinkField({ value, statusApi }: LinkFieldProps) {
361 const { reload, error } = statusApi
362 // workaround to get fresh data and be rerendered even when mounted inside imperative dialog
363 const requestRender = useRequestRender()
364 useEffect(() => statusApi.sub(requestRender), [])
365 const data = statusApi.getData()
366
367 const urls: string[] = data && (data.urls.https || data.urls.http || [data.base_url])
368 const baseHost = try_(() => new URL(data?.baseUrl).host) // URL can throw on malformed data
369 const roots = data?.roots || {}
370 const root = baseHost && _.find(roots, (_root, host) => matches(baseHost, host))
371 const originalValue = value
372 if (root)
373 value = pathInRoot(value, root)
374 let linkBase = data?.baseUrl || ''
375 if (value === undefined) { // baseUrl didn't match, but other hosts in roots may
376 const base = try_(() => new URL(linkBase))
377 if (base) {
378 const sorted = _.sortBy(Object.entries(roots), ([, root]) => -String(root).length) // prioritize longer roots because are more specific
379 for (const [hostMask, root] of sorted) {
380 if (typeof root !== 'string') continue
381 value = pathInRoot(originalValue, root)
382 const host = value && hostMask.split('|').find(x => x && !/[*?]/.test(x) && x !== baseHost)
383 if (!host) continue
384 linkBase = base.protocol + '//' + host
385 break
386 }
387 }
388 }
389 const link = prefix(linkBase, value)
390 const RenderLink = useMemo(() => forwardRef((props: any, ref) =>
391 h(Link, {
392 ref,
393 ...props,
394 href: link,
395 style: { height: 'auto', overflow: 'hidden', textOverflow: 'ellipsis' },
396 target: 'frontend',
397 }, link)
398 ), [link])
399 return h(Box, { sx: { display: 'flex' } },
400 !baseHost ? "Invalid baseUrl" : !urls ? 'error' : // check data is ok
401 h(DisplayField, {
402 label: "Link",
403 className: MASK_IN_TESTS,
404 value: link || `outside of configured main address (${baseHost})`,
405 error,
406 InputProps: link ? { inputComponent: RenderLink } : undefined,
407 end: h(Box, {},
408 h(IconBtn, {
409 icon: ContentCopy,
410 title: "Copy",
411 disabled: !link,
412 doneAnimation: true,
413 onClick: () => copyTextToClipboard(link)
414 }),
415 h(IconBtn, { icon: QrCode2, title: "QR Code", onClick: showQr, disabled: !link }),
416 h(IconBtn, { icon: Edit, title: "Change", onClick() { changeBaseUrl().then(reload) } }),
417 )
418 }),
419 )
420
421 function showQr() {
422 newDialog({
423 title: "QR Code",
424 dialogProps: { sx: { bgcolor: 'background.default', border: '1px solid' } },
425 Content() {
426 const theme = useTheme()
427 return h('canvas', {
428 ref: (canvas: HTMLCanvasElement) => canvas && generateQRCode(canvas, link, theme.palette.text.primary),
429 style: { width: '100%' },
430 })
431 }
432 })
433 }
434
435 async function generateQRCode(canvas: HTMLCanvasElement, text: string, color: string) {
436 try {
437 QrCreator.render({
438 text,
439 radius: 0.0, // 0.0 to 0.5
440 ecLevel: 'H', // L, M, Q, H
441 fill: color, // foreground color
442 background: null, // color or null for transparent
443 size: 300 // in pixels
444 }, canvas)
445 } catch (error) {
446 console.error('Error generating QR code:', error)
447 }
448 }
449
450 function pathInRoot(uri: string | undefined, root: string | undefined) {
451 if (!root || root === '/') return uri
452 root = pathEncode(root)
453 return uri?.startsWith(root, 1) ? uri.slice(root.length) : undefined
454 }
455 }
456
457 export async function changeBaseUrl() {
458 return new Promise(async resolve => {
459 const res = await apiCall('get_status')
460 const { base_url, roots } = await apiCall('get_config', { only: [CFG.base_url, CFG.roots] })
461 const urls: string[] = res.urls.https || res.urls.http
462 const domainsFromRoots = Object.keys(roots).map(x => x.split('|')).flat().filter(x => !/[*?]/.test(x))
463 const proto = splitAt('//', urls[0])[0] + '//'
464 urls.push(..._.difference(domainsFromRoots.map(x => proto + x), urls))
465 const { close } = newDialog({
466 title: "Main address",
467 Content() {
468 const [v, setV] = useState(base_url || '')
469 const proto = stringBefore('//', v || urls[0]) + '//'
470 const host = urls.includes(v) ? '' : v.slice(proto.length)
471 const check = h(Check, { sx: { ml: 2 } })
472 return h(Box, { sx: { display: 'flex', flexDirection: 'column' } },
473 h(Box, { sx: { mb: 2 } }, "Choose a main address for your links"),
474 h(MenuList, {},
475 h(MenuItem, {
476 selected: !v,
477 onClick: () => set(''),
478 }, "Automatic", !v && check),
479 urls.map(u => h(MenuItem, {
480 key: u,
481 selected: u === v,
482 onClick: () => set(u),
483 }, u, u === v && check))
484 ),
485 h(StringField, {
486 label: "Custom IP or domain",
487 helperText: md("You can type any address but *you* are responsible to make the address work.\nThis functionality is just to help you copy the link in case you have a domain or a complex network configuration."),
488 value: host,
489 onChange: v => set(prefix(proto, ipForUrl(v))),
490 start: h(SelectField as Field<string>, {
491 value: proto,
492 onChange: v => host ? set(v + host) : toast("Enter domain first"),
493 options: ['http://','https://'],
494 size: 'small',
495 variant: 'standard',
496 sx: { '& .MuiSelect-select': { pt: '1px', pb: 0 } },
497 }),
498 sx: { mt: 2 }
499 }),
500 h(Box, { sx: { mt: 2, textAlign: 'right' } },
501 h(Btn, {
502 icon: Save,
503 children: "Save",
504 async onClick() {
505 if (v !== base_url)
506 await apiCall('set_config', { values: { [CFG.base_url]: v.replace(/\/$/, '') } })
507 close()
508 resolve(v)
509 },
510 }) ),
511 )
512
513 function set(u: string) {
514 if (u.endsWith('/'))
515 u = u.slice(0, -1)
516 setV(u)
517 }
518 }
519 })
520 })
521 }
522
523
524 interface IconProps { name:string, className?:string, alt?:string, [rest:string]: any }
525 // name = null ? none : unicode ? unicode : "?" ? file_url : font_icon_class
526 const Icon = memo(({ name, alt, className='', ...props }: IconProps) => {
527 if (!name) return null
528 const [emoji, clazz=name] = SYS_ICONS[name] || []
529 className += ' icon'
530 const nameIsTheIcon = name.length === 1 ||
531 name.match(/^[\uD800-\uDFFF\u2600-\u27BF\u2B00-\u2BFF\u3030-\u303F\u3297\u3299\u00A9\u00AE\u200D\u20E3\uFE0F\u2190-\u21FF\u2300-\u23FF\u2400-\u243F\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF]*$/)
532 const nameIsUrl = !nameIsTheIcon && /[/?]/.test(name)
533 const isFontIcon = clazz
534 className += nameIsUrl ? ' file-icon' : isFontIcon ? ` font-icon fa-${clazz}` : ' emoji-icon'
535 return h('span',{
536 ...alt ? { 'aria-label': alt } : { 'aria-hidden': true },
537 role: 'img',
538 ...props,
539 ...nameIsUrl ? { style: { backgroundImage: `url(${JSON.stringify(name)})`, ...props?.style } } : undefined,
540 className,
541 }, nameIsTheIcon ? name : isFontIcon ? null : (emoji||'#'))
542 })
543
544 function hIcon(name: string, props?: Omit<IconProps, 'name'>) {
545 return h(Icon, { name, ...props })
546 }