Frontend created and rewritten a few times, with some backend fixes #1
|
@ -15,7 +15,6 @@
|
|||
"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",
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
@media screen and (orientation: landscape) and (min-width: 800px) {
|
||||
@media screen and (orientation: landscape) and (min-width: 1200px) {
|
||||
/* Breadcrumbs and buttons side by side */
|
||||
header {
|
||||
display: flex;
|
||||
|
|
|
@ -92,6 +92,7 @@
|
|||
"
|
||||
/></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"
|
||||
|
@ -136,7 +137,7 @@ import { useDocumentStore } from '@/stores/documents'
|
|||
import type { Document, FolderDocument } from '@/repositories/Document'
|
||||
import FileRenameInput from './FileRenameInput.vue'
|
||||
import createWebSocket from '@/repositories/WS'
|
||||
import { formatSize, formatUnixDate } from '@/utils'
|
||||
import { collator, formatSize, formatUnixDate } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = withDefaults(
|
||||
|
@ -292,8 +293,7 @@ const toggleSort = (name: string) => {
|
|||
}
|
||||
const sort = ref<string>('')
|
||||
const sortCompare = {
|
||||
name: (a: Document, b: Document) =>
|
||||
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
|
||||
name: (a: Document, b: Document) => collator.compare(a.name, b.name),
|
||||
modified: (a: FolderDocument, b: FolderDocument) => b.mtime - a.mtime,
|
||||
size: (a: FolderDocument, b: FolderDocument) => b.size - a.size
|
||||
}
|
||||
|
|
|
@ -41,9 +41,10 @@ defineExpose({
|
|||
<input
|
||||
ref="search"
|
||||
type="search"
|
||||
v-model="documentStore.search"
|
||||
class="margin-input"
|
||||
@blur="() => { if (documentStore.search === '') toggleSearchInput() }"
|
||||
@keyup.esc="toggleSearchInput"
|
||||
@input="executeSearch"
|
||||
/>
|
||||
</template>
|
||||
<SvgButton ref="searchButton" name="find" @click="toggleSearchInput" />
|
||||
|
|
|
@ -4,21 +4,19 @@ import createWebSocket from './WS'
|
|||
|
||||
export type FUID = string
|
||||
|
||||
type BaseDocument = {
|
||||
export type Document = {
|
||||
loc: string[]
|
||||
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
|
||||
|
@ -110,9 +108,9 @@ export class DocumentHandler {
|
|||
|
||||
private handleRootMessage({ root }: { root: DirEntry }) {
|
||||
console.log('Watch root', root)
|
||||
if (this.store && this.store.root) {
|
||||
if (this.store) {
|
||||
this.store.user.isLoggedIn = true
|
||||
this.store.root = root
|
||||
this.store.updateRoot(root)
|
||||
}
|
||||
}
|
||||
private handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
||||
|
@ -132,6 +130,7 @@ 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) {
|
||||
|
|
|
@ -6,10 +6,9 @@ import type {
|
|||
DirList,
|
||||
SelectedItems
|
||||
} from '@/repositories/Document'
|
||||
import { formatSize, formatUnixDate } from '@/utils'
|
||||
import { needleFormat, formatSize, formatUnixDate, haystackFormat, localeIncludes } from '@/utils'
|
||||
import { defineStore } from 'pinia'
|
||||
// @ts-ignore
|
||||
import { localeIncludes } from 'locale-includes'
|
||||
import { collator } from '@/utils'
|
||||
|
||||
type FileData = { id: string; mtime: number; size: number; dir: DirectoryData }
|
||||
type DirectoryData = {
|
||||
|
@ -25,6 +24,7 @@ type User = {
|
|||
export type DocumentStore = {
|
||||
root: DirEntry
|
||||
document: Document[]
|
||||
search: string
|
||||
selected: Set<FUID>
|
||||
uploadingDocuments: Array<{ key: number; name: string; progress: number }>
|
||||
uploadCount: number
|
||||
|
@ -40,6 +40,7 @@ export const useDocumentStore = defineStore({
|
|||
state: (): DocumentStore => ({
|
||||
root: {} as DirEntry,
|
||||
document: [] as Document[],
|
||||
search: "" as string,
|
||||
selected: new Set<FUID>(),
|
||||
uploadingDocuments: [],
|
||||
uploadCount: 0 as number,
|
||||
|
@ -56,89 +57,46 @@ export const useDocumentStore = defineStore({
|
|||
}),
|
||||
|
||||
actions: {
|
||||
updateTable(matched: DirList) {
|
||||
// Transform data
|
||||
const dataMapped = []
|
||||
for (const [name, attr] of Object.entries(matched)) {
|
||||
const { key, size, mtime } = attr
|
||||
const element: Document = {
|
||||
updateRoot(root: DirEntry | null = null) {
|
||||
root ??= this.root
|
||||
// Transform tree data to flat documents array
|
||||
let loc = [] as Array<string>
|
||||
const mapper = ([name, attr]: [string, FileEntry | DirEntry]) => ({
|
||||
loc,
|
||||
name,
|
||||
key,
|
||||
size,
|
||||
sizedisp: formatSize(size),
|
||||
mtime,
|
||||
modified: formatUnixDate(mtime),
|
||||
type: 'dir' in attr ? 'folder' : 'file'
|
||||
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.name]
|
||||
queue.push(...Object.entries(doc.dir).map(mapper))
|
||||
}
|
||||
dataMapped.push(element)
|
||||
}
|
||||
// Pre sort directory entries folders first then files, names in natural ordering
|
||||
dataMapped.sort((a, b) =>
|
||||
a.type === b.type
|
||||
? a.name.localeCompare(b.name, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
})
|
||||
: a.type === 'folder'
|
||||
? -1
|
||||
: 1
|
||||
docs.sort((a, b) =>
|
||||
// @ts-ignore
|
||||
(a.type === "file") - (b.type === "file") ||
|
||||
collator.compare(a.name, b.name)
|
||||
)
|
||||
this.document = dataMapped
|
||||
this.root = root
|
||||
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)
|
||||
find(query: string): Document[] {
|
||||
const needle = needleFormat(query)
|
||||
return this.document.filter(doc => localeIncludes(doc.haystack, needle))
|
||||
},
|
||||
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
|
||||
directory(loc: string[]) {
|
||||
const ret = this.document.filter(
|
||||
doc => doc.loc.length === loc.length && doc.loc.every((e, i) => e === loc[i])
|
||||
)
|
||||
}
|
||||
if (!('dir' in data)) {
|
||||
// Target folder not available
|
||||
this.document = []
|
||||
return
|
||||
}
|
||||
this.updateTable(data.dir)
|
||||
return ret
|
||||
},
|
||||
updateUploadingDocuments(key: number, progress: number) {
|
||||
for (const d of this.uploadingDocuments) {
|
||||
|
|
|
@ -57,44 +57,40 @@ export function getFileExtension(filename: string) {
|
|||
return '' // No hay extensión
|
||||
}
|
||||
}
|
||||
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'
|
||||
}
|
||||
interface FileTypes {
|
||||
[key: string]: string[]
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' })
|
||||
const filetypes: FileTypes = {
|
||||
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) {
|
||||
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
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))
|
||||
}
|
||||
|
||||
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,7 +3,12 @@
|
|||
ref="fileExplorer"
|
||||
:key="Router.currentRoute.value.path"
|
||||
:path="props.path"
|
||||
:documents="documentStore.mainDocument"
|
||||
:documents="
|
||||
documentStore.search ?
|
||||
documentStore.find(documentStore.search) :
|
||||
documentStore.directory(props.path)
|
||||
"
|
||||
v-if="props.path"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
@ -14,15 +19,10 @@ import Router from '@/router/index'
|
|||
|
||||
const documentStore = useDocumentStore()
|
||||
const fileExplorer = ref()
|
||||
|
||||
const props = defineProps({
|
||||
path: Array<string>
|
||||
})
|
||||
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