Multiple file downloads via Filesystem API, and other tuning.
This commit is contained in:
@@ -82,7 +82,9 @@ const props = withDefaults(
|
||||
}
|
||||
.breadcrumb svg {
|
||||
/* FIXME: Custom positioning to align it well; needs proper solution */
|
||||
transform: translate(0.3rem, -0.3rem) scale(80%);
|
||||
padding-left: .6rem;
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
fill: var(--breadcrumb-color);
|
||||
transition: fill var(--breadcrumb-transtime);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { ref, nextTick, watchEffect } from 'vue'
|
||||
import { ref, nextTick } from 'vue'
|
||||
|
||||
const documentStore = useDocumentStore()
|
||||
const showSearchInput = ref<boolean>(false)
|
||||
const search = ref<HTMLInputElement | null>()
|
||||
const searchButton = ref<HTMLButtonElement | null>()
|
||||
|
||||
const toggleSearchInput = () => {
|
||||
showSearchInput.value = !showSearchInput.value
|
||||
nextTick(() => {
|
||||
const input = search.value
|
||||
if (input) input.focus()
|
||||
else if (searchButton.value) searchButton.value.blur()
|
||||
executeSearch()
|
||||
})
|
||||
}
|
||||
@@ -29,16 +31,7 @@ defineExpose({
|
||||
<div class="buttons">
|
||||
<UploadButton />
|
||||
<SvgButton name="create-folder" @click="() => documentStore.fileExplorer.newFolder()"/>
|
||||
<template v-if="documentStore.selected.size > 0">
|
||||
<div class="smallgap"></div>
|
||||
<p class="select-text">{{ documentStore.selected.size }} selected ➤</p>
|
||||
<!-- Needs better icons for copy/move/remove -->
|
||||
<SvgButton name="download" />
|
||||
<SvgButton name="copy" />
|
||||
<SvgButton name="paste" />
|
||||
<SvgButton name="trash" />
|
||||
<button @click="documentStore.selected.clear()">❌</button>
|
||||
</template>
|
||||
<HeaderSelected />
|
||||
<div class="spacer"></div>
|
||||
<template v-if="showSearchInput">
|
||||
<input
|
||||
@@ -49,7 +42,7 @@ defineExpose({
|
||||
@input="executeSearch"
|
||||
/>
|
||||
</template>
|
||||
<SvgButton name="find" @click="toggleSearchInput" />
|
||||
<SvgButton ref="searchButton" name="find" @click="toggleSearchInput" />
|
||||
<SvgButton name="cog" @click="console.log('TODO open settings')" />
|
||||
</div>
|
||||
</nav>
|
||||
@@ -61,6 +54,9 @@ defineExpose({
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.buttons > * {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
@@ -70,17 +66,14 @@ defineExpose({
|
||||
.select-text {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.search-widget {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
input[type='search'] {
|
||||
background: var(--primary-background);
|
||||
color: var(--text-color);
|
||||
color: var(--primary-color);
|
||||
border: 0;
|
||||
border-radius: 0.1rem;
|
||||
padding: 0.5rem;
|
||||
outline: none;
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.5rem;
|
||||
max-width: 30vw;
|
||||
}
|
||||
</style>
|
||||
|
||||
113
cista-front/src/components/HeaderSelected.vue
Normal file
113
cista-front/src/components/HeaderSelected.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template v-if="documentStore.selected.size">
|
||||
<div class="smallgap"></div>
|
||||
<div class="selected-actions">
|
||||
<p class="select-text">{{ documentStore.selected.size }} selected ➤</p>
|
||||
<SvgButton name="download" @click="download"/>
|
||||
<SvgButton name="copy" />
|
||||
<SvgButton name="paste" />
|
||||
<SvgButton name="trash" />
|
||||
<button @click="documentStore.selected.clear()">❌</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
|
||||
const documentStore = useDocumentStore()
|
||||
|
||||
|
||||
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
|
||||
let filelist = []
|
||||
for (const id of sel.ids) {
|
||||
filelist.push(sel.relpath[id])
|
||||
}
|
||||
console.log("Downloading to filesystem", filelist)
|
||||
for (const id of sel.ids) {
|
||||
const rel = sel.relpath[id]
|
||||
const url = sel.url[id] // Only files, not folders
|
||||
// Create any missing directories
|
||||
if (!rel.startsWith(hdir)) {
|
||||
hdir = ""
|
||||
h = handle
|
||||
}
|
||||
const r = rel.slice(hdir.length)
|
||||
for (const dir of r.split('/').slice(0, url ? -1 : undefined)) {
|
||||
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 (!url) 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", hdir + name, error)
|
||||
return
|
||||
}
|
||||
const writable = await fileHandle.createWritable()
|
||||
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 = documentStore.selectedFiles
|
||||
console.log("Download", sel)
|
||||
if (sel.selected.size === 0) {
|
||||
console.warn("Attempted download but no files found. Missing:", sel.missing)
|
||||
documentStore.selected.clear()
|
||||
return
|
||||
}
|
||||
// Plain old a href download if only one file (ignoring any folders)
|
||||
const urls = Object.values(sel.url)
|
||||
if (urls.length === 1) {
|
||||
documentStore.selected.clear()
|
||||
return linkdl(urls[0] as string)
|
||||
}
|
||||
// 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(() => { documentStore.selected.clear() })
|
||||
return
|
||||
} catch (e) {
|
||||
console.error("Download to folder aborted", e)
|
||||
}
|
||||
}
|
||||
// Otherwise, zip and download
|
||||
linkdl(`/zip/${sel.selected.join('+')}/download.zip`)
|
||||
documentStore.selected.clear()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.selected-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -22,7 +22,9 @@ button {
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0.5rem;
|
||||
padding: 0.2rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
button:hover, button:focus {
|
||||
color: #fff;
|
||||
@@ -31,8 +33,6 @@ button:hover, button:focus {
|
||||
svg {
|
||||
fill: #ccc;
|
||||
transform: fill 0.2s ease;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
button:hover svg, button:focus svg {
|
||||
fill: #fff;
|
||||
|
||||
Reference in New Issue
Block a user