| 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 { createElement as h, DragEvent, Fragment, useMemo, useState, useEffect, CSSProperties } from 'react' |
| 4 | import { Btn, Flex, FlexV, iconBtn, Select } from './components' |
| 5 | import { |
| 6 | basename, formatBytes, formatPerc, hIcon, useIsMobile, newDialog, selectFiles, working, copyTextToClipboard, |
| 7 | HTTP_CONFLICT, formatSpeed, getHFS, onlyTruthy, cpuSpeedIndex, closeDialog, prefix, operationSuccessful, pathEncode, |
| 8 | getPrefixUrl, |
| 9 | } from './misc' |
| 10 | import _ from 'lodash' |
| 11 | import { INTERNAL_Snapshot, ref, useSnapshot } from 'valtio' |
| 12 | import { alertDialog, promptDialog } from './dialog' |
| 13 | import { reloadList } from './useFetchList' |
| 14 | import { apiCall } from '@hfs/shared/api' |
| 15 | import { state, useSnapState } from './state' |
| 16 | import { Link } from 'wouter' |
| 17 | import { LinkClosingDialog } from './fileMenu' |
| 18 | import { |
| 19 | abortCurrentUpload, enqueueUpload, getFilePath, normalizeAccept, resetCounters, resetReloadOnClose, |
| 20 | simulateBrowserAccept, startUpload, ToUpload, uploadState |
| 21 | } from './uploadQueue' |
| 22 | import i18n from './i18n' |
| 23 | const { t } = i18n |
| 24 | |
| 25 | const renameEnabled = getHFS().dontOverwriteUploading |
| 26 | |
| 27 | export function showUpload() { |
| 28 | if (!uploadState.qs.length) |
| 29 | resetCounters() |
| 30 | uploadState.uploadDialogIsOpen = true |
| 31 | const { close } = newDialog({ |
| 32 | dialogProps: { id: 'upload-dialog', style: { minHeight: '6em', minWidth: 'min(20em, 100vw - 1em)' } }, |
| 33 | title: t`Upload`, |
| 34 | icon: () => hIcon('upload'), |
| 35 | Content, |
| 36 | onClose() { |
| 37 | uploadState.uploadDialogIsOpen = false |
| 38 | if (resetReloadOnClose()) |
| 39 | reloadList() |
| 40 | } |
| 41 | }) |
| 42 | |
| 43 | function clear() { |
| 44 | uploadState.adding.splice(0,Infinity) |
| 45 | } |
| 46 | |
| 47 | function Content(){ |
| 48 | const { qs, paused, eta, speed, adding } = useSnapshot(uploadState) as Readonly<typeof uploadState> |
| 49 | const { props, uploadOnExisting } = useSnapState() |
| 50 | const etaStr = useMemo(() => !eta || eta === Infinity ? '' : formatTime(eta*1000, 0, 2), [eta]) |
| 51 | const inQ = _.sumBy(qs, q => q.entries.length) - (uploadState.uploading ? 1 : 0) |
| 52 | const queueStr = inQ && t('in_queue', { n: inQ }, "{n} in queue") |
| 53 | const size = formatBytes(adding.reduce((a, x) => a + x.file.size, 0)) |
| 54 | const isMobile = useIsMobile() |
| 55 | |
| 56 | return h(FlexV, { gap: '.5em' }, |
| 57 | h(FlexV, { className: 'upload-toolbar' }, |
| 58 | props && !props.can_upload ? t('no_upload_here', "No upload permission for the current folder") |
| 59 | : h(FlexV, {}, |
| 60 | h(Flex, { center: true, flexWrap: 'wrap', alignItems: 'stretch' }, |
| 61 | h('button', { |
| 62 | className: 'upload-files', |
| 63 | onClick: () => pickFiles({ accept: normalizeAccept(props?.accept) }) |
| 64 | }, t`Pick files`), |
| 65 | !isMobile && h('button', { |
| 66 | className: 'upload-folder', |
| 67 | onClick: () => pickFiles({ folder: true }) |
| 68 | }, t`Pick folder`), |
| 69 | h('button', { className: 'create-folder', onClick: createFolder }, t`Create folder`), |
| 70 | h(Select<typeof uploadOnExisting>, { |
| 71 | style: { width: 'unset' }, |
| 72 | 'aria-label': t`Overwrite policy`, |
| 73 | value: uploadOnExisting || '', |
| 74 | onChange: v => state.uploadOnExisting = v, |
| 75 | options: onlyTruthy([ |
| 76 | { value: 'skip', label: t`Skip existing files` }, |
| 77 | renameEnabled && { value: 'rename', label: t`Rename to avoid overwriting` }, |
| 78 | props?.can_overwrite && { value: 'overwrite', label: t`Overwrite existing files` }, |
| 79 | ]) |
| 80 | }), |
| 81 | ), |
| 82 | !isMobile && h(Flex, { gap: 4 }, hIcon('info'), t('upload_dd_hint', "You can upload files by dragging and dropping them onto the file list")), |
| 83 | h(UploadStatus, { margin: '.5em 0' }), |
| 84 | adding.length > 0 && h(Flex, { center: true, flexWrap: 'wrap' }, |
| 85 | t('ready_to_upload', { n: adding.length, size }, "{n,plural,one{# file} other{# files}}, {size}, ready to upload"), |
| 86 | h(Flex, {}, // avoid just one button to wrap |
| 87 | h('button', { |
| 88 | className: 'upload-send', |
| 89 | onClick() { |
| 90 | void enqueueUpload(uploadState.adding) |
| 91 | clear() |
| 92 | } |
| 93 | }, t`Send`), |
| 94 | h('button', { onClick: clear }, t`Clear`), |
| 95 | ), |
| 96 | ) |
| 97 | ), |
| 98 | ), |
| 99 | h(FileList, { |
| 100 | entries: uploadState.adding, |
| 101 | actions: { |
| 102 | cancel: rec => _.remove(uploadState.adding, rec), |
| 103 | async comment(rec){ |
| 104 | if (!props?.can_comment) return |
| 105 | const s = await inputComment(basename(rec.file.name), rec.comment) |
| 106 | if (s === undefined) return |
| 107 | rec.comment = s || undefined |
| 108 | }, |
| 109 | async edit(rec) { |
| 110 | const was = rec.path |
| 111 | const s = await promptDialog(t('upload_name', "Upload with new name"), { |
| 112 | value: was, |
| 113 | onField: el => { |
| 114 | const ofs = was.lastIndexOf('/') + 1 // browsers picking a folder use / as separator even on Windows |
| 115 | const end = was.slice(ofs).lastIndexOf('.') |
| 116 | el.setSelectionRange(ofs, end < 0 ? was.length : ofs + end) |
| 117 | }, |
| 118 | }) |
| 119 | if (!s) return |
| 120 | rec.path = s |
| 121 | }, |
| 122 | }, |
| 123 | }), |
| 124 | qs.length > 0 && h('div', {}, |
| 125 | h(Flex, { center: true, borderTop: '1px dashed', padding: '.5em' }, |
| 126 | [etaStr, formatSpeed(speed), queueStr].filter(Boolean).join(', '), |
| 127 | inQ > 0 && iconBtn('delete', ()=> { |
| 128 | uploadState.qs = [] |
| 129 | abortCurrentUpload(true) |
| 130 | }, { title: t`Clear` }), |
| 131 | iconBtn(paused ? 'play' : 'pause', () => { |
| 132 | uploadState.paused = !uploadState.paused |
| 133 | if (uploadState.paused) |
| 134 | abortCurrentUpload() |
| 135 | else if (uploadState.uploading) |
| 136 | startUpload(uploadState.uploading, uploadState.qs[0].to) |
| 137 | }), |
| 138 | ), |
| 139 | qs.map((q,idx) => |
| 140 | h('div', { key: q.to }, |
| 141 | h(Link, { href: q.to, onClick: close }, t`Destination`, ' ', decodeURI(q.to)), |
| 142 | h(FileList, { |
| 143 | entries: uploadState.qs[idx].entries, |
| 144 | actions: { |
| 145 | cancel: f => { |
| 146 | if (f === uploadState.uploading) { |
| 147 | if (!uploadState.paused) |
| 148 | return abortCurrentUpload(true) |
| 149 | f.error = t`Interrupted` |
| 150 | uploadState.interrupted.push(f) |
| 151 | uploadState.uploading = undefined |
| 152 | } |
| 153 | const q = uploadState.qs[idx] |
| 154 | _.pull(q.entries, f) |
| 155 | if (!q.entries.length) |
| 156 | uploadState.qs.splice(idx,1) |
| 157 | } |
| 158 | } |
| 159 | }), |
| 160 | )) |
| 161 | ) |
| 162 | ) |
| 163 | |
| 164 | function pickFiles(options: Parameters<typeof selectFiles>[1]) { |
| 165 | selectFiles(list => { |
| 166 | uploadState.adding.push( ...Array.from(list || []).filter(simulateBrowserAccept) |
| 167 | .map(f => ({ file: ref(f), path: getFilePath(f) })) ) |
| 168 | }, options) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | } |
| 173 | |
| 174 | function FileList({ entries, actions }: { entries: ToUpload[], actions: { [icon:string]: null | ((rec :ToUpload) => any) } }) { |
| 175 | const { uploading, progress, partial, hashing } = useSnapshot(uploadState) |
| 176 | const snapEntries = useSnapshot(entries) |
| 177 | const [all, setAll] = useState(false) |
| 178 | useEffect(() => setAll(false), [entries.length]) |
| 179 | const MAX = all ? Infinity : _.round(_.clamp(100 * cpuSpeedIndex, 10, 100)) |
| 180 | const rest = Math.max(0, snapEntries.length - MAX) |
| 181 | const title = formatPerc(progress) |
| 182 | return !snapEntries.length ? null : h('table', { className: 'upload-list', width: '100%' }, |
| 183 | h('tbody', {}, |
| 184 | snapEntries.slice(0, MAX).map((e, i) => { |
| 185 | const working = e.file === uploading?.file // e is a proxy, so we check 'file' as it's a ref |
| 186 | return h(Fragment, { key: i }, |
| 187 | h('tr', {}, |
| 188 | h('td', { className: 'nowrap upload-list-actions' }, |
| 189 | h('span', { className: 'upload-list-inline-actions' }, ..._.map(actions, (cb, icon) => |
| 190 | cb && iconBtn(icon, () => cb(entries[i]), { className: `action-${icon}` })) ), |
| 191 | iconBtn('menu', () => openUploadActions(entries[i], actions), { className: 'upload-list-menu-button' }), |
| 192 | ), |
| 193 | h('td', { className: 'upload-list-size' }, formatBytes(e.file.size)), |
| 194 | h('td', {}, |
| 195 | h('span', {}, e.path), |
| 196 | working && h('span', { className: 'upload-progress', title }, formatBytes(partial)), |
| 197 | working && hashing && h('span', { className: 'upload-hashing' }, t`Considering resume`, ' (', formatPerc(hashing), ')'), |
| 198 | working && h('progress', { className: 'upload-progress-bar', title, max: 1, value: _.round(progress, 3) }), // round for fewer dom updates |
| 199 | ), |
| 200 | ), |
| 201 | e.comment && h('tr', {}, h('td', { colSpan: 3 }, h('div', { className: 'entry-comment' }, e.comment)) ) |
| 202 | ) |
| 203 | }), |
| 204 | rest > 0 && h('tr', {}, h('td', { colSpan: 99 }, h('a', { href: '#', onClick: () => setAll(true) }, t('more_items', { n: rest }, "{n} more item(s)")))) |
| 205 | ) |
| 206 | ) |
| 207 | } |
| 208 | |
| 209 | function openUploadActions(rec: ToUpload, actions: { [icon:string]: null | ((rec :ToUpload) => any) }) { |
| 210 | const { close } = newDialog({ |
| 211 | title: t`Menu`, |
| 212 | icon: () => hIcon('menu'), |
| 213 | Content() { |
| 214 | return h(Fragment, {}, |
| 215 | h('dl', { className: 'file-dialog-properties upload-action-properties' }, |
| 216 | h('div', {}, |
| 217 | h('dt', {}, t`Size`), |
| 218 | h('dd', {}, formatBytes(rec.file.size)) |
| 219 | ) |
| 220 | ), |
| 221 | h('div', { className: 'upload-action-menu file-menu' }, |
| 222 | ..._.map(actions, (cb, icon) => cb && h('a', { |
| 223 | href: '#', |
| 224 | className: `action-${icon}`, |
| 225 | onClick(ev) { |
| 226 | ev.preventDefault() |
| 227 | close() |
| 228 | void cb(rec) |
| 229 | } |
| 230 | }, |
| 231 | hIcon(icon), |
| 232 | h('label', {}, uploadActionLabel(icon)) |
| 233 | )) |
| 234 | ) |
| 235 | ) |
| 236 | } |
| 237 | }) |
| 238 | } |
| 239 | |
| 240 | function uploadActionLabel(icon: string) { |
| 241 | // edit changes the upload path, so users see the familiar rename label |
| 242 | return icon === 'edit' ? t`Rename` : t(_.capitalize(icon)) |
| 243 | } |
| 244 | |
| 245 | function formatTime(time: number, decimals=0, length=Infinity) { |
| 246 | time /= 1000 |
| 247 | const ret = [(time % 1).toFixed(decimals).slice(1)] |
| 248 | for (const [c,mod,pad] of [['s', 60, 2], ['m', 60, 2], ['h', 24], ['d', 36], ['y', 1 ]] as [string,number,number|undefined][]) { |
| 249 | ret.push( _.padStart(String(time % mod | 0), pad || 0,'0') + c ) |
| 250 | time /= mod |
| 251 | if (time < 1) break |
| 252 | } |
| 253 | return ret.slice(-length).reverse().join('') |
| 254 | } |
| 255 | |
| 256 | |
| 257 | export function UploadStatus({ snapshot, ...props }: { snapshot?: INTERNAL_Snapshot<typeof uploadState> } & CSSProperties) { |
| 258 | const current = useSnapshot(uploadState) |
| 259 | const { done, doneByte, errors, interrupted } = snapshot || current |
| 260 | const msgDone = done.length > 0 && t('upload_finished', { n: done.length, size: formatBytes(doneByte) }, "{n} finished ({size})") |
| 261 | const msgInterrupted = interrupted.length > 0 && t('upload_interrupted', { n: interrupted.length }, "{n} interrupted") |
| 262 | const msgErrors = errors.length > 0 && t('upload_errors', { n: errors.length }, "{n} failed") |
| 263 | const msg = [msgDone, msgInterrupted, msgErrors].filter(Boolean).join(' – ') |
| 264 | if (!msg) return null |
| 265 | const sep = h('span', { className: 'horiz-sep' }, ' – ') |
| 266 | return h('div', { style: { ...props } }, |
| 267 | msg, sep, h(Btn, { label: t`Show details`, asText: true, onClick: showDetails }), |
| 268 | sep, h(Btn, { |
| 269 | label: t('copy_links', "Copy links"), |
| 270 | asText: true, |
| 271 | successFeedback: true, |
| 272 | async onClick() { |
| 273 | await copyTextToClipboard(done.map(x => location.origin + getPrefixUrl() + x.response.uri).join('\n')) |
| 274 | operationSuccessful() |
| 275 | } |
| 276 | }), |
| 277 | ) |
| 278 | |
| 279 | function showDetails() { |
| 280 | if (!uploadState.uploadDialogIsOpen) |
| 281 | closeDialog() // don't nest dialogs unnecessarily (apply only to the dialog outside upload-dialog) |
| 282 | alertDialog(h('div', {}, |
| 283 | ([ |
| 284 | [msgDone, done], |
| 285 | [msgInterrupted, interrupted], |
| 286 | [msgErrors, errors] |
| 287 | ] as const).map(([msg, list], i) => |
| 288 | msg && h('div', { key: i }, msg, h('ul', {}, |
| 289 | list.map((x, i) => |
| 290 | h('li', { key: i }, x.path, prefix(' (', x.error, ')')) |
| 291 | ))) |
| 292 | ) |
| 293 | )) |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | export function acceptDropFiles(cb: false | undefined | ((files:File[], to: string) => void)) { |
| 298 | return { |
| 299 | onDragOver(ev: DragEvent) { |
| 300 | ev.preventDefault() |
| 301 | ev.dataTransfer!.dropEffect = cb && ev.dataTransfer.types.includes('Files') ? 'copy' : 'none' |
| 302 | }, |
| 303 | onDrop(ev: DragEvent) { |
| 304 | ev.preventDefault() |
| 305 | if (!cb) return |
| 306 | for (const it of ev.dataTransfer.items) { |
| 307 | const entry = it.webkitGetAsEntry() |
| 308 | if (entry) |
| 309 | (function recur(entry: FileSystemEntry, to = '') { |
| 310 | if (entry.isFile) |
| 311 | (entry as FileSystemFileEntry).file(x => cb([x], x.webkitRelativePath ? '' : to)) // ff130 fills webkitRelativePath when dropping a folder, while chrome128 doesn't and we pass 'to' to preserve the structure |
| 312 | else (entry as FileSystemDirectoryEntry).createReader?.().readEntries(entries => { |
| 313 | const newTo = to + entry.name + '/' |
| 314 | for (const e of entries) |
| 315 | recur(e, newTo) |
| 316 | }) |
| 317 | })(entry) |
| 318 | } |
| 319 | }, |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | export async function createFolder() { |
| 324 | const name = await promptDialog(t`Enter folder name`) |
| 325 | if (!name) return |
| 326 | const uri = location.pathname |
| 327 | try { |
| 328 | await apiCall('create_folder', { uri, name }, { modal: working }) |
| 329 | reloadList() |
| 330 | await alertDialog(h(() => |
| 331 | h(FlexV, {}, |
| 332 | h('div', {}, t`Successfully created`), |
| 333 | h(LinkClosingDialog, { to: uri + pathEncode(name) + '/' }, t('enter_folder', "Enter the folder")), |
| 334 | ))) |
| 335 | } |
| 336 | catch(e: any) { |
| 337 | await alertDialog(e.code === HTTP_CONFLICT ? t('folder_exists', "Folder with same name already exists") : e) |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | export function inputComment(filename: string, value?: string) { |
| 342 | return promptDialog(t('enter_comment', { name: filename }, "Comment for {name}"), { value, type: 'textarea' }) |
| 343 | } |