Frontend created and rewritten a few times, with some backend fixes #1

Merged
leo merged 110 commits from plaintable into main 2023-11-08 20:38:40 +00:00
7 changed files with 166 additions and 123 deletions
Showing only changes of commit 1f24313d23 - Show all commits

View File

@ -187,10 +187,10 @@ table {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
nav { header nav.headermain {
/* Position so that tooltips can appear on top of other positioned elements */ /* Position so that tooltips can appear on top of other positioned elements */
position: relative; position: relative;
z-index: 10; z-index: 100;
} }
main { main {
height: calc(100svh - var(--header-height)); height: calc(100svh - var(--header-height));
@ -198,7 +198,7 @@ main {
overflow-y: scroll; overflow-y: scroll;
} }
[data-tooltip]:hover:after { [data-tooltip]:hover:after {
z-index: 1000; z-index: 101;
content: attr(data-tooltip); content: attr(data-tooltip);
position: absolute; position: absolute;
font-size: 1rem; font-size: 1rem;

View File

@ -41,19 +41,12 @@ const props = defineProps<{
const longest = ref<Array<string>>([]) const longest = ref<Array<string>>([])
watchEffect(() => {
const longcut = longest.value.slice(0, props.path.length)
const same = longcut.every((value, index) => value === props.path[index])
if (!same) longest.value = props.path
else if (props.path.length > longcut.length) {
longest.value = longcut.concat(props.path.slice(longcut.length))
}
})
const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined
const navigate = (index: number) => { const navigate = (index: number) => {
links[index].focus() const link = links[index]
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
link.focus()
router.replace(`/${longest.value.slice(0, index).join('/')}`) router.replace(`/${longest.value.slice(0, index).join('/')}`)
} }
@ -62,6 +55,18 @@ const move = (dir: number) => {
if (index < 0 || index > longest.value.length) return if (index < 0 || index > longest.value.length) return
navigate(index) navigate(index)
} }
watchEffect(() => {
const longcut = longest.value.slice(0, props.path.length)
const same = longcut.every((value, index) => value === props.path[index])
if (!same) longest.value = props.path
else if (props.path.length > longcut.length) {
longest.value = longcut.concat(props.path.slice(longcut.length))
}
})
watchEffect(() => {
if (links.length) navigate(props.path.length)
})
</script> </script>
<style> <style>

View File

@ -56,83 +56,90 @@
<td class="size right">{{ editing.sizedisp }}</td> <td class="size right">{{ editing.sizedisp }}</td>
<td class="menu"></td> <td class="menu"></td>
</tr> </tr>
<tr <template
v-for="doc of sorted(props.documents as FolderDocument[])" v-for="doc of sorted(props.documents as FolderDocument[])"
:key="doc.key" :key="doc.key">
:id="`file-${doc.key}`" <tr v-if="doc.loc !== prevloc && ((prevloc = doc.loc) || true)">
:class="{ <th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
file: doc.type === 'file', </tr>
folder: doc.type === 'folder',
cursor: cursor === doc <tr
}" :id="`file-${doc.key}`"
@click="cursor = cursor === doc ? null : doc" :class="{
@contextmenu.prevent="contextMenu($event, doc)" file: doc.type === 'file',
> folder: doc.type === 'folder',
<td class="selection" @click.up.stop="cursor = cursor === doc ? doc : null"> cursor: cursor === doc
<input }"
type="checkbox" @click="cursor = cursor === doc ? null : doc"
tabindex="-1" @contextmenu.prevent="contextMenu($event, doc)"
:checked="documentStore.selected.has(doc.key)" >
@change=" <td class="selection" @click.up.stop="cursor = cursor === doc ? doc : null">
($event.target as HTMLInputElement).checked <input
? documentStore.selected.add(doc.key) type="checkbox"
: documentStore.selected.delete(doc.key)
"
/>
</td>
<td class="name">
<template v-if="editing === doc"
><FileRenameInput
:doc="doc"
:rename="rename"
:exit="
() => {
editing = null
}
"
/></template>
<template v-else>
<span class="loc" v-if="doc.loc.join('/') !== props.path.join('/')">{{ doc.loc.join('/') + '/'}}</span>
<a
:href="url_for(doc)"
tabindex="-1" tabindex="-1"
@contextmenu.prevent :checked="documentStore.selected.has(doc.key)"
@focus.stop="cursor = doc" @change="
@blur="ev => { if (!editing) cursor = null }" ($event.target as HTMLInputElement).checked
>{{ doc.name }}</a ? documentStore.selected.add(doc.key)
: documentStore.selected.delete(doc.key)
"
/>
</td>
<td class="name">
<template v-if="editing === doc"
><FileRenameInput
:doc="doc"
:rename="rename"
:exit="
() => {
editing = null
}
"
/></template>
<template v-else>
<a
:href="url_for(doc)"
tabindex="-1"
@contextmenu.prevent
@focus.stop="cursor = doc"
@blur="ev => { if (!editing) cursor = null }"
@keyup.left="router.back()"
@keyup.right.stop="ev => { if (doc.type === 'folder') (ev.target as HTMLElement).click() }"
>{{ doc.name }}</a
>
<button
v-if="cursor == doc"
class="rename-button"
@click="() => (editing = doc)"
>
🖊
</button>
</template>
</td>
<td class="modified right">
<time
:data-tooltip="new Date(1000 * doc.mtime).toISOString().replace('T', '\n').replace('.000Z', ' UTC')"
>{{ doc.modified }}</time
> >
</td>
<td class="size right">{{ doc.sizedisp }}</td>
<td class="menu">
<button <button
v-if="cursor == doc" tabindex="-1"
class="rename-button" @click.stop="contextMenu($event, doc)"
@click="() => (editing = doc)"
> >
🖊
</button> </button>
</template> </td>
</td> </tr>
<td class="modified right"> </template>
<time
:data-tooltip="new Date(1000 * doc.mtime).toISOString().replace('T', '\n').replace('.000Z', ' UTC')"
>{{ doc.modified }}</time
>
</td>
<td class="size right">{{ doc.sizedisp }}</td>
<td class="menu">
<button
tabindex="-1"
@click.stop="contextMenu($event, doc)"
>
</button>
</td>
</tr>
</tbody> </tbody>
</table> </table>
<div v-else class="empty-container">Nothing to see here</div> <div v-else class="empty-container">Nothing to see here</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watchEffect } from 'vue' import { ref, computed, watchEffect, onBeforeUpdate } from 'vue'
import { useDocumentStore } from '@/stores/documents' import { useDocumentStore } from '@/stores/documents'
import type { Document, FolderDocument } from '@/repositories/Document' import type { Document, FolderDocument } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue' import FileRenameInput from './FileRenameInput.vue'
@ -147,15 +154,12 @@ const props = withDefaults(
}>(), }>(),
{} {}
) )
const documentStore = useDocumentStore() const documentStore = useDocumentStore()
const router = useRouter() const router = useRouter()
const linkBasePath = computed(() => props.path.join('/')) const url_for = (doc: FolderDocument) => {
const filesBasePath = computed(() => `/files/${linkBasePath.value}`) const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
const url_for = (doc: FolderDocument) => return doc.type === 'folder' ? `#/${p}/` : `/files/${p}`
doc.type === 'folder' }
? `#${linkBasePath.value}/${doc.name}/`
: `${filesBasePath.value}/${doc.name}`
const cursor = ref<FolderDocument | null>(null) const cursor = ref<FolderDocument | null>(null)
// File rename // File rename
const editing = ref<FolderDocument | null>(null) const editing = ref<FolderDocument | null>(null)
@ -174,7 +178,7 @@ const rename = (doc: FolderDocument, newName: string) => {
control.send( control.send(
JSON.stringify({ JSON.stringify({
op: 'rename', op: 'rename',
path: `${decodeURIComponent(linkBasePath.value)}/${oldName}`, path: `${doc.loc}/${oldName}`,
to: newName to: newName
}) })
) )
@ -185,6 +189,7 @@ defineExpose({
newFolder() { newFolder() {
const now = Date.now() / 1000 const now = Date.now() / 1000
editing.value = { editing.value = {
loc,
key: 'new', key: 'new',
name: 'New Folder', name: 'New Folder',
type: 'folder', type: 'folder',
@ -252,12 +257,18 @@ defineExpose({
scrolltimer = null scrolltimer = null
}, 300) }, 300)
} }
if (moveto === N) focusBreadcrumb()
} }
}) })
const focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus()
}
let scrolltimer: any = null let scrolltimer: any = null
let scrolltr: any = null let scrolltr: any = null
watchEffect(() => { watchEffect(() => {
if (cursor.value && cursor.value !== editing.value) editing.value = null
if (editing.value) cursor.value = editing.value
if (cursor.value) { if (cursor.value) {
const a = document.querySelector( const a = document.querySelector(
`#file-${cursor.value.key} .name a` `#file-${cursor.value.key} .name a`
@ -265,7 +276,13 @@ watchEffect(() => {
if (a) a.focus() if (a) a.focus()
} }
}) })
const mkdir = (doc: FolderDocument, name: string) => { watchEffect(() => {
if (!props.documents.length && cursor.value) {
cursor.value = null
focusBreadcrumb()
}
})
const mkdir = (doc: Document, name: string) => {
const control = createWebSocket('/api/control', (ev: MessageEvent) => { const control = createWebSocket('/api/control', (ev: MessageEvent) => {
const msg = JSON.parse(ev.data) const msg = JSON.parse(ev.data)
if ('error' in msg) { if ('error' in msg) {
@ -273,14 +290,14 @@ const mkdir = (doc: FolderDocument, name: string) => {
editing.value = null editing.value = null
} else { } else {
console.log('mkdir', msg) console.log('mkdir', msg)
router.push(`/${linkBasePath.value}/${name}/`) router.push(`/${doc.loc}/${name}/`)
} }
}) })
control.onopen = () => { control.onopen = () => {
control.send( control.send(
JSON.stringify({ JSON.stringify({
op: 'mkdir', op: 'mkdir',
path: `${decodeURIComponent(linkBasePath.value)}/${name}` path: `${doc.loc}/${name}`
}) })
) )
} }
@ -332,10 +349,11 @@ const allSelected = computed({
} }
} }
}) })
watchEffect(() => {
if (cursor.value && cursor.value !== editing.value) editing.value = null const loc = computed(() => props.path.join('/'))
if (editing.value) cursor.value = editing.value let prevloc = ''
}) onBeforeUpdate(() => { prevloc = loc.value })
const contextMenu = (ev: Event, doc: Document) => { const contextMenu = (ev: Event, doc: Document) => {
cursor.value = doc cursor.value = doc
console.log('Context menu', ev, doc) console.log('Context menu', ev, doc)
@ -475,4 +493,7 @@ tbody .selection input {
font-size: 3rem; font-size: 3rem;
color: var(--accent-color); color: var(--accent-color);
} }
.loc {
color: #888;
}
</style> </style>

View File

@ -12,22 +12,17 @@ const toggleSearchInput = () => {
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() //else if (searchButton.value) document.querySelector('.breadcrumb')!.focus()
executeSearch()
}) })
} }
const executeSearch = () => {
documentStore.setFilter(search.value?.value ?? '')
}
defineExpose({ defineExpose({
toggleSearchInput toggleSearchInput
}) })
</script> </script>
<template> <template>
<nav> <nav class="headermain">
<div class="buttons"> <div class="buttons">
<UploadButton /> <UploadButton />
<SvgButton <SvgButton

View File

@ -5,7 +5,7 @@ import createWebSocket from './WS'
export type FUID = string export type FUID = string
export type Document = { export type Document = {
loc: string[] loc: string
name: string name: string
key: FUID key: FUID
type: 'folder' | 'file' type: 'folder' | 'file'

View File

@ -60,7 +60,7 @@ export const useDocumentStore = defineStore({
updateRoot(root: DirEntry | null = null) { updateRoot(root: DirEntry | null = null) {
root ??= this.root root ??= this.root
// Transform tree data to flat documents array // Transform tree data to flat documents array
let loc = [] as Array<string> let loc = ""
const mapper = ([name, attr]: [string, FileEntry | DirEntry]) => ({ const mapper = ([name, attr]: [string, FileEntry | DirEntry]) => ({
loc, loc,
name, name,
@ -75,7 +75,7 @@ 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) {
loc = [...doc.loc, 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))
} }
} }
@ -88,16 +88,6 @@ export const useDocumentStore = defineStore({
this.root = root this.root = root
this.document = docs this.document = docs
}, },
find(query: string): Document[] {
const needle = needleFormat(query)
return this.document.filter(doc => localeIncludes(doc.haystack, needle))
},
directory(loc: string[]) {
const ret = this.document.filter(
doc => doc.loc.length === loc.length && doc.loc.every((e, i) => e === loc[i])
)
return ret
},
updateUploadingDocuments(key: number, progress: number) { updateUploadingDocuments(key: number, progress: number) {
for (const d of this.uploadingDocuments) { for (const d of this.uploadingDocuments) {
if (d.key === key) d.progress = progress if (d.key === key) d.progress = progress
@ -129,12 +119,19 @@ export const useDocumentStore = defineStore({
} }
}, },
getters: { getters: {
mainDocument(): Document[] {
return this.document
},
isUserLogged(): boolean { isUserLogged(): boolean {
return this.user.isLoggedIn return this.user.isLoggedIn
}, },
recentDocuments(): Document[] {
const ret = [...this.document]
ret.sort((a, b) => b.mtime - a.mtime)
return ret
},
largeDocuments(): Document[] {
const ret = [...this.document]
ret.sort((a, b) => b.size - a.size)
return ret
},
selectedFiles(): SelectedItems { selectedFiles(): SelectedItems {
function traverseDir(data: DirEntry | FileEntry, path: string, relpath: string) { function traverseDir(data: DirEntry | FileEntry, path: string, relpath: string) {
if (!('dir' in data)) return if (!('dir' in data)) return

View File

@ -3,25 +3,50 @@
ref="fileExplorer" ref="fileExplorer"
:key="Router.currentRoute.value.path" :key="Router.currentRoute.value.path"
:path="props.path" :path="props.path"
:documents=" :documents="documents"
documentStore.search ?
documentStore.find(documentStore.search) :
documentStore.directory(props.path)
"
v-if="props.path" v-if="props.path"
/> />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { watchEffect, ref } from 'vue' import { watchEffect, ref, computed } from 'vue'
import { useDocumentStore } from '@/stores/documents' import { useDocumentStore } from '@/stores/documents'
import Router from '@/router/index' import Router from '@/router/index'
import { needleFormat, localeIncludes, collator } from '@/utils';
const documentStore = useDocumentStore() const documentStore = useDocumentStore()
const fileExplorer = ref() const fileExplorer = ref()
const props = defineProps({ const props = defineProps({
path: Array<string> path: Array<string>
}) })
const documents = computed(() => {
if (!props.path) return []
const loc = props.path.join('/')
// List the current location
if (!documentStore.search) return documentStore.document.filter(doc => doc.loc === loc)
// Find up to 100 newest documents that match the search
let docs = documentStore.recentDocuments
const search = documentStore.search
console.log('Searching for', search)
const needle = needleFormat(search)
docs = docs.filter(doc => localeIncludes(doc.haystack, needle)).slice(0, 100)
// Organize by folder, by relevance
const locsub = loc + '/'
docs.sort((a, b) => (
// @ts-ignore
(b.loc === loc) - (a.loc === loc) ||
// @ts-ignore
(b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) ||
collator.compare(a.loc, b.loc) ||
// @ts-ignore
(a.type === 'file') - (b.type === 'file') ||
// @ts-ignore
b.name.includes(search) - a.name.includes(search) ||
collator.compare(a.name, b.name)
))
return docs
})
watchEffect(() => { watchEffect(() => {
documentStore.fileExplorer = fileExplorer.value documentStore.fileExplorer = fileExplorer.value
}) })