Download progress bar
This commit is contained in:
parent
52beedcef0
commit
0965a56204
171
frontend/src/components/DownloadButton.vue
Normal file
171
frontend/src/components/DownloadButton.vue
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
<template>
|
||||||
|
<SvgButton name="download" data-tooltip="Download" @click="download" />
|
||||||
|
<TransferBar :status=progress @cancel=cancelDownloads />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import type { SelectedItems } from '@/repositories/Document'
|
||||||
|
import { reactive } from 'vue';
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
|
||||||
|
const status_init = {
|
||||||
|
total: 0,
|
||||||
|
xfer: 0,
|
||||||
|
t0: 0,
|
||||||
|
tlast: 0,
|
||||||
|
statbytes: 0,
|
||||||
|
statdur: 0,
|
||||||
|
files: [] as string[],
|
||||||
|
filestart: 0,
|
||||||
|
fileidx: 0,
|
||||||
|
filecount: 0,
|
||||||
|
filename: '',
|
||||||
|
filesize: 0,
|
||||||
|
filepos: 0,
|
||||||
|
status: 'idle',
|
||||||
|
}
|
||||||
|
const progress = reactive({...status_init})
|
||||||
|
setInterval(() => {
|
||||||
|
if (Date.now() - progress.tlast > 3000) {
|
||||||
|
// Reset
|
||||||
|
progress.statbytes = 0
|
||||||
|
progress.statdur = 1
|
||||||
|
} else {
|
||||||
|
// Running average by decay
|
||||||
|
progress.statbytes *= .9
|
||||||
|
progress.statdur *= .9
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
const statReset = () => {
|
||||||
|
Object.assign(progress, status_init)
|
||||||
|
progress.t0 = Date.now()
|
||||||
|
progress.tlast = progress.t0 + 1
|
||||||
|
}
|
||||||
|
const cancelDownloads = () => {
|
||||||
|
location.reload() // FIXME
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const linkdl = (href: string) => {
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = href
|
||||||
|
a.download = ''
|
||||||
|
a.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => {
|
||||||
|
let hdir = ''
|
||||||
|
let h = handle
|
||||||
|
console.log('Downloading to filesystem', sel.recursive)
|
||||||
|
for (const [rel, full, doc] of sel.recursive) {
|
||||||
|
if (doc.dir) continue
|
||||||
|
progress.files.push(rel)
|
||||||
|
++progress.filecount
|
||||||
|
progress.total += doc.size
|
||||||
|
}
|
||||||
|
for (const [rel, full, doc] of sel.recursive) {
|
||||||
|
// Create any missing directories
|
||||||
|
if (hdir && !rel.startsWith(hdir + '/')) {
|
||||||
|
hdir = ''
|
||||||
|
h = handle
|
||||||
|
}
|
||||||
|
const r = rel.slice(hdir.length)
|
||||||
|
for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) {
|
||||||
|
hdir += `${dir}/`
|
||||||
|
try {
|
||||||
|
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create directory', hdir, error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('Created', hdir)
|
||||||
|
}
|
||||||
|
if (doc.dir) continue // Target was a folder and was created
|
||||||
|
const name = rel.split('/').pop()!.normalize('NFC')
|
||||||
|
// Download file
|
||||||
|
let fileHandle
|
||||||
|
try {
|
||||||
|
fileHandle = await h.getFileHandle(name, { create: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create file', rel, full, hdir + name, error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const writable = await fileHandle.createWritable()
|
||||||
|
const url = `/files/${rel}`
|
||||||
|
console.log('Fetching', url)
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) {
|
||||||
|
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
|
||||||
|
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
|
||||||
|
}
|
||||||
|
if (res.body) {
|
||||||
|
++progress.fileidx
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
await writable.truncate(0)
|
||||||
|
store.error = "Direct download."
|
||||||
|
progress.tlast = Date.now()
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
await writable.write(value)
|
||||||
|
const now = Date.now()
|
||||||
|
const size = value.byteLength
|
||||||
|
progress.xfer += size
|
||||||
|
progress.filepos += size
|
||||||
|
progress.statbytes += size
|
||||||
|
progress.statdur += now - progress.tlast
|
||||||
|
progress.tlast = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writable.close()
|
||||||
|
console.log('Saved', hdir + name)
|
||||||
|
}
|
||||||
|
statReset()
|
||||||
|
}
|
||||||
|
|
||||||
|
const download = async () => {
|
||||||
|
const sel = store.selectedFiles
|
||||||
|
console.log('Download', sel)
|
||||||
|
if (sel.keys.length === 0) {
|
||||||
|
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
|
||||||
|
store.error = 'No existing files selected'
|
||||||
|
store.selected.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Plain old a href download if only one file (ignoring any folders)
|
||||||
|
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
|
||||||
|
if (files.length === 1) {
|
||||||
|
store.selected.clear()
|
||||||
|
store.error = "Single file via browser downloads"
|
||||||
|
return linkdl(`/files/${files[0][1]}`)
|
||||||
|
}
|
||||||
|
// Use FileSystem API if multiple files and the browser supports it
|
||||||
|
if ('showDirectoryPicker' in window) {
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const handle = await window.showDirectoryPicker({
|
||||||
|
startIn: 'downloads',
|
||||||
|
mode: 'readwrite'
|
||||||
|
})
|
||||||
|
await filesystemdl(sel, handle)
|
||||||
|
store.selected.clear()
|
||||||
|
return
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Download to folder aborted', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise, zip and download
|
||||||
|
console.log("Falling back to zip download")
|
||||||
|
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
|
||||||
|
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
||||||
|
store.error = "Downloading as ZIP via browser downloads"
|
||||||
|
store.selected.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
|
@ -2,7 +2,7 @@
|
||||||
<template v-if="store.selected.size">
|
<template v-if="store.selected.size">
|
||||||
<div class="smallgap"></div>
|
<div class="smallgap"></div>
|
||||||
<p class="select-text">{{ store.selected.size }} selected ➤</p>
|
<p class="select-text">{{ store.selected.size }} selected ➤</p>
|
||||||
<SvgButton name="download" data-tooltip="Download" @click="download" />
|
<DownloadButton />
|
||||||
<SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" />
|
<SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" />
|
||||||
<SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" />
|
<SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" />
|
||||||
<SvgButton name="trash" data-tooltip="Delete ⚠️" @click="op('rm')" />
|
<SvgButton name="trash" data-tooltip="Delete ⚠️" @click="op('rm')" />
|
||||||
|
@ -14,7 +14,6 @@
|
||||||
import {connect, controlUrl} from '@/repositories/WS'
|
import {connect, controlUrl} from '@/repositories/WS'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { SelectedItems } from '@/repositories/Document'
|
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
@ -53,95 +52,6 @@ const op = (op: string, dst?: string) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const linkdl = (href: string) => {
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = href
|
|
||||||
a.download = ''
|
|
||||||
a.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => {
|
|
||||||
let hdir = ''
|
|
||||||
let h = handle
|
|
||||||
console.log('Downloading to filesystem', sel.recursive)
|
|
||||||
for (const [rel, full, doc] of sel.recursive) {
|
|
||||||
// Create any missing directories
|
|
||||||
if (hdir && !rel.startsWith(hdir + '/')) {
|
|
||||||
hdir = ''
|
|
||||||
h = handle
|
|
||||||
}
|
|
||||||
const r = rel.slice(hdir.length)
|
|
||||||
for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) {
|
|
||||||
hdir += `${dir}/`
|
|
||||||
try {
|
|
||||||
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create directory', hdir, error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.log('Created', hdir)
|
|
||||||
}
|
|
||||||
if (doc.dir) continue // Target was a folder and was created
|
|
||||||
const name = rel.split('/').pop()!.normalize('NFC')
|
|
||||||
// Download file
|
|
||||||
let fileHandle
|
|
||||||
try {
|
|
||||||
fileHandle = await h.getFileHandle(name, { create: true })
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create file', rel, full, hdir + name, error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const writable = await fileHandle.createWritable()
|
|
||||||
const url = `/files/${rel}`
|
|
||||||
console.log('Fetching', url)
|
|
||||||
const res = await fetch(url)
|
|
||||||
if (!res.ok)
|
|
||||||
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
|
|
||||||
if (res.body) await res.body.pipeTo(writable)
|
|
||||||
else {
|
|
||||||
// Zero-sized files don't have a body, so we need to create an empty file
|
|
||||||
await writable.truncate(0)
|
|
||||||
await writable.close()
|
|
||||||
}
|
|
||||||
console.log('Saved', hdir + name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const download = async () => {
|
|
||||||
const sel = store.selectedFiles
|
|
||||||
console.log('Download', sel)
|
|
||||||
if (sel.keys.length === 0) {
|
|
||||||
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
|
|
||||||
store.selected.clear()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Plain old a href download if only one file (ignoring any folders)
|
|
||||||
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
|
|
||||||
if (files.length === 1) {
|
|
||||||
store.selected.clear()
|
|
||||||
return linkdl(`/files/${files[0][1]}`)
|
|
||||||
}
|
|
||||||
// Use FileSystem API if multiple files and the browser supports it
|
|
||||||
if ('showDirectoryPicker' in window) {
|
|
||||||
try {
|
|
||||||
// @ts-ignore
|
|
||||||
const handle = await window.showDirectoryPicker({
|
|
||||||
startIn: 'downloads',
|
|
||||||
mode: 'readwrite'
|
|
||||||
})
|
|
||||||
filesystemdl(sel, handle).then(() => {
|
|
||||||
store.selected.clear()
|
|
||||||
})
|
|
||||||
return
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Download to folder aborted', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Otherwise, zip and download
|
|
||||||
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
|
|
||||||
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
|
||||||
store.selected.clear()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
87
frontend/src/components/TransferBar.vue
Normal file
87
frontend/src/components/TransferBar.vue
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
<template>
|
||||||
|
<div class="transferprogress" v-if="status.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="status.filecount > 1" class="index">
|
||||||
|
[{{ status.fileidx }}/{{ status.filecount }}]
|
||||||
|
</span>
|
||||||
|
<span class="filename">{{ status.filename.split('/').pop() }}
|
||||||
|
<span v-if="status.filesize > 1e7" class="percent">
|
||||||
|
{{ (status.filepos / status.filesize * 100).toFixed(0) + '\u202F%' }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="position" v-if="status.total > 1e7">
|
||||||
|
{{ (status.xfer / 1e6).toFixed(0) + '\u202F/\u202F' + (status.total / 1e6).toFixed(0) + '\u202FMB' }}
|
||||||
|
</span>
|
||||||
|
<span class="speed">{{ speeddisp }}</span>
|
||||||
|
<button class="close" @click="$emit('cancel')">❌</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
defineEmits(['cancel'])
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
status: {
|
||||||
|
total: number
|
||||||
|
xfer: number
|
||||||
|
filecount: number
|
||||||
|
fileidx: number
|
||||||
|
filesize: number
|
||||||
|
filepos: number
|
||||||
|
filename: string
|
||||||
|
statbytes: number
|
||||||
|
statdur: number
|
||||||
|
tlast: number
|
||||||
|
}
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const percent = computed(() => props.status.xfer / props.status.total * 100)
|
||||||
|
const speed = computed(() => {
|
||||||
|
let s = props.status.statbytes / props.status.statdur / 1e3
|
||||||
|
const tsince = (Date.now() - props.status.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')
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.transferprogress {
|
||||||
|
--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>
|
|
@ -2,7 +2,7 @@
|
||||||
import { connect, uploadUrl } from '@/repositories/WS';
|
import { connect, uploadUrl } from '@/repositories/WS';
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { collator } from '@/utils';
|
import { collator } from '@/utils';
|
||||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
|
|
||||||
const fileInput = ref()
|
const fileInput = ref()
|
||||||
const folderInput = ref()
|
const folderInput = ref()
|
||||||
|
@ -94,7 +94,7 @@ const cancelUploads = () => {
|
||||||
|
|
||||||
const uprogress_init = {
|
const uprogress_init = {
|
||||||
total: 0,
|
total: 0,
|
||||||
uploaded: 0,
|
xfer: 0,
|
||||||
t0: 0,
|
t0: 0,
|
||||||
tlast: 0,
|
tlast: 0,
|
||||||
statbytes: 0,
|
statbytes: 0,
|
||||||
|
@ -109,15 +109,6 @@ const uprogress_init = {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
}
|
}
|
||||||
const uprogress = reactive({...uprogress_init})
|
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(() => {
|
setInterval(() => {
|
||||||
if (Date.now() - uprogress.tlast > 3000) {
|
if (Date.now() - uprogress.tlast > 3000) {
|
||||||
// Reset
|
// Reset
|
||||||
|
@ -132,7 +123,7 @@ setInterval(() => {
|
||||||
const statUpdate = ({name, size, start, end}: {name: string, size: number, start: number, end: number}) => {
|
const statUpdate = ({name, size, start, end}: {name: string, size: number, start: number, end: number}) => {
|
||||||
if (name !== uprogress.filename) return // If stats have been reset
|
if (name !== uprogress.filename) return // If stats have been reset
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
uprogress.uploaded = uprogress.filestart + end
|
uprogress.xfer = uprogress.filestart + end
|
||||||
uprogress.filepos = end
|
uprogress.filepos = end
|
||||||
uprogress.statbytes += end - start
|
uprogress.statbytes += end - start
|
||||||
uprogress.statdur += now - uprogress.tlast
|
uprogress.statdur += now - uprogress.tlast
|
||||||
|
@ -249,57 +240,5 @@ onUnmounted(() => {
|
||||||
</template>
|
</template>
|
||||||
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" />
|
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" />
|
||||||
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderInput.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%);`">
|
<TransferBar :status=uprogress @cancel=cancelUploads />
|
||||||
<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>
|
</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
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user