Multiple file downloads via Filesystem API, and other tuning.

This commit is contained in:
Leo Vasanko 2023-11-04 19:50:05 +00:00
parent 40a45568c1
commit 41fbd3d122
10 changed files with 203 additions and 66 deletions

View File

@ -12,6 +12,7 @@ declare module 'vue' {
FileRenameInput: typeof import('./src/components/FileRenameInput.vue')['default'] FileRenameInput: typeof import('./src/components/FileRenameInput.vue')['default']
FileViewer: typeof import('./src/components/FileViewer.vue')['default'] FileViewer: typeof import('./src/components/FileViewer.vue')['default']
HeaderMain: typeof import('./src/components/HeaderMain.vue')['default'] HeaderMain: typeof import('./src/components/HeaderMain.vue')['default']
HeaderSelected: typeof import('./src/components/HeaderSelected.vue')['default']
LoginModal: typeof import('./src/components/LoginModal.vue')['default'] LoginModal: typeof import('./src/components/LoginModal.vue')['default']
ModalDialog: typeof import('./src/components/ModalDialog.vue')['default'] ModalDialog: typeof import('./src/components/ModalDialog.vue')['default']
NotificationLoading: typeof import('./src/components/NotificationLoading.vue')['default'] NotificationLoading: typeof import('./src/components/NotificationLoading.vue')['default']

View File

@ -52,7 +52,7 @@ let vert = 0
let vertInterval: any = null let vertInterval: any = null
const globalShortcutHandler = (event: KeyboardEvent) => { const globalShortcutHandler = (event: KeyboardEvent) => {
if (event.repeat) return if (event.repeat) return
console.log("key pressed", event) //console.log("key pressed", event)
const c = documentStore.fileExplorer.isCursor const c = documentStore.fileExplorer.isCursor
const keyup = event.type === "keyup" const keyup = event.type === "keyup"
// For up/down implement custom fast repeat // For up/down implement custom fast repeat

View File

@ -30,6 +30,12 @@
html { html {
font-size: 1.5rem; font-size: 1.5rem;
} }
header .buttons:has(input[type=search]) > div {
display: none;
}
header .buttons > div:has(input[type=search]) {
display: inherit;
}
} }
@media screen and (min-width: 1400px) and (--webkit-min-device-pixel-ratio: 3) { @media screen and (min-width: 1400px) and (--webkit-min-device-pixel-ratio: 3) {
html { html {

View File

@ -82,7 +82,9 @@ const props = withDefaults(
} }
.breadcrumb svg { .breadcrumb svg {
/* FIXME: Custom positioning to align it well; needs proper solution */ /* 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); fill: var(--breadcrumb-color);
transition: fill var(--breadcrumb-transtime); transition: fill var(--breadcrumb-transtime);
} }

View File

@ -1,16 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { useDocumentStore } from '@/stores/documents' import { useDocumentStore } from '@/stores/documents'
import { ref, nextTick, watchEffect } from 'vue' import { ref, nextTick } from 'vue'
const documentStore = useDocumentStore() const documentStore = useDocumentStore()
const showSearchInput = ref<boolean>(false) const showSearchInput = ref<boolean>(false)
const search = ref<HTMLInputElement | null>() const search = ref<HTMLInputElement | null>()
const searchButton = ref<HTMLButtonElement | null>()
const toggleSearchInput = () => { const toggleSearchInput = () => {
showSearchInput.value = !showSearchInput.value showSearchInput.value = !showSearchInput.value
nextTick(() => { nextTick(() => {
const input = search.value const input = search.value
if (input) input.focus() if (input) input.focus()
else if (searchButton.value) searchButton.value.blur()
executeSearch() executeSearch()
}) })
} }
@ -29,16 +31,7 @@ defineExpose({
<div class="buttons"> <div class="buttons">
<UploadButton /> <UploadButton />
<SvgButton name="create-folder" @click="() => documentStore.fileExplorer.newFolder()"/> <SvgButton name="create-folder" @click="() => documentStore.fileExplorer.newFolder()"/>
<template v-if="documentStore.selected.size > 0"> <HeaderSelected />
<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>
<div class="spacer"></div> <div class="spacer"></div>
<template v-if="showSearchInput"> <template v-if="showSearchInput">
<input <input
@ -49,7 +42,7 @@ defineExpose({
@input="executeSearch" @input="executeSearch"
/> />
</template> </template>
<SvgButton name="find" @click="toggleSearchInput" /> <SvgButton ref="searchButton" name="find" @click="toggleSearchInput" />
<SvgButton name="cog" @click="console.log('TODO open settings')" /> <SvgButton name="cog" @click="console.log('TODO open settings')" />
</div> </div>
</nav> </nav>
@ -61,6 +54,9 @@ defineExpose({
display: flex; display: flex;
align-items: center; align-items: center;
} }
.buttons > * {
flex-shrink: 1;
}
.spacer { .spacer {
flex-grow: 1; flex-grow: 1;
} }
@ -70,17 +66,14 @@ defineExpose({
.select-text { .select-text {
color: var(--accent-color); color: var(--accent-color);
} }
.search-widget {
display: flex;
align-items: center;
}
input[type='search'] { input[type='search'] {
background: var(--primary-background); background: var(--primary-background);
color: var(--text-color); color: var(--primary-color);
border: 0; border: 0;
border-radius: 0.1rem; border-radius: 0.1rem;
padding: 0.5rem; padding: 0.5rem;
outline: none; outline: none;
font-size: 1.2rem; font-size: 1.5rem;
max-width: 30vw;
} }
</style> </style>

View 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>

View File

@ -22,7 +22,9 @@ button {
color: #ccc; color: #ccc;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
padding: 0.5rem; padding: 0.2rem;
width: 3rem;
height: 3rem;
} }
button:hover, button:focus { button:hover, button:focus {
color: #fff; color: #fff;
@ -31,8 +33,6 @@ button:hover, button:focus {
svg { svg {
fill: #ccc; fill: #ccc;
transform: fill 0.2s ease; transform: fill 0.2s ease;
width: 1rem;
height: 1rem;
} }
button:hover svg, button:focus svg { button:hover svg, button:focus svg {
fill: #fff; fill: #fff;

View File

@ -53,6 +53,19 @@ export type UpdateEntry = {
dir?: DirList dir?: DirList
} }
// Helper structure for selections
export interface SelectedItems {
selected: Set<FUID>
missing: Set<FUID>
rootdir: DirList
entries: Record<FUID, FileEntry | DirEntry>
fullpath: Record<FUID, string>
relpath: Record<FUID, string>
url: Record<FUID, string>
ids: FUID[]
}
export const url_document_watch_ws = '/api/watch' export const url_document_watch_ws = '/api/watch'
export const url_document_upload_ws = '/api/upload' export const url_document_upload_ws = '/api/upload'
export const url_document_get = '/files' export const url_document_get = '/files'

View File

@ -3,7 +3,8 @@ import type {
DirEntry, DirEntry,
FileEntry, FileEntry,
FUID, FUID,
DirList DirList,
SelectedItems,
} from '@/repositories/Document' } from '@/repositories/Document'
import { formatSize, formatUnixDate } from '@/utils' import { formatSize, formatUnixDate } from '@/utils'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
@ -162,6 +163,50 @@ export const useDocumentStore = defineStore({
}, },
isUserLogged(): boolean { isUserLogged(): boolean {
return this.user.isLoggedIn return this.user.isLoggedIn
},
selectedFiles(): SelectedItems {
function traverseDir(data: DirEntry | FileEntry, path: string, relpath: string) {
if (!('dir' in data)) return
for (const [name, attr] of Object.entries(data.dir)) {
const fullname = path ? `${path}/${name}` : name
// Is this the file we are looking for? Ignore if nested within another selection.
let r = relpath
if (selected.has(attr.id) && !relpath) {
ret.selected.add(attr.id)
ret.rootdir[name] = attr
r = name
} else if (relpath) {
r = `${relpath}/${name}`
}
if (r) {
ret.entries[attr.id] = attr
ret.fullpath[attr.id] = fullname
ret.relpath[attr.id] = r
ret.ids.push(attr.id)
if (!("dir" in attr)) ret.url[attr.id] = `/files/${fullname}`
}
traverseDir(attr, fullname, r)
}
}
const selected = this.selected
const ret: SelectedItems = {
selected: new Set<FUID>(),
missing: new Set<FUID>(),
rootdir: {} as DirList,
entries: {} as Record<FUID, FileEntry | DirEntry>,
fullpath: {} as Record<FUID, string>,
relpath: {} as Record<FUID, string>,
url: {} as Record<FUID, string>,
ids: [] as FUID[]
}
traverseDir(this.root, '', '')
// What did we not select?
for (const id of selected) {
if (!ret.selected.has(id)) ret.missing.add(id)
}
// Sorted array of FUIDs for easy traversal
ret.ids.sort((a, b) => ret.relpath[a].localeCompare(ret.relpath[b] , undefined, {numeric: true, sensitivity: 'base'}))
return ret
} }
} }
}) })

View File

@ -1,17 +1,10 @@
<template> <template>
<transition
name="slide-fade"
mode="out-in"
@before-enter="beforeEnter"
@enter="enter"
@leave="leave"
>
<FileExplorer <FileExplorer
ref="fileExplorer" ref="fileExplorer"
:key="Router.currentRoute.value.path"
:path="Router.currentRoute.value.path" :path="Router.currentRoute.value.path"
:documents="documentStore.mainDocument" :documents="documentStore.mainDocument"
/> />
</transition>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -27,33 +20,4 @@ watchEffect(async () => {
const path = new String(Router.currentRoute.value.path) as string const path = new String(Router.currentRoute.value.path) as string
documentStore.setActualDocument(path.toString()) documentStore.setActualDocument(path.toString())
}) })
function beforeEnter(el: Element) {
const elem = el as HTMLElement
elem.style.transform = 'translateX(100%)'
}
function enter(el: Element, done: () => void) {
const elem = el as HTMLElement
setTimeout(() => {
elem.style.transform = 'translateX(0)'
done()
}, 0)
}
function leave(el: Element, done: () => void) {
const elem = el as HTMLElement
elem.style.transform = 'translateX(-100%)'
setTimeout(done, 200) // Should match --transition-time
}
</script> </script>
<style scoped>
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: transform var(--transition-time) linear;
}
.slide-fade-enter,
.slide-fade-leave-to /* .slide-fade-leave-active for <2.1.8 */ {
transform: translateX(100%);
}
</style>