- Major refactoring that makes Doc a class with properties - Data made only shallow reactive, for a good speedup of initial load - Minor bugfixes and UX improvements along the way - Fixed handling of hash and question marks in URLs (was confusing Vue Router) - Search made stricter to find good results (not ignore all punctuation) Reviewed-on: #5
306 lines
9.2 KiB
Vue
306 lines
9.2 KiB
Vue
<script setup lang="ts">
|
|
import { connect, uploadUrl } from '@/repositories/WS';
|
|
import { useMainStore } from '@/stores/main'
|
|
import { collator } from '@/utils';
|
|
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
|
|
|
const fileInput = ref()
|
|
const folderInput = ref()
|
|
const store = useMainStore()
|
|
const props = defineProps({
|
|
path: Array<string>
|
|
})
|
|
|
|
type CloudFile = {
|
|
file: File
|
|
cloudName: string
|
|
cloudPos: number
|
|
}
|
|
function pasteHandler(event: ClipboardEvent) {
|
|
const items = Array.from(event.clipboardData?.items ?? [])
|
|
const infiles = [] as File[]
|
|
const dirs = [] as FileSystemDirectoryEntry[]
|
|
for (const item of items) {
|
|
if (item.kind !== 'file') continue
|
|
const entry = item.webkitGetAsEntry()
|
|
if (entry?.isFile) {
|
|
const file = item.getAsFile()
|
|
if (file) infiles.push(file)
|
|
} else if (entry?.isDirectory) {
|
|
dirs.push(entry as FileSystemDirectoryEntry)
|
|
}
|
|
}
|
|
if (infiles.length || dirs.length) {
|
|
event.preventDefault()
|
|
uploadFiles(infiles)
|
|
for (const entry of dirs) pasteDirectory(entry, `${props.path!.join('/')}/${entry.name}`)
|
|
}
|
|
}
|
|
const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
|
|
const reader = entry.createReader()
|
|
const entries = await new Promise<any[]>(resolve => reader.readEntries(resolve))
|
|
const cloudfiles = [] as CloudFile[]
|
|
for (const entry of entries) {
|
|
const cloudName = `${loc}/${entry.name}`
|
|
if (entry.isFile) {
|
|
const file = await new Promise(resolve => entry.file(resolve)) as File
|
|
cloudfiles.push({file, cloudName, cloudPos: 0})
|
|
} else if (entry.isDirectory) {
|
|
await pasteDirectory(entry, cloudName)
|
|
}
|
|
}
|
|
if (cloudfiles.length) uploadCloudFiles(cloudfiles)
|
|
}
|
|
function uploadHandler(event: Event) {
|
|
event.preventDefault()
|
|
// @ts-ignore
|
|
const input = event.target as HTMLInputElement | null
|
|
const infiles = Array.from((input ?? (event as DragEvent).dataTransfer)?.files ?? []) as File[]
|
|
if (input) input.value = ''
|
|
if (infiles.length) uploadFiles(infiles)
|
|
}
|
|
|
|
const uploadFiles = (infiles: File[]) => {
|
|
const loc = props.path!.join('/')
|
|
let files = []
|
|
for (const file of infiles) {
|
|
files.push({
|
|
file,
|
|
cloudName: loc + '/' + (file.webkitRelativePath || file.name),
|
|
cloudPos: 0,
|
|
})
|
|
}
|
|
uploadCloudFiles(files)
|
|
}
|
|
const uploadCloudFiles = (files: CloudFile[]) => {
|
|
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
|
|
if (dotfiles.length) {
|
|
store.error = "Won't upload dotfiles"
|
|
console.log("Dotfiles omitted", dotfiles)
|
|
files = files.filter(f => !f.cloudName.includes('/.'))
|
|
}
|
|
if (!files.length) return
|
|
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
|
|
// @ts-ignore
|
|
upqueue = [...upqueue, ...files]
|
|
statsAdd(files)
|
|
startWorker()
|
|
}
|
|
|
|
const cancelUploads = () => {
|
|
upqueue = []
|
|
statReset()
|
|
}
|
|
|
|
const uprogress_init = {
|
|
total: 0,
|
|
uploaded: 0,
|
|
t0: 0,
|
|
tlast: 0,
|
|
statbytes: 0,
|
|
statdur: 0,
|
|
files: [] as CloudFile[],
|
|
filestart: 0,
|
|
fileidx: 0,
|
|
filecount: 0,
|
|
filename: '',
|
|
filesize: 0,
|
|
filepos: 0,
|
|
status: 'idle',
|
|
}
|
|
const uprogress = reactive({...uprogress_init})
|
|
const percent = computed(() => uprogress.uploaded / uprogress.total * 100)
|
|
const speed = computed(() => {
|
|
let s = uprogress.statbytes / uprogress.statdur / 1e3
|
|
const tsince = (Date.now() - uprogress.tlast) / 1e3
|
|
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
|
|
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
|
|
return s // "Current speed"
|
|
})
|
|
const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s': 'stalled')
|
|
setInterval(() => {
|
|
if (Date.now() - uprogress.tlast > 3000) {
|
|
// Reset
|
|
uprogress.statbytes = 0
|
|
uprogress.statdur = 1
|
|
} else {
|
|
// Running average by decay
|
|
uprogress.statbytes *= .9
|
|
uprogress.statdur *= .9
|
|
}
|
|
}, 100)
|
|
const statUpdate = ({name, size, start, end}: {name: string, size: number, start: number, end: number}) => {
|
|
if (name !== uprogress.filename) return // If stats have been reset
|
|
const now = Date.now()
|
|
uprogress.uploaded = uprogress.filestart + end
|
|
uprogress.filepos = end
|
|
uprogress.statbytes += end - start
|
|
uprogress.statdur += now - uprogress.tlast
|
|
uprogress.tlast = now
|
|
// File finished?
|
|
if (end === size) {
|
|
uprogress.filestart += size
|
|
statNextFile()
|
|
if (++uprogress.fileidx >= uprogress.filecount) statReset()
|
|
}
|
|
}
|
|
const statNextFile = () => {
|
|
const f = uprogress.files.shift()
|
|
if (!f) return statReset()
|
|
uprogress.filepos = 0
|
|
uprogress.filesize = f.file.size
|
|
uprogress.filename = f.cloudName
|
|
}
|
|
const statReset = () => {
|
|
Object.assign(uprogress, uprogress_init)
|
|
uprogress.t0 = Date.now()
|
|
uprogress.tlast = uprogress.t0 + 1
|
|
}
|
|
const statsAdd = (f: CloudFile[]) => {
|
|
if (uprogress.files.length === 0) statReset()
|
|
uprogress.total += f.reduce((a, b) => a + b.file.size, 0)
|
|
uprogress.filecount += f.length
|
|
uprogress.files = [...uprogress.files, ...f]
|
|
statNextFile()
|
|
}
|
|
let upqueue = [] as CloudFile[]
|
|
|
|
// TODO: Rewrite as WebSocket class
|
|
const WSCreate = async () => await new Promise<WebSocket>(resolve => {
|
|
const ws = connect(uploadUrl, {
|
|
open(ev: Event) { resolve(ws) },
|
|
error(ev: Event) {
|
|
console.error('Upload socket error', ev)
|
|
store.error = 'Upload socket error'
|
|
},
|
|
message(ev: MessageEvent) {
|
|
const res = JSON.parse(ev!.data)
|
|
if ('error' in res) {
|
|
console.error('Upload socket error', res.error)
|
|
store.error = res.error.message
|
|
return
|
|
}
|
|
if (res.status === 'ack') {
|
|
statUpdate(res.req)
|
|
} else console.log('Unknown upload response', res)
|
|
},
|
|
})
|
|
// @ts-ignore
|
|
ws.sendMsg = (msg: any) => ws.send(JSON.stringify(msg))
|
|
// @ts-ignore
|
|
ws.sendData = async (data: any) => {
|
|
// Wait until the WS is ready to send another message
|
|
uprogress.status = "uploading"
|
|
await new Promise(resolve => {
|
|
const t = setInterval(() => {
|
|
if (ws.bufferedAmount > 1<<20) return
|
|
resolve(undefined)
|
|
clearInterval(t)
|
|
}, 1)
|
|
})
|
|
uprogress.status = "processing"
|
|
ws.send(data)
|
|
}
|
|
})
|
|
const worker = async () => {
|
|
const ws = await WSCreate()
|
|
while (upqueue.length) {
|
|
const f = upqueue[0]
|
|
const start = f.cloudPos
|
|
const end = Math.min(f.file.size, start + (1<<20))
|
|
const control = { name: f.cloudName, size: f.file.size, start, end }
|
|
const data = f.file.slice(start, end)
|
|
f.cloudPos = end
|
|
// Note: files may get modified during I/O
|
|
// @ts-ignore FIXME proper WebSocket class, avoid attaching functions to WebSocket object
|
|
ws.sendMsg(control)
|
|
// @ts-ignore
|
|
await ws.sendData(data)
|
|
if (f.cloudPos === f.file.size) upqueue.shift()
|
|
}
|
|
if (upqueue.length) startWorker()
|
|
uprogress.status = "idle"
|
|
workerRunning = false
|
|
}
|
|
let workerRunning: any = false
|
|
const startWorker = () => {
|
|
if (workerRunning === false) workerRunning = setTimeout(() => {
|
|
workerRunning = true
|
|
worker()
|
|
}, 0)
|
|
}
|
|
|
|
onMounted(() => {
|
|
// Need to prevent both to prevent browser from opening the file
|
|
addEventListener('dragover', uploadHandler)
|
|
addEventListener('drop', uploadHandler)
|
|
addEventListener('paste', pasteHandler)
|
|
})
|
|
onUnmounted(() => {
|
|
removeEventListener('paste', pasteHandler)
|
|
removeEventListener('dragover', uploadHandler)
|
|
removeEventListener('drop', uploadHandler)
|
|
})
|
|
</script>
|
|
<template>
|
|
<template>
|
|
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
|
|
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
|
|
</template>
|
|
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" />
|
|
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderInput.click()" />
|
|
<div class="uploadprogress" v-if="uprogress.total" :style="`background: linear-gradient(to right, var(--bar) 0, var(--bar) ${percent}%, var(--nobar) ${percent}%, var(--nobar) 100%);`">
|
|
<div class="statustext">
|
|
<span v-if="uprogress.filecount > 1" class="index">
|
|
[{{ uprogress.fileidx }}/{{ uprogress.filecount }}]
|
|
</span>
|
|
<span class="filename">{{ uprogress.filename.split('/').pop() }}
|
|
<span v-if="uprogress.filesize > 1e7" class="percent">
|
|
{{ (uprogress.filepos / uprogress.filesize * 100).toFixed(0) + '\u202F%' }}
|
|
</span>
|
|
</span>
|
|
<span class="position" v-if="uprogress.total > 1e7">
|
|
{{ (uprogress.uploaded / 1e6).toFixed(0) + '\u202F/\u202F' + (uprogress.total / 1e6).toFixed(0) + '\u202FMB' }}
|
|
</span>
|
|
<span class="speed">{{ speeddisp }}</span>
|
|
<button class="close" @click="cancelUploads">❌</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.uploadprogress {
|
|
--bar: var(--accent-color);
|
|
--nobar: var(--header-background);
|
|
display: flex;
|
|
flex-direction: column;
|
|
color: var(--primary-color);
|
|
position: fixed;
|
|
left: 0;
|
|
bottom: 0;
|
|
width: 100vw;
|
|
}
|
|
.statustext {
|
|
display: flex;
|
|
padding: 0.5rem 0;
|
|
}
|
|
span {
|
|
color: #ccc;
|
|
white-space: nowrap;
|
|
text-align: right;
|
|
padding: 0 0.5em;
|
|
}
|
|
.filename {
|
|
color: #fff;
|
|
flex: 1 1;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
text-align: left;
|
|
}
|
|
.index { min-width: 3.5em }
|
|
.position { min-width: 4em }
|
|
.speed { min-width: 4em }
|
|
</style>
|
|
@/stores/main
|