Compare commits
84 Commits
v0.3.0
...
b25d0fc14b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b25d0fc14b | ||
|
|
5386508e28 | ||
|
|
129250e072 | ||
|
|
c2be2ecd31 | ||
|
|
dd1d85f412 | ||
|
|
4c7b310f82 | ||
|
|
1250037cfd | ||
|
|
cdc936d2d5 | ||
|
|
4f370440d9 | ||
|
|
feaa8e315e | ||
|
|
14f7253ece | ||
|
|
9d3d27faf3 | ||
|
|
dd235e8f25 | ||
|
|
139ff51dcd | ||
|
|
589e5a682c | ||
|
|
8114d679ef | ||
|
|
32b8e0702c | ||
|
|
cc74912bb9 | ||
|
|
c3cf4caa9a | ||
|
|
b3eacf04f7 | ||
|
|
047facaacb | ||
|
|
41fbd3d122 | ||
|
|
40a45568c1 | ||
|
|
8c6690ea98 | ||
|
|
997e0b8549 | ||
|
|
115bb5db59 | ||
|
|
5f1eb0503a | ||
|
|
4aae194060 | ||
|
|
12eabd29c3 | ||
|
|
589b21f944 | ||
|
|
d3f584b738 | ||
|
|
225f2b0651 | ||
|
|
b759d8324c | ||
|
|
119aba2b3c | ||
|
|
f52d58d645 | ||
|
|
6cba674b30 | ||
|
|
831b2716f7 | ||
|
|
7e5901a2cf | ||
|
|
a4f95d730b | ||
|
|
56082cba15 | ||
|
|
3479a0da57 | ||
|
|
f99d92b217 | ||
|
|
68a701538b | ||
|
|
05a16e3037 | ||
|
|
52ecbc3d36 | ||
|
|
042f1b7f42 | ||
|
|
d27cb2133a | ||
|
|
a8ea43194d | ||
|
|
07fe7448cc | ||
|
|
783af44e26 | ||
|
|
0d6180e8a4 | ||
|
|
bdc0bbd44f | ||
|
|
ba36eaec1b | ||
|
|
a435a30c88 | ||
|
|
742b05ed66 | ||
|
|
a26dc42d88 | ||
|
|
9002afbc7e | ||
|
|
acdd776b92 | ||
| b3fd9637eb | |||
| 2b72508206 | |||
|
|
8cc3ed1a04 | ||
|
|
0d186726b5 | ||
|
|
63bbe84859 | ||
|
|
202f28ff15 | ||
| 41772e6c18 | |||
| e52379d515 | |||
| 74987898c9 | |||
| 859d312913 | |||
| 4bc9cf4534 | |||
| 754d779069 | |||
| 367e4ba0ea | |||
| c2e9a4af05 | |||
| 6cdc37a172 | |||
| 19699564c2 | |||
| 7baf8b3f9b | |||
| 47329ac04e | |||
| f4013d1196 | |||
| 3672156b5e | |||
| f2b37852da | |||
| 708e54d080 | |||
| d051265f40 | |||
| 5cf133465e | |||
| 1c91bf2e87 | |||
| 9cd6f83bec |
@@ -1,7 +1,6 @@
|
|||||||
# Web File Storage
|
# Web File Storage
|
||||||
|
|
||||||
Run directly from repository with Hatch (or use pip install as usual):
|
Run directly from repository with Hatch (or use pip install as usual):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
hatch run cista -l :3000 /path/to/files
|
hatch run cista -l :3000 /path/to/files
|
||||||
```
|
```
|
||||||
@@ -9,17 +8,16 @@ hatch run cista -l :3000 /path/to/files
|
|||||||
Settings incl. these arguments are stored to config file on the first startup and later `hatch run cista` is sufficient. If the `cista` script is missing, consider `pip install -e .` (within `hatch shell`) or some other trickery (known issue with installs made prior to adding the startup script).
|
Settings incl. these arguments are stored to config file on the first startup and later `hatch run cista` is sufficient. If the `cista` script is missing, consider `pip install -e .` (within `hatch shell`) or some other trickery (known issue with installs made prior to adding the startup script).
|
||||||
|
|
||||||
Create your user account:
|
Create your user account:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
hatch run cista --user admin --privileged
|
hatch run cista --user admin --privileged
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build frontend
|
## Build frontend
|
||||||
|
|
||||||
Frontend needs to be built before using and after any frontend changes:
|
Prebuilt frontend is provided in repository but for any changes it will need to be manually rebuilt:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd frontend
|
cd cista-front
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang=en>
|
<html lang=en>
|
||||||
<meta charset=UTF-8>
|
<meta charset=UTF-8>
|
||||||
<title>Cista Storage</title>
|
<title>Cista</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<link rel="icon" href="/src/assets/logo.svg">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@400;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@400;700&display=swap" rel="stylesheet">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "cista-frontend",
|
"name": "front",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@imengyu/vue3-context-menu": "^1.3.3",
|
|
||||||
"@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",
|
||||||
BIN
cista-front/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
241
cista-front/public/old-index.html
Executable file
@@ -0,0 +1,241 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<title>Storage</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
max-width: 100ch;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1em;
|
||||||
|
background-color: #333;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
text-align: right;
|
||||||
|
padding: .5em;
|
||||||
|
}
|
||||||
|
td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div>
|
||||||
|
<h2>Quick file upload</h2>
|
||||||
|
<p>Uses parallel WebSocket connections for increased bandwidth /api/upload</p>
|
||||||
|
<input type=file id=fileInput>
|
||||||
|
<progress id=progressBar value=0 max=1></progress>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>Files</h2>
|
||||||
|
<ul id=file_list></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let files = {}
|
||||||
|
let flatfiles = {}
|
||||||
|
|
||||||
|
function createWatchSocket() {
|
||||||
|
const wsurl = new URL("/api/watch", location.href.replace(/^http/, 'ws'))
|
||||||
|
const ws = new WebSocket(wsurl)
|
||||||
|
ws.onmessage = event => {
|
||||||
|
msg = JSON.parse(event.data)
|
||||||
|
if (msg.update) {
|
||||||
|
tree_update(msg.update)
|
||||||
|
file_list(files)
|
||||||
|
} else {
|
||||||
|
console.log("Unkonwn message from watch socket", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createWatchSocket()
|
||||||
|
|
||||||
|
function tree_update(msg) {
|
||||||
|
console.log("Tree update", msg)
|
||||||
|
let node = files
|
||||||
|
for (const elem of msg) {
|
||||||
|
if (elem.deleted) {
|
||||||
|
const p = node.dir[elem.name].path
|
||||||
|
delete node.dir[elem.name]
|
||||||
|
delete flatfiles[p]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (elem.name !== undefined) node = node.dir[elem.name] ||= {}
|
||||||
|
if (elem.size !== undefined) node.size = elem.size
|
||||||
|
if (elem.mtime !== undefined) node.mtime = elem.mtime
|
||||||
|
if (elem.dir !== undefined) node.dir = elem.dir
|
||||||
|
}
|
||||||
|
// Update paths and flatfiles
|
||||||
|
files.path = "/"
|
||||||
|
const nodes = [files]
|
||||||
|
flatfiles = {}
|
||||||
|
while (node = nodes.pop()) {
|
||||||
|
flatfiles[node.path] = node
|
||||||
|
if (node.dir === undefined) continue
|
||||||
|
for (const name of Object.keys(node.dir)) {
|
||||||
|
const child = node.dir[name]
|
||||||
|
child.path = node.path + name + (child.dir === undefined ? "" : "/")
|
||||||
|
nodes.push(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
|
||||||
|
|
||||||
|
const compare_path = (a, b) => collator.compare(a.path, b.path)
|
||||||
|
const compare_time = (a, b) => a.mtime > b.mtime
|
||||||
|
|
||||||
|
function file_list(files) {
|
||||||
|
const table = document.getElementById("file_list")
|
||||||
|
const sorted = Object.values(flatfiles).sort(compare_time)
|
||||||
|
table.innerHTML = ""
|
||||||
|
for (const f of sorted) {
|
||||||
|
const {path, size, mtime} = f
|
||||||
|
const tr = document.createElement("tr")
|
||||||
|
const name_td = document.createElement("td")
|
||||||
|
const size_td = document.createElement("td")
|
||||||
|
const mtime_td = document.createElement("td")
|
||||||
|
const a = document.createElement("a")
|
||||||
|
table.appendChild(tr)
|
||||||
|
tr.appendChild(name_td)
|
||||||
|
tr.appendChild(size_td)
|
||||||
|
tr.appendChild(mtime_td)
|
||||||
|
name_td.appendChild(a)
|
||||||
|
size_td.textContent = size
|
||||||
|
mtime_td.textContent = formatUnixDate(mtime)
|
||||||
|
a.textContent = path
|
||||||
|
a.href = `/files${path}`
|
||||||
|
/*a.onclick = event => {
|
||||||
|
if (window.showSaveFilePicker) {
|
||||||
|
event.preventDefault()
|
||||||
|
download_ws(name, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.download = ""*/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUnixDate(t) {
|
||||||
|
const date = new Date(t * 1000)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = date - now
|
||||||
|
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
||||||
|
|
||||||
|
if (Math.abs(diff) <= 60000) {
|
||||||
|
return formatter.format(Math.round(diff / 1000), 'second')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(diff) <= 3600000) {
|
||||||
|
return formatter.format(Math.round(diff / 60000), 'minute')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(diff) <= 86400000) {
|
||||||
|
return formatter.format(Math.round(diff / 3600000), 'hour')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(diff) <= 604800000) {
|
||||||
|
return formatter.format(Math.round(diff / 86400000), 'day')
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download_ws(name, size) {
|
||||||
|
const fh = await window.showSaveFilePicker({
|
||||||
|
suggestedName: name,
|
||||||
|
})
|
||||||
|
const writer = await fh.createWritable()
|
||||||
|
writer.truncate(size)
|
||||||
|
const wsurl = new URL("/api/download", location.href.replace(/^http/, 'ws'))
|
||||||
|
const ws = new WebSocket(wsurl)
|
||||||
|
let pos = 0
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log("Downloading over WebSocket", name, size)
|
||||||
|
ws.send(JSON.stringify({name, start: 0, end: size, size}))
|
||||||
|
}
|
||||||
|
ws.onmessage = event => {
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
const msg = JSON.parse(event.data)
|
||||||
|
console.log("Download finished", msg)
|
||||||
|
ws.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log("Received chunk", name, pos, pos + event.data.size)
|
||||||
|
pos += event.data.size
|
||||||
|
writer.write(event.data)
|
||||||
|
}
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (pos < size) {
|
||||||
|
console.log("Download aborted", name, pos)
|
||||||
|
writer.truncate(pos)
|
||||||
|
}
|
||||||
|
writer.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileInput = document.getElementById("fileInput")
|
||||||
|
const progress = document.getElementById("progressBar")
|
||||||
|
const numConnections = 2
|
||||||
|
const chunkSize = 1<<20
|
||||||
|
const wsConnections = new Set()
|
||||||
|
|
||||||
|
//for (let i = 0; i < numConnections; i++) createUploadWS()
|
||||||
|
|
||||||
|
function createUploadWS() {
|
||||||
|
const wsurl = new URL("/api/upload", location.href.replace(/^http/, 'ws'))
|
||||||
|
const ws = new WebSocket(wsurl)
|
||||||
|
ws.binaryType = 'arraybuffer'
|
||||||
|
ws.onopen = () => {
|
||||||
|
wsConnections.add(ws)
|
||||||
|
console.log("Upload socket connected")
|
||||||
|
}
|
||||||
|
ws.onmessage = event => {
|
||||||
|
msg = JSON.parse(event.data)
|
||||||
|
if (msg.written) progress.value += +msg.written
|
||||||
|
else console.log(`Error: ${msg.error}`)
|
||||||
|
}
|
||||||
|
ws.onclose = () => {
|
||||||
|
wsConnections.delete(ws)
|
||||||
|
console.log("Upload socket disconnected, reconnecting...")
|
||||||
|
setTimeout(createUploadWS, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(file, start, end) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
const load = new Promise(resolve => reader.onload = resolve)
|
||||||
|
reader.readAsArrayBuffer(file.slice(start, end))
|
||||||
|
const event = await load
|
||||||
|
return event.target.result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendChunk(file, start, end, ws) {
|
||||||
|
const chunk = await load(file, start, end)
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
start: start,
|
||||||
|
end: end
|
||||||
|
}))
|
||||||
|
ws.send(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", async function() {
|
||||||
|
const file = this.files[0]
|
||||||
|
const numChunks = Math.ceil(file.size / chunkSize)
|
||||||
|
progress.value = 0
|
||||||
|
progress.max = file.size
|
||||||
|
|
||||||
|
console.log(wsConnections)
|
||||||
|
for (let i = 0; i < numChunks; i++) {
|
||||||
|
const ws = Array.from(wsConnections)[i % wsConnections.size]
|
||||||
|
const start = i * chunkSize
|
||||||
|
const end = Math.min(file.size, start + chunkSize)
|
||||||
|
const res = await sendChunk(file, start, end, ws)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<LoginModal />
|
<LoginModal />
|
||||||
<header>
|
<header>
|
||||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
<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" :query="path.query" />
|
<RouterView :path="path.pathList" />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -16,7 +16,13 @@ import { RouterView } from 'vue-router'
|
|||||||
import type { ComputedRef } from 'vue'
|
import type { ComputedRef } from 'vue'
|
||||||
import type HeaderMain from '@/components/HeaderMain.vue'
|
import type HeaderMain from '@/components/HeaderMain.vue'
|
||||||
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||||
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
import createWebSocket from '@/repositories/WS'
|
||||||
|
import {
|
||||||
|
url_document_watch_ws,
|
||||||
|
url_document_upload_ws,
|
||||||
|
DocumentHandler,
|
||||||
|
DocumentUploadHandler
|
||||||
|
} from '@/repositories/Document'
|
||||||
import { useDocumentStore } from '@/stores/documents'
|
import { useDocumentStore } from '@/stores/documents'
|
||||||
|
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
@@ -25,32 +31,38 @@ import Router from '@/router/index'
|
|||||||
interface Path {
|
interface Path {
|
||||||
path: string
|
path: string
|
||||||
pathList: string[]
|
pathList: string[]
|
||||||
query: string
|
|
||||||
}
|
}
|
||||||
const documentStore = useDocumentStore()
|
const documentStore = useDocumentStore()
|
||||||
const path: ComputedRef<Path> = computed(() => {
|
const path: ComputedRef<Path> = computed(() => {
|
||||||
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
|
const p = decodeURIComponent(Router.currentRoute.value.path)
|
||||||
const pathList = p[0].split('/').filter(value => value !== '')
|
const pathList = p.split('/').filter(value => value !== '')
|
||||||
const query = p.slice(1).join('//')
|
|
||||||
return {
|
return {
|
||||||
path: p[0],
|
path: p,
|
||||||
pathList,
|
pathList
|
||||||
query
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// Update human-readable x seconds ago messages from mtimes
|
||||||
|
setInterval(documentStore.updateModified, 1000)
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || documentStore.server.name || 'Cista Storage'
|
const documentHandler = new DocumentHandler()
|
||||||
|
const documentUploadHandler = new DocumentUploadHandler()
|
||||||
|
const wsWatch = createWebSocket(
|
||||||
|
url_document_watch_ws,
|
||||||
|
documentHandler.handleWebSocketMessage
|
||||||
|
)
|
||||||
|
const wsUpload = createWebSocket(
|
||||||
|
url_document_upload_ws,
|
||||||
|
documentUploadHandler.handleWebSocketMessage
|
||||||
|
)
|
||||||
|
|
||||||
|
documentStore.wsWatch = wsWatch
|
||||||
|
documentStore.wsUpload = wsUpload
|
||||||
})
|
})
|
||||||
onMounted(loadSession)
|
|
||||||
onMounted(watchConnect)
|
|
||||||
onUnmounted(watchDisconnect)
|
|
||||||
const headerMain = ref<typeof HeaderMain | null>(null)
|
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 (
|
||||||
@@ -72,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 (
|
||||||
@@ -80,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) {
|
||||||
@@ -102,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
|
||||||
)
|
)
|
||||||
@@ -3,39 +3,44 @@
|
|||||||
:root {
|
:root {
|
||||||
--primary-color: #000;
|
--primary-color: #000;
|
||||||
--primary-background: #ddd;
|
--primary-background: #ddd;
|
||||||
--header-background: var(--soft-color);
|
--header-background: #246;
|
||||||
--header-color: #ccc;
|
--header-color: #ccc;
|
||||||
--input-background: #fff;
|
|
||||||
--input-color: #000;
|
|
||||||
--primary-color: #000;
|
--primary-color: #000;
|
||||||
--soft-color: #146;
|
|
||||||
--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(6.5 * var(--header-font-size));
|
--header-height: calc(8 * var(--header-font-size));
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--primary-color: #ddd;
|
--primary-color: #ddd;
|
||||||
--primary-background: var(--soft-color);
|
--primary-background: #003;
|
||||||
--header-background: #000;
|
--header-background: #000;
|
||||||
--header-color: #ccc;
|
--header-color: #ccc;
|
||||||
--input-background: var(--soft-color);
|
}
|
||||||
--input-color: #ddd;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@media screen and (max-width: 600px) {
|
@media screen and (orientation: portrait) and (max-width: 600px) {
|
||||||
.size,
|
.size,
|
||||||
.modified,
|
.modified {
|
||||||
.summary {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media screen and (min-width: 1000px) {
|
@media screen and (orientation: landscape) and (min-width: 600px) {
|
||||||
:root {
|
/* Breadcrumbs and buttons side by side */
|
||||||
--root-font-size: calc(8px + 8 * 100vw / 1000);
|
header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
header .buttons:has(input[type='search']) > div {
|
header .buttons:has(input[type='search']) > div {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -44,51 +49,20 @@
|
|||||||
display: inherit;
|
display: inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media screen and (min-width: 2000px) {
|
@media screen and (min-width: 1400px) and (--webkit-min-device-pixel-ratio: 3) {
|
||||||
:root {
|
html {
|
||||||
--root-font-size: 1.5rem;
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* Low (landscape) screens: smaller header */
|
|
||||||
@media screen and (max-height: 600px) {
|
@media screen and (max-height: 600px) {
|
||||||
:root {
|
:root {
|
||||||
--header-font-size: calc(10px + 10 * 100vh / 600); /* 20px at 600px height */
|
--header-font-size: calc(16 * 100vh / 600); /* 16px (1rem nominal) at 600px height */
|
||||||
--root-font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
header .breadcrumb > * {
|
|
||||||
padding-top: calc(8 + 8 * 100vh / 600) !important;
|
|
||||||
padding-bottom: calc(8 + 8 * 100vh / 600) !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media screen and (max-height: 300px) {
|
@media screen and (max-height: 300px) {
|
||||||
:root {
|
:root {
|
||||||
--header-font-size: 15px; /* Don't go smaller than this, no benefit */
|
--header-font-size: 0.5rem; /* Don't go smaller than this, no benefit */
|
||||||
--header-height: calc(1.75 * 16px);
|
--header-height: calc(1.75 * 16px);
|
||||||
--root-font-size: 0.6rem;
|
|
||||||
}
|
|
||||||
header .breadcrumb > * {
|
|
||||||
padding-top: 14px !important;
|
|
||||||
padding-bottom: 14px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media screen and (orientation: landscape) and (min-width: 700px) {
|
|
||||||
/* Breadcrumbs and buttons side by side */
|
|
||||||
:root {
|
|
||||||
--header-font-size: calc(8px + 8 * 100vh / 600); /* 16px (1rem nominal) at 600px height */
|
|
||||||
}
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: end;
|
|
||||||
}
|
|
||||||
header .breadcrumb {
|
|
||||||
flex-shrink: 1;
|
|
||||||
}
|
|
||||||
header .breadcrumb > * {
|
|
||||||
flex-shrink: 1;
|
|
||||||
padding-top: 1rem !important;
|
|
||||||
padding-bottom: 1rem !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media print {
|
@media print {
|
||||||
@@ -143,7 +117,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
font-size: var(--root-font-size);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
/* Hide scrollbar for all browsers */
|
/* Hide scrollbar for all browsers */
|
||||||
@@ -212,21 +185,18 @@ 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));
|
||||||
padding-bottom: 3em; /* convenience space on the bottom */
|
padding-bottom: 3em; /* convenience space on the bottom */
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
.spacer { flex-grow: 1 }
|
|
||||||
.smallgap { flex-shrink: 1; width: 2em }
|
|
||||||
|
|
||||||
[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;
|
||||||
@@ -234,7 +204,7 @@ main {
|
|||||||
padding: .5rem 1rem;
|
padding: .5rem 1rem;
|
||||||
border-radius: 3rem 0 3rem 0;
|
border-radius: 3rem 0 3rem 0;
|
||||||
box-shadow: 0 0 1rem var(--accent-color);
|
box-shadow: 0 0 1rem var(--accent-color);
|
||||||
transform: translate(calc(1rem + -50%), 150%);
|
transform: translate(calc(1rem + -50%), 100%);
|
||||||
background-color: var(--accent-color);
|
background-color: var(--accent-color);
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
@@ -260,9 +230,3 @@ main {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.error-message {
|
|
||||||
padding: .5em;
|
|
||||||
font-weight: bold;
|
|
||||||
background: var(--accent-color);
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 158 B After Width: | Height: | Size: 158 B |
|
Before Width: | Height: | Size: 168 B After Width: | Height: | Size: 168 B |
|
Before Width: | Height: | Size: 388 B After Width: | Height: | Size: 388 B |
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 128 B |
|
Before Width: | Height: | Size: 126 B After Width: | Height: | Size: 126 B |
|
Before Width: | Height: | Size: 158 B After Width: | Height: | Size: 158 B |
|
Before Width: | Height: | Size: 208 B After Width: | Height: | Size: 208 B |
|
Before Width: | Height: | Size: 563 B After Width: | Height: | Size: 563 B |
|
Before Width: | Height: | Size: 212 B After Width: | Height: | Size: 212 B |
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 293 B |
|
Before Width: | Height: | Size: 310 B After Width: | Height: | Size: 310 B |
|
Before Width: | Height: | Size: 193 B After Width: | Height: | Size: 193 B |
|
Before Width: | Height: | Size: 278 B After Width: | Height: | Size: 278 B |
|
Before Width: | Height: | Size: 711 B After Width: | Height: | Size: 711 B |
|
Before Width: | Height: | Size: 365 B After Width: | Height: | Size: 365 B |
|
Before Width: | Height: | Size: 783 B After Width: | Height: | Size: 783 B |
|
Before Width: | Height: | Size: 382 B After Width: | Height: | Size: 382 B |
|
Before Width: | Height: | Size: 200 B After Width: | Height: | Size: 200 B |
|
Before Width: | Height: | Size: 698 B After Width: | Height: | Size: 698 B |
|
Before Width: | Height: | Size: 156 B After Width: | Height: | Size: 156 B |
|
Before Width: | Height: | Size: 416 B After Width: | Height: | Size: 416 B |
|
Before Width: | Height: | Size: 517 B After Width: | Height: | Size: 517 B |
|
Before Width: | Height: | Size: 257 B After Width: | Height: | Size: 257 B |
|
Before Width: | Height: | Size: 297 B After Width: | Height: | Size: 297 B |
|
Before Width: | Height: | Size: 312 B After Width: | Height: | Size: 312 B |
|
Before Width: | Height: | Size: 109 B After Width: | Height: | Size: 109 B |
|
Before Width: | Height: | Size: 587 B After Width: | Height: | Size: 587 B |
|
Before Width: | Height: | Size: 269 B After Width: | Height: | Size: 269 B |
|
Before Width: | Height: | Size: 106 B After Width: | Height: | Size: 106 B |
|
Before Width: | Height: | Size: 393 B After Width: | Height: | Size: 393 B |
|
Before Width: | Height: | Size: 94 B After Width: | Height: | Size: 94 B |
|
Before Width: | Height: | Size: 229 B After Width: | Height: | Size: 229 B |
|
Before Width: | Height: | Size: 108 B After Width: | Height: | Size: 108 B |
|
Before Width: | Height: | Size: 407 B After Width: | Height: | Size: 407 B |
|
Before Width: | Height: | Size: 887 B After Width: | Height: | Size: 887 B |
|
Before Width: | Height: | Size: 908 B After Width: | Height: | Size: 908 B |
|
Before Width: | Height: | Size: 417 B After Width: | Height: | Size: 417 B |
|
Before Width: | Height: | Size: 554 B After Width: | Height: | Size: 554 B |
|
Before Width: | Height: | Size: 552 B After Width: | Height: | Size: 552 B |
|
Before Width: | Height: | Size: 114 B After Width: | Height: | Size: 114 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 91 B After Width: | Height: | Size: 91 B |
|
Before Width: | Height: | Size: 647 B After Width: | Height: | Size: 647 B |
|
Before Width: | Height: | Size: 95 B After Width: | Height: | Size: 95 B |
|
Before Width: | Height: | Size: 208 B After Width: | Height: | Size: 208 B |
|
Before Width: | Height: | Size: 104 B After Width: | Height: | Size: 104 B |
|
Before Width: | Height: | Size: 508 B After Width: | Height: | Size: 508 B |
|
Before Width: | Height: | Size: 1009 B After Width: | Height: | Size: 1009 B |
|
Before Width: | Height: | Size: 278 B After Width: | Height: | Size: 278 B |
|
Before Width: | Height: | Size: 753 B After Width: | Height: | Size: 753 B |
|
Before Width: | Height: | Size: 353 B After Width: | Height: | Size: 353 B |
|
Before Width: | Height: | Size: 542 B After Width: | Height: | Size: 542 B |
|
Before Width: | Height: | Size: 292 B After Width: | Height: | Size: 292 B |
|
Before Width: | Height: | Size: 621 B After Width: | Height: | Size: 621 B |
|
Before Width: | Height: | Size: 517 B After Width: | Height: | Size: 517 B |
|
Before Width: | Height: | Size: 289 B After Width: | Height: | Size: 289 B |
|
Before Width: | Height: | Size: 498 B After Width: | Height: | Size: 498 B |
|
Before Width: | Height: | Size: 464 B After Width: | Height: | Size: 464 B |
@@ -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,24 +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})`)
|
|
||||||
const url = `/${longest.value.slice(0, index).join('/')}/`
|
|
||||||
const here = `/${longest.value.join('/')}/`
|
|
||||||
link.focus()
|
|
||||||
if (here.startsWith(location.hash.slice(1))) router.replace(url)
|
|
||||||
else router.push(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
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])
|
||||||
@@ -67,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>
|
||||||
@@ -1,13 +1,36 @@
|
|||||||
<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">
|
||||||
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
|
<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>
|
||||||
<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>
|
<th class="menu"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -15,63 +38,91 @@
|
|||||||
<tr v-if="editing?.key === 'new'" class="folder">
|
<tr v-if="editing?.key === 'new'" class="folder">
|
||||||
<td class="selection"></td>
|
<td class="selection"></td>
|
||||||
<td class="name">
|
<td class="name">
|
||||||
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
|
<FileRenameInput
|
||||||
|
:doc="editing"
|
||||||
|
:rename="mkdir"
|
||||||
|
:exit="
|
||||||
|
() => {
|
||||||
|
editing = null
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<FileModified :doc=editing />
|
<td class="modified right">
|
||||||
<FileSize :doc=editing />
|
<time :datetime="new Date(editing.mtime).toISOString().replace('.000', '')">{{
|
||||||
|
editing.modified
|
||||||
|
}}</time>
|
||||||
|
</td>
|
||||||
|
<td class="size right">{{ editing.sizedisp }}</td>
|
||||||
<td class="menu"></td>
|
<td class="menu"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="(doc, index) in sortedDocuments" :key="doc.key">
|
<tr
|
||||||
<tr class="folder-change" v-if="showFolderBreadcrumb(index)">
|
v-for="doc of sorted(props.documents as FolderDocument[])"
|
||||||
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
|
:key="doc.key"
|
||||||
</tr>
|
:id="`file-${doc.key}`"
|
||||||
|
:class="{
|
||||||
<tr
|
file: doc.type === 'file',
|
||||||
:id="`file-${doc.key}`"
|
folder: doc.type === 'folder',
|
||||||
:class="{ file: !doc.dir, folder: doc.dir, cursor: cursor === doc }"
|
cursor: cursor === doc
|
||||||
@click="cursor = cursor === doc ? null : doc"
|
}"
|
||||||
@contextmenu.prevent="contextMenu($event, doc)"
|
@click="cursor = cursor === doc ? null : doc"
|
||||||
>
|
@contextmenu.prevent="contextMenu($event, doc)"
|
||||||
<td class="selection" @click.up.stop="cursor = cursor === doc ? doc : null">
|
>
|
||||||
<input
|
<td class="selection" @click.up.stop="cursor = cursor === doc ? doc : null">
|
||||||
type="checkbox"
|
<input
|
||||||
tabindex="-1"
|
type="checkbox"
|
||||||
:checked="documentStore.selected.has(doc.key)"
|
tabindex="-1"
|
||||||
@change="
|
:checked="documentStore.selected.has(doc.key)"
|
||||||
($event.target as HTMLInputElement).checked
|
@change="
|
||||||
? documentStore.selected.add(doc.key)
|
($event.target as HTMLInputElement).checked
|
||||||
: documentStore.selected.delete(doc.key)
|
? 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>
|
||||||
</td>
|
<template v-else>
|
||||||
<td class="name">
|
<a
|
||||||
<template v-if="editing === doc">
|
:href="url_for(doc)"
|
||||||
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
|
tabindex="-1"
|
||||||
</template>
|
@contextmenu.stop
|
||||||
<template v-else>
|
@focus.stop="cursor = doc"
|
||||||
<a
|
>{{ doc.name }}</a
|
||||||
:href="url_for(doc)"
|
>
|
||||||
tabindex="-1"
|
<button
|
||||||
@contextmenu.prevent
|
v-if="cursor == doc"
|
||||||
@focus.stop="cursor = doc"
|
class="rename-button"
|
||||||
@keyup.left="router.back()"
|
@click="() => (editing = doc)"
|
||||||
@keyup.right.stop="ev => { if (doc.dir) (ev.target as HTMLElement).click() }"
|
>
|
||||||
>{{ doc.name }}</a
|
🖊️
|
||||||
>
|
</button>
|
||||||
<button v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
</template>
|
||||||
</template>
|
</td>
|
||||||
</td>
|
<td class="modified right">
|
||||||
<FileModified :doc=doc />
|
<time
|
||||||
<FileSize :doc=doc />
|
:data-tooltip="new Date(1000 * doc.mtime).toISOString().replace('T', '\n').replace('.000Z', ' UTC')"
|
||||||
<td class="menu">
|
>{{ doc.modified }}</time
|
||||||
<button tabindex="-1" @click.stop="contextMenu($event, doc)">⋮</button>
|
>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
<td class="size right">{{ doc.sizedisp }}</td>
|
||||||
</template>
|
<td class="menu">
|
||||||
<tr class="summary" v-if="props.documents.length > 1">
|
<button
|
||||||
<td colspan="3" class="right">{{props.documents.length}} items</td>
|
tabindex="-1"
|
||||||
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
|
@click.stop="contextMenu($event, doc)"
|
||||||
<td class="menu"></td>
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -79,72 +130,67 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watchEffect, onMounted, onUnmounted } 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 { connect, controlUrl } 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 = defineProps<{
|
const props = withDefaults(
|
||||||
path: Array<string>
|
defineProps<{
|
||||||
documents: Document[]
|
path: Array<string>
|
||||||
}>()
|
documents: Document[]
|
||||||
|
}>(),
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
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.dir ? `#/${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 = connect(controlUrl, {
|
const control = createWebSocket('/api/control', (ev: MessageEvent) => {
|
||||||
message(ev: MessageEvent) {
|
const msg = JSON.parse(ev.data)
|
||||||
const msg = JSON.parse(ev.data)
|
if ('error' in msg) {
|
||||||
if ('error' in msg) {
|
console.error('Rename failed', msg.error.message, msg.error)
|
||||||
console.error('Rename failed', msg.error.message, msg.error)
|
doc.name = oldName
|
||||||
doc.name = oldName
|
} else {
|
||||||
} else {
|
console.log('Rename succeeded', msg)
|
||||||
console.log('Rename succeeded', msg)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
control.onopen = () => {
|
control.onopen = () => {
|
||||||
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
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
doc.name = newName // We should get an update from watch but this is quicker
|
doc.name = newName // We should get an update from watch but this is quicker
|
||||||
}
|
}
|
||||||
const sortedDocuments = computed(() => sorted(props.documents as Document[]))
|
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
|
||||||
const docs = sortedDocuments.value
|
|
||||||
const docloc = docs[i].loc
|
|
||||||
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
|
|
||||||
}
|
|
||||||
defineExpose({
|
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',
|
||||||
dir: true,
|
type: 'folder',
|
||||||
mtime: now,
|
mtime: now,
|
||||||
size: 0,
|
size: 0,
|
||||||
sizedisp: formatSize(0),
|
sizedisp: formatSize(0),
|
||||||
modified: formatUnixDate(now),
|
modified: formatUnixDate(now)
|
||||||
haystack: '',
|
|
||||||
}
|
}
|
||||||
console.log("New")
|
|
||||||
},
|
},
|
||||||
toggleSelectAll() {
|
toggleSelectAll() {
|
||||||
console.log('Select')
|
console.log('Select')
|
||||||
@@ -172,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 = sortedDocuments.value
|
const documents = sorted(props.documents as FolderDocument[])
|
||||||
if (documents.length === 0) {
|
if (documents.length === 0) {
|
||||||
cursor.value = null
|
cursor.value = null
|
||||||
return
|
return
|
||||||
@@ -199,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`
|
||||||
@@ -223,40 +262,25 @@ watchEffect(() => {
|
|||||||
if (a) a.focus()
|
if (a) a.focus()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
watchEffect(() => {
|
const mkdir = (doc: FolderDocument, name: string) => {
|
||||||
if (!props.documents.length && cursor.value) {
|
const control = createWebSocket('/api/control', (ev: MessageEvent) => {
|
||||||
cursor.value = null
|
const msg = JSON.parse(ev.data)
|
||||||
focusBreadcrumb()
|
if ('error' in msg) {
|
||||||
}
|
console.error('Mkdir failed', msg.error.message, msg.error)
|
||||||
})
|
editing.value = null
|
||||||
// Update human-readable x seconds ago messages from mtimes
|
} else {
|
||||||
let modifiedTimer: any = null
|
console.log('mkdir', msg)
|
||||||
const updateModified = () => {
|
router.push(`/${linkBasePath.value}/${name}/`)
|
||||||
for (const doc of props.documents) doc.modified = formatUnixDate(doc.mtime)
|
|
||||||
}
|
|
||||||
onMounted(() => { updateModified(); modifiedTimer = setInterval(updateModified, 1000) })
|
|
||||||
onUnmounted(() => { clearInterval(modifiedTimer) })
|
|
||||||
const mkdir = (doc: Document, name: string) => {
|
|
||||||
const control = connect(controlUrl, {
|
|
||||||
open() {
|
|
||||||
control.send(
|
|
||||||
JSON.stringify({
|
|
||||||
op: 'mkdir',
|
|
||||||
path: `${doc.loc}/${name}`
|
|
||||||
})
|
|
||||||
)
|
|
||||||
},
|
|
||||||
message(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(doc.loc ? `/${doc.loc}/${name}/` : `/${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
|
doc.name = name // We should get an update from watch but this is quicker
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,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)
|
||||||
@@ -305,9 +330,10 @@ const allSelected = computed({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
watchEffect(() => {
|
||||||
const loc = computed(() => props.path.join('/'))
|
if (cursor.value && cursor.value !== editing.value) editing.value = null
|
||||||
|
if (editing.value) cursor.value = editing.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)
|
||||||
@@ -344,10 +370,10 @@ table .selection {
|
|||||||
text-overflow: clip;
|
text-overflow: clip;
|
||||||
}
|
}
|
||||||
table .modified {
|
table .modified {
|
||||||
width: 9em;
|
width: 8rem;
|
||||||
}
|
}
|
||||||
table .size {
|
table .size {
|
||||||
width: 5em;
|
width: 4rem;
|
||||||
}
|
}
|
||||||
table .menu {
|
table .menu {
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
@@ -385,7 +411,6 @@ table td {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
thead tr {
|
thead tr {
|
||||||
font-size: var(--header-font-size);
|
|
||||||
background: linear-gradient(to bottom, #eee, #fff 30%, #ddd);
|
background: linear-gradient(to bottom, #eee, #fff 30%, #ddd);
|
||||||
color: #000;
|
color: #000;
|
||||||
box-shadow: 0 0 .2rem black;
|
box-shadow: 0 0 .2rem black;
|
||||||
@@ -448,13 +473,4 @@ tbody .selection input {
|
|||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
.folder-change {
|
|
||||||
margin-left: -.5rem;
|
|
||||||
}
|
|
||||||
.loc {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
.summary {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -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
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -46,8 +46,8 @@ const apply = () => {
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
input#FileRenameInput {
|
input#FileRenameInput {
|
||||||
color: var(--input-color);
|
color: var(--primary-color);
|
||||||
background: var(--input-background);
|
background: var(--primary-background);
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0.3rem;
|
border-radius: 0.3rem;
|
||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
52
cista-front/src/components/FileViewer.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<object
|
||||||
|
v-if="props.type === 'pdf'"
|
||||||
|
:data="dataURL"
|
||||||
|
type="application/pdf"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
></object>
|
||||||
|
<a-image
|
||||||
|
v-else-if="props.type === 'image'"
|
||||||
|
width="50%"
|
||||||
|
:src="dataURL"
|
||||||
|
@click="() => setVisible(true)"
|
||||||
|
:previewMask="false"
|
||||||
|
:preview="{
|
||||||
|
visibleImg,
|
||||||
|
onVisibleChange: setVisible
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<!-- Unknown case -->
|
||||||
|
<h1 v-else>Unsupported file type</h1>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { watchEffect, ref } from 'vue'
|
||||||
|
import Router from '@/router/index'
|
||||||
|
import { url_document_get } from '@/repositories/Document'
|
||||||
|
|
||||||
|
const dataURL = ref('')
|
||||||
|
watchEffect(() => {
|
||||||
|
dataURL.value = new URL(
|
||||||
|
url_document_get + Router.currentRoute.value.path,
|
||||||
|
location.origin
|
||||||
|
).toString()
|
||||||
|
})
|
||||||
|
const emit = defineEmits({
|
||||||
|
visibleImg(value: boolean) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function setVisible(value: boolean) {
|
||||||
|
emit('visibleImg', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
type?: string
|
||||||
|
visibleImg: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style></style>
|
||||||
82
cista-front/src/components/HeaderMain.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useDocumentStore } from '@/stores/documents'
|
||||||
|
import { ref, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const documentStore = useDocumentStore()
|
||||||
|
const showSearchInput = ref<boolean>(false)
|
||||||
|
const search = ref<HTMLInputElement | null>()
|
||||||
|
const searchButton = ref<HTMLButtonElement | null>()
|
||||||
|
|
||||||
|
const toggleSearchInput = () => {
|
||||||
|
showSearchInput.value = !showSearchInput.value
|
||||||
|
nextTick(() => {
|
||||||
|
const input = search.value
|
||||||
|
if (input) input.focus()
|
||||||
|
else if (searchButton.value) searchButton.value.blur()
|
||||||
|
executeSearch()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeSearch = () => {
|
||||||
|
documentStore.setFilter(search.value?.value ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
toggleSearchInput
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav>
|
||||||
|
<div class="buttons">
|
||||||
|
<UploadButton />
|
||||||
|
<SvgButton
|
||||||
|
name="create-folder"
|
||||||
|
data-tooltip="New folder"
|
||||||
|
@click="() => documentStore.fileExplorer.newFolder()"
|
||||||
|
/>
|
||||||
|
<slot></slot>
|
||||||
|
<div class="spacer smallgap"></div>
|
||||||
|
<template v-if="showSearchInput">
|
||||||
|
<input
|
||||||
|
ref="search"
|
||||||
|
type="search"
|
||||||
|
class="margin-input"
|
||||||
|
@keyup.esc="toggleSearchInput"
|
||||||
|
@input="executeSearch"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<SvgButton ref="searchButton" name="find" @click="toggleSearchInput" />
|
||||||
|
<SvgButton name="cog" @click="console.log('settings menu')" />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.buttons {
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 3.5em;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.buttons > * {
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
.spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
.smallgap {
|
||||||
|
margin-left: 2em;
|
||||||
|
}
|
||||||
|
input[type='search'] {
|
||||||
|
background: var(--primary-background);
|
||||||
|
color: var(--primary-color);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.1em;
|
||||||
|
padding: 0.5em;
|
||||||
|
outline: none;
|
||||||
|
font-size: 1.5em;
|
||||||
|
max-width: 30vw;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {connect, controlUrl} from '@/repositories/WS'
|
import createWebSocket from '@/repositories/WS'
|
||||||
import { useDocumentStore } from '@/stores/documents'
|
import { useDocumentStore } from '@/stores/documents'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { SelectedItems } from '@/repositories/Document'
|
import type { SelectedItems } from '@/repositories/Document'
|
||||||
@@ -26,27 +26,21 @@ const op = (op: string, dst?: string) => {
|
|||||||
const sel = documentStore.selectedFiles
|
const sel = documentStore.selectedFiles
|
||||||
const msg = {
|
const msg = {
|
||||||
op,
|
op,
|
||||||
sel: sel.keys.map(key => {
|
sel: sel.ids.filter(id => sel.selected.has(id)).map(id => sel.fullpath[id])
|
||||||
const doc = sel.docs[key]
|
|
||||||
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (dst !== undefined) msg.dst = dst
|
if (dst !== undefined) msg.dst = dst
|
||||||
const control = connect(controlUrl, {
|
const control = createWebSocket('/api/control', ev => {
|
||||||
message(ev: MessageEvent) {
|
const res = JSON.parse(ev.data)
|
||||||
const res = JSON.parse(ev.data)
|
if ('error' in res) {
|
||||||
if ('error' in res) {
|
console.error('Control socket error', msg, res.error)
|
||||||
console.error('Control socket error', msg, res.error)
|
return
|
||||||
documentStore.error = res.error.message
|
} else if (res.status === 'ack') {
|
||||||
return
|
console.log('Control ack OK', res)
|
||||||
} else if (res.status === 'ack') {
|
control.close()
|
||||||
console.log('Control ack OK', res)
|
documentStore.selected.clear()
|
||||||
control.close()
|
return
|
||||||
documentStore.selected.clear()
|
} else console.log('Unknown control respons', msg, res)
|
||||||
return
|
|
||||||
} else console.log('Unknown control response', msg, res)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
control.onopen = () => {
|
control.onopen = () => {
|
||||||
control.send(JSON.stringify(msg))
|
control.send(JSON.stringify(msg))
|
||||||
@@ -63,15 +57,21 @@ const linkdl = (href: string) => {
|
|||||||
const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => {
|
const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => {
|
||||||
let hdir = ''
|
let hdir = ''
|
||||||
let h = handle
|
let h = handle
|
||||||
console.log('Downloading to filesystem', sel.recursive)
|
let filelist = []
|
||||||
for (const [rel, full, doc] of sel.recursive) {
|
for (const id of sel.ids) {
|
||||||
|
filelist.push(sel.relpath[id])
|
||||||
|
}
|
||||||
|
console.log('Downloading to filesystem', filelist)
|
||||||
|
for (const id of sel.ids) {
|
||||||
|
const rel = sel.relpath[id]
|
||||||
|
const url = sel.url[id] // Only files, not folders
|
||||||
// Create any missing directories
|
// Create any missing directories
|
||||||
if (hdir && !rel.startsWith(hdir + '/')) {
|
if (!rel.startsWith(hdir)) {
|
||||||
hdir = ''
|
hdir = ''
|
||||||
h = handle
|
h = handle
|
||||||
}
|
}
|
||||||
const r = rel.slice(hdir.length)
|
const r = rel.slice(hdir.length)
|
||||||
for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) {
|
for (const dir of r.split('/').slice(0, url ? -1 : undefined)) {
|
||||||
hdir += `${dir}/`
|
hdir += `${dir}/`
|
||||||
try {
|
try {
|
||||||
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
|
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
|
||||||
@@ -81,18 +81,17 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
|||||||
}
|
}
|
||||||
console.log('Created', hdir)
|
console.log('Created', hdir)
|
||||||
}
|
}
|
||||||
if (doc.dir) continue // Target was a folder and was created
|
if (!url) continue // Target was a folder and was created
|
||||||
const name = rel.split('/').pop()!.normalize('NFC')
|
const name = rel.split('/').pop()!.normalize('NFC')
|
||||||
// Download file
|
// Download file
|
||||||
let fileHandle
|
let fileHandle
|
||||||
try {
|
try {
|
||||||
fileHandle = await h.getFileHandle(name, { create: true })
|
fileHandle = await h.getFileHandle(name, { create: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create file', rel, full, hdir + name, error)
|
console.error('Failed to create file', hdir + name, error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const writable = await fileHandle.createWritable()
|
const writable = await fileHandle.createWritable()
|
||||||
const url = `/files/${rel}`
|
|
||||||
console.log('Fetching', url)
|
console.log('Fetching', url)
|
||||||
const res = await fetch(url)
|
const res = await fetch(url)
|
||||||
if (!res.ok)
|
if (!res.ok)
|
||||||
@@ -110,16 +109,16 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
|||||||
const download = async () => {
|
const download = async () => {
|
||||||
const sel = documentStore.selectedFiles
|
const sel = documentStore.selectedFiles
|
||||||
console.log('Download', sel)
|
console.log('Download', sel)
|
||||||
if (sel.keys.length === 0) {
|
if (sel.selected.size === 0) {
|
||||||
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
|
console.warn('Attempted download but no files found. Missing:', sel.missing)
|
||||||
documentStore.selected.clear()
|
documentStore.selected.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Plain old a href download if only one file (ignoring any folders)
|
// Plain old a href download if only one file (ignoring any folders)
|
||||||
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
|
const urls = Object.values(sel.url)
|
||||||
if (files.length === 1) {
|
if (urls.length === 1) {
|
||||||
documentStore.selected.clear()
|
documentStore.selected.clear()
|
||||||
return linkdl(`/files/${files[0][1]}`)
|
return linkdl(urls[0] as string)
|
||||||
}
|
}
|
||||||
// Use FileSystem API if multiple files and the browser supports it
|
// Use FileSystem API if multiple files and the browser supports it
|
||||||
if ('showDirectoryPicker' in window) {
|
if ('showDirectoryPicker' in window) {
|
||||||
@@ -138,8 +137,7 @@ const download = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Otherwise, zip and download
|
// Otherwise, zip and download
|
||||||
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
|
linkdl(`/zip/${Array.from(sel.selected).join('+')}/download.zip`)
|
||||||
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
|
||||||
documentStore.selected.clear()
|
documentStore.selected.clear()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -147,7 +145,7 @@ const download = async () => {
|
|||||||
<style>
|
<style>
|
||||||
.select-text {
|
.select-text {
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
white-space: nowrap;
|
text-wrap: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<ModalDialog v-if="store.user.isOpenLoginModal" title="Authentication required" @blur="store.user.isOpenLoginModal = false">
|
<button v-if="store.isUserLogged" @click="logout" class="action-button">
|
||||||
|
Logout {{ store.user.username }}
|
||||||
|
</button>
|
||||||
|
<ModalDialog v-if="store.user.isOpenLoginModal" title="Login">
|
||||||
<form @submit.prevent="login">
|
<form @submit.prevent="login">
|
||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
<label for="username">Username:</label>
|
<label for="username">Username:</label>
|
||||||
@@ -7,8 +10,6 @@
|
|||||||
id="username"
|
id="username"
|
||||||
name="username"
|
name="username"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
spellcheck="false"
|
|
||||||
autocorrect="off"
|
|
||||||
required
|
required
|
||||||
v-model="loginForm.username"
|
v-model="loginForm.username"
|
||||||
/>
|
/>
|
||||||
@@ -18,32 +19,35 @@
|
|||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
spellcheck="false"
|
|
||||||
autocorrect="off"
|
|
||||||
required
|
required
|
||||||
v-model="loginForm.password"
|
v-model="loginForm.password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="error-text">
|
<h3 v-if="loginForm.error.length > 0" class="error-text">
|
||||||
{{ loginForm.error || '\u00A0' }}
|
{{ loginForm.error }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="dialog-buttons">
|
<input id="submit" type="submit" class="button-login" />
|
||||||
<div class="spacer"></div>
|
|
||||||
<input id="submit" type="submit" value="Login" class="button-login" />
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { reactive, ref } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
import { loginUser } from '@/repositories/User'
|
import { loginUser, logoutUser } from '@/repositories/User'
|
||||||
import type { ISimpleError } from '@/repositories/Client'
|
import type { ISimpleError } from '@/repositories/Client'
|
||||||
import { useDocumentStore } from '@/stores/documents'
|
import { useDocumentStore } from '@/stores/documents'
|
||||||
|
|
||||||
const confirmLoading = ref<boolean>(false)
|
const confirmLoading = ref<boolean>(false)
|
||||||
const store = useDocumentStore()
|
const store = useDocumentStore()
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await logoutUser()
|
||||||
|
} finally {
|
||||||
|
location.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loginForm = reactive({
|
const loginForm = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
@@ -55,10 +59,13 @@ const login = async () => {
|
|||||||
loginForm.error = ''
|
loginForm.error = ''
|
||||||
confirmLoading.value = true
|
confirmLoading.value = true
|
||||||
const msg = await loginUser(loginForm.username, loginForm.password)
|
const msg = await loginUser(loginForm.username, loginForm.password)
|
||||||
store.login(msg.data.username, !!msg.data.privileged)
|
console.log('Logged in', msg)
|
||||||
|
store.login(msg.username, !!msg.privileged)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const httpError = error as ISimpleError
|
const httpError = error as ISimpleError
|
||||||
loginForm.error = httpError.message || '🛑 Unknown error'
|
if (httpError.name) {
|
||||||
|
loginForm.error = httpError.message
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
confirmLoading.value = false
|
confirmLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -74,28 +81,15 @@ const login = async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
.dialog-buttons {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.button-login {
|
.button-login {
|
||||||
color: #fff;
|
|
||||||
background: var(--soft-color);
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: bold;
|
|
||||||
border: 0;
|
|
||||||
border-radius: .5rem;
|
|
||||||
padding: .5rem 2rem;
|
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
transition: all var(--transition-time) linear;
|
background-color: var(--secondary-color);
|
||||||
|
color: var(--secondary-background);
|
||||||
}
|
}
|
||||||
.button-login:hover, .button-login:focus {
|
.ant-btn-primary:not(:disabled):hover {
|
||||||
background: var(--accent-color);
|
background-color: var(--blue-color);
|
||||||
box-shadow: 0 0 .3rem #000;
|
|
||||||
}
|
}
|
||||||
.error-text {
|
.error-text {
|
||||||
color: var(--red-color);
|
color: var(--red-color);
|
||||||
height: 1em;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -23,18 +23,15 @@ const props = withDefaults(
|
|||||||
title: ''
|
title: ''
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
const show = () => {
|
|
||||||
dialog.value!.showModal()
|
|
||||||
}
|
|
||||||
defineExpose({ show })
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
show()
|
dialog.value!.showModal()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Style for the background */
|
/* Style for the background */
|
||||||
dialog::backdrop {
|
body:has(dialog[open])::before {
|
||||||
content: '';
|
content: '';
|
||||||
display: block;
|
display: block;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -43,17 +40,14 @@ dialog::backdrop {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #0008;
|
background: #0008;
|
||||||
backdrop-filter: blur(0.4em);
|
backdrop-filter: blur(0.2em);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide the dialog by default */
|
/* Hide the dialog by default */
|
||||||
dialog[open] {
|
dialog[open] {
|
||||||
background: #ddd;
|
|
||||||
color: black;
|
|
||||||
display: block;
|
display: block;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 1.2rem;
|
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0.2rem 0.2rem 1rem #000;
|
box-shadow: 0.2rem 0.2rem 1rem #000;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@@ -62,13 +56,11 @@ dialog[open] {
|
|||||||
left: 0;
|
left: 0;
|
||||||
z-index: 1001;
|
z-index: 1001;
|
||||||
}
|
}
|
||||||
input {
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
dialog[open] > h1 {
|
dialog[open] > h1 {
|
||||||
background: var(--soft-color);
|
background: #00f;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 1.2rem;
|
font-size: 1rem;
|
||||||
margin: -1rem -1rem 0 -1rem;
|
margin: -1rem -1rem 0 -1rem;
|
||||||
padding: 0.5rem 1rem 0.5rem 1rem;
|
padding: 0.5rem 1rem 0.5rem 1rem;
|
||||||
}
|
}
|
||||||
27
cista-front/src/components/NotificationLoading.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<template v-for="upload in documentStore.uploadingDocuments" :key="upload.key">
|
||||||
|
<span>{{ upload.name }}</span>
|
||||||
|
<div class="progress-container">
|
||||||
|
<a-progress :percent="upload.progress" />
|
||||||
|
<CloseCircleOutlined class="close-button" @click="dismissUpload(upload.key)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useDocumentStore } from '@/stores/documents'
|
||||||
|
const documentStore = useDocumentStore()
|
||||||
|
|
||||||
|
function dismissUpload(key: number) {
|
||||||
|
documentStore.deleteUploadingDocument(key)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.progress-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.close-button:hover {
|
||||||
|
color: #b81414;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
96
cista-front/src/components/UploadButton.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useDocumentStore } from '@/stores/documents'
|
||||||
|
import { h, ref } from 'vue'
|
||||||
|
|
||||||
|
const fileUploadButton = ref()
|
||||||
|
const folderUploadButton = ref()
|
||||||
|
const documentStore = useDocumentStore()
|
||||||
|
const open = (placement: any) => openNotification(placement)
|
||||||
|
|
||||||
|
const isNotificationOpen = ref(false)
|
||||||
|
const openNotification = (placement: any) => {
|
||||||
|
if (!isNotificationOpen.value) {
|
||||||
|
/*
|
||||||
|
api.open({
|
||||||
|
message: `Uploading documents`,
|
||||||
|
description: h(NotificationLoading),
|
||||||
|
placement,
|
||||||
|
duration: 0,
|
||||||
|
onClose: () => { isNotificationOpen.value = false }
|
||||||
|
});*/
|
||||||
|
isNotificationOpen.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFileHandler() {
|
||||||
|
fileUploadButton.value.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(file: File, start: number, end: number): Promise<ArrayBuffer> {
|
||||||
|
const reader = new FileReader()
|
||||||
|
const load = new Promise<Event>(resolve => (reader.onload = resolve))
|
||||||
|
reader.readAsArrayBuffer(file.slice(start, end))
|
||||||
|
const event = await load
|
||||||
|
if (event.target && event.target instanceof FileReader) {
|
||||||
|
return event.target.result as ArrayBuffer
|
||||||
|
} else {
|
||||||
|
throw new Error('Error loading file')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendChunk(file: File, start: number, end: number) {
|
||||||
|
const ws = documentStore.wsUpload
|
||||||
|
if (ws) {
|
||||||
|
const chunk = await load(file, start, end)
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
start: start,
|
||||||
|
end: end
|
||||||
|
})
|
||||||
|
)
|
||||||
|
ws.send(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFileChangeHandler(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const chunkSize = 1 << 20
|
||||||
|
if (target && target.files && target.files.length > 0) {
|
||||||
|
const file = target.files[0]
|
||||||
|
const numChunks = Math.ceil(file.size / chunkSize)
|
||||||
|
const document = documentStore.pushUploadingDocuments(file.name)
|
||||||
|
open('bottomRight')
|
||||||
|
for (let i = 0; i < numChunks; i++) {
|
||||||
|
const start = i * chunkSize
|
||||||
|
const end = Math.min(file.size, start + chunkSize)
|
||||||
|
const res = await sendChunk(file, start, end)
|
||||||
|
console.log('progress: ' + (100 * (i + 1)) / numChunks)
|
||||||
|
console.log('Num Chunks: ' + numChunks)
|
||||||
|
documentStore.updateUploadingDocuments(document.key, (100 * (i + 1)) / numChunks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<template>
|
||||||
|
<input
|
||||||
|
ref="fileUploadButton"
|
||||||
|
@change="uploadFileChangeHandler"
|
||||||
|
class="upload-input"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="folderUploadButton"
|
||||||
|
@change="uploadFileChangeHandler"
|
||||||
|
class="upload-input"
|
||||||
|
type="file"
|
||||||
|
webkitdirectory
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileUploadButton.click()" />
|
||||||
|
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderUploadButton.click()" />
|
||||||
|
</template>
|
||||||
@@ -8,9 +8,6 @@ import router from './router'
|
|||||||
|
|
||||||
import piniaPluginPersistedState from 'pinia-plugin-persistedstate'
|
import piniaPluginPersistedState from 'pinia-plugin-persistedstate'
|
||||||
|
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
|
||||||
import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css'
|
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.config.errorHandler = err => {
|
app.config.errorHandler = err => {
|
||||||
/* handle error */
|
/* handle error */
|
||||||
@@ -20,6 +17,6 @@ app.config.errorHandler = err => {
|
|||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
pinia.use(piniaPluginPersistedState)
|
pinia.use(piniaPluginPersistedState)
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(ContextMenu)
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
@@ -12,7 +12,7 @@ class ClientClass {
|
|||||||
try {
|
try {
|
||||||
msg = await res.json()
|
msg = await res.json()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
throw new SimpleError(res.status, `HTTP ${res.status} ${res.statusText}`)
|
||||||
}
|
}
|
||||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
return msg
|
return msg
|
||||||
164
cista-front/src/repositories/Document.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import type { DocumentStore } from '@/stores/documents'
|
||||||
|
import { useDocumentStore } from '@/stores/documents'
|
||||||
|
import createWebSocket from './WS'
|
||||||
|
|
||||||
|
export type FUID = string
|
||||||
|
|
||||||
|
type BaseDocument = {
|
||||||
|
name: string
|
||||||
|
key: FUID
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FolderDocument = BaseDocument & {
|
||||||
|
type: 'folder' | 'file'
|
||||||
|
size: number
|
||||||
|
sizedisp: string
|
||||||
|
mtime: number
|
||||||
|
modified: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Document = FolderDocument
|
||||||
|
|
||||||
|
export type errorEvent = {
|
||||||
|
error: {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
redirect: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw types the backend /api/watch sends us
|
||||||
|
|
||||||
|
export type FileEntry = {
|
||||||
|
key: FUID
|
||||||
|
size: number
|
||||||
|
mtime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DirEntry = {
|
||||||
|
key: FUID
|
||||||
|
size: number
|
||||||
|
mtime: number
|
||||||
|
dir: DirList
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DirList = Record<string, FileEntry | DirEntry>
|
||||||
|
|
||||||
|
export type UpdateEntry = {
|
||||||
|
name: string
|
||||||
|
deleted?: boolean
|
||||||
|
key?: FUID
|
||||||
|
size?: number
|
||||||
|
mtime?: number
|
||||||
|
dir?: DirList
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper structure for selections
|
||||||
|
export interface SelectedItems {
|
||||||
|
selected: Set<FUID>
|
||||||
|
missing: Set<FUID>
|
||||||
|
rootdir: DirList
|
||||||
|
entries: Record<FUID, FileEntry | DirEntry>
|
||||||
|
fullpath: Record<FUID, string>
|
||||||
|
relpath: Record<FUID, string>
|
||||||
|
url: Record<FUID, string>
|
||||||
|
ids: FUID[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const url_document_watch_ws = '/api/watch'
|
||||||
|
export const url_document_upload_ws = '/api/upload'
|
||||||
|
export const url_document_get = '/files'
|
||||||
|
|
||||||
|
export class DocumentHandler {
|
||||||
|
constructor(private store: DocumentStore = useDocumentStore()) {
|
||||||
|
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleWebSocketMessage(event: MessageEvent) {
|
||||||
|
const msg = JSON.parse(event.data)
|
||||||
|
if ('error' in msg) {
|
||||||
|
if (msg.error.code === 401) {
|
||||||
|
this.store.user.isLoggedIn = false
|
||||||
|
this.store.user.isOpenLoginModal = true
|
||||||
|
} else {
|
||||||
|
this.store.error = msg.error.message
|
||||||
|
}
|
||||||
|
// The server closes the websocket after errors, so we need to reopen it
|
||||||
|
setTimeout(() => {
|
||||||
|
this.store.wsWatch = createWebSocket(
|
||||||
|
url_document_watch_ws,
|
||||||
|
this.handleWebSocketMessage
|
||||||
|
)
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
switch (true) {
|
||||||
|
case !!msg.root:
|
||||||
|
this.handleRootMessage(msg)
|
||||||
|
break
|
||||||
|
case !!msg.update:
|
||||||
|
this.handleUpdateMessage(msg)
|
||||||
|
break
|
||||||
|
case !!msg.space:
|
||||||
|
console.log('Watch space', msg.space)
|
||||||
|
break
|
||||||
|
case !!msg.error:
|
||||||
|
this.handleError(msg)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRootMessage({ root }: { root: DirEntry }) {
|
||||||
|
console.log('Watch root', root)
|
||||||
|
if (this.store && this.store.root) {
|
||||||
|
this.store.user.isLoggedIn = true
|
||||||
|
this.store.root = root
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
||||||
|
console.log('Watch update', updateData.update)
|
||||||
|
let node: DirEntry = this.store.root
|
||||||
|
for (const elem of updateData.update) {
|
||||||
|
if (elem.deleted) {
|
||||||
|
delete node.dir[elem.name]
|
||||||
|
break // Deleted elements can't have further children
|
||||||
|
}
|
||||||
|
if (elem.name !== undefined) {
|
||||||
|
// @ts-ignore
|
||||||
|
node = node.dir[elem.name] ||= {}
|
||||||
|
}
|
||||||
|
if (elem.key !== undefined) node.key = elem.key
|
||||||
|
if (elem.size !== undefined) node.size = elem.size
|
||||||
|
if (elem.mtime !== undefined) node.mtime = elem.mtime
|
||||||
|
if (elem.dir !== undefined) node.dir = elem.dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private handleError(msg: errorEvent) {
|
||||||
|
if (msg.error.code === 401) {
|
||||||
|
this.store.user.isOpenLoginModal = true
|
||||||
|
this.store.user.isLoggedIn = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocumentUploadHandler {
|
||||||
|
constructor(private store: DocumentStore = useDocumentStore()) {
|
||||||
|
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleWebSocketMessage(event: MessageEvent) {
|
||||||
|
const msg = JSON.parse(event.data)
|
||||||
|
switch (true) {
|
||||||
|
case !!msg.written:
|
||||||
|
this.handleWrittenMessage(msg)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleWrittenMessage(msg: { written: number }) {
|
||||||
|
// if (this.store && this.store.root) this.store.root = root;
|
||||||
|
console.log('Written message', msg.written)
|
||||||
|
}
|
||||||
|
}
|
||||||
8
cista-front/src/repositories/WS.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
function createWebSocket(url: string, eventHandler: (event: MessageEvent) => void) {
|
||||||
|
const urlObject = new URL(url, location.origin.replace(/^http/, 'ws'))
|
||||||
|
const webSocket = new WebSocket(urlObject)
|
||||||
|
webSocket.onmessage = eventHandler
|
||||||
|
return webSocket
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createWebSocket
|
||||||
231
cista-front/src/stores/documents.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import type {
|
||||||
|
Document,
|
||||||
|
DirEntry,
|
||||||
|
FileEntry,
|
||||||
|
FUID,
|
||||||
|
DirList,
|
||||||
|
SelectedItems
|
||||||
|
} from '@/repositories/Document'
|
||||||
|
import { formatSize, formatUnixDate } from '@/utils'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
// @ts-ignore
|
||||||
|
import { localeIncludes } from 'locale-includes'
|
||||||
|
|
||||||
|
type FileData = { id: string; mtime: number; size: number; dir: DirectoryData }
|
||||||
|
type DirectoryData = {
|
||||||
|
[filename: string]: FileData
|
||||||
|
}
|
||||||
|
type User = {
|
||||||
|
username: string
|
||||||
|
privileged: boolean
|
||||||
|
isOpenLoginModal: 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({
|
||||||
|
id: 'documents',
|
||||||
|
state: (): DocumentStore => ({
|
||||||
|
root: {} as DirEntry,
|
||||||
|
document: [] as Document[],
|
||||||
|
selected: new Set<FUID>(),
|
||||||
|
uploadingDocuments: [],
|
||||||
|
uploadCount: 0 as number,
|
||||||
|
wsWatch: undefined,
|
||||||
|
wsUpload: undefined,
|
||||||
|
fileExplorer: null,
|
||||||
|
error: '' as string,
|
||||||
|
user: {
|
||||||
|
username: '',
|
||||||
|
privileged: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
isOpenLoginModal: false
|
||||||
|
} as User
|
||||||
|
}),
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
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
|
||||||
|
)
|
||||||
|
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) {
|
||||||
|
if (d.key === key) d.progress = progress
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pushUploadingDocuments(name: string) {
|
||||||
|
this.uploadCount++
|
||||||
|
const document = {
|
||||||
|
key: this.uploadCount,
|
||||||
|
name: name,
|
||||||
|
progress: 0
|
||||||
|
}
|
||||||
|
this.uploadingDocuments.push(document)
|
||||||
|
return document
|
||||||
|
},
|
||||||
|
deleteUploadingDocument(key: number) {
|
||||||
|
this.uploadingDocuments = this.uploadingDocuments.filter(e => e.key !== key)
|
||||||
|
},
|
||||||
|
updateModified() {
|
||||||
|
for (const d of this.document) {
|
||||||
|
if ('mtime' in d) d.modified = formatUnixDate(d.mtime)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
login(username: string, privileged: boolean) {
|
||||||
|
this.user.username = username
|
||||||
|
this.user.privileged = privileged
|
||||||
|
this.user.isLoggedIn = true
|
||||||
|
this.user.isOpenLoginModal = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
mainDocument(): Document[] {
|
||||||
|
return this.document
|
||||||
|
},
|
||||||
|
isUserLogged(): boolean {
|
||||||
|
return this.user.isLoggedIn
|
||||||
|
},
|
||||||
|
selectedFiles(): SelectedItems {
|
||||||
|
function traverseDir(data: DirEntry | FileEntry, path: string, relpath: string) {
|
||||||
|
if (!('dir' in data)) return
|
||||||
|
for (const [name, attr] of Object.entries(data.dir)) {
|
||||||
|
const fullname = path ? `${path}/${name}` : name
|
||||||
|
const key = attr.key
|
||||||
|
// Is this the file we are looking for? Ignore if nested within another selection.
|
||||||
|
let r = relpath
|
||||||
|
if (selected.has(key) && !relpath) {
|
||||||
|
ret.selected.add(key)
|
||||||
|
ret.rootdir[name] = attr
|
||||||
|
r = name
|
||||||
|
} else if (relpath) {
|
||||||
|
r = `${relpath}/${name}`
|
||||||
|
}
|
||||||
|
if (r) {
|
||||||
|
ret.entries[key] = attr
|
||||||
|
ret.fullpath[key] = fullname
|
||||||
|
ret.relpath[key] = r
|
||||||
|
ret.ids.push(key)
|
||||||
|
if (!('dir' in attr)) ret.url[key] = `/files/${fullname}`
|
||||||
|
}
|
||||||
|
traverseDir(attr, fullname, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const selected = this.selected
|
||||||
|
const ret: SelectedItems = {
|
||||||
|
selected: new Set<FUID>(),
|
||||||
|
missing: new Set<FUID>(),
|
||||||
|
rootdir: {} as DirList,
|
||||||
|
entries: {} as Record<FUID, FileEntry | DirEntry>,
|
||||||
|
fullpath: {} as Record<FUID, string>,
|
||||||
|
relpath: {} as Record<FUID, string>,
|
||||||
|
url: {} as Record<FUID, string>,
|
||||||
|
ids: [] as FUID[]
|
||||||
|
}
|
||||||
|
traverseDir(this.root, '', '')
|
||||||
|
// What did we not select?
|
||||||
|
for (const id of selected) {
|
||||||
|
if (!ret.selected.has(id)) ret.missing.add(id)
|
||||||
|
}
|
||||||
|
// Sorted array of FUIDs for easy traversal
|
||||||
|
ret.ids.sort((a, b) =>
|
||||||
|
ret.relpath[a].localeCompare(ret.relpath[b], undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
28
cista-front/src/views/ExplorerView.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<FileExplorer
|
||||||
|
ref="fileExplorer"
|
||||||
|
:key="Router.currentRoute.value.path"
|
||||||
|
:path="props.path"
|
||||||
|
:documents="documentStore.mainDocument"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { watchEffect, ref } from 'vue'
|
||||||
|
import { useDocumentStore } from '@/stores/documents'
|
||||||
|
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>
|
||||||
@@ -44,7 +44,6 @@ export default defineConfig({
|
|||||||
"/files": dev_backend,
|
"/files": dev_backend,
|
||||||
"/login": dev_backend,
|
"/login": dev_backend,
|
||||||
"/logout": dev_backend,
|
"/logout": dev_backend,
|
||||||
"/zip": dev_backend,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
@@ -105,9 +105,9 @@ def _confdir(args):
|
|||||||
if confdir.exists() and not confdir.is_dir():
|
if confdir.exists() and not confdir.is_dir():
|
||||||
if confdir.name != config.conffile.name:
|
if confdir.name != config.conffile.name:
|
||||||
raise ValueError("Config path is not a directory")
|
raise ValueError("Config path is not a directory")
|
||||||
# Accidentally pointed to the db.toml, use parent
|
# Accidentally pointed to the cista.toml, use parent
|
||||||
confdir = confdir.parent
|
confdir = confdir.parent
|
||||||
config.conffile = confdir / config.conffile.name
|
config.conffile = config.conffile.with_parent(confdir)
|
||||||
|
|
||||||
|
|
||||||
def _user(args):
|
def _user(args):
|
||||||
|
|||||||
43
cista/api.py
@@ -1,11 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import typing
|
import typing
|
||||||
from secrets import token_bytes
|
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from sanic import Blueprint
|
from sanic import Blueprint
|
||||||
|
|
||||||
from cista import __version__, config, watching
|
from cista import watching
|
||||||
from cista.fileio import FileServer
|
from cista.fileio import FileServer
|
||||||
from cista.protocol import ControlTypes, FileRange, StatusMsg
|
from cista.protocol import ControlTypes, FileRange, StatusMsg
|
||||||
from cista.util.apphelpers import asend, websocket_wrapper
|
from cista.util.apphelpers import asend, websocket_wrapper
|
||||||
@@ -37,18 +36,10 @@ async def upload(req, ws):
|
|||||||
)
|
)
|
||||||
req = msgspec.json.decode(text, type=FileRange)
|
req = msgspec.json.decode(text, type=FileRange)
|
||||||
pos = req.start
|
pos = req.start
|
||||||
while True:
|
data = None
|
||||||
data = await ws.recv()
|
while pos < req.end and (data := await ws.recv()) and isinstance(data, bytes):
|
||||||
if not isinstance(data, bytes):
|
|
||||||
break
|
|
||||||
if len(data) > req.end - pos:
|
|
||||||
raise ValueError(
|
|
||||||
f"Expected up to {req.end - pos} bytes, got {len(data)} bytes"
|
|
||||||
)
|
|
||||||
sentsize = await alink(("upload", req.name, pos, data, req.size))
|
sentsize = await alink(("upload", req.name, pos, data, req.size))
|
||||||
pos += typing.cast(int, sentsize)
|
pos += typing.cast(int, sentsize)
|
||||||
if pos >= req.end:
|
|
||||||
break
|
|
||||||
if pos != req.end:
|
if pos != req.end:
|
||||||
d = f"{len(data)} bytes" if isinstance(data, bytes) else data
|
d = f"{len(data)} bytes" if isinstance(data, bytes) else data
|
||||||
raise ValueError(f"Expected {req.end - pos} more bytes, got {d}")
|
raise ValueError(f"Expected {req.end - pos} more bytes, got {d}")
|
||||||
@@ -92,32 +83,14 @@ async def control(req, ws):
|
|||||||
@bp.websocket("watch")
|
@bp.websocket("watch")
|
||||||
@websocket_wrapper
|
@websocket_wrapper
|
||||||
async def watch(req, ws):
|
async def watch(req, ws):
|
||||||
await ws.send(
|
|
||||||
msgspec.json.encode(
|
|
||||||
{
|
|
||||||
"server": {
|
|
||||||
"name": config.config.name or config.config.path.name,
|
|
||||||
"version": __version__,
|
|
||||||
"public": config.config.public,
|
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"username": req.ctx.username,
|
|
||||||
"privileged": req.ctx.user.privileged,
|
|
||||||
}
|
|
||||||
if req.ctx.user
|
|
||||||
else None,
|
|
||||||
}
|
|
||||||
).decode()
|
|
||||||
)
|
|
||||||
uuid = token_bytes(16)
|
|
||||||
try:
|
try:
|
||||||
with watching.state.lock:
|
with watching.tree_lock:
|
||||||
q = watching.pubsub[uuid] = asyncio.Queue()
|
q = watching.pubsub[ws] = asyncio.Queue()
|
||||||
# Init with disk usage and full tree
|
# Init with disk usage and full tree
|
||||||
await ws.send(watching.format_space(watching.state.space))
|
await ws.send(watching.format_du())
|
||||||
await ws.send(watching.format_root(watching.state.root))
|
await ws.send(watching.format_tree())
|
||||||
# Send updates
|
# Send updates
|
||||||
while True:
|
while True:
|
||||||
await ws.send(await q.get())
|
await ws.send(await q.get())
|
||||||
finally:
|
finally:
|
||||||
del watching.pubsub[uuid]
|
del watching.pubsub[ws]
|
||||||
|
|||||||
140
cista/app.py
@@ -1,9 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from importlib.resources import files
|
||||||
from pathlib import Path, PurePath, PurePosixPath
|
|
||||||
from stat import S_IFDIR, S_IFREG
|
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
from wsgiref.handlers import format_date_time
|
from wsgiref.handlers import format_date_time
|
||||||
|
|
||||||
@@ -11,9 +8,7 @@ import brotli
|
|||||||
import sanic.helpers
|
import sanic.helpers
|
||||||
from blake3 import blake3
|
from blake3 import blake3
|
||||||
from sanic import Blueprint, Sanic, empty, raw
|
from sanic import Blueprint, Sanic, empty, raw
|
||||||
from sanic.exceptions import Forbidden, NotFound, ServerError
|
from sanic.exceptions import Forbidden, NotFound
|
||||||
from sanic.log import logging
|
|
||||||
from stream_zip import ZIP_AUTO, stream_zip
|
|
||||||
|
|
||||||
from cista import auth, config, session, watching
|
from cista import auth, config, session, watching
|
||||||
from cista.api import bp
|
from cista.api import bp
|
||||||
@@ -32,25 +27,19 @@ app.exception(Exception)(handle_sanic_exception)
|
|||||||
async def main_start(app, loop):
|
async def main_start(app, loop):
|
||||||
config.load_config()
|
config.load_config()
|
||||||
await watching.start(app, loop)
|
await watching.start(app, loop)
|
||||||
app.ctx.threadexec = ThreadPoolExecutor(
|
|
||||||
max_workers=8, thread_name_prefix="cista-ioworker"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.after_server_stop
|
@app.after_server_stop
|
||||||
async def main_stop(app, loop):
|
async def main_stop(app, loop):
|
||||||
await watching.stop(app, loop)
|
await watching.stop(app, loop)
|
||||||
app.ctx.threadexec.shutdown()
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_request
|
@app.on_request
|
||||||
async def use_session(req):
|
async def use_session(req):
|
||||||
req.ctx.session = session.get(req)
|
req.ctx.session = session.get(req)
|
||||||
try:
|
try:
|
||||||
req.ctx.username = req.ctx.session["username"] # type: ignore
|
req.ctx.user = config.config.users[req.ctx.session["username"]] # type: ignore
|
||||||
req.ctx.user = config.config.users[req.ctx.username]
|
|
||||||
except (AttributeError, KeyError, TypeError):
|
except (AttributeError, KeyError, TypeError):
|
||||||
req.ctx.username = None
|
|
||||||
req.ctx.user = None
|
req.ctx.user = None
|
||||||
# CSRF protection
|
# CSRF protection
|
||||||
if req.method == "GET" and req.headers.upgrade != "websocket":
|
if req.method == "GET" and req.headers.upgrade != "websocket":
|
||||||
@@ -79,16 +68,22 @@ def http_fileserver(app, _):
|
|||||||
www = {}
|
www = {}
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_server_start
|
||||||
|
async def load_wwwroot(*_ignored):
|
||||||
|
global www
|
||||||
|
www = await asyncio.get_event_loop().run_in_executor(None, _load_wwwroot, www)
|
||||||
|
|
||||||
|
|
||||||
def _load_wwwroot(www):
|
def _load_wwwroot(www):
|
||||||
wwwnew = {}
|
wwwnew = {}
|
||||||
base = Path(__file__).with_name("wwwroot")
|
base = files("cista") / "wwwroot"
|
||||||
paths = [PurePath()]
|
paths = ["."]
|
||||||
while paths:
|
while paths:
|
||||||
path = paths.pop(0)
|
path = paths.pop(0)
|
||||||
current = base / path
|
current = base / path
|
||||||
for p in current.iterdir():
|
for p in current.iterdir():
|
||||||
if p.is_dir():
|
if p.is_dir():
|
||||||
paths.append(p.relative_to(base))
|
paths.append(current / p.parts[-1])
|
||||||
continue
|
continue
|
||||||
name = p.relative_to(base).as_posix()
|
name = p.relative_to(base).as_posix()
|
||||||
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||||
@@ -119,35 +114,15 @@ def _load_wwwroot(www):
|
|||||||
if len(br) >= len(data):
|
if len(br) >= len(data):
|
||||||
br = False
|
br = False
|
||||||
wwwnew[name] = data, br, headers
|
wwwnew[name] = data, br, headers
|
||||||
if not wwwnew:
|
|
||||||
raise ServerError(
|
|
||||||
"Web frontend missing. Did you forget npm run build?",
|
|
||||||
extra={"wwwroot": str(base)},
|
|
||||||
quiet=True,
|
|
||||||
)
|
|
||||||
return wwwnew
|
return wwwnew
|
||||||
|
|
||||||
|
|
||||||
@app.before_server_start
|
@app.add_task
|
||||||
async def start(app):
|
|
||||||
await load_wwwroot(app)
|
|
||||||
if app.debug:
|
|
||||||
app.add_task(refresh_wwwroot())
|
|
||||||
|
|
||||||
|
|
||||||
async def load_wwwroot(app):
|
|
||||||
global www
|
|
||||||
www = await asyncio.get_event_loop().run_in_executor(
|
|
||||||
app.ctx.threadexec, _load_wwwroot, www
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def refresh_wwwroot():
|
async def refresh_wwwroot():
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
try:
|
try:
|
||||||
wwwold = www
|
wwwold = www
|
||||||
await load_wwwroot(app)
|
await load_wwwroot()
|
||||||
changes = ""
|
changes = ""
|
||||||
for name in sorted(www):
|
for name in sorted(www):
|
||||||
attr = www[name]
|
attr = www[name]
|
||||||
@@ -163,6 +138,7 @@ async def refresh_wwwroot():
|
|||||||
print("Error loading wwwroot", e)
|
print("Error loading wwwroot", e)
|
||||||
if not app.debug:
|
if not app.debug:
|
||||||
return
|
return
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/<path:path>", methods=["GET", "HEAD"])
|
@app.route("/<path:path>", methods=["GET", "HEAD"])
|
||||||
@@ -177,87 +153,9 @@ async def wwwroot(req, path=""):
|
|||||||
return empty(304, headers=headers)
|
return empty(304, headers=headers)
|
||||||
# Brotli compressed?
|
# Brotli compressed?
|
||||||
if br and "br" in req.headers.accept_encoding.split(", "):
|
if br and "br" in req.headers.accept_encoding.split(", "):
|
||||||
headers = {**headers, "content-encoding": "br"}
|
headers = {
|
||||||
|
**headers,
|
||||||
|
"content-encoding": "br",
|
||||||
|
}
|
||||||
data = br
|
data = br
|
||||||
return raw(data, headers=headers)
|
return raw(data, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
|
||||||
loc = PurePosixPath()
|
|
||||||
idx = 0
|
|
||||||
ret = []
|
|
||||||
level: int | None = None
|
|
||||||
parent: PurePosixPath | None = None
|
|
||||||
with watching.state.lock:
|
|
||||||
root = watching.state.root
|
|
||||||
while idx < len(root):
|
|
||||||
f = root[idx]
|
|
||||||
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
|
|
||||||
if parent is not None and f.level <= level:
|
|
||||||
level = parent = None
|
|
||||||
if f.key in wanted:
|
|
||||||
level, parent = f.level, loc.parent
|
|
||||||
if parent is not None:
|
|
||||||
wanted.discard(f.key)
|
|
||||||
ret.append((loc.relative_to(parent), watching.rootpath / loc))
|
|
||||||
idx += 1
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/zip/<keys>/<zipfile:ext=zip>")
|
|
||||||
async def zip_download(req, keys, zipfile, ext):
|
|
||||||
"""Download a zip archive of the given keys"""
|
|
||||||
|
|
||||||
wanted = set(keys.split("+"))
|
|
||||||
files = get_files(wanted)
|
|
||||||
|
|
||||||
if not files:
|
|
||||||
raise NotFound(
|
|
||||||
"No files found",
|
|
||||||
context={"keys": keys, "zipfile": f"{zipfile}.{ext}", "wanted": wanted},
|
|
||||||
)
|
|
||||||
if wanted:
|
|
||||||
raise NotFound("Files not found", context={"missing": wanted})
|
|
||||||
|
|
||||||
def local_files(files):
|
|
||||||
for rel, p in files:
|
|
||||||
s = p.stat()
|
|
||||||
size = s.st_size
|
|
||||||
modified = datetime.datetime.fromtimestamp(s.st_mtime, datetime.UTC)
|
|
||||||
name = rel.as_posix()
|
|
||||||
if p.is_dir():
|
|
||||||
yield f"{name}/", modified, S_IFDIR | 0o755, ZIP_AUTO(size), iter(b"")
|
|
||||||
else:
|
|
||||||
yield name, modified, S_IFREG | 0o644, ZIP_AUTO(size), contents(p, size)
|
|
||||||
|
|
||||||
def contents(name, size):
|
|
||||||
with name.open("rb") as f:
|
|
||||||
while size > 0 and (chunk := f.read(min(size, 1 << 20))):
|
|
||||||
size -= len(chunk)
|
|
||||||
yield chunk
|
|
||||||
assert size == 0
|
|
||||||
|
|
||||||
def worker():
|
|
||||||
try:
|
|
||||||
for chunk in stream_zip(local_files(files)):
|
|
||||||
asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result()
|
|
||||||
except Exception:
|
|
||||||
logging.exception("Error streaming ZIP")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
|
|
||||||
|
|
||||||
# Don't block the event loop: run in a thread
|
|
||||||
queue = asyncio.Queue(maxsize=1)
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
thread = loop.run_in_executor(app.ctx.threadexec, worker)
|
|
||||||
|
|
||||||
# Stream the response
|
|
||||||
res = await req.respond(
|
|
||||||
content_type="application/zip",
|
|
||||||
headers={"cache-control": "no-store"},
|
|
||||||
)
|
|
||||||
while chunk := await queue.get():
|
|
||||||
await res.send(chunk)
|
|
||||||
|
|
||||||
await thread # If it raises, the response will fail download
|
|
||||||
|
|||||||
@@ -68,10 +68,10 @@ def verify(request, *, privileged=False):
|
|||||||
if request.ctx.user:
|
if request.ctx.user:
|
||||||
if request.ctx.user.privileged:
|
if request.ctx.user.privileged:
|
||||||
return
|
return
|
||||||
raise Forbidden("Access Forbidden: Only for privileged users", quiet=True)
|
raise Forbidden("Access Forbidden: Only for privileged users")
|
||||||
elif config.config.public or request.ctx.user:
|
elif config.config.public or request.ctx.user:
|
||||||
return
|
return
|
||||||
raise Unauthorized("Login required", "cookie", quiet=True)
|
raise Unauthorized("Login required", "cookie", context={"redirect": "/login"})
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint("auth")
|
bp = Blueprint("auth")
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ class Config(msgspec.Struct):
|
|||||||
listen: str
|
listen: str
|
||||||
secret: str = secrets.token_hex(12)
|
secret: str = secrets.token_hex(12)
|
||||||
public: bool = False
|
public: bool = False
|
||||||
name: str = ""
|
|
||||||
users: dict[str, User] = {}
|
users: dict[str, User] = {}
|
||||||
links: dict[str, Link] = {}
|
links: dict[str, Link] = {}
|
||||||
|
|
||||||
|
|||||||
@@ -34,11 +34,9 @@ class File:
|
|||||||
self.open_rw()
|
self.open_rw()
|
||||||
assert self.fd is not None
|
assert self.fd is not None
|
||||||
if file_size is not None:
|
if file_size is not None:
|
||||||
assert pos + len(buffer) <= file_size
|
|
||||||
os.ftruncate(self.fd, file_size)
|
os.ftruncate(self.fd, file_size)
|
||||||
if buffer:
|
os.lseek(self.fd, pos, os.SEEK_SET)
|
||||||
os.lseek(self.fd, pos, os.SEEK_SET)
|
os.write(self.fd, buffer)
|
||||||
os.write(self.fd, buffer)
|
|
||||||
|
|
||||||
def __getitem__(self, slice):
|
def __getitem__(self, slice):
|
||||||
if self.fd is None:
|
if self.fd is None:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class MkDir(ControlBase):
|
|||||||
|
|
||||||
def __call__(self):
|
def __call__(self):
|
||||||
path = config.config.path / filename.sanitize(self.path)
|
path = config.config.path / filename.sanitize(self.path)
|
||||||
path.mkdir(parents=True, exist_ok=False)
|
path.mkdir(parents=False, exist_ok=False)
|
||||||
|
|
||||||
|
|
||||||
class Rename(ControlBase):
|
class Rename(ControlBase):
|
||||||
@@ -45,7 +45,7 @@ class Rm(ControlBase):
|
|||||||
sel = [root / filename.sanitize(p) for p in self.sel]
|
sel = [root / filename.sanitize(p) for p in self.sel]
|
||||||
for p in sel:
|
for p in sel:
|
||||||
if p.is_dir():
|
if p.is_dir():
|
||||||
shutil.rmtree(p)
|
shutil.rmtree(p, ignore_errors=True)
|
||||||
else:
|
else:
|
||||||
p.unlink()
|
p.unlink()
|
||||||
|
|
||||||
@@ -112,43 +112,47 @@ class ErrorMsg(msgspec.Struct):
|
|||||||
## Directory listings
|
## Directory listings
|
||||||
|
|
||||||
|
|
||||||
class FileEntry(msgspec.Struct, array_like=True):
|
class FileEntry(msgspec.Struct):
|
||||||
level: int
|
|
||||||
name: str
|
|
||||||
key: str
|
key: str
|
||||||
mtime: int
|
|
||||||
size: int
|
size: int
|
||||||
isfile: int
|
mtime: int
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return self.key or "FileEntry()"
|
|
||||||
|
|
||||||
|
|
||||||
class Update(msgspec.Struct, array_like=True):
|
class DirEntry(msgspec.Struct):
|
||||||
...
|
key: str
|
||||||
|
size: int
|
||||||
|
mtime: int
|
||||||
|
dir: DirList
|
||||||
|
|
||||||
|
def __getitem__(self, name):
|
||||||
|
return self.dir[name]
|
||||||
|
|
||||||
|
def __setitem__(self, name, value):
|
||||||
|
self.dir[name] = value
|
||||||
|
|
||||||
|
def __contains__(self, name):
|
||||||
|
return name in self.dir
|
||||||
|
|
||||||
|
def __delitem__(self, name):
|
||||||
|
del self.dir[name]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def props(self):
|
||||||
|
return {k: v for k, v in self.__struct_fields__ if k != "dir"}
|
||||||
|
|
||||||
|
|
||||||
class UpdKeep(Update, tag="k"):
|
DirList = dict[str, FileEntry | DirEntry]
|
||||||
count: int
|
|
||||||
|
|
||||||
|
|
||||||
class UpdDel(Update, tag="d"):
|
class UpdateEntry(msgspec.Struct, omit_defaults=True):
|
||||||
count: int
|
"""Updates the named entry in the tree. Fields that are set replace old values. A list of entries recurses directories."""
|
||||||
|
|
||||||
|
name: str = ""
|
||||||
class UpdIns(Update, tag="i"):
|
deleted: bool = False
|
||||||
items: list[FileEntry]
|
key: str | None = None
|
||||||
|
size: int | None = None
|
||||||
|
mtime: int | None = None
|
||||||
class UpdateMessage(msgspec.Struct):
|
dir: DirList | None = None
|
||||||
update: list[UpdKeep | UpdDel | UpdIns]
|
|
||||||
|
|
||||||
|
|
||||||
class Space(msgspec.Struct):
|
|
||||||
disk: int
|
|
||||||
free: int
|
|
||||||
usage: int
|
|
||||||
storage: int
|
|
||||||
|
|
||||||
|
|
||||||
def make_dir_data(root):
|
def make_dir_data(root):
|
||||||
|
|||||||