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