Frontend created and rewritten a few times, with some backend fixes #1
|
@ -15,14 +15,8 @@
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
import type { ComputedRef } from 'vue'
|
import type { ComputedRef } from 'vue'
|
||||||
import type HeaderMain from '@/components/HeaderMain.vue'
|
import type HeaderMain from '@/components/HeaderMain.vue'
|
||||||
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import createWebSocket from '@/repositories/WS'
|
import { watchConnect, watchDisconnect } from '@/repositories/WS'
|
||||||
import {
|
|
||||||
url_document_watch_ws,
|
|
||||||
url_document_upload_ws,
|
|
||||||
DocumentHandler,
|
|
||||||
DocumentUploadHandler
|
|
||||||
} from '@/repositories/Document'
|
|
||||||
import { useDocumentStore } from '@/stores/documents'
|
import { useDocumentStore } from '@/stores/documents'
|
||||||
|
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
@ -41,23 +35,10 @@ const path: ComputedRef<Path> = computed(() => {
|
||||||
pathList
|
pathList
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
onMounted(watchConnect)
|
||||||
|
onUnmounted(watchDisconnect)
|
||||||
// Update human-readable x seconds ago messages from mtimes
|
// Update human-readable x seconds ago messages from mtimes
|
||||||
setInterval(documentStore.updateModified, 1000)
|
setInterval(documentStore.updateModified, 1000)
|
||||||
watchEffect(() => {
|
|
||||||
const documentHandler = new DocumentHandler()
|
|
||||||
const documentUploadHandler = new DocumentUploadHandler()
|
|
||||||
const wsWatch = createWebSocket(
|
|
||||||
url_document_watch_ws,
|
|
||||||
documentHandler.handleWebSocketMessage
|
|
||||||
)
|
|
||||||
const wsUpload = createWebSocket(
|
|
||||||
url_document_upload_ws,
|
|
||||||
documentUploadHandler.handleWebSocketMessage
|
|
||||||
)
|
|
||||||
|
|
||||||
documentStore.wsWatch = wsWatch
|
|
||||||
documentStore.wsUpload = wsUpload
|
|
||||||
})
|
|
||||||
const headerMain = ref<typeof HeaderMain | null>(null)
|
const headerMain = ref<typeof HeaderMain | null>(null)
|
||||||
let vert = 0
|
let vert = 0
|
||||||
let timer: any = null
|
let timer: any = null
|
||||||
|
|
|
@ -197,6 +197,9 @@ main {
|
||||||
padding-bottom: 3em; /* convenience space on the bottom */
|
padding-bottom: 3em; /* convenience space on the bottom */
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
|
.spacer { flex-grow: 1 }
|
||||||
|
.smallgap { margin-left: 2em }
|
||||||
|
|
||||||
[data-tooltip]:hover:after {
|
[data-tooltip]:hover:after {
|
||||||
z-index: 101;
|
z-index: 101;
|
||||||
content: attr(data-tooltip);
|
content: attr(data-tooltip);
|
||||||
|
@ -206,7 +209,7 @@ main {
|
||||||
padding: .5rem 1rem;
|
padding: .5rem 1rem;
|
||||||
border-radius: 3rem 0 3rem 0;
|
border-radius: 3rem 0 3rem 0;
|
||||||
box-shadow: 0 0 1rem var(--accent-color);
|
box-shadow: 0 0 1rem var(--accent-color);
|
||||||
transform: translate(calc(1rem + -50%), 100%);
|
transform: translate(calc(1rem + -50%), 150%);
|
||||||
background-color: var(--accent-color);
|
background-color: var(--accent-color);
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
|
|
|
@ -59,17 +59,13 @@
|
||||||
<template
|
<template
|
||||||
v-for="doc of sorted(props.documents as Document[])"
|
v-for="doc of sorted(props.documents as Document[])"
|
||||||
:key="doc.key">
|
:key="doc.key">
|
||||||
<tr v-if="doc.loc !== prevloc && ((prevloc = doc.loc) || true)">
|
<tr class="folder-change" v-if="doc.loc !== prevloc && ((prevloc = doc.loc) || true)">
|
||||||
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
|
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr
|
<tr
|
||||||
:id="`file-${doc.key}`"
|
:id="`file-${doc.key}`"
|
||||||
:class="{
|
:class="{ file: !doc.dir, folder: doc.dir, cursor: cursor === doc }"
|
||||||
file: doc.type === 'file',
|
|
||||||
folder: doc.type === 'folder',
|
|
||||||
cursor: cursor === doc
|
|
||||||
}"
|
|
||||||
@click="cursor = cursor === doc ? null : doc"
|
@click="cursor = cursor === doc ? null : doc"
|
||||||
@contextmenu.prevent="contextMenu($event, doc)"
|
@contextmenu.prevent="contextMenu($event, doc)"
|
||||||
>
|
>
|
||||||
|
@ -104,7 +100,7 @@
|
||||||
@focus.stop="cursor = doc"
|
@focus.stop="cursor = doc"
|
||||||
@blur="ev => { if (!editing) cursor = null }"
|
@blur="ev => { if (!editing) cursor = null }"
|
||||||
@keyup.left="router.back()"
|
@keyup.left="router.back()"
|
||||||
@keyup.right.stop="ev => { if (doc.type === 'folder') (ev.target as HTMLElement).click() }"
|
@keyup.right.stop="ev => { if (doc.dir) (ev.target as HTMLElement).click() }"
|
||||||
>{{ doc.name }}</a
|
>{{ doc.name }}</a
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
@ -133,7 +129,7 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr>
|
<tr class="summary">
|
||||||
<td colspan="3" class="right">{{props.documents.length}} items shown:</td>
|
<td colspan="3" class="right">{{props.documents.length}} items shown:</td>
|
||||||
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
|
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
|
||||||
<td class="menu"></td>
|
<td class="menu"></td>
|
||||||
|
@ -148,7 +144,7 @@ import { ref, computed, watchEffect, onBeforeUpdate } from 'vue'
|
||||||
import { useDocumentStore } from '@/stores/documents'
|
import { useDocumentStore } from '@/stores/documents'
|
||||||
import type { Document } from '@/repositories/Document'
|
import type { Document } from '@/repositories/Document'
|
||||||
import FileRenameInput from './FileRenameInput.vue'
|
import FileRenameInput from './FileRenameInput.vue'
|
||||||
import createWebSocket from '@/repositories/WS'
|
import { connect, controlUrl } from '@/repositories/WS'
|
||||||
import { collator, formatSize, formatUnixDate } from '@/utils'
|
import { collator, formatSize, formatUnixDate } from '@/utils'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
@ -163,14 +159,15 @@ const documentStore = useDocumentStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const url_for = (doc: Document) => {
|
const url_for = (doc: Document) => {
|
||||||
const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
return doc.type === 'folder' ? `#/${p}/` : `/files/${p}`
|
return doc.dir ? `#/${p}/` : `/files/${p}`
|
||||||
}
|
}
|
||||||
const cursor = ref<Document | null>(null)
|
const cursor = ref<Document | null>(null)
|
||||||
// File rename
|
// File rename
|
||||||
const editing = ref<Document | null>(null)
|
const editing = ref<Document | null>(null)
|
||||||
const rename = (doc: Document, newName: string) => {
|
const rename = (doc: Document, newName: string) => {
|
||||||
const oldName = doc.name
|
const oldName = doc.name
|
||||||
const control = createWebSocket('/api/control', (ev: MessageEvent) => {
|
const control = connect(controlUrl, {
|
||||||
|
message(ev: MessageEvent) {
|
||||||
const msg = JSON.parse(ev.data)
|
const msg = JSON.parse(ev.data)
|
||||||
if ('error' in msg) {
|
if ('error' in msg) {
|
||||||
console.error('Rename failed', msg.error.message, msg.error)
|
console.error('Rename failed', msg.error.message, msg.error)
|
||||||
|
@ -178,6 +175,7 @@ const rename = (doc: Document, newName: string) => {
|
||||||
} else {
|
} else {
|
||||||
console.log('Rename succeeded', msg)
|
console.log('Rename succeeded', msg)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
control.onopen = () => {
|
control.onopen = () => {
|
||||||
control.send(
|
control.send(
|
||||||
|
@ -204,6 +202,7 @@ defineExpose({
|
||||||
modified: formatUnixDate(now),
|
modified: formatUnixDate(now),
|
||||||
haystack: '',
|
haystack: '',
|
||||||
}
|
}
|
||||||
|
console.log("New")
|
||||||
},
|
},
|
||||||
toggleSelectAll() {
|
toggleSelectAll() {
|
||||||
console.log('Select')
|
console.log('Select')
|
||||||
|
@ -289,7 +288,16 @@ watchEffect(() => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const mkdir = (doc: Document, name: string) => {
|
const mkdir = (doc: Document, name: string) => {
|
||||||
const control = createWebSocket('/api/control', (ev: MessageEvent) => {
|
const control = connect(controlUrl, {
|
||||||
|
open() {
|
||||||
|
control.send(
|
||||||
|
JSON.stringify({
|
||||||
|
op: 'mkdir',
|
||||||
|
path: `${doc.loc}/${name}`
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
message(ev: MessageEvent) {
|
||||||
const msg = JSON.parse(ev.data)
|
const msg = JSON.parse(ev.data)
|
||||||
if ('error' in msg) {
|
if ('error' in msg) {
|
||||||
console.error('Mkdir failed', msg.error.message, msg.error)
|
console.error('Mkdir failed', msg.error.message, msg.error)
|
||||||
|
@ -298,15 +306,8 @@ const mkdir = (doc: Document, name: string) => {
|
||||||
console.log('mkdir', msg)
|
console.log('mkdir', msg)
|
||||||
router.push(`/${doc.loc}/${name}/`)
|
router.push(`/${doc.loc}/${name}/`)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
control.onopen = () => {
|
|
||||||
control.send(
|
|
||||||
JSON.stringify({
|
|
||||||
op: 'mkdir',
|
|
||||||
path: `${doc.loc}/${name}`
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
doc.name = name // We should get an update from watch but this is quicker
|
doc.name = name // We should get an update from watch but this is quicker
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -499,6 +500,9 @@ tbody .selection input {
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
.folder-change {
|
||||||
|
margin-left: -.5rem;
|
||||||
|
}
|
||||||
.loc {
|
.loc {
|
||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,26 +1,3 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { useDocumentStore } from '@/stores/documents'
|
|
||||||
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) document.querySelector('.breadcrumb')!.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
toggleSearchInput
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="headermain">
|
<nav class="headermain">
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
|
@ -39,16 +16,46 @@ defineExpose({
|
||||||
v-model="documentStore.search"
|
v-model="documentStore.search"
|
||||||
placeholder="Search words"
|
placeholder="Search words"
|
||||||
class="margin-input"
|
class="margin-input"
|
||||||
@blur="() => { if (documentStore.search === '') toggleSearchInput() }"
|
@keyup.escape="closeSearch"
|
||||||
@keyup.esc="toggleSearchInput"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<SvgButton ref="searchButton" name="find" @click="toggleSearchInput" />
|
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
|
||||||
<SvgButton name="cog" @click="console.log('settings menu')" />
|
<SvgButton name="cog" @click="console.log('settings menu')" />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useDocumentStore } from '@/stores/documents'
|
||||||
|
import { ref, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const documentStore = useDocumentStore()
|
||||||
|
const showSearchInput = ref<boolean>(false)
|
||||||
|
const search = ref<HTMLInputElement | null>()
|
||||||
|
const searchButton = ref<HTMLButtonElement | null>()
|
||||||
|
|
||||||
|
const closeSearch = () => {
|
||||||
|
if (!showSearchInput.value) return // Already closing
|
||||||
|
showSearchInput.value = false
|
||||||
|
documentStore.search = ''
|
||||||
|
const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement
|
||||||
|
breadcrumb.focus()
|
||||||
|
}
|
||||||
|
const toggleSearchInput = () => {
|
||||||
|
showSearchInput.value = !showSearchInput.value
|
||||||
|
if (!showSearchInput.value) return closeSearch()
|
||||||
|
nextTick(() => {
|
||||||
|
const input = search.value
|
||||||
|
if (input) input.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
toggleSearchInput,
|
||||||
|
closeSearch,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.buttons {
|
.buttons {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -60,12 +67,6 @@ defineExpose({
|
||||||
.buttons > * {
|
.buttons > * {
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
}
|
}
|
||||||
.spacer {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
.smallgap {
|
|
||||||
margin-left: 2em;
|
|
||||||
}
|
|
||||||
input[type='search'] {
|
input[type='search'] {
|
||||||
background: var(--primary-background);
|
background: var(--primary-background);
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import createWebSocket from '@/repositories/WS'
|
import {connect, controlUrl} from '@/repositories/WS'
|
||||||
import { useDocumentStore } from '@/stores/documents'
|
import { useDocumentStore } from '@/stores/documents'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { SelectedItems } from '@/repositories/Document'
|
import type { SelectedItems } from '@/repositories/Document'
|
||||||
|
@ -26,21 +26,27 @@ const op = (op: string, dst?: string) => {
|
||||||
const sel = documentStore.selectedFiles
|
const sel = documentStore.selectedFiles
|
||||||
const msg = {
|
const msg = {
|
||||||
op,
|
op,
|
||||||
sel: sel.ids.filter(id => sel.selected.has(id)).map(id => sel.fullpath[id])
|
sel: sel.keys.map(key => {
|
||||||
|
const doc = sel.docs[key]
|
||||||
|
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (dst !== undefined) msg.dst = dst
|
if (dst !== undefined) msg.dst = dst
|
||||||
const control = createWebSocket('/api/control', ev => {
|
const control = connect(controlUrl, {
|
||||||
|
message(ev: WebSocmetMessageEvent) {
|
||||||
const res = JSON.parse(ev.data)
|
const res = JSON.parse(ev.data)
|
||||||
if ('error' in res) {
|
if ('error' in res) {
|
||||||
console.error('Control socket error', msg, res.error)
|
console.error('Control socket error', msg, res.error)
|
||||||
|
documentStore.error = res.error.message
|
||||||
return
|
return
|
||||||
} else if (res.status === 'ack') {
|
} else if (res.status === 'ack') {
|
||||||
console.log('Control ack OK', res)
|
console.log('Control ack OK', res)
|
||||||
control.close()
|
control.close()
|
||||||
documentStore.selected.clear()
|
documentStore.selected.clear()
|
||||||
return
|
return
|
||||||
} else console.log('Unknown control respons', msg, res)
|
} else console.log('Unknown control response', msg, res)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
control.onopen = () => {
|
control.onopen = () => {
|
||||||
control.send(JSON.stringify(msg))
|
control.send(JSON.stringify(msg))
|
||||||
|
@ -57,21 +63,15 @@ const linkdl = (href: string) => {
|
||||||
const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => {
|
const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => {
|
||||||
let hdir = ''
|
let hdir = ''
|
||||||
let h = handle
|
let h = handle
|
||||||
let filelist = []
|
console.log('Downloading to filesystem', sel.recursive)
|
||||||
for (const id of sel.ids) {
|
for (const [rel, full, doc] of sel.recursive) {
|
||||||
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
|
// Create any missing directories
|
||||||
if (!rel.startsWith(hdir)) {
|
if (hdir && !rel.startsWith(hdir + '/')) {
|
||||||
hdir = ''
|
hdir = ''
|
||||||
h = handle
|
h = handle
|
||||||
}
|
}
|
||||||
const r = rel.slice(hdir.length)
|
const r = rel.slice(hdir.length)
|
||||||
for (const dir of r.split('/').slice(0, url ? -1 : undefined)) {
|
for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) {
|
||||||
hdir += `${dir}/`
|
hdir += `${dir}/`
|
||||||
try {
|
try {
|
||||||
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
|
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
|
||||||
|
@ -81,17 +81,18 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
||||||
}
|
}
|
||||||
console.log('Created', hdir)
|
console.log('Created', hdir)
|
||||||
}
|
}
|
||||||
if (!url) continue // Target was a folder and was created
|
if (doc.dir) continue // Target was a folder and was created
|
||||||
const name = rel.split('/').pop()!.normalize('NFC')
|
const name = rel.split('/').pop()!.normalize('NFC')
|
||||||
// Download file
|
// Download file
|
||||||
let fileHandle
|
let fileHandle
|
||||||
try {
|
try {
|
||||||
fileHandle = await h.getFileHandle(name, { create: true })
|
fileHandle = await h.getFileHandle(name, { create: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create file', hdir + name, error)
|
console.error('Failed to create file', rel, full, hdir + name, error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const writable = await fileHandle.createWritable()
|
const writable = await fileHandle.createWritable()
|
||||||
|
const url = `/files/${rel}`
|
||||||
console.log('Fetching', url)
|
console.log('Fetching', url)
|
||||||
const res = await fetch(url)
|
const res = await fetch(url)
|
||||||
if (!res.ok)
|
if (!res.ok)
|
||||||
|
@ -109,16 +110,16 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
||||||
const download = async () => {
|
const download = async () => {
|
||||||
const sel = documentStore.selectedFiles
|
const sel = documentStore.selectedFiles
|
||||||
console.log('Download', sel)
|
console.log('Download', sel)
|
||||||
if (sel.selected.size === 0) {
|
if (sel.keys.length === 0) {
|
||||||
console.warn('Attempted download but no files found. Missing:', sel.missing)
|
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
|
||||||
documentStore.selected.clear()
|
documentStore.selected.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Plain old a href download if only one file (ignoring any folders)
|
// Plain old a href download if only one file (ignoring any folders)
|
||||||
const urls = Object.values(sel.url)
|
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
|
||||||
if (urls.length === 1) {
|
if (files.length === 1) {
|
||||||
documentStore.selected.clear()
|
documentStore.selected.clear()
|
||||||
return linkdl(urls[0] as string)
|
return linkdl(`/files/${files[0][1]}`)
|
||||||
}
|
}
|
||||||
// Use FileSystem API if multiple files and the browser supports it
|
// Use FileSystem API if multiple files and the browser supports it
|
||||||
if ('showDirectoryPicker' in window) {
|
if ('showDirectoryPicker' in window) {
|
||||||
|
@ -137,7 +138,7 @@ const download = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Otherwise, zip and download
|
// Otherwise, zip and download
|
||||||
linkdl(`/zip/${Array.from(sel.selected).join('+')}/download.zip`)
|
linkdl(`/zip/${Array.from(sel.keys).join('+')}/download.zip`)
|
||||||
documentStore.selected.clear()
|
documentStore.selected.clear()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,6 +1,3 @@
|
||||||
import { useDocumentStore } from '@/stores/documents'
|
|
||||||
import createWebSocket from './WS'
|
|
||||||
|
|
||||||
export type FUID = string
|
export type FUID = string
|
||||||
|
|
||||||
export type Document = {
|
export type Document = {
|
||||||
|
@ -13,7 +10,7 @@ export type Document = {
|
||||||
mtime: number
|
mtime: number
|
||||||
modified: string
|
modified: string
|
||||||
haystack: string
|
haystack: string
|
||||||
dir?: DirList
|
dir: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type errorEvent = {
|
export type errorEvent = {
|
||||||
|
@ -52,111 +49,8 @@ export type UpdateEntry = {
|
||||||
|
|
||||||
// Helper structure for selections
|
// Helper structure for selections
|
||||||
export interface SelectedItems {
|
export interface SelectedItems {
|
||||||
selected: Set<FUID>
|
keys: FUID[]
|
||||||
|
docs: Record<FUID, Document>
|
||||||
|
recursive: Array<[string, string, Document]>
|
||||||
missing: 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'
|
|
||||||
|
|
||||||
export class DocumentHandler {
|
|
||||||
constructor(private store = useDocumentStore()) {
|
|
||||||
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
handleWebSocketMessage(event: MessageEvent) {
|
|
||||||
const msg = JSON.parse(event.data)
|
|
||||||
if ('error' in msg) {
|
|
||||||
if (msg.error.code === 401) {
|
|
||||||
this.store.user.isLoggedIn = false
|
|
||||||
this.store.user.isOpenLoginModal = true
|
|
||||||
} else {
|
|
||||||
this.store.error = msg.error.message
|
|
||||||
}
|
|
||||||
// The server closes the websocket after errors, so we need to reopen it
|
|
||||||
setTimeout(() => {
|
|
||||||
this.store.wsWatch = createWebSocket(
|
|
||||||
url_document_watch_ws,
|
|
||||||
this.handleWebSocketMessage
|
|
||||||
)
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
switch (true) {
|
|
||||||
case !!msg.root:
|
|
||||||
this.handleRootMessage(msg)
|
|
||||||
break
|
|
||||||
case !!msg.update:
|
|
||||||
this.handleUpdateMessage(msg)
|
|
||||||
break
|
|
||||||
case !!msg.space:
|
|
||||||
console.log('Watch space', msg.space)
|
|
||||||
break
|
|
||||||
case !!msg.error:
|
|
||||||
this.handleError(msg)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleRootMessage({ root }: { root: DirEntry }) {
|
|
||||||
console.log('Watch root', root)
|
|
||||||
if (this.store) {
|
|
||||||
this.store.user.isLoggedIn = true
|
|
||||||
this.store.updateRoot(root)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
|
||||||
console.log('Watch update', updateData.update)
|
|
||||||
let node: DirEntry = this.store.root
|
|
||||||
for (const elem of updateData.update) {
|
|
||||||
if (elem.deleted) {
|
|
||||||
delete node.dir[elem.name]
|
|
||||||
break // Deleted elements can't have further children
|
|
||||||
}
|
|
||||||
if (elem.name !== undefined) {
|
|
||||||
// @ts-ignore
|
|
||||||
node = node.dir[elem.name] ||= {}
|
|
||||||
}
|
|
||||||
if (elem.key !== undefined) node.key = elem.key
|
|
||||||
if (elem.size !== undefined) node.size = elem.size
|
|
||||||
if (elem.mtime !== undefined) node.mtime = elem.mtime
|
|
||||||
if (elem.dir !== undefined) node.dir = elem.dir
|
|
||||||
}
|
|
||||||
this.store.updateRoot()
|
|
||||||
}
|
|
||||||
private handleError(msg: errorEvent) {
|
|
||||||
if (msg.error.code === 401) {
|
|
||||||
this.store.user.isOpenLoginModal = true
|
|
||||||
this.store.user.isLoggedIn = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DocumentUploadHandler {
|
|
||||||
constructor(private store = useDocumentStore()) {
|
|
||||||
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
handleWebSocketMessage(event: MessageEvent) {
|
|
||||||
const msg = JSON.parse(event.data)
|
|
||||||
switch (true) {
|
|
||||||
case !!msg.written:
|
|
||||||
this.handleWrittenMessage(msg)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleWrittenMessage(msg: { written: number }) {
|
|
||||||
// if (this.store && this.store.root) this.store.root = root;
|
|
||||||
console.log('Written message', msg.written)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,118 @@
|
||||||
function createWebSocket(url: string, eventHandler: (event: MessageEvent) => void) {
|
import { useDocumentStore } from "@/stores/documents"
|
||||||
const urlObject = new URL(url, location.origin.replace(/^http/, 'ws'))
|
import type { DirEntry, UpdateEntry, errorEvent } from "./Document"
|
||||||
const webSocket = new WebSocket(urlObject)
|
|
||||||
webSocket.onmessage = eventHandler
|
export const controlUrl = '/api/control'
|
||||||
|
export const uploadUrl = '/api/upload'
|
||||||
|
export const watchUrl = '/api/watch'
|
||||||
|
|
||||||
|
let tree = null as DirEntry | null
|
||||||
|
let reconnectDuration = 500
|
||||||
|
let wsWatch = null as WebSocket | null
|
||||||
|
|
||||||
|
export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => {
|
||||||
|
const webSocket = new WebSocket(new URL(path, location.origin.replace(/^http/, 'ws')))
|
||||||
|
for (const [event, handler] of Object.entries(handlers)) webSocket.addEventListener(event, handler)
|
||||||
return webSocket
|
return webSocket
|
||||||
}
|
}
|
||||||
|
|
||||||
export default createWebSocket
|
export const watchConnect = async () => {
|
||||||
|
wsWatch = connect(watchUrl, {
|
||||||
|
open() { console.log("Connected to", watchUrl)},
|
||||||
|
message: handleWatchMessage,
|
||||||
|
close: watchReconnect,
|
||||||
|
})
|
||||||
|
await wsWatch
|
||||||
|
}
|
||||||
|
|
||||||
|
export const watchDisconnect = () => {
|
||||||
|
if (!wsWatch) return
|
||||||
|
wsWatch.close()
|
||||||
|
wsWatch = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchReconnect = (event: MessageEvent) => {
|
||||||
|
const store = useDocumentStore()
|
||||||
|
if (store.connected) {
|
||||||
|
console.warn("Disconnected from server", event)
|
||||||
|
store.connected = false
|
||||||
|
}
|
||||||
|
reconnectDuration = Math.min(5000, reconnectDuration + 500)
|
||||||
|
// The server closes the websocket after errors, so we need to reopen it
|
||||||
|
setTimeout(() => {
|
||||||
|
wsWatch = connect(watchUrl, {
|
||||||
|
message: handleWatchMessage,
|
||||||
|
close: watchReconnect,
|
||||||
|
})
|
||||||
|
console.log("Attempting to reconnect...")
|
||||||
|
}, reconnectDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const handleWatchMessage = (event: MessageEvent) => {
|
||||||
|
const store = useDocumentStore()
|
||||||
|
const msg = JSON.parse(event.data)
|
||||||
|
if ('error' in msg) {
|
||||||
|
if (msg.error.code === 401) {
|
||||||
|
store.user.isLoggedIn = false
|
||||||
|
store.user.isOpenLoginModal = true
|
||||||
|
} else {
|
||||||
|
store.error = msg.error.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (true) {
|
||||||
|
case !!msg.root:
|
||||||
|
handleRootMessage(msg)
|
||||||
|
break
|
||||||
|
case !!msg.update:
|
||||||
|
handleUpdateMessage(msg)
|
||||||
|
break
|
||||||
|
case !!msg.space:
|
||||||
|
console.log('Watch space', msg.space)
|
||||||
|
break
|
||||||
|
case !!msg.error:
|
||||||
|
handleError(msg)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRootMessage({ root }: { root: DirEntry }) {
|
||||||
|
const store = useDocumentStore()
|
||||||
|
console.log('Watch root', root)
|
||||||
|
reconnectDuration = 500
|
||||||
|
store.connected = true
|
||||||
|
store.user.isLoggedIn = true
|
||||||
|
store.updateRoot(root)
|
||||||
|
tree = root
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
||||||
|
const store = useDocumentStore()
|
||||||
|
console.log('Watch update', updateData.update)
|
||||||
|
if (!tree) return console.error('Watch update before root')
|
||||||
|
let node: DirEntry = tree
|
||||||
|
for (const elem of updateData.update) {
|
||||||
|
if (elem.deleted) {
|
||||||
|
delete node.dir[elem.name]
|
||||||
|
break // Deleted elements can't have further children
|
||||||
|
}
|
||||||
|
if (elem.name !== undefined) {
|
||||||
|
// @ts-ignore
|
||||||
|
node = node.dir[elem.name] ||= {}
|
||||||
|
}
|
||||||
|
if (elem.key !== undefined) node.key = elem.key
|
||||||
|
if (elem.size !== undefined) node.size = elem.size
|
||||||
|
if (elem.mtime !== undefined) node.mtime = elem.mtime
|
||||||
|
if (elem.dir !== undefined) node.dir = elem.dir
|
||||||
|
}
|
||||||
|
store.updateRoot(tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(msg: errorEvent) {
|
||||||
|
const store = useDocumentStore()
|
||||||
|
if (msg.error.code === 401) {
|
||||||
|
store.user.isOpenLoginModal = true
|
||||||
|
store.user.isLoggedIn = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -30,10 +30,9 @@ export const useDocumentStore = defineStore({
|
||||||
selected: new Set<FUID>(),
|
selected: new Set<FUID>(),
|
||||||
uploadingDocuments: [],
|
uploadingDocuments: [],
|
||||||
uploadCount: 0 as number,
|
uploadCount: 0 as number,
|
||||||
wsWatch: undefined,
|
|
||||||
wsUpload: undefined,
|
|
||||||
fileExplorer: null,
|
fileExplorer: null,
|
||||||
error: '' as string,
|
error: '' as string,
|
||||||
|
connected: false,
|
||||||
user: {
|
user: {
|
||||||
username: '',
|
username: '',
|
||||||
privileged: false,
|
privileged: false,
|
||||||
|
@ -44,14 +43,16 @@ export const useDocumentStore = defineStore({
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
updateRoot(root: DirEntry | null = null) {
|
updateRoot(root: DirEntry | null = null) {
|
||||||
root ??= this.root
|
if (!root) {
|
||||||
|
this.document = []
|
||||||
|
return
|
||||||
|
}
|
||||||
// Transform tree data to flat documents array
|
// Transform tree data to flat documents array
|
||||||
let loc = ""
|
let loc = ""
|
||||||
const mapper = ([name, attr]: [string, FileEntry | DirEntry]) => ({
|
const mapper = ([name, attr]: [string, FileEntry | DirEntry]) => ({
|
||||||
|
...attr,
|
||||||
loc,
|
loc,
|
||||||
name,
|
name,
|
||||||
type: 'dir' in attr ? 'folder' : 'file' as 'folder' | 'file',
|
|
||||||
...attr,
|
|
||||||
sizedisp: formatSize(attr.size),
|
sizedisp: formatSize(attr.size),
|
||||||
modified: formatUnixDate(attr.mtime),
|
modified: formatUnixDate(attr.mtime),
|
||||||
haystack: haystackFormat(name),
|
haystack: haystackFormat(name),
|
||||||
|
@ -61,17 +62,19 @@ export const useDocumentStore = defineStore({
|
||||||
for (let doc; (doc = queue.shift()) !== undefined;) {
|
for (let doc; (doc = queue.shift()) !== undefined;) {
|
||||||
docs.push(doc)
|
docs.push(doc)
|
||||||
if ("dir" in doc) {
|
if ("dir" in doc) {
|
||||||
|
// Recurse but replace recursive structure with boolean
|
||||||
loc = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
loc = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
queue.push(...Object.entries(doc.dir).map(mapper))
|
queue.push(...Object.entries(doc.dir).map(mapper))
|
||||||
|
doc.dir = true
|
||||||
}
|
}
|
||||||
|
else doc.dir = false
|
||||||
}
|
}
|
||||||
// Pre sort directory entries folders first then files, names in natural ordering
|
// Pre sort directory entries folders first then files, names in natural ordering
|
||||||
docs.sort((a, b) =>
|
docs.sort((a, b) =>
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
(a.type === "file") - (b.type === "file") ||
|
b.dir - a.dir ||
|
||||||
collator.compare(a.name, b.name)
|
collator.compare(a.name, b.name)
|
||||||
)
|
)
|
||||||
this.root = root
|
|
||||||
this.document = docs
|
this.document = docs
|
||||||
},
|
},
|
||||||
updateUploadingDocuments(key: number, progress: number) {
|
updateUploadingDocuments(key: number, progress: number) {
|
||||||
|
@ -119,53 +122,46 @@ export const useDocumentStore = defineStore({
|
||||||
return ret
|
return ret
|
||||||
},
|
},
|
||||||
selectedFiles(): SelectedItems {
|
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
|
|
||||||
const key = attr.key
|
|
||||||
// Is this the file we are looking for? Ignore if nested within another selection.
|
|
||||||
let r = relpath
|
|
||||||
if (selected.has(key) && !relpath) {
|
|
||||||
ret.selected.add(key)
|
|
||||||
ret.rootdir[name] = attr
|
|
||||||
r = name
|
|
||||||
} else if (relpath) {
|
|
||||||
r = `${relpath}/${name}`
|
|
||||||
}
|
|
||||||
if (r) {
|
|
||||||
ret.entries[key] = attr
|
|
||||||
ret.fullpath[key] = fullname
|
|
||||||
ret.relpath[key] = r
|
|
||||||
ret.ids.push(key)
|
|
||||||
if (!('dir' in attr)) ret.url[key] = `/files/${fullname}`
|
|
||||||
}
|
|
||||||
traverseDir(attr, fullname, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const selected = this.selected
|
const selected = this.selected
|
||||||
|
const found = new Set<FUID>()
|
||||||
const ret: SelectedItems = {
|
const ret: SelectedItems = {
|
||||||
selected: new Set<FUID>(),
|
missing: new Set(),
|
||||||
missing: new Set<FUID>(),
|
docs: {},
|
||||||
rootdir: {} as DirList,
|
keys: [],
|
||||||
entries: {} as Record<FUID, FileEntry | DirEntry>,
|
recursive: [],
|
||||||
fullpath: {} as Record<FUID, string>,
|
}
|
||||||
relpath: {} as Record<FUID, string>,
|
for (const doc of this.document) {
|
||||||
url: {} as Record<FUID, string>,
|
if (selected.has(doc.key)) {
|
||||||
ids: [] as FUID[]
|
found.add(doc.key)
|
||||||
|
ret.keys.push(doc.key)
|
||||||
|
ret.docs[doc.key] = doc
|
||||||
|
}
|
||||||
}
|
}
|
||||||
traverseDir(this.root, '', '')
|
|
||||||
// What did we not select?
|
// What did we not select?
|
||||||
for (const id of selected) {
|
for (const key of selected) if (!found.has(key)) ret.missing.add(key)
|
||||||
if (!ret.selected.has(id)) ret.missing.add(id)
|
// Build a flat list including contents recursively
|
||||||
|
const relnames = new Set<string>()
|
||||||
|
function add(rel: string, full: string, doc: Document) {
|
||||||
|
if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`)
|
||||||
|
relnames.add(rel)
|
||||||
|
ret.recursive.push([rel, full, doc])
|
||||||
}
|
}
|
||||||
// Sorted array of FUIDs for easy traversal
|
for (const key of ret.keys) {
|
||||||
ret.ids.sort((a, b) =>
|
const base = ret.docs[key]
|
||||||
ret.relpath[a].localeCompare(ret.relpath[b], undefined, {
|
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
|
||||||
numeric: true,
|
const nremove = base.loc.length
|
||||||
sensitivity: 'base'
|
add(base.name, basepath, base)
|
||||||
})
|
for (const doc of this.document) {
|
||||||
)
|
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
|
||||||
|
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
|
const rel = full.slice(nremove)
|
||||||
|
add(rel, full, doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort by rel (name stored as on download)
|
||||||
|
ret.recursive.sort((a, b) => collator.compare(a[0], b[0]))
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user