main
ts 343 lines 16.5 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 { 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 }