Refactoring Document storage (#5)

- Major refactoring that makes Doc a class with properties
- Data made only shallow reactive, for a good speedup of initial load
- Minor bugfixes and UX improvements along the way
- Fixed handling of hash and question marks in URLs (was confusing Vue Router)
- Search made stricter to find good results (not ignore all punctuation)

Reviewed-on: #5
This commit is contained in:
Leo Vasanko
2023-11-13 17:52:57 +00:00
parent dc4bb494f3
commit 41e8c78ecd
15 changed files with 141 additions and 248 deletions

View File

@@ -4,7 +4,7 @@
aria-label="Breadcrumb"
@keyup.left.stop="move(-1)"
@keyup.right.stop="move(1)"
@focus="move(0)"
@keyup.enter="move(0)"
>
<a href="#/"
:ref="el => setLinkRef(0, el)"
@@ -48,9 +48,11 @@ const navigate = (index: number) => {
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
const url = `/${longest.value.slice(0, index).join('/')}/`
const here = `/${longest.value.join('/')}/`
const current = decodeURIComponent(location.hash.slice(1).split('//')[0])
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
if (here.startsWith(current)) router.replace(u)
else router.push(u)
link.focus()
if (here.startsWith(location.hash.slice(1))) router.replace(url)
else router.push(url)
}
const move = (dir: number) => {

View File

@@ -17,7 +17,7 @@
<td class="name">
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
</td>
<FileModified :doc=editing />
<FileModified :doc=editing :key=nowkey />
<FileSize :doc=editing />
<td class="menu"></td>
</tr>
@@ -36,11 +36,11 @@
<input
type="checkbox"
tabindex="-1"
:checked="documentStore.selected.has(doc.key)"
:checked="store.selected.has(doc.key)"
@change="
($event.target as HTMLInputElement).checked
? documentStore.selected.add(doc.key)
: documentStore.selected.delete(doc.key)
? store.selected.add(doc.key)
: store.selected.delete(doc.key)
"
/>
</td>
@@ -50,7 +50,7 @@
</template>
<template v-else>
<a
:href="url_for(doc)"
:href="doc.url"
tabindex="-1"
@contextmenu.prevent
@focus.stop="cursor = doc"
@@ -61,7 +61,7 @@
<button v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊</button>
</template>
</td>
<FileModified :doc=doc />
<FileModified :doc=doc :key=nowkey />
<FileSize :doc=doc />
<td class="menu">
<button tabindex="-1" @click.stop="contextMenu($event, doc)"></button>
@@ -79,28 +79,24 @@
</template>
<script setup lang="ts">
import { ref, computed, watchEffect, onMounted, onUnmounted } from 'vue'
import { useDocumentStore } from '@/stores/documents'
import type { Document } from '@/repositories/Document'
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue'
import { connect, controlUrl } from '@/repositories/WS'
import { collator, formatSize, formatUnixDate } from '@/utils'
import { collator, formatSize } from '@/utils'
import { useRouter } from 'vue-router'
const props = defineProps<{
path: Array<string>
documents: Document[]
documents: Doc[]
}>()
const documentStore = useDocumentStore()
const store = useMainStore()
const router = useRouter()
const url_for = (doc: Document) => {
const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
return doc.dir ? `#/${p}/` : `/files/${p}`
}
const cursor = ref<Document | null>(null)
const cursor = shallowRef<Doc | null>(null)
// File rename
const editing = ref<Document | null>(null)
const rename = (doc: Document, newName: string) => {
const editing = shallowRef<Doc | null>(null)
const rename = (doc: Doc, newName: string) => {
const oldName = doc.name
const control = connect(controlUrl, {
message(ev: MessageEvent) {
@@ -124,7 +120,7 @@ const rename = (doc: Document, newName: string) => {
}
doc.name = newName // We should get an update from watch but this is quicker
}
const sortedDocuments = computed(() => sorted(props.documents as Document[]))
const sortedDocuments = computed(() => sorted(props.documents))
const showFolderBreadcrumb = (i: number) => {
const docs = sortedDocuments.value
const docloc = docs[i].loc
@@ -132,19 +128,15 @@ const showFolderBreadcrumb = (i: number) => {
}
defineExpose({
newFolder() {
const now = Date.now() / 1000
editing.value = {
const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({
loc: loc.value,
key: 'new',
name: 'New Folder',
dir: true,
mtime: now,
size: 0,
sizedisp: formatSize(0),
modified: formatUnixDate(now),
haystack: '',
}
console.log("New")
})
},
toggleSelectAll() {
console.log('Select')
@@ -163,10 +155,10 @@ defineExpose({
cursorSelect() {
const doc = cursor.value
if (!doc) return
if (documentStore.selected.has(doc.key)) {
documentStore.selected.delete(doc.key)
if (store.selected.has(doc.key)) {
store.selected.delete(doc.key)
} else {
documentStore.selected.add(doc.key)
store.selected.add(doc.key)
}
this.cursorMove(1)
},
@@ -191,8 +183,8 @@ defineExpose({
for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue
const key = documents[p].key
if (documentStore.selected.has(key)) documentStore.selected.delete(key)
else documentStore.selected.add(key)
if (store.selected.has(key)) store.selected.delete(key)
else store.selected.add(key)
}
}
// @ts-ignore
@@ -229,14 +221,14 @@ watchEffect(() => {
focusBreadcrumb()
}
})
// Update human-readable x seconds ago messages from mtimes
let nowkey = ref(0)
let modifiedTimer: any = null
const updateModified = () => {
for (const doc of props.documents) doc.modified = formatUnixDate(doc.mtime)
nowkey.value = Math.floor(Date.now() / 1000)
}
onMounted(() => { updateModified(); modifiedTimer = setInterval(updateModified, 1000) })
onUnmounted(() => { clearInterval(modifiedTimer) })
const mkdir = (doc: Document, name: string) => {
const mkdir = (doc: Doc, name: string) => {
const control = connect(controlUrl, {
open() {
control.send(
@@ -253,11 +245,13 @@ const mkdir = (doc: Document, name: string) => {
editing.value = null
} else {
console.log('mkdir', msg)
router.push(doc.loc ? `/${doc.loc}/${name}/` : `/${name}/`)
router.push(doc.urlrouter)
}
}
})
doc.name = name // We should get an update from watch but this is quicker
// We should get an update from watch but this is quicker
doc.name = name
doc.key = crypto.randomUUID()
}
// Column sort
@@ -266,11 +260,11 @@ const toggleSort = (name: string) => {
}
const sort = ref<string>('')
const sortCompare = {
name: (a: Document, b: Document) => collator.compare(a.name, b.name),
modified: (a: Document, b: Document) => b.mtime - a.mtime,
size: (a: Document, b: Document) => b.size - a.size
name: (a: Doc, b: Doc) => collator.compare(a.name, b.name),
modified: (a: Doc, b: Doc) => b.mtime - a.mtime,
size: (a: Doc, b: Doc) => b.size - a.size
}
const sorted = (documents: Document[]) => {
const sorted = (documents: Doc[]) => {
const cmp = sortCompare[sort.value as keyof typeof sortCompare]
const sorted = [...documents]
if (cmp) sorted.sort(cmp)
@@ -280,7 +274,7 @@ const selectionIndeterminate = computed({
get: () => {
return (
props.documents.length > 0 &&
props.documents.some((doc: Document) => documentStore.selected.has(doc.key)) &&
props.documents.some((doc: Doc) => store.selected.has(doc.key)) &&
!allSelected.value
)
},
@@ -291,16 +285,16 @@ const allSelected = computed({
get: () => {
return (
props.documents.length > 0 &&
props.documents.every((doc: Document) => documentStore.selected.has(doc.key))
props.documents.every((doc: Doc) => store.selected.has(doc.key))
)
},
set: (value: boolean) => {
console.log('Setting allSelected', value)
for (const doc of props.documents) {
if (value) {
documentStore.selected.add(doc.key)
store.selected.add(doc.key)
} else {
documentStore.selected.delete(doc.key)
store.selected.delete(doc.key)
}
}
}
@@ -308,7 +302,7 @@ const allSelected = computed({
const loc = computed(() => props.path.join('/'))
const contextMenu = (ev: Event, doc: Document) => {
const contextMenu = (ev: Event, doc: Doc) => {
cursor.value = doc
console.log('Context menu', ev, doc)
}
@@ -458,3 +452,4 @@ tbody .selection input {
color: #888;
}
</style>
@/stores/main

View File

@@ -5,7 +5,7 @@
</template>
<script setup lang="ts">
import type { Document } from '@/repositories/Document'
import { Doc } from '@/repositories/Document'
import { computed } from 'vue'
const datetime = computed(() =>
@@ -17,6 +17,6 @@ const tooltip = computed(() =>
)
const props = defineProps<{
doc: Document
doc: Doc
}>()
</script>

View File

@@ -12,7 +12,7 @@
</template>
<script setup lang="ts">
import type { Document } from '@/repositories/Document'
import { Doc } from '@/repositories/Document'
import { ref, onMounted, nextTick } from 'vue'
const input = ref<HTMLInputElement | null>(null)
@@ -28,8 +28,8 @@ onMounted(() => {
})
const props = defineProps<{
doc: Document
rename: (doc: Document, newName: string) => void
doc: Doc
rename: (doc: Doc, newName: string) => void
exit: () => void
}>()

View File

@@ -3,7 +3,7 @@
</template>
<script setup lang="ts">
import type { Document } from '@/repositories/Document'
import { Doc } from '@/repositories/Document'
import { computed } from 'vue'
const sizeClass = computed(() => {
@@ -12,7 +12,7 @@ const sizeClass = computed(() => {
})
const props = defineProps<{
doc: Document
doc: Doc
}>()
</script>

View File

@@ -1,15 +1,15 @@
<template>
<nav class="headermain">
<div class="buttons">
<template v-if="documentStore.error">
<div class="error-message" @click="documentStore.error = ''">{{ documentStore.error }}</div>
<template v-if="store.error">
<div class="error-message" @click="store.error = ''">{{ store.error }}</div>
<div class="smallgap"></div>
</template>
<UploadButton :path="props.path" />
<SvgButton
name="create-folder"
data-tooltip="New folder"
@click="() => documentStore.fileExplorer!.newFolder()"
@click="() => store.fileExplorer!.newFolder()"
/>
<slot></slot>
<div class="spacer smallgap"></div>
@@ -18,7 +18,6 @@
ref="search"
type="search"
:value="query"
@blur="ev => { if (!query) closeSearch(ev) }"
@input="updateSearch"
placeholder="Search words"
class="margin-input"
@@ -32,15 +31,19 @@
</template>
<script setup lang="ts">
import { useDocumentStore } from '@/stores/documents'
import { useMainStore } from '@/stores/main'
import { ref, nextTick, watchEffect } from 'vue'
import ContextMenu from '@imengyu/vue3-context-menu'
import router from '@/router';
const documentStore = useDocumentStore()
const store = useMainStore()
const showSearchInput = ref<boolean>(false)
const search = ref<HTMLInputElement | null>()
const searchButton = ref<HTMLButtonElement | null>()
const props = defineProps<{
path: Array<string>
query: string
}>()
const closeSearch = (ev: Event) => {
if (!showSearchInput.value) return // Already closing
@@ -54,9 +57,9 @@ const updateSearch = (ev: Event) => {
let p = props.path.join('/')
p = p ? `/${p}` : ''
const url = q ? `${p}//${q}` : (p || '/')
console.log("Update search", url)
if (!props.query && q) router.push(url)
else router.replace(url)
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
if (!props.query && q) router.push(u)
else router.replace(u)
}
const toggleSearchInput = (ev: Event) => {
showSearchInput.value = !showSearchInput.value
@@ -72,10 +75,10 @@ watchEffect(() => {
const settingsMenu = (e: Event) => {
// show the context menu
const items = []
if (documentStore.user.isLoggedIn) {
items.push({ label: `Logout ${documentStore.user.username ?? ''}`, onClick: () => documentStore.logout() })
if (store.user.isLoggedIn) {
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
} else {
items.push({ label: 'Login', onClick: () => documentStore.loginDialog() })
items.push({ label: 'Login', onClick: () => store.loginDialog() })
}
ContextMenu.showContextMenu({
// @ts-ignore
@@ -83,11 +86,6 @@ const settingsMenu = (e: Event) => {
items,
})
}
const props = defineProps<{
path: Array<string>
query: string
}>()
defineExpose({
toggleSearchInput,
closeSearch,
@@ -116,3 +114,4 @@ input[type='search'] {
max-width: 30vw;
}
</style>
@/stores/main

View File

@@ -1,29 +1,29 @@
<template>
<template v-if="documentStore.selected.size">
<template v-if="store.selected.size">
<div class="smallgap"></div>
<p class="select-text">{{ documentStore.selected.size }} selected </p>
<p class="select-text">{{ store.selected.size }} selected </p>
<SvgButton name="download" data-tooltip="Download" @click="download" />
<SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" />
<SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" />
<SvgButton name="trash" data-tooltip="Delete " @click="op('rm')" />
<button class="action-button unselect" data-tooltip="Unselect all" @click="documentStore.selected.clear()"></button>
<button class="action-button unselect" data-tooltip="Unselect all" @click="store.selected.clear()"></button>
</template>
</template>
<script setup lang="ts">
import {connect, controlUrl} from '@/repositories/WS'
import { useDocumentStore } from '@/stores/documents'
import { useMainStore } from '@/stores/main'
import { computed } from 'vue'
import type { SelectedItems } from '@/repositories/Document'
const documentStore = useDocumentStore()
const store = useMainStore()
const props = defineProps({
path: Array<string>
})
const dst = computed(() => props.path!.join('/'))
const op = (op: string, dst?: string) => {
const sel = documentStore.selectedFiles
const sel = store.selectedFiles
const msg = {
op,
sel: sel.keys.map(key => {
@@ -38,12 +38,12 @@ const op = (op: string, dst?: string) => {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Control socket error', msg, res.error)
documentStore.error = res.error.message
store.error = res.error.message
return
} else if (res.status === 'ack') {
console.log('Control ack OK', res)
control.close()
documentStore.selected.clear()
store.selected.clear()
return
} else console.log('Unknown control response', msg, res)
}
@@ -108,17 +108,17 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
}
const download = async () => {
const sel = documentStore.selectedFiles
const sel = store.selectedFiles
console.log('Download', sel)
if (sel.keys.length === 0) {
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
documentStore.selected.clear()
store.selected.clear()
return
}
// Plain old a href download if only one file (ignoring any folders)
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
if (files.length === 1) {
documentStore.selected.clear()
store.selected.clear()
return linkdl(`/files/${files[0][1]}`)
}
// Use FileSystem API if multiple files and the browser supports it
@@ -130,7 +130,7 @@ const download = async () => {
mode: 'readwrite'
})
filesystemdl(sel, handle).then(() => {
documentStore.selected.clear()
store.selected.clear()
})
return
} catch (e) {
@@ -140,7 +140,7 @@ const download = async () => {
// Otherwise, zip and download
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
documentStore.selected.clear()
store.selected.clear()
}
</script>
@@ -152,3 +152,4 @@ const download = async () => {
text-overflow: ellipsis;
}
</style>
@/stores/main

View File

@@ -39,10 +39,10 @@
import { reactive, ref } from 'vue'
import { loginUser } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useDocumentStore } from '@/stores/documents'
import { useMainStore } from '@/stores/main'
const confirmLoading = ref<boolean>(false)
const store = useDocumentStore()
const store = useMainStore()
const loginForm = reactive({
username: '',
@@ -99,3 +99,4 @@ const login = async () => {
height: 1em;
}
</style>
@/stores/main

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { connect, uploadUrl } from '@/repositories/WS';
import { useDocumentStore } from '@/stores/documents'
import { useMainStore } from '@/stores/main'
import { collator } from '@/utils';
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
const fileInput = ref()
const folderInput = ref()
const documentStore = useDocumentStore()
const store = useMainStore()
const props = defineProps({
path: Array<string>
})
@@ -75,7 +75,7 @@ const uploadFiles = (infiles: File[]) => {
const uploadCloudFiles = (files: CloudFile[]) => {
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
if (dotfiles.length) {
documentStore.error = "Won't upload dotfiles"
store.error = "Won't upload dotfiles"
console.log("Dotfiles omitted", dotfiles)
files = files.filter(f => !f.cloudName.includes('/.'))
}
@@ -171,13 +171,13 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
open(ev: Event) { resolve(ws) },
error(ev: Event) {
console.error('Upload socket error', ev)
documentStore.error = 'Upload socket error'
store.error = 'Upload socket error'
},
message(ev: MessageEvent) {
const res = JSON.parse(ev!.data)
if ('error' in res) {
console.error('Upload socket error', res.error)
documentStore.error = res.error.message
store.error = res.error.message
return
}
if (res.status === 'ack') {
@@ -302,3 +302,4 @@ span {
.position { min-width: 4em }
.speed { min-width: 4em }
</style>
@/stores/main