Compare commits

...

6 Commits

11 changed files with 284 additions and 286 deletions

View File

@ -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",

View File

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

View File

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

View File

@ -2,7 +2,6 @@
<nav
class="breadcrumb"
aria-label="Breadcrumb"
tabindex="0"
@keyup.left.stop="move(-1)"
@keyup.right.stop="move(1)"
@focus="move(0)"
@ -42,19 +41,12 @@ const props = defineProps<{
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 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('/')}`)
}
@ -63,6 +55,18 @@ const move = (dir: number) => {
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])
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>
<style>

View File

@ -1,5 +1,5 @@
<template>
<table v-if="props.documents.length || editing" @blur="cursor = null">
<table v-if="props.documents.length || editing">
<thead>
<tr>
<th class="selection">
@ -56,86 +56,100 @@
<td class="size right">{{ editing.sizedisp }}</td>
<td class="menu"></td>
</tr>
<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
}
"
/></template>
<template v-else>
<a
:href="url_for(doc)"
<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"
@contextmenu.stop
@focus.stop="cursor = doc"
>{{ doc.name }}</a
: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
}
"
/></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
v-if="cursor == doc"
class="rename-button"
@click="() => (editing = doc)"
tabindex="-1"
@click.stop="contextMenu($event, 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
tabindex="-1"
@click.stop="contextMenu($event, doc)"
>
</button>
</td>
</tr>
</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>
</tbody>
</table>
<div v-else class="empty-container">Nothing to see here</div>
</template>
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
import { ref, computed, watchEffect, onBeforeUpdate } from 'vue'
import { useDocumentStore } from '@/stores/documents'
import type { Document, FolderDocument } from '@/repositories/Document'
import type { Document } 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(
@ -145,19 +159,16 @@ const props = withDefaults(
}>(),
{}
)
const documentStore = useDocumentStore()
const router = useRouter()
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)
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)
// File rename
const editing = ref<FolderDocument | null>(null)
const rename = (doc: FolderDocument, newName: string) => {
const editing = ref<Document | null>(null)
const rename = (doc: Document, newName: string) => {
const oldName = doc.name
const control = createWebSocket('/api/control', (ev: MessageEvent) => {
const msg = JSON.parse(ev.data)
@ -172,7 +183,7 @@ const rename = (doc: FolderDocument, newName: string) => {
control.send(
JSON.stringify({
op: 'rename',
path: `${decodeURIComponent(linkBasePath.value)}/${oldName}`,
path: `${doc.loc}/${oldName}`,
to: newName
})
)
@ -183,13 +194,15 @@ 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)
modified: formatUnixDate(now),
haystack: '',
}
},
toggleSelectAll() {
@ -218,7 +231,7 @@ defineExpose({
},
cursorMove(d: number, select = false) {
// Move cursor up or down (keyboard navigation)
const documents = sorted(props.documents as FolderDocument[])
const documents = sorted(props.documents as Document[])
if (documents.length === 0) {
cursor.value = null
return
@ -245,16 +258,23 @@ defineExpose({
scrolltr = tr
if (!scrolltimer) {
scrolltimer = setTimeout(() => {
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
if (scrolltr)
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`
@ -262,7 +282,13 @@ watchEffect(() => {
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 msg = JSON.parse(ev.data)
if ('error' in msg) {
@ -270,14 +296,14 @@ const mkdir = (doc: FolderDocument, name: string) => {
editing.value = null
} else {
console.log('mkdir', msg)
router.push(`/${linkBasePath.value}/${name}/`)
router.push(`/${doc.loc}/${name}/`)
}
})
control.onopen = () => {
control.send(
JSON.stringify({
op: 'mkdir',
path: `${decodeURIComponent(linkBasePath.value)}/${name}`
path: `${doc.loc}/${name}`
})
)
}
@ -290,12 +316,11 @@ 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' }),
modified: (a: FolderDocument, b: FolderDocument) => b.mtime - a.mtime,
size: (a: FolderDocument, b: FolderDocument) => b.size - a.size
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
}
const sorted = (documents: FolderDocument[]) => {
const sorted = (documents: Document[]) => {
const cmp = sortCompare[sort.value as keyof typeof sortCompare]
const sorted = [...documents]
if (cmp) sorted.sort(cmp)
@ -330,10 +355,11 @@ const allSelected = computed({
}
}
})
watchEffect(() => {
if (cursor.value && cursor.value !== editing.value) editing.value = null
if (editing.value) cursor.value = editing.value
})
const loc = computed(() => props.path.join('/'))
let prevloc = ''
onBeforeUpdate(() => { prevloc = loc.value })
const contextMenu = (ev: Event, doc: Document) => {
cursor.value = doc
console.log('Context menu', ev, doc)
@ -370,10 +396,10 @@ table .selection {
text-overflow: clip;
}
table .modified {
width: 8rem;
width: 8em;
}
table .size {
width: 4rem;
width: 5em;
}
table .menu {
width: 1rem;
@ -473,4 +499,7 @@ tbody .selection input {
font-size: 3rem;
color: var(--accent-color);
}
.loc {
color: #888;
}
</style>

View File

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

View File

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

View File

@ -1,24 +1,21 @@
import type { DocumentStore } from '@/stores/documents'
import { useDocumentStore } from '@/stores/documents'
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
@ -70,7 +67,7 @@ export const url_document_upload_ws = '/api/upload'
export const url_document_get = '/files'
export class DocumentHandler {
constructor(private store: DocumentStore = useDocumentStore()) {
constructor(private store = useDocumentStore()) {
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
}
@ -110,9 +107,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 +129,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) {
@ -143,7 +141,7 @@ export class DocumentHandler {
}
export class DocumentUploadHandler {
constructor(private store: DocumentStore = useDocumentStore()) {
constructor(private store = useDocumentStore()) {
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
}

View File

@ -6,10 +6,9 @@ import type {
DirList,
SelectedItems
} from '@/repositories/Document'
import { formatSize, formatUnixDate } from '@/utils'
import { formatSize, formatUnixDate, haystackFormat } 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 = {
@ -22,24 +21,12 @@ 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: (): DocumentStore => ({
state: () => ({
root: {} as DirEntry,
document: [] as Document[],
search: "" as string,
selected: new Set<FUID>(),
uploadingDocuments: [],
uploadCount: 0 as number,
@ -56,89 +43,36 @@ 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 = {
name,
key,
size,
sizedisp: formatSize(size),
mtime,
modified: formatUnixDate(mtime),
type: 'dir' in attr ? 'folder' : 'file'
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))
}
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
},
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)
this.root = root
this.document = docs
},
updateUploadingDocuments(key: number, progress: number) {
for (const d of this.uploadingDocuments) {
@ -171,12 +105,19 @@ 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

View File

@ -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
}

View File

@ -3,26 +3,56 @@
ref="fileExplorer"
:key="Router.currentRoute.value.path"
:path="props.path"
:documents="documentStore.mainDocument"
:documents="documents"
v-if="props.path"
/>
</template>
<script setup lang="ts">
import { watchEffect, ref } from 'vue'
import { watchEffect, ref, computed } 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>