475 lines
12 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<table v-if="props.documents.length || editing" @blur="cursor = null">
<thead>
<tr>
<th class="selection">
<input
type="checkbox"
tabindex="-1"
v-model="allSelected"
:indeterminate="selectionIndeterminate"
/>
</th>
<th
class="sortcolumn"
:class="{ sortactive: sort === 'name' }"
@click="toggleSort('name')"
>
Name
</th>
<th
class="sortcolumn modified right"
:class="{ sortactive: sort === 'modified' }"
@click="toggleSort('modified')"
>
Modified
</th>
<th
class="sortcolumn size right"
:class="{ sortactive: sort === 'size' }"
@click="toggleSort('size')"
>
Size
</th>
<th class="menu"></th>
</tr>
</thead>
<tbody>
<tr v-if="editing?.key === 'new'" class="folder">
<td class="selection"></td>
<td class="name">
<FileRenameInput
:doc="editing"
:rename="mkdir"
:exit="
() => {
editing = null
}
"
/>
</td>
<td class="modified right">
<time :datetime="new Date(editing.mtime).toISOString().replace('.000', '')">{{
editing.modified
}}</time>
</td>
<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)"
tabindex="-1"
@contextmenu.stop
@focus.stop="cursor = doc"
>{{ doc.name }}</a
>
<button
v-if="cursor == doc"
class="rename-button"
@click="() => (editing = doc)"
>
🖊
</button>
</template>
</td>
<td class="modified right">
<time
:datetime="new Date(1000 * doc.mtime).toISOString().replace('.000', '')"
>{{ 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 } from 'vue'
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 { isNavigationFailure, useRouter } from 'vue-router'
const props = withDefaults(
defineProps<{
path: Array<string>
documents: Document[]
}>(),
{}
)
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)
// File rename
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)
if ('error' in msg) {
console.error('Rename failed', msg.error.message, msg.error)
doc.name = oldName
} else {
console.log('Rename succeeded', msg)
}
})
control.onopen = () => {
control.send(
JSON.stringify({
op: 'rename',
path: `${decodeURIComponent(linkBasePath.value)}/${oldName}`,
to: newName
})
)
}
doc.name = newName // We should get an update from watch but this is quicker
}
defineExpose({
newFolder() {
const now = Date.now() / 1000
editing.value = {
key: 'new',
name: 'New Folder',
type: 'folder',
mtime: now,
size: 0,
sizedisp: formatSize(0),
modified: formatUnixDate(now)
}
},
toggleSelectAll() {
console.log('Select')
allSelected.value = !allSelected.value
},
toggleSortColumn(column: number) {
const columns = ['', 'name', 'modified', 'size', '']
toggleSort(columns[column])
},
isCursor() {
return cursor.value !== null && editing.value === null
},
cursorRename() {
editing.value = cursor.value
},
cursorSelect() {
const doc = cursor.value
if (!doc) return
if (documentStore.selected.has(doc.key)) {
documentStore.selected.delete(doc.key)
} else {
documentStore.selected.add(doc.key)
}
this.cursorMove(1)
},
cursorMove(d: number, select = false) {
// Move cursor up or down (keyboard navigation)
const documents = sorted(props.documents as FolderDocument[])
if (documents.length === 0) {
cursor.value = null
return
}
const N = documents.length
const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1)
const index =
cursor.value !== null ? documents.indexOf(cursor.value) : documents.length
const moveto = increment(index, d)
cursor.value = documents[moveto] ?? null
const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null
if (select) {
// Go forwards, possibly wrapping over the end; the last entry is not toggled
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue
const key = documents[p].key
if (documentStore.selected.has(key)) documentStore.selected.delete(key)
else documentStore.selected.add(key)
}
}
// @ts-ignore
scrolltr = tr
if (!scrolltimer) {
scrolltimer = setTimeout(() => {
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltimer = null
}, 300)
}
}
})
let scrolltimer: any = null
let scrolltr: any = null
watchEffect(() => {
if (cursor.value) {
const a = document.querySelector(
`#file-${cursor.value.key} .name a`
) as HTMLAnchorElement | null
if (a) a.focus()
}
})
const mkdir = (doc: FolderDocument, name: string) => {
const control = createWebSocket('/api/control', (ev: MessageEvent) => {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Mkdir failed', msg.error.message, msg.error)
editing.value = null
} else {
console.log('mkdir', msg)
router.push(`/${linkBasePath.value}/${name}/`)
}
})
control.onopen = () => {
control.send(
JSON.stringify({
op: 'mkdir',
path: `${decodeURIComponent(linkBasePath.value)}/${name}`
})
)
}
doc.name = name // We should get an update from watch but this is quicker
}
// Column sort
const toggleSort = (name: string) => {
sort.value = sort.value === name ? '' : name
}
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
}
const sorted = (documents: FolderDocument[]) => {
const cmp = sortCompare[sort.value as keyof typeof sortCompare]
const sorted = [...documents]
if (cmp) sorted.sort(cmp)
return sorted
}
const selectionIndeterminate = computed({
get: () => {
return (
props.documents.length > 0 &&
props.documents.some((doc: Document) => documentStore.selected.has(doc.key)) &&
!allSelected.value
)
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
set: (value: boolean) => {}
})
const allSelected = computed({
get: () => {
return (
props.documents.length > 0 &&
props.documents.every((doc: Document) => documentStore.selected.has(doc.key))
)
},
set: (value: boolean) => {
console.log('Setting allSelected', value)
for (const doc of props.documents) {
if (value) {
documentStore.selected.add(doc.key)
} else {
documentStore.selected.delete(doc.key)
}
}
}
})
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)
}
</script>
<style scoped>
table {
width: 100%;
table-layout: fixed;
}
thead tr {
position: sticky;
top: 0;
z-index: 2;
}
tbody tr {
position: relative;
}
table thead input[type='checkbox'] {
position: inherit;
width: 1rem;
height: 1rem;
margin: 0.5rem;
}
table tbody input[type='checkbox'] {
width: 2rem;
height: 2rem;
}
table .selection {
width: 2rem;
height: 2rem;
text-align: center;
text-overflow: clip;
}
table .modified {
width: 8rem;
}
table .size {
width: 4rem;
}
table .menu {
width: 1rem;
}
tbody td {
font-size: 1.2rem;
}
table th,
table td {
padding: 0 0.5rem;
font-weight: normal;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.name {
white-space: nowrap;
position: relative;
}
.name .rename-button {
position: absolute;
right: 0;
animation: appear calc(5 * var(--transition-time)) linear;
}
@keyframes appear {
from {
opacity: 0;
}
80% {
opacity: 0;
}
to {
opacity: 1;
}
}
thead tr {
background: linear-gradient(to bottom, #eee, #fff 30%, #ddd);
color: #000;
}
tbody tr.cursor {
background: var(--accent-color);
}
.right {
text-align: right;
}
.sortcolumn:hover {
cursor: pointer;
}
.sortcolumn:hover::after {
color: var(--accent-color);
}
.sortcolumn {
padding-right: 1.7em;
}
.sortcolumn::after {
content: '▸';
color: #888;
margin: 0 1em 0 0.5em;
position: absolute;
transition: all var(--transition-time) linear;
}
.sortactive::after {
transform: rotate(90deg);
color: #000;
}
.name a {
text-decoration: none;
}
tbody .selection input {
z-index: 1;
position: absolute;
opacity: 0;
left: 0.5rem;
top: 0;
}
.selection {
width: 2em;
height: 2em;
}
.selection input:checked {
opacity: 0.7;
}
.file .selection::before {
content: '📄 ';
font-size: 1.5em;
}
.folder .selection::before {
content: '📁 ';
font-size: 1.5em;
}
.empty-container {
padding-top: 3rem;
text-align: center;
font-size: 3rem;
color: var(--accent-color);
}
</style>