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']
|
||||
FileViewer: typeof import('./src/components/FileViewer.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']
|
||||
ModalDialog: typeof import('./src/components/ModalDialog.vue')['default']
|
||||
NotificationLoading: typeof import('./src/components/NotificationLoading.vue')['default']
|
||||
|
|
|
@ -52,7 +52,7 @@ let vert = 0
|
|||
let vertInterval: any = null
|
||||
const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
if (event.repeat) return
|
||||
console.log("key pressed", event)
|
||||
//console.log("key pressed", event)
|
||||
const c = documentStore.fileExplorer.isCursor
|
||||
const keyup = event.type === "keyup"
|
||||
// For up/down implement custom fast repeat
|
||||
|
|
|
@ -30,6 +30,12 @@
|
|||
html {
|
||||
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) {
|
||||
html {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -53,6 +53,19 @@ export type UpdateEntry = {
|
|||
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_upload_ws = '/api/upload'
|
||||
export const url_document_get = '/files'
|
||||
|
|
|
@ -3,7 +3,8 @@ import type {
|
|||
DirEntry,
|
||||
FileEntry,
|
||||
FUID,
|
||||
DirList
|
||||
DirList,
|
||||
SelectedItems,
|
||||
} from '@/repositories/Document'
|
||||
import { formatSize, formatUnixDate } from '@/utils'
|
||||
import { defineStore } from 'pinia'
|
||||
|
@ -162,6 +163,50 @@ export const useDocumentStore = defineStore({
|
|||
},
|
||||
isUserLogged(): boolean {
|
||||
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>
|
||||
<transition
|
||||
name="slide-fade"
|
||||
mode="out-in"
|
||||
@before-enter="beforeEnter"
|
||||
@enter="enter"
|
||||
@leave="leave"
|
||||
>
|
||||
<FileExplorer
|
||||
ref="fileExplorer"
|
||||
:key="Router.currentRoute.value.path"
|
||||
:path="Router.currentRoute.value.path"
|
||||
:documents="documentStore.mainDocument"
|
||||
/>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
@ -27,33 +20,4 @@ watchEffect(async () => {
|
|||
const path = new String(Router.currentRoute.value.path) as string
|
||||
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>
|
||||
|
||||
<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