Compare commits

..

No commits in common. "32fa005c62d0717eed17580e25782ceb01557533" and "b25d0fc14be1a1b08d31c69abcb4e1e232be3566" have entirely different histories.

11 changed files with 290 additions and 288 deletions

View File

@ -15,6 +15,7 @@
"dependencies": { "dependencies": {
"@vueuse/core": "^10.4.1", "@vueuse/core": "^10.4.1",
"esbuild": "^0.19.5", "esbuild": "^0.19.5",
"locale-includes": "^1.0.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"pinia": "^2.1.6", "pinia": "^2.1.6",

View File

@ -4,7 +4,7 @@
<HeaderMain ref="headerMain"> <HeaderMain ref="headerMain">
<HeaderSelected :path="path.pathList" /> <HeaderSelected :path="path.pathList" />
</HeaderMain> </HeaderMain>
<BreadCrumb :path="path.pathList" tabindex="-1"/> <BreadCrumb :path="path.pathList" />
</header> </header>
<main> <main>
<RouterView :path="path.pathList" /> <RouterView :path="path.pathList" />
@ -62,9 +62,7 @@ const headerMain = ref<typeof HeaderMain | null>(null)
let vert = 0 let vert = 0
let timer: any = null let timer: any = null
const globalShortcutHandler = (event: KeyboardEvent) => { const globalShortcutHandler = (event: KeyboardEvent) => {
const fileExplorer = documentStore.fileExplorer as any const c = documentStore.fileExplorer.isCursor()
if (!fileExplorer) return
const c = fileExplorer.isCursor()
const keyup = event.type === 'keyup' const keyup = event.type === 'keyup'
if (event.repeat) { if (event.repeat) {
if ( if (
@ -86,7 +84,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
} }
// Select all (toggle); keydown to prevent builtin // Select all (toggle); keydown to prevent builtin
else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) { else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
fileExplorer.toggleSelectAll() documentStore.fileExplorer.toggleSelectAll()
} }
// Keys 1-3 to sort columns // Keys 1-3 to sort columns
else if ( else if (
@ -94,16 +92,16 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
keyup && keyup &&
(event.key === '1' || event.key === '2' || event.key === '3') (event.key === '1' || event.key === '2' || event.key === '3')
) { ) {
fileExplorer.toggleSortColumn(+event.key) documentStore.fileExplorer.toggleSortColumn(+event.key)
} }
// Rename // Rename
else if (c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) { else if (c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
fileExplorer.cursorRename() documentStore.fileExplorer.cursorRename()
} }
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey // Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
else if (c && event.code === 'Space') { else if (c && event.code === 'Space') {
if (keyup && !event.altKey && !event.ctrlKey) if (keyup && !event.altKey && !event.ctrlKey)
fileExplorer.cursorSelect() documentStore.fileExplorer.cursorSelect()
} else return } else return
event.preventDefault() event.preventDefault()
if (!vert) { if (!vert) {
@ -116,13 +114,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
if (!timer) { if (!timer) {
// Initial move, then t0 delay until repeats at tr intervals // Initial move, then t0 delay until repeats at tr intervals
const select = event.shiftKey const select = event.shiftKey
fileExplorer.cursorMove(vert, select) documentStore.fileExplorer.cursorMove(vert, select)
const t0 = 200, const t0 = 200,
tr = 30 tr = 30
timer = setTimeout( timer = setTimeout(
() => () =>
(timer = setInterval(() => { (timer = setInterval(() => {
fileExplorer.cursorMove(vert, select) documentStore.fileExplorer.cursorMove(vert, select)
}, tr)), }, tr)),
t0 - tr t0 - tr
) )

View File

@ -8,8 +8,6 @@
--primary-color: #000; --primary-color: #000;
--accent-color: #f80; --accent-color: #f80;
--transition-time: 0.2s; --transition-time: 0.2s;
/* The following are overridden by responsive layouts */
--root-font-size: 1rem;
--header-font-size: 1rem; --header-font-size: 1rem;
--header-height: calc(8 * var(--header-font-size)); --header-height: calc(8 * var(--header-font-size));
} }
@ -21,13 +19,13 @@
--header-color: #ccc; --header-color: #ccc;
} }
} }
@media screen and (max-width: 600px) { @media screen and (orientation: portrait) and (max-width: 600px) {
.size, .size,
.modified { .modified {
display: none; display: none;
} }
} }
@media screen and (orientation: landscape) and (min-width: 1200px) { @media screen and (orientation: landscape) and (min-width: 600px) {
/* Breadcrumbs and buttons side by side */ /* Breadcrumbs and buttons side by side */
header { header {
display: flex; display: flex;
@ -35,14 +33,14 @@
justify-content: space-between; justify-content: space-between;
align-items: end; align-items: end;
} }
header .breadcrumb { .breadcrumb {
font-size: 1.7em; font-size: 1.7em;
flex-shrink: 10; flex-shrink: 10;
} }
} }
@media screen and (min-width: 800px) and (--webkit-min-device-pixel-ratio: 2) { @media screen and (min-width: 800px) and (--webkit-min-device-pixel-ratio: 2) {
:root { html {
--root-font-size: calc(16 * 100vw / 800); font-size: 1.5rem;
} }
header .buttons:has(input[type='search']) > div { header .buttons:has(input[type='search']) > div {
display: none; display: none;
@ -51,9 +49,9 @@
display: inherit; display: inherit;
} }
} }
@media screen and (min-width: 1600px) and (--webkit-min-device-pixel-ratio: 3) { @media screen and (min-width: 1400px) and (--webkit-min-device-pixel-ratio: 3) {
:root { html {
--root-font-size: 2rem; font-size: 2rem;
} }
} }
@media screen and (max-height: 600px) { @media screen and (max-height: 600px) {
@ -187,10 +185,10 @@ table {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
header nav.headermain { nav {
/* 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: 100; z-index: 10;
} }
main { main {
height: calc(100svh - var(--header-height)); height: calc(100svh - var(--header-height));
@ -198,7 +196,7 @@ main {
overflow-y: scroll; overflow-y: scroll;
} }
[data-tooltip]:hover:after { [data-tooltip]:hover:after {
z-index: 101; z-index: 1000;
content: attr(data-tooltip); content: attr(data-tooltip);
position: absolute; position: absolute;
font-size: 1rem; font-size: 1rem;

View File

@ -2,6 +2,7 @@
<nav <nav
class="breadcrumb" class="breadcrumb"
aria-label="Breadcrumb" aria-label="Breadcrumb"
tabindex="0"
@keyup.left.stop="move(-1)" @keyup.left.stop="move(-1)"
@keyup.right.stop="move(1)" @keyup.right.stop="move(1)"
@focus="move(0)" @focus="move(0)"
@ -41,21 +42,6 @@ const props = defineProps<{
const longest = ref<Array<string>>([]) const longest = ref<Array<string>>([])
const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined
const navigate = (index: number) => {
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('/')}`)
}
const move = (dir: number) => {
const index = props.path.length + dir
if (index < 0 || index > longest.value.length) return
navigate(index)
}
watchEffect(() => { watchEffect(() => {
const longcut = longest.value.slice(0, props.path.length) const longcut = longest.value.slice(0, props.path.length)
const same = longcut.every((value, index) => value === props.path[index]) const same = longcut.every((value, index) => value === props.path[index])
@ -64,9 +50,19 @@ watchEffect(() => {
longest.value = longcut.concat(props.path.slice(longcut.length)) longest.value = longcut.concat(props.path.slice(longcut.length))
} }
}) })
watchEffect(() => {
if (links.length) navigate(props.path.length) const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined
})
const navigate = (index: number) => {
links[index].focus()
router.replace(`/${longest.value.slice(0, index).join('/')}`)
}
const move = (dir: number) => {
const index = props.path.length + dir
if (index < 0 || index > longest.value.length) return
navigate(index)
}
</script> </script>
<style> <style>

View File

@ -1,5 +1,5 @@
<template> <template>
<table v-if="props.documents.length || editing"> <table v-if="props.documents.length || editing" @blur="cursor = null">
<thead> <thead>
<tr> <tr>
<th class="selection"> <th class="selection">
@ -56,14 +56,9 @@
<td class="size right">{{ editing.sizedisp }}</td> <td class="size right">{{ editing.sizedisp }}</td>
<td class="menu"></td> <td class="menu"></td>
</tr> </tr>
<template
v-for="doc of sorted(props.documents as Document[])"
:key="doc.key">
<tr v-if="doc.loc !== prevloc && ((prevloc = doc.loc) || true)">
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
</tr>
<tr <tr
v-for="doc of sorted(props.documents as FolderDocument[])"
:key="doc.key"
:id="`file-${doc.key}`" :id="`file-${doc.key}`"
:class="{ :class="{
file: doc.type === 'file', file: doc.type === 'file',
@ -100,11 +95,8 @@
<a <a
:href="url_for(doc)" :href="url_for(doc)"
tabindex="-1" tabindex="-1"
@contextmenu.prevent @contextmenu.stop
@focus.stop="cursor = doc" @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 >{{ doc.name }}</a
> >
<button <button
@ -132,24 +124,18 @@
</button> </button>
</td> </td>
</tr> </tr>
</template>
<tr>
<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="menu"></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, onBeforeUpdate } from 'vue' import { ref, computed, watchEffect } from 'vue'
import { useDocumentStore } from '@/stores/documents' import { useDocumentStore } from '@/stores/documents'
import type { Document } from '@/repositories/Document' import type { Document, FolderDocument } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue' import FileRenameInput from './FileRenameInput.vue'
import createWebSocket from '@/repositories/WS' import createWebSocket from '@/repositories/WS'
import { collator, formatSize, formatUnixDate } from '@/utils' import { formatSize, formatUnixDate } from '@/utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const props = withDefaults( const props = withDefaults(
@ -159,16 +145,19 @@ const props = withDefaults(
}>(), }>(),
{} {}
) )
const documentStore = useDocumentStore() const documentStore = useDocumentStore()
const router = useRouter() const router = useRouter()
const url_for = (doc: Document) => { const linkBasePath = computed(() => props.path.join('/'))
const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name const filesBasePath = computed(() => `/files/${linkBasePath.value}`)
return doc.type === 'folder' ? `#/${p}/` : `/files/${p}` const url_for = (doc: FolderDocument) =>
} doc.type === 'folder'
const cursor = ref<Document | null>(null) ? `#${linkBasePath.value}/${doc.name}/`
: `${filesBasePath.value}/${doc.name}`
const cursor = ref<FolderDocument | null>(null)
// File rename // File rename
const editing = ref<Document | null>(null) const editing = ref<FolderDocument | null>(null)
const rename = (doc: Document, newName: string) => { const rename = (doc: FolderDocument, newName: string) => {
const oldName = doc.name const oldName = doc.name
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)
@ -183,7 +172,7 @@ const rename = (doc: Document, newName: string) => {
control.send( control.send(
JSON.stringify({ JSON.stringify({
op: 'rename', op: 'rename',
path: `${doc.loc}/${oldName}`, path: `${decodeURIComponent(linkBasePath.value)}/${oldName}`,
to: newName to: newName
}) })
) )
@ -194,15 +183,13 @@ defineExpose({
newFolder() { newFolder() {
const now = Date.now() / 1000 const now = Date.now() / 1000
editing.value = { editing.value = {
loc: loc.value,
key: 'new', key: 'new',
name: 'New Folder', name: 'New Folder',
type: 'folder', type: 'folder',
mtime: now, mtime: now,
size: 0, size: 0,
sizedisp: formatSize(0), sizedisp: formatSize(0),
modified: formatUnixDate(now), modified: formatUnixDate(now)
haystack: '',
} }
}, },
toggleSelectAll() { toggleSelectAll() {
@ -231,7 +218,7 @@ defineExpose({
}, },
cursorMove(d: number, select = false) { cursorMove(d: number, select = false) {
// Move cursor up or down (keyboard navigation) // Move cursor up or down (keyboard navigation)
const documents = sorted(props.documents as Document[]) const documents = sorted(props.documents as FolderDocument[])
if (documents.length === 0) { if (documents.length === 0) {
cursor.value = null cursor.value = null
return return
@ -258,23 +245,16 @@ defineExpose({
scrolltr = tr scrolltr = tr
if (!scrolltimer) { if (!scrolltimer) {
scrolltimer = setTimeout(() => { scrolltimer = setTimeout(() => {
if (scrolltr)
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' }) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
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`
@ -282,13 +262,7 @@ watchEffect(() => {
if (a) a.focus() if (a) a.focus()
} }
}) })
watchEffect(() => { const mkdir = (doc: FolderDocument, name: string) => {
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) {
@ -296,14 +270,14 @@ const mkdir = (doc: Document, name: string) => {
editing.value = null editing.value = null
} else { } else {
console.log('mkdir', msg) console.log('mkdir', msg)
router.push(`/${doc.loc}/${name}/`) router.push(`/${linkBasePath.value}/${name}/`)
} }
}) })
control.onopen = () => { control.onopen = () => {
control.send( control.send(
JSON.stringify({ JSON.stringify({
op: 'mkdir', op: 'mkdir',
path: `${doc.loc}/${name}` path: `${decodeURIComponent(linkBasePath.value)}/${name}`
}) })
) )
} }
@ -316,11 +290,12 @@ const toggleSort = (name: string) => {
} }
const sort = ref<string>('') const sort = ref<string>('')
const sortCompare = { const sortCompare = {
name: (a: Document, b: Document) => collator.compare(a.name, b.name), name: (a: Document, b: Document) =>
modified: (a: Document, b: Document) => b.mtime - a.mtime, a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
size: (a: Document, b: Document) => b.size - a.size modified: (a: FolderDocument, b: FolderDocument) => b.mtime - a.mtime,
size: (a: FolderDocument, b: FolderDocument) => b.size - a.size
} }
const sorted = (documents: Document[]) => { const sorted = (documents: FolderDocument[]) => {
const cmp = sortCompare[sort.value as keyof typeof sortCompare] const cmp = sortCompare[sort.value as keyof typeof sortCompare]
const sorted = [...documents] const sorted = [...documents]
if (cmp) sorted.sort(cmp) if (cmp) sorted.sort(cmp)
@ -355,11 +330,10 @@ const allSelected = computed({
} }
} }
}) })
watchEffect(() => {
const loc = computed(() => props.path.join('/')) if (cursor.value && cursor.value !== editing.value) editing.value = null
let prevloc = '' if (editing.value) cursor.value = editing.value
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)
@ -396,10 +370,10 @@ table .selection {
text-overflow: clip; text-overflow: clip;
} }
table .modified { table .modified {
width: 8em; width: 8rem;
} }
table .size { table .size {
width: 5em; width: 4rem;
} }
table .menu { table .menu {
width: 1rem; width: 1rem;
@ -499,7 +473,4 @@ 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,7 +12,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Document } from '@/repositories/Document' import type { FolderDocument } from '@/repositories/Document'
import { ref, onMounted, nextTick } from 'vue' import { ref, onMounted, nextTick } from 'vue'
const input = ref<HTMLInputElement | null>(null) const input = ref<HTMLInputElement | null>(null)
@ -28,8 +28,8 @@ onMounted(() => {
}) })
const props = defineProps<{ const props = defineProps<{
doc: Document doc: FolderDocument
rename: (doc: Document, newName: string) => void rename: (doc: FolderDocument, newName: string) => void
exit: () => void exit: () => void
}>() }>()

View File

@ -12,17 +12,22 @@ const toggleSearchInput = () => {
nextTick(() => { nextTick(() => {
const input = search.value const input = search.value
if (input) input.focus() if (input) input.focus()
//else if (searchButton.value) document.querySelector('.breadcrumb')!.focus() else if (searchButton.value) searchButton.value.blur()
executeSearch()
}) })
} }
const executeSearch = () => {
documentStore.setFilter(search.value?.value ?? '')
}
defineExpose({ defineExpose({
toggleSearchInput toggleSearchInput
}) })
</script> </script>
<template> <template>
<nav class="headermain"> <nav>
<div class="buttons"> <div class="buttons">
<UploadButton /> <UploadButton />
<SvgButton <SvgButton
@ -36,11 +41,9 @@ defineExpose({
<input <input
ref="search" ref="search"
type="search" type="search"
v-model="documentStore.search"
placeholder="Search words"
class="margin-input" class="margin-input"
@blur="() => { if (documentStore.search === '') toggleSearchInput() }"
@keyup.esc="toggleSearchInput" @keyup.esc="toggleSearchInput"
@input="executeSearch"
/> />
</template> </template>
<SvgButton ref="searchButton" name="find" @click="toggleSearchInput" /> <SvgButton ref="searchButton" name="find" @click="toggleSearchInput" />

View File

@ -1,21 +1,24 @@
import type { DocumentStore } from '@/stores/documents'
import { useDocumentStore } from '@/stores/documents' import { useDocumentStore } from '@/stores/documents'
import createWebSocket from './WS' import createWebSocket from './WS'
export type FUID = string export type FUID = string
export type Document = { type BaseDocument = {
loc: string
name: string name: string
key: FUID key: FUID
}
export type FolderDocument = BaseDocument & {
type: 'folder' | 'file' type: 'folder' | 'file'
size: number size: number
sizedisp: string sizedisp: string
mtime: number mtime: number
modified: string modified: string
haystack: string
dir?: DirList
} }
export type Document = FolderDocument
export type errorEvent = { export type errorEvent = {
error: { error: {
code: number code: number
@ -67,7 +70,7 @@ export const url_document_upload_ws = '/api/upload'
export const url_document_get = '/files' export const url_document_get = '/files'
export class DocumentHandler { export class DocumentHandler {
constructor(private store = useDocumentStore()) { constructor(private store: DocumentStore = useDocumentStore()) {
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this) this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
} }
@ -107,9 +110,9 @@ export class DocumentHandler {
private handleRootMessage({ root }: { root: DirEntry }) { private handleRootMessage({ root }: { root: DirEntry }) {
console.log('Watch root', root) console.log('Watch root', root)
if (this.store) { if (this.store && this.store.root) {
this.store.user.isLoggedIn = true this.store.user.isLoggedIn = true
this.store.updateRoot(root) this.store.root = root
} }
} }
private handleUpdateMessage(updateData: { update: UpdateEntry[] }) { private handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
@ -129,7 +132,6 @@ export class DocumentHandler {
if (elem.mtime !== undefined) node.mtime = elem.mtime if (elem.mtime !== undefined) node.mtime = elem.mtime
if (elem.dir !== undefined) node.dir = elem.dir if (elem.dir !== undefined) node.dir = elem.dir
} }
this.store.updateRoot()
} }
private handleError(msg: errorEvent) { private handleError(msg: errorEvent) {
if (msg.error.code === 401) { if (msg.error.code === 401) {
@ -141,7 +143,7 @@ export class DocumentHandler {
} }
export class DocumentUploadHandler { export class DocumentUploadHandler {
constructor(private store = useDocumentStore()) { constructor(private store: DocumentStore = useDocumentStore()) {
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this) this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
} }

View File

@ -6,9 +6,10 @@ import type {
DirList, DirList,
SelectedItems SelectedItems
} from '@/repositories/Document' } from '@/repositories/Document'
import { formatSize, formatUnixDate, haystackFormat } from '@/utils' import { formatSize, formatUnixDate } from '@/utils'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { collator } from '@/utils' // @ts-ignore
import { localeIncludes } from 'locale-includes'
type FileData = { id: string; mtime: number; size: number; dir: DirectoryData } type FileData = { id: string; mtime: number; size: number; dir: DirectoryData }
type DirectoryData = { type DirectoryData = {
@ -21,12 +22,24 @@ type User = {
isLoggedIn: boolean isLoggedIn: boolean
} }
export type DocumentStore = {
root: DirEntry
document: Document[]
selected: Set<FUID>
uploadingDocuments: Array<{ key: number; name: string; progress: number }>
uploadCount: number
wsWatch: WebSocket | undefined
wsUpload: WebSocket | undefined
fileExplorer: any
user: User
error: string
}
export const useDocumentStore = defineStore({ export const useDocumentStore = defineStore({
id: 'documents', id: 'documents',
state: () => ({ state: (): DocumentStore => ({
root: {} as DirEntry, root: {} as DirEntry,
document: [] as Document[], document: [] as Document[],
search: "" as string,
selected: new Set<FUID>(), selected: new Set<FUID>(),
uploadingDocuments: [], uploadingDocuments: [],
uploadCount: 0 as number, uploadCount: 0 as number,
@ -43,36 +56,89 @@ export const useDocumentStore = defineStore({
}), }),
actions: { actions: {
updateRoot(root: DirEntry | null = null) { updateTable(matched: DirList) {
root ??= this.root // Transform data
// Transform tree data to flat documents array const dataMapped = []
let loc = "" for (const [name, attr] of Object.entries(matched)) {
const mapper = ([name, attr]: [string, FileEntry | DirEntry]) => ({ const { key, size, mtime } = attr
loc, const element: Document = {
name, name,
type: 'dir' in attr ? 'folder' : 'file' as 'folder' | 'file', key,
...attr, size,
sizedisp: formatSize(attr.size), sizedisp: formatSize(size),
modified: formatUnixDate(attr.mtime), mtime,
haystack: haystackFormat(name), modified: formatUnixDate(mtime),
}) type: 'dir' in attr ? 'folder' : 'file'
const queue = [...Object.entries(root.dir ?? {}).map(mapper)]
const docs = []
for (let doc; (doc = queue.shift()) !== undefined;) {
docs.push(doc)
if ("dir" in doc) {
loc = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
queue.push(...Object.entries(doc.dir).map(mapper))
} }
dataMapped.push(element)
} }
// 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) => dataMapped.sort((a, b) =>
// @ts-ignore a.type === b.type
(a.type === "file") - (b.type === "file") || ? a.name.localeCompare(b.name, undefined, {
collator.compare(a.name, b.name) numeric: true,
sensitivity: 'base'
})
: a.type === 'folder'
? -1
: 1
) )
this.root = root this.document = dataMapped
this.document = docs },
setFilter(filter: string) {
if (filter === '') return this.updateTable({})
function traverseDir(data: DirEntry | FileEntry, path: string) {
if (!('dir' in data)) return
for (const [name, attr] of Object.entries(data.dir)) {
const fullname = `${path}/${name}`
if (
localeIncludes(name, filter, {
usage: 'search',
numeric: true,
sensitivity: 'base'
})
) {
matched[fullname.slice(1)] = attr // No initial slash on name
if (!--count) throw Error('Too many matches')
}
traverseDir(attr, fullname)
}
}
let count = 100
const matched: any = {}
try {
traverseDir(this.root, '')
} catch (error: any) {
if (error.message !== 'Too many matches') throw error
}
this.updateTable(matched)
},
setActualDocument(location: string) {
location = decodeURIComponent(location)
let data: FileEntry | DirEntry = this.root
const actualDirArr = []
try {
// Navigate to target folder
for (const dirname of location.split('/').slice(1)) {
if (!dirname) continue
if (!('dir' in data)) throw Error('Target folder not available')
actualDirArr.push(dirname)
data = data.dir[dirname]
}
} catch (error) {
console.error(
'Cannot show requested folder',
location,
actualDirArr.join('/'),
error
)
}
if (!('dir' in data)) {
// Target folder not available
this.document = []
return
}
this.updateTable(data.dir)
}, },
updateUploadingDocuments(key: number, progress: number) { updateUploadingDocuments(key: number, progress: number) {
for (const d of this.uploadingDocuments) { for (const d of this.uploadingDocuments) {
@ -105,19 +171,12 @@ 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

@ -57,40 +57,44 @@ export function getFileExtension(filename: string) {
return '' // No hay extensión return '' // No hay extensión
} }
} }
interface FileTypes { export function getFileType(extension: string): string {
[key: string]: string[] const videoExtensions = ['mp4', 'avi', 'mkv', 'mov']
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif']
const pdfExtensions = ['pdf']
if (videoExtensions.includes(extension)) {
return 'video'
} else if (imageExtensions.includes(extension)) {
return 'image'
} else if (pdfExtensions.includes(extension)) {
return 'pdf'
} else {
return 'unknown'
}
} }
const filetypes: FileTypes = { const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' })
video: ['avi', 'mkv', 'mov', 'mp4', 'webm'],
image: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
pdf: ['pdf'],
}
export function getFileType(name: string): string {
const ext = name.split('.').pop()?.toLowerCase()
if (!ext || ext.length === name.length) return 'unknown'
return Object.keys(filetypes).find(type => filetypes[type].includes(ext)) || 'unknown'
}
// Prebuilt for fast & consistent sorting
export const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' })
// Preformat document names for faster search
export function haystackFormat(str: string) { export function haystackFormat(str: string) {
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
return '^' + based + '$' return '^' + based + '$'
} }
export function localeIncludes(haystack: string, based: string, words: string[]) {
// Preformat search string for faster search
export function needleFormat(query: string) {
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
return {based, words: based.split(/\W+/)}
}
// Test if haystack includes needle
export function localeIncludes(haystack: string, filter: { based: string, words: string[] }) {
const {based, words} = filter
return haystack.includes(based) || words && words.every(word => haystack.includes(word)) return haystack.includes(based) || words && words.every(word => haystack.includes(word))
} }
export function buildCorpus(data: any[]) {
return data.map(item => [haystackFormat(item.name), item])
}
export function search(corpus: [string, any][], search: string) {
const based = search.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
const words = based.split(/\W+/)
const ret = []
for (const [haystack, item] of corpus) {
if (localeIncludes(haystack, based, words))
ret.push(item)
}
return ret
}

View File

@ -3,56 +3,26 @@
ref="fileExplorer" ref="fileExplorer"
:key="Router.currentRoute.value.path" :key="Router.currentRoute.value.path"
:path="props.path" :path="props.path"
:documents="documents" :documents="documentStore.mainDocument"
v-if="props.path"
/> />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { watchEffect, ref, computed } from 'vue' import { watchEffect, ref } 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
const search = documentStore.search
const needle = needleFormat(search)
let limit = 100
let docs = []
for (const doc of documentStore.recentDocuments) {
if (localeIncludes(doc.haystack, needle)) {
docs.push(doc)
if (--limit === 0) break
}
}
// 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
}) })
watchEffect(async () => {
const path = new String(Router.currentRoute.value.path) as string
documentStore.setActualDocument(path.toString())
})
</script> </script>