Compare commits
No commits in common. "32fa005c62d0717eed17580e25782ceb01557533" and "b25d0fc14be1a1b08d31c69abcb4e1e232be3566" have entirely different histories.
32fa005c62
...
b25d0fc14b
|
@ -15,6 +15,7 @@
|
|||
"dependencies": {
|
||||
"@vueuse/core": "^10.4.1",
|
||||
"esbuild": "^0.19.5",
|
||||
"locale-includes": "^1.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"pinia": "^2.1.6",
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<HeaderMain ref="headerMain">
|
||||
<HeaderSelected :path="path.pathList" />
|
||||
</HeaderMain>
|
||||
<BreadCrumb :path="path.pathList" tabindex="-1"/>
|
||||
<BreadCrumb :path="path.pathList" />
|
||||
</header>
|
||||
<main>
|
||||
<RouterView :path="path.pathList" />
|
||||
|
@ -62,9 +62,7 @@ const headerMain = ref<typeof HeaderMain | null>(null)
|
|||
let vert = 0
|
||||
let timer: any = null
|
||||
const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
const fileExplorer = documentStore.fileExplorer as any
|
||||
if (!fileExplorer) return
|
||||
const c = fileExplorer.isCursor()
|
||||
const c = documentStore.fileExplorer.isCursor()
|
||||
const keyup = event.type === 'keyup'
|
||||
if (event.repeat) {
|
||||
if (
|
||||
|
@ -86,7 +84,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||
}
|
||||
// Select all (toggle); keydown to prevent builtin
|
||||
else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
|
||||
fileExplorer.toggleSelectAll()
|
||||
documentStore.fileExplorer.toggleSelectAll()
|
||||
}
|
||||
// Keys 1-3 to sort columns
|
||||
else if (
|
||||
|
@ -94,16 +92,16 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||
keyup &&
|
||||
(event.key === '1' || event.key === '2' || event.key === '3')
|
||||
) {
|
||||
fileExplorer.toggleSortColumn(+event.key)
|
||||
documentStore.fileExplorer.toggleSortColumn(+event.key)
|
||||
}
|
||||
// Rename
|
||||
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
|
||||
else if (c && event.code === 'Space') {
|
||||
if (keyup && !event.altKey && !event.ctrlKey)
|
||||
fileExplorer.cursorSelect()
|
||||
documentStore.fileExplorer.cursorSelect()
|
||||
} else return
|
||||
event.preventDefault()
|
||||
if (!vert) {
|
||||
|
@ -116,13 +114,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||
if (!timer) {
|
||||
// Initial move, then t0 delay until repeats at tr intervals
|
||||
const select = event.shiftKey
|
||||
fileExplorer.cursorMove(vert, select)
|
||||
documentStore.fileExplorer.cursorMove(vert, select)
|
||||
const t0 = 200,
|
||||
tr = 30
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
(timer = setInterval(() => {
|
||||
fileExplorer.cursorMove(vert, select)
|
||||
documentStore.fileExplorer.cursorMove(vert, select)
|
||||
}, tr)),
|
||||
t0 - tr
|
||||
)
|
||||
|
|
|
@ -8,8 +8,6 @@
|
|||
--primary-color: #000;
|
||||
--accent-color: #f80;
|
||||
--transition-time: 0.2s;
|
||||
/* The following are overridden by responsive layouts */
|
||||
--root-font-size: 1rem;
|
||||
--header-font-size: 1rem;
|
||||
--header-height: calc(8 * var(--header-font-size));
|
||||
}
|
||||
|
@ -21,13 +19,13 @@
|
|||
--header-color: #ccc;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 600px) {
|
||||
@media screen and (orientation: portrait) and (max-width: 600px) {
|
||||
.size,
|
||||
.modified {
|
||||
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 */
|
||||
header {
|
||||
display: flex;
|
||||
|
@ -35,14 +33,14 @@
|
|||
justify-content: space-between;
|
||||
align-items: end;
|
||||
}
|
||||
header .breadcrumb {
|
||||
.breadcrumb {
|
||||
font-size: 1.7em;
|
||||
flex-shrink: 10;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 800px) and (--webkit-min-device-pixel-ratio: 2) {
|
||||
:root {
|
||||
--root-font-size: calc(16 * 100vw / 800);
|
||||
html {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
header .buttons:has(input[type='search']) > div {
|
||||
display: none;
|
||||
|
@ -51,9 +49,9 @@
|
|||
display: inherit;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 1600px) and (--webkit-min-device-pixel-ratio: 3) {
|
||||
:root {
|
||||
--root-font-size: 2rem;
|
||||
@media screen and (min-width: 1400px) and (--webkit-min-device-pixel-ratio: 3) {
|
||||
html {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
@media screen and (max-height: 600px) {
|
||||
|
@ -187,10 +185,10 @@ table {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
header nav.headermain {
|
||||
nav {
|
||||
/* Position so that tooltips can appear on top of other positioned elements */
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
z-index: 10;
|
||||
}
|
||||
main {
|
||||
height: calc(100svh - var(--header-height));
|
||||
|
@ -198,7 +196,7 @@ main {
|
|||
overflow-y: scroll;
|
||||
}
|
||||
[data-tooltip]:hover:after {
|
||||
z-index: 101;
|
||||
z-index: 1000;
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
font-size: 1rem;
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
<nav
|
||||
class="breadcrumb"
|
||||
aria-label="Breadcrumb"
|
||||
tabindex="0"
|
||||
@keyup.left.stop="move(-1)"
|
||||
@keyup.right.stop="move(1)"
|
||||
@focus="move(0)"
|
||||
|
@ -41,21 +42,6 @@ const props = defineProps<{
|
|||
|
||||
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(() => {
|
||||
const longcut = longest.value.slice(0, props.path.length)
|
||||
const same = longcut.every((value, index) => value === props.path[index])
|
||||
|
@ -64,9 +50,19 @@ watchEffect(() => {
|
|||
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>
|
||||
|
||||
<style>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<table v-if="props.documents.length || editing">
|
||||
<table v-if="props.documents.length || editing" @blur="cursor = null">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="selection">
|
||||
|
@ -56,100 +56,86 @@
|
|||
<td class="size right">{{ editing.sizedisp }}</td>
|
||||
<td class="menu"></td>
|
||||
</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
|
||||
:id="`file-${doc.key}`"
|
||||
:class="{
|
||||
file: doc.type === 'file',
|
||||
folder: doc.type === 'folder',
|
||||
cursor: cursor === doc
|
||||
}"
|
||||
@click="cursor = cursor === doc ? null : doc"
|
||||
@contextmenu.prevent="contextMenu($event, doc)"
|
||||
>
|
||||
<td class="selection" @click.up.stop="cursor = cursor === doc ? doc : null">
|
||||
<input
|
||||
type="checkbox"
|
||||
tabindex="-1"
|
||||
:checked="documentStore.selected.has(doc.key)"
|
||||
@change="
|
||||
($event.target as HTMLInputElement).checked
|
||||
? documentStore.selected.add(doc.key)
|
||||
: documentStore.selected.delete(doc.key)
|
||||
<tr
|
||||
v-for="doc of sorted(props.documents as FolderDocument[])"
|
||||
:key="doc.key"
|
||||
:id="`file-${doc.key}`"
|
||||
:class="{
|
||||
file: doc.type === 'file',
|
||||
folder: doc.type === 'folder',
|
||||
cursor: cursor === doc
|
||||
}"
|
||||
@click="cursor = cursor === doc ? null : doc"
|
||||
@contextmenu.prevent="contextMenu($event, doc)"
|
||||
>
|
||||
<td class="selection" @click.up.stop="cursor = cursor === doc ? doc : null">
|
||||
<input
|
||||
type="checkbox"
|
||||
tabindex="-1"
|
||||
:checked="documentStore.selected.has(doc.key)"
|
||||
@change="
|
||||
($event.target as HTMLInputElement).checked
|
||||
? 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
|
||||
}
|
||||
"
|
||||
/>
|
||||
</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
|
||||
/></template>
|
||||
<template v-else>
|
||||
<a
|
||||
:href="url_for(doc)"
|
||||
tabindex="-1"
|
||||
@click.stop="contextMenu($event, doc)"
|
||||
@contextmenu.stop
|
||||
@focus.stop="cursor = doc"
|
||||
>{{ doc.name }}</a
|
||||
>
|
||||
⋮
|
||||
<button
|
||||
v-if="cursor == doc"
|
||||
class="rename-button"
|
||||
@click="() => (editing = doc)"
|
||||
>
|
||||
🖊️
|
||||
</button>
|
||||
</td>
|
||||
</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>
|
||||
</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
|
||||
tabindex="-1"
|
||||
@click.stop="contextMenu($event, doc)"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-container">Nothing to see here</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watchEffect, onBeforeUpdate } from 'vue'
|
||||
import { ref, computed, watchEffect } from 'vue'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import type { Document } from '@/repositories/Document'
|
||||
import type { Document, FolderDocument } from '@/repositories/Document'
|
||||
import FileRenameInput from './FileRenameInput.vue'
|
||||
import createWebSocket from '@/repositories/WS'
|
||||
import { collator, formatSize, formatUnixDate } from '@/utils'
|
||||
import { formatSize, formatUnixDate } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = withDefaults(
|
||||
|
@ -159,16 +145,19 @@ const props = withDefaults(
|
|||
}>(),
|
||||
{}
|
||||
)
|
||||
|
||||
const documentStore = useDocumentStore()
|
||||
const router = useRouter()
|
||||
const url_for = (doc: Document) => {
|
||||
const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
return doc.type === 'folder' ? `#/${p}/` : `/files/${p}`
|
||||
}
|
||||
const cursor = ref<Document | null>(null)
|
||||
const linkBasePath = computed(() => props.path.join('/'))
|
||||
const filesBasePath = computed(() => `/files/${linkBasePath.value}`)
|
||||
const url_for = (doc: FolderDocument) =>
|
||||
doc.type === 'folder'
|
||||
? `#${linkBasePath.value}/${doc.name}/`
|
||||
: `${filesBasePath.value}/${doc.name}`
|
||||
const cursor = ref<FolderDocument | null>(null)
|
||||
// File rename
|
||||
const editing = ref<Document | null>(null)
|
||||
const rename = (doc: Document, newName: string) => {
|
||||
const editing = ref<FolderDocument | null>(null)
|
||||
const rename = (doc: FolderDocument, newName: string) => {
|
||||
const oldName = doc.name
|
||||
const control = createWebSocket('/api/control', (ev: MessageEvent) => {
|
||||
const msg = JSON.parse(ev.data)
|
||||
|
@ -183,7 +172,7 @@ const rename = (doc: Document, newName: string) => {
|
|||
control.send(
|
||||
JSON.stringify({
|
||||
op: 'rename',
|
||||
path: `${doc.loc}/${oldName}`,
|
||||
path: `${decodeURIComponent(linkBasePath.value)}/${oldName}`,
|
||||
to: newName
|
||||
})
|
||||
)
|
||||
|
@ -194,15 +183,13 @@ defineExpose({
|
|||
newFolder() {
|
||||
const now = Date.now() / 1000
|
||||
editing.value = {
|
||||
loc: loc.value,
|
||||
key: 'new',
|
||||
name: 'New Folder',
|
||||
type: 'folder',
|
||||
mtime: now,
|
||||
size: 0,
|
||||
sizedisp: formatSize(0),
|
||||
modified: formatUnixDate(now),
|
||||
haystack: '',
|
||||
modified: formatUnixDate(now)
|
||||
}
|
||||
},
|
||||
toggleSelectAll() {
|
||||
|
@ -231,7 +218,7 @@ defineExpose({
|
|||
},
|
||||
cursorMove(d: number, select = false) {
|
||||
// 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) {
|
||||
cursor.value = null
|
||||
return
|
||||
|
@ -258,23 +245,16 @@ defineExpose({
|
|||
scrolltr = tr
|
||||
if (!scrolltimer) {
|
||||
scrolltimer = setTimeout(() => {
|
||||
if (scrolltr)
|
||||
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
scrolltimer = null
|
||||
}, 300)
|
||||
}
|
||||
if (moveto === N) focusBreadcrumb()
|
||||
}
|
||||
})
|
||||
const focusBreadcrumb = () => {
|
||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
let scrolltimer: any = null
|
||||
let scrolltr: any = null
|
||||
|
||||
watchEffect(() => {
|
||||
if (cursor.value && cursor.value !== editing.value) editing.value = null
|
||||
if (editing.value) cursor.value = editing.value
|
||||
if (cursor.value) {
|
||||
const a = document.querySelector(
|
||||
`#file-${cursor.value.key} .name a`
|
||||
|
@ -282,13 +262,7 @@ watchEffect(() => {
|
|||
if (a) a.focus()
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (!props.documents.length && cursor.value) {
|
||||
cursor.value = null
|
||||
focusBreadcrumb()
|
||||
}
|
||||
})
|
||||
const mkdir = (doc: Document, name: string) => {
|
||||
const mkdir = (doc: FolderDocument, name: string) => {
|
||||
const control = createWebSocket('/api/control', (ev: MessageEvent) => {
|
||||
const msg = JSON.parse(ev.data)
|
||||
if ('error' in msg) {
|
||||
|
@ -296,14 +270,14 @@ const mkdir = (doc: Document, name: string) => {
|
|||
editing.value = null
|
||||
} else {
|
||||
console.log('mkdir', msg)
|
||||
router.push(`/${doc.loc}/${name}/`)
|
||||
router.push(`/${linkBasePath.value}/${name}/`)
|
||||
}
|
||||
})
|
||||
control.onopen = () => {
|
||||
control.send(
|
||||
JSON.stringify({
|
||||
op: 'mkdir',
|
||||
path: `${doc.loc}/${name}`
|
||||
path: `${decodeURIComponent(linkBasePath.value)}/${name}`
|
||||
})
|
||||
)
|
||||
}
|
||||
|
@ -316,11 +290,12 @@ 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: Document, b: Document) =>
|
||||
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
|
||||
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 sorted = [...documents]
|
||||
if (cmp) sorted.sort(cmp)
|
||||
|
@ -355,11 +330,10 @@ const allSelected = computed({
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
const loc = computed(() => props.path.join('/'))
|
||||
let prevloc = ''
|
||||
onBeforeUpdate(() => { prevloc = loc.value })
|
||||
|
||||
watchEffect(() => {
|
||||
if (cursor.value && cursor.value !== editing.value) editing.value = null
|
||||
if (editing.value) cursor.value = editing.value
|
||||
})
|
||||
const contextMenu = (ev: Event, doc: Document) => {
|
||||
cursor.value = doc
|
||||
console.log('Context menu', ev, doc)
|
||||
|
@ -396,10 +370,10 @@ table .selection {
|
|||
text-overflow: clip;
|
||||
}
|
||||
table .modified {
|
||||
width: 8em;
|
||||
width: 8rem;
|
||||
}
|
||||
table .size {
|
||||
width: 5em;
|
||||
width: 4rem;
|
||||
}
|
||||
table .menu {
|
||||
width: 1rem;
|
||||
|
@ -499,7 +473,4 @@ tbody .selection input {
|
|||
font-size: 3rem;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.loc {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Document } from '@/repositories/Document'
|
||||
import type { FolderDocument } 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: FolderDocument
|
||||
rename: (doc: FolderDocument, newName: string) => void
|
||||
exit: () => void
|
||||
}>()
|
||||
|
||||
|
|
|
@ -12,17 +12,22 @@ const toggleSearchInput = () => {
|
|||
nextTick(() => {
|
||||
const input = search.value
|
||||
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({
|
||||
toggleSearchInput
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="headermain">
|
||||
<nav>
|
||||
<div class="buttons">
|
||||
<UploadButton />
|
||||
<SvgButton
|
||||
|
@ -36,11 +41,9 @@ defineExpose({
|
|||
<input
|
||||
ref="search"
|
||||
type="search"
|
||||
v-model="documentStore.search"
|
||||
placeholder="Search words"
|
||||
class="margin-input"
|
||||
@blur="() => { if (documentStore.search === '') toggleSearchInput() }"
|
||||
@keyup.esc="toggleSearchInput"
|
||||
@input="executeSearch"
|
||||
/>
|
||||
</template>
|
||||
<SvgButton ref="searchButton" name="find" @click="toggleSearchInput" />
|
||||
|
|
|
@ -1,21 +1,24 @@
|
|||
import type { DocumentStore } from '@/stores/documents'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import createWebSocket from './WS'
|
||||
|
||||
export type FUID = string
|
||||
|
||||
export type Document = {
|
||||
loc: string
|
||||
type BaseDocument = {
|
||||
name: string
|
||||
key: FUID
|
||||
}
|
||||
|
||||
export type FolderDocument = BaseDocument & {
|
||||
type: 'folder' | 'file'
|
||||
size: number
|
||||
sizedisp: string
|
||||
mtime: number
|
||||
modified: string
|
||||
haystack: string
|
||||
dir?: DirList
|
||||
}
|
||||
|
||||
export type Document = FolderDocument
|
||||
|
||||
export type errorEvent = {
|
||||
error: {
|
||||
code: number
|
||||
|
@ -67,7 +70,7 @@ export const url_document_upload_ws = '/api/upload'
|
|||
export const url_document_get = '/files'
|
||||
|
||||
export class DocumentHandler {
|
||||
constructor(private store = useDocumentStore()) {
|
||||
constructor(private store: DocumentStore = useDocumentStore()) {
|
||||
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
|
||||
}
|
||||
|
||||
|
@ -107,9 +110,9 @@ export class DocumentHandler {
|
|||
|
||||
private handleRootMessage({ root }: { root: DirEntry }) {
|
||||
console.log('Watch root', root)
|
||||
if (this.store) {
|
||||
if (this.store && this.store.root) {
|
||||
this.store.user.isLoggedIn = true
|
||||
this.store.updateRoot(root)
|
||||
this.store.root = root
|
||||
}
|
||||
}
|
||||
private handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
||||
|
@ -129,7 +132,6 @@ export class DocumentHandler {
|
|||
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) {
|
||||
|
@ -141,7 +143,7 @@ export class DocumentHandler {
|
|||
}
|
||||
|
||||
export class DocumentUploadHandler {
|
||||
constructor(private store = useDocumentStore()) {
|
||||
constructor(private store: DocumentStore = useDocumentStore()) {
|
||||
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
|
||||
}
|
||||
|
||||
|
|
|
@ -6,9 +6,10 @@ import type {
|
|||
DirList,
|
||||
SelectedItems
|
||||
} from '@/repositories/Document'
|
||||
import { formatSize, formatUnixDate, haystackFormat } from '@/utils'
|
||||
import { formatSize, formatUnixDate } from '@/utils'
|
||||
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 DirectoryData = {
|
||||
|
@ -21,12 +22,24 @@ type User = {
|
|||
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({
|
||||
id: 'documents',
|
||||
state: () => ({
|
||||
state: (): DocumentStore => ({
|
||||
root: {} as DirEntry,
|
||||
document: [] as Document[],
|
||||
search: "" as string,
|
||||
selected: new Set<FUID>(),
|
||||
uploadingDocuments: [],
|
||||
uploadCount: 0 as number,
|
||||
|
@ -43,36 +56,89 @@ export const useDocumentStore = defineStore({
|
|||
}),
|
||||
|
||||
actions: {
|
||||
updateRoot(root: DirEntry | null = null) {
|
||||
root ??= this.root
|
||||
// Transform tree data to flat documents array
|
||||
let loc = ""
|
||||
const mapper = ([name, attr]: [string, FileEntry | DirEntry]) => ({
|
||||
loc,
|
||||
name,
|
||||
type: 'dir' in attr ? 'folder' : 'file' as 'folder' | 'file',
|
||||
...attr,
|
||||
sizedisp: formatSize(attr.size),
|
||||
modified: formatUnixDate(attr.mtime),
|
||||
haystack: haystackFormat(name),
|
||||
})
|
||||
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))
|
||||
updateTable(matched: DirList) {
|
||||
// Transform data
|
||||
const dataMapped = []
|
||||
for (const [name, attr] of Object.entries(matched)) {
|
||||
const { key, size, mtime } = attr
|
||||
const element: Document = {
|
||||
name,
|
||||
key,
|
||||
size,
|
||||
sizedisp: formatSize(size),
|
||||
mtime,
|
||||
modified: formatUnixDate(mtime),
|
||||
type: 'dir' in attr ? 'folder' : 'file'
|
||||
}
|
||||
dataMapped.push(element)
|
||||
}
|
||||
// Pre sort directory entries folders first then files, names in natural ordering
|
||||
docs.sort((a, b) =>
|
||||
// @ts-ignore
|
||||
(a.type === "file") - (b.type === "file") ||
|
||||
collator.compare(a.name, b.name)
|
||||
dataMapped.sort((a, b) =>
|
||||
a.type === b.type
|
||||
? a.name.localeCompare(b.name, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
})
|
||||
: a.type === 'folder'
|
||||
? -1
|
||||
: 1
|
||||
)
|
||||
this.root = root
|
||||
this.document = docs
|
||||
this.document = dataMapped
|
||||
},
|
||||
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) {
|
||||
for (const d of this.uploadingDocuments) {
|
||||
|
@ -105,19 +171,12 @@ export const useDocumentStore = defineStore({
|
|||
}
|
||||
},
|
||||
getters: {
|
||||
mainDocument(): Document[] {
|
||||
return this.document
|
||||
},
|
||||
isUserLogged(): boolean {
|
||||
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 {
|
||||
function traverseDir(data: DirEntry | FileEntry, path: string, relpath: string) {
|
||||
if (!('dir' in data)) return
|
||||
|
|
|
@ -57,40 +57,44 @@ export function getFileExtension(filename: string) {
|
|||
return '' // No hay extensión
|
||||
}
|
||||
}
|
||||
interface FileTypes {
|
||||
[key: string]: string[]
|
||||
export function getFileType(extension: 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 = {
|
||||
video: ['avi', 'mkv', 'mov', 'mp4', 'webm'],
|
||||
image: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
|
||||
pdf: ['pdf'],
|
||||
}
|
||||
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' })
|
||||
|
||||
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) {
|
||||
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
return '^' + based + '$'
|
||||
}
|
||||
|
||||
|
||||
// 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
|
||||
export function localeIncludes(haystack: string, based: string, words: string[]) {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -3,56 +3,26 @@
|
|||
ref="fileExplorer"
|
||||
:key="Router.currentRoute.value.path"
|
||||
:path="props.path"
|
||||
:documents="documents"
|
||||
v-if="props.path"
|
||||
:documents="documentStore.mainDocument"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watchEffect, ref, computed } from 'vue'
|
||||
import { watchEffect, ref } from 'vue'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import Router from '@/router/index'
|
||||
import { needleFormat, localeIncludes, collator } from '@/utils';
|
||||
|
||||
const documentStore = useDocumentStore()
|
||||
const fileExplorer = ref()
|
||||
|
||||
const props = defineProps({
|
||||
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(() => {
|
||||
documentStore.fileExplorer = fileExplorer.value
|
||||
})
|
||||
watchEffect(async () => {
|
||||
const path = new String(Router.currentRoute.value.path) as string
|
||||
documentStore.setActualDocument(path.toString())
|
||||
})
|
||||
</script>
|
||||
|
|
Loading…
Reference in New Issue
Block a user