| 1 | import { |
| 2 | dontBotherWithKeys, formatBytes, getHFS, hfsEvent, hIcon, newDialog, prefix, with_, working, |
| 3 | pathEncode, closeDialog, anyDialogOpen, Falsy, operationSuccessful, randomId, err2msg, HIDE_IN_TESTS |
| 4 | } from './misc' |
| 5 | import { createElement as h, Fragment, isValidElement, MouseEvent, ReactNode, useState } from 'react' |
| 6 | import { Btn, Bytes, Spinner } from './components' |
| 7 | import _ from 'lodash' |
| 8 | import { getEntryIcon, MISSING_PERM } from './BrowseFiles' |
| 9 | import { DirEntry, state } from './state' |
| 10 | import { deleteFiles } from './menu' |
| 11 | import { Link, LinkProps } from 'wouter' |
| 12 | import { fileShow, getShowComponent } from './show' |
| 13 | import { alertDialog, promptDialog, toast } from './dialog' |
| 14 | import { apiCall, useApi } from '@hfs/shared/api' |
| 15 | import { inputComment } from './upload' |
| 16 | import { cut } from './clip' |
| 17 | import { loginDialog } from './login' |
| 18 | import { useInterval } from 'usehooks-ts' |
| 19 | import i18n from './i18n' |
| 20 | import { frontEndApis } from '../../src/frontEndApis' |
| 21 | const { t, useI18N } = i18n |
| 22 | |
| 23 | interface FileMenuEntry { |
| 24 | id?: string |
| 25 | label: ReactNode |
| 26 | subLabel?: ReactNode |
| 27 | href?: string |
| 28 | icon?: string |
| 29 | toggled?: boolean |
| 30 | onClick?: (ev:MouseEvent) => any |
| 31 | } |
| 32 | |
| 33 | export async function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (Falsy | FileMenuEntry | 'open' | 'delete' | 'show')[]) { |
| 34 | const { uri, isFolder, s } = entry |
| 35 | const canRead = !entry.p?.includes('r') |
| 36 | const canList = !entry.p?.match(/L/i) |
| 37 | const forbidden = entry.cantOpen === DirEntry.FORBIDDEN |
| 38 | const cantDownload = forbidden || isFolder && !(canRead && entry.canArchive() && canList) // folders needs list+read+archive |
| 39 | const menu = [ |
| 40 | !cantDownload && { id: 'download', label: t`Download`, href: uri + (isFolder ? '?get=zip' : '?dl'), icon: 'download', target: '_blank' }, |
| 41 | state.props?.can_comment && { id: 'comment', label: t`Comment`, icon: 'comment', onClick: () => editComment(entry) }, |
| 42 | ...addToMenu.map(x => { |
| 43 | if (x === 'open') { |
| 44 | if (forbidden) return |
| 45 | const open = { |
| 46 | id: 'open', |
| 47 | icon: 'play', |
| 48 | label: t('file_open', "Open"), |
| 49 | href: uri, |
| 50 | target: isFolder || entry.web ? undefined : '_blank', |
| 51 | rel: isFolder || entry.web ? undefined : 'noopener noreferrer', |
| 52 | onClick: makeOnClickOpen(entry) |
| 53 | } |
| 54 | return !isFolder || open.onClick ? open : h(LinkClosingDialog, { to: uri, reloadDocument: entry.web }, hIcon(open.icon), open.label) |
| 55 | } |
| 56 | if (x === 'delete') |
| 57 | return entry.canDelete() && { |
| 58 | id: 'delete', |
| 59 | label: t`Delete`, |
| 60 | icon: 'delete', |
| 61 | onClick: () => deleteFiles([entry.uri]) |
| 62 | } |
| 63 | if (x === 'show') |
| 64 | return !entry.cantOpen && getShowComponent(entry) && { |
| 65 | id: 'show', |
| 66 | label: t`Show`, |
| 67 | icon: 'image', |
| 68 | onClick: () => fileShow(entry) |
| 69 | } |
| 70 | return x |
| 71 | }), |
| 72 | entry.canDelete() && { id: 'rename', label: t`Rename`, icon: 'edit', onClick: () => rename(entry) }, |
| 73 | entry.canDelete() && { id: 'cut', label: t`Cut`, icon: 'cut', onClick: () => close(cut([entry])) }, |
| 74 | isFolder && !entry.web && !entry.cantOpen && { id: 'list', label: t`Get list`, href: uri + '?get=list&folders=*', icon: 'list', target: '_blank' }, |
| 75 | ].filter(Boolean) |
| 76 | const folder = entry.n.slice(0, -1 - entry.name.length) |
| 77 | const props = [ |
| 78 | { id: 'name', label: t`Name`, value: entry.name }, |
| 79 | typeof s === 'number' && { id: 'size', label: t`Size`, |
| 80 | value: h(Fragment, {}, formatBytes(s), h('small', { className: HIDE_IN_TESTS }, prefix(' (', s > getHFS().kb && s.toLocaleString(), ')')) ) }, |
| 81 | entry.m && { id: 'timestamp', label: t`Timestamp`, value: entry.m.toLocaleString() }, |
| 82 | entry.c && { id: 'creation', label: t`Creation`, value: entry.c.toLocaleString() }, |
| 83 | folder && { |
| 84 | id: 'folder', |
| 85 | label: t`Folder`, |
| 86 | value: h(Link, { |
| 87 | href: (folder.startsWith('/') ? '' : location.pathname) + pathEncode(folder) + '/', |
| 88 | onClick: () => closeDialog(null, true) |
| 89 | }, folder.replaceAll('/', ' / ')) |
| 90 | }, |
| 91 | isFolder && !entry.cantOpen && { id: 'folderSize', label: t`Size`, value: h(FolderSize) }, |
| 92 | ].filter(Boolean) |
| 93 | const res = await Promise.all(hfsEvent('fileMenu', { entry, menu, props })) |
| 94 | menu.push(...res.flat().filter(Boolean)) // flat because each plugin may return an array of entries |
| 95 | _.remove(menu, (x, i) => _.find(menu, y => x.id ? x.id === y.id : (!y.id && x.label === y.label), i + 1)) // avoid duplicates, keeping later ones |
| 96 | const ico = getEntryIcon(entry) |
| 97 | const { close } = newDialog({ |
| 98 | title: isFolder ? t`Folder menu` : t`File menu`, |
| 99 | className: 'file-dialog', |
| 100 | icon: () => ico, |
| 101 | position: Math.min(innerWidth, innerHeight) < 800 ? undefined |
| 102 | : [ev.pageX, ev.pageY - scrollY] as [number, number], |
| 103 | restoreFocus: ev.screenY || ev.screenX ? false : undefined, |
| 104 | Content() { |
| 105 | const {t} = useI18N() |
| 106 | const { data } = useApi<typeof frontEndApis.get_file_details>('get_file_details', { uris: [entry.uri] }) |
| 107 | const details = data?.details?.[0] |
| 108 | const showProps = [ ...props, |
| 109 | with_(renderUploaderFromDetails(details), value => |
| 110 | value && { id: 'uploader', label: t`Uploader`, value }) |
| 111 | ] |
| 112 | return h(Fragment, {}, |
| 113 | h('dl', { className: 'file-dialog-properties' }, |
| 114 | dontBotherWithKeys(showProps.map(prop => isValidElement(prop) ? prop |
| 115 | : _.isPlainObject(prop) ? h('div', { id: `menu-prop-${prop.id}` }, h('dt', {}, prop.label), h('dd', {}, prop.value)) |
| 116 | : null |
| 117 | )), |
| 118 | entry.cantOpen && h('div', {}, hIcon('password', { style: { marginRight: '.5em', marginTop: '.5em' } }), t(MISSING_PERM)), |
| 119 | ), |
| 120 | h('div', { className: 'file-menu' }, |
| 121 | dontBotherWithKeys(menu.map((entry: FileMenuEntry, i) => // render menu entries |
| 122 | isValidElement(entry) ? entry |
| 123 | : entry?.label && h('a', { |
| 124 | key: i, |
| 125 | href: '#', |
| 126 | ..._.omit(entry, ['label', 'icon', 'toggled']), |
| 127 | id: entry.id && `menu-entry-${entry.id}`, |
| 128 | className: entry.toggled ? 'toggled' : undefined, |
| 129 | async onClick(ev: MouseEvent) { |
| 130 | if (!entry.href) |
| 131 | ev.preventDefault() |
| 132 | try { |
| 133 | if (false !== await entry.onClick?.(ev)) |
| 134 | close() |
| 135 | } |
| 136 | catch(e: any) { |
| 137 | alertDialog(e) |
| 138 | } |
| 139 | } |
| 140 | }, |
| 141 | hIcon(entry.icon || 'file'), |
| 142 | h('label', { style: { display: 'flex', flexDirection: 'column' } }, |
| 143 | h('div', {}, entry.label), |
| 144 | h('small', {}, entry.subLabel) ) |
| 145 | ) |
| 146 | )) |
| 147 | ) |
| 148 | ) |
| 149 | } |
| 150 | }) |
| 151 | |
| 152 | function FolderSize() { |
| 153 | const [go, setGo] = useState(false) |
| 154 | const [id] = useState(() => randomId()) |
| 155 | const { data, error, loading } = useApi(go && 'get_folder_size', { uri: entry.uri, id }, { timeout: false }) |
| 156 | const partial = useApi(loading && 'get_folder_size_partial', { id }) |
| 157 | useInterval(partial.reload, 1000) |
| 158 | return showRes(data || error) |
| 159 | || (!loading ? h(Btn, { asText: true, label: t`Calculate`, onClick() { setGo(true) } }) |
| 160 | : h('span', {}, showRes(partial.data), ' ', |
| 161 | h(Btn, { asText: true, label: t`Cancel`, icon: h(Spinner), onClick() { setGo(false) } }) ) |
| 162 | ) |
| 163 | |
| 164 | function showRes(data: any) { |
| 165 | return data && ( |
| 166 | data.code ? err2msg(data) |
| 167 | : h('span', {}, h(Bytes, _.pick(data,'bytes')), |
| 168 | ' / ', t('n_files', { n: data.files.toLocaleString() }, '{n,plural,one{# file} other{# files}}'), |
| 169 | ' / ', t('n_folders', { n: data.folders.toLocaleString() }, '{n,plural,one{# folder} other{# folders}}'), |
| 170 | ) |
| 171 | ) |
| 172 | } |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | async function rename(entry: DirEntry) { |
| 177 | const dest = await promptDialog(t`Name`, { |
| 178 | value: entry.name, |
| 179 | title: t`Rename`, |
| 180 | onField: el => el.setSelectionRange(0, entry.name.lastIndexOf('.')) |
| 181 | }) |
| 182 | if (!dest) return |
| 183 | const { n, uri } = entry |
| 184 | await apiCall('rename', { uri, dest }, { modal: working }) |
| 185 | const MSG = t`Operation successful` |
| 186 | if (uri === location.pathname) //current folder |
| 187 | return alertDialog(MSG)?.then(() => |
| 188 | getHFS().navigate(uri + '../' + pathEncode(dest) + '/') ) |
| 189 | // update state instead of re-getting the list |
| 190 | const newN = n.replace(/(.*?)[^/]+$/, (_,before) => before + dest) |
| 191 | const newEntry = new DirEntry(newN, { key: n, ...entry }) // by keeping the old key, we avoid unmounting the element, that's causing focus lost |
| 192 | const i = _.findIndex(state.list, { n }) |
| 193 | state.list[i] = newEntry |
| 194 | // update filteredList too |
| 195 | const j = _.findIndex(state.filteredList, { n }) |
| 196 | if (j >= 0) |
| 197 | state.filteredList![j] = newEntry |
| 198 | toast(MSG, 'success') |
| 199 | } |
| 200 | |
| 201 | async function editComment(entry: DirEntry) { |
| 202 | const res = await inputComment(entry.name, entry.comment) |
| 203 | if (res === undefined) return |
| 204 | await apiCall('comment', { uri: entry.uri, comment: res }, { modal: working }) |
| 205 | // location is fully encoded, while entry.uri uses pathEncode |
| 206 | if (decodeURI(entry.uri) === decodeURI(location.pathname)) // is current folder = is from breadcrumb |
| 207 | _.set(state, 'props.comment', res) |
| 208 | else |
| 209 | updateEntry(entry, e => e.comment = res) |
| 210 | operationSuccessful() |
| 211 | } |
| 212 | |
| 213 | function updateEntry(entry: DirEntry, cb: (e: DirEntry) => unknown) { |
| 214 | cb(_.find(state.list, { n: entry.n })!) |
| 215 | } |
| 216 | |
| 217 | export function LinkClosingDialog({ to, reloadDocument, ...props }: LinkProps & { reloadDocument?: boolean }) { |
| 218 | return reloadDocument ? h('a', { ...props, href: to }) : h(Link, { |
| 219 | ...props, |
| 220 | href: '', // workaround to get dialogs and browser-history work correctly |
| 221 | async onClick(ev: MouseEvent) { |
| 222 | ev.preventDefault() |
| 223 | while (anyDialogOpen()) |
| 224 | await closeDialog()?.closed |
| 225 | getHFS().navigate(to) |
| 226 | } |
| 227 | }) |
| 228 | } |
| 229 | |
| 230 | export function makeOnClickOpen(entry: DirEntry) { |
| 231 | return !entry.cantOpen ? undefined : async (ev: any) => { |
| 232 | ev.preventDefault() |
| 233 | if (entry.cantOpen === DirEntry.FORBIDDEN) |
| 234 | return alertDialog(t`Forbidden`, 'warning') |
| 235 | if (!await loginDialog(true, false)) return // if we get here, login is required, and we can go on only if it succeeds |
| 236 | if (entry.isFolder && !entry.web) // internal navigation |
| 237 | return setTimeout(() => getHFS().navigate(entry.uri)) // couldn't find the reason why navigating sync is reverted, so setTimeout |
| 238 | location.href = entry.uri |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | function renderUploaderFromDetails(details: any) { |
| 243 | if (!details) return |
| 244 | const { upload: u } = details |
| 245 | return u && `${u.username||''}${prefix('@', u.ip)}` |
| 246 | } |