Multiple file downloads via Filesystem API, and other tuning.
This commit is contained in:
parent
40a45568c1
commit
41fbd3d122
1
cista-front/components.d.ts
vendored
1
cista-front/components.d.ts
vendored
|
@ -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']
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
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;
|
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;
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -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>
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user