84 Commits

Author SHA1 Message Date
Leo Vasanko
b25d0fc14b Breadcrumbs keep longest path, browsing breadcrumbs with left/right arrows, highlight current. 2023-11-06 18:51:51 +00:00
Leo Vasanko
5386508e28 Improved mobile layout for landscape. Oneline header. 2023-11-06 16:51:10 +00:00
Leo Vasanko
129250e072 Implement tooltips and other UI tuning. 2023-11-06 15:33:43 +00:00
Leo Vasanko
c2be2ecd31 Fix copying of files. 2023-11-06 15:32:58 +00:00
Leo Vasanko
dd1d85f412 dateformat code cleanup 2023-11-06 11:38:11 +00:00
Leo Vasanko
4c7b310f82 Added time element on mtimes, dates smaller, code formatted 2023-11-05 23:12:42 +00:00
Leo Vasanko
1250037cfd Always the same date formatting by fixing a suitable locale 2023-11-05 17:14:10 +00:00
Leo Vasanko
cdc936d2d5 Fix bug when browsing around the end 2023-11-05 16:49:24 +00:00
Leo Vasanko
4f370440d9 Shift-Up/Down selection 2023-11-05 16:07:28 +00:00
Leo Vasanko
feaa8e315e Tighter table 2023-11-05 15:55:23 +00:00
Leo Vasanko
14f7253ece Rename FUID field to key everywhere. 2023-11-05 15:54:55 +00:00
Leo Vasanko
9d3d27faf3 Remove generated file from repo 2023-11-05 15:36:37 +00:00
Leo Vasanko
dd235e8f25 Fix clicking files 2023-11-05 14:15:48 +00:00
Leo Vasanko
139ff51dcd Remove build (wwwroot) from repo. UI tweaks. 2023-11-05 14:07:07 +00:00
Leo Vasanko
589e5a682c Smoother UI and various other adjustments. 2023-11-05 13:13:32 +00:00
Leo Vasanko
8114d679ef Merged checkboxes and file icons. 2023-11-05 04:11:24 +00:00
Leo Vasanko
32b8e0702c Fix file deletion, now erases folders and files. 2023-11-05 02:19:46 +00:00
Leo Vasanko
cc74912bb9 Print CSS 2023-11-05 01:31:18 +00:00
Leo Vasanko
c3cf4caa9a Create new folder automatically navigates into it. Rename still flaky. 2023-11-04 21:41:44 +00:00
Leo Vasanko
b3eacf04f7 Fix selection button visibility 2023-11-04 21:04:29 +00:00
Leo Vasanko
047facaacb Style tuning 2023-11-04 20:54:14 +00:00
Leo Vasanko
41fbd3d122 Multiple file downloads via Filesystem API, and other tuning. 2023-11-04 19:50:05 +00:00
Leo Vasanko
40a45568c1 Remove width and height attributes of SVGs to make them scalable via CSS 2023-11-04 14:29:49 +00:00
Leo Vasanko
8c6690ea98 Major changes:
- File selections working
- CSS more responsive, more consistent use of colors and variables
- Keyboard navigation
- Added context menu buttons and handler, the menu is still missing
- Added download and settings buttons (no functions yet)
- Various minor fixes everywhere
2023-11-04 14:10:18 +00:00
Leo Vasanko
997e0b8549 Numeric sorting and filter. "New Folder 12" > "New Folder 2" 2023-11-04 01:27:09 +00:00
Leo Vasanko
115bb5db59 Restore quick search functionality. 2023-11-04 00:57:54 +00:00
Leo Vasanko
5f1eb0503a ... 2023-11-04 00:44:13 +00:00
Leo Vasanko
4aae194060 Remove extra new folder button, instead make header button work 2023-11-04 00:43:37 +00:00
Leo Vasanko
12eabd29c3 Recreated page navigation buttons. 2023-11-04 00:21:35 +00:00
Leo Vasanko
589b21f944 Don't need to import components anymore 2023-11-03 22:26:30 +00:00
Leo Vasanko
d3f584b738 URL decode before using control API 2023-11-03 21:59:44 +00:00
Leo Vasanko
225f2b0651 A bit hacky but working mkdir 2023-11-03 21:54:11 +00:00
Leo Vasanko
b759d8324c Login still a bit buggy but working... 2023-11-03 21:19:26 +00:00
Leo Vasanko
119aba2b3c Cleanup, lint, format etc. 2023-11-03 20:07:05 +00:00
Leo Vasanko
f52d58d645 Big changes...
- Added Droppy SVG icons
- Implemented Droppy-style Breadcrumb component
- Implemented a Dialog component
- Attempted transition effects on file explorer (not yet functional)
- Changed FileExplorer to take list of documents and current path via props.
- Various other cleanup etc.
2023-11-03 16:19:21 +00:00
Leo Vasanko
6cba674b30 Build down to 90 kB, 1.5 MB less after removing ant-design. 2023-11-02 22:34:57 +00:00
Leo Vasanko
831b2716f7 Removing stuff, refactoring for simplicity 2023-11-02 21:52:21 +00:00
Leo Vasanko
7e5901a2cf Fix indent 2023-11-02 18:51:34 +00:00
Leo Vasanko
a4f95d730b Fix file links 2023-11-02 17:58:36 +00:00
Leo Vasanko
56082cba15 Faster animation to match the fast update 2023-11-02 17:56:55 +00:00
Leo Vasanko
3479a0da57 Remove node_modules 2023-11-02 17:40:25 +00:00
Leo Vasanko
f99d92b217 Add support for actual renaming of files, and UI on plain tree. 2023-11-02 17:37:28 +00:00
Leo Vasanko
68a701538b Prototyping plain table for files list 2023-11-02 15:34:37 +00:00
Leo Vasanko
05a16e3037 New build, typing ignores... 2023-11-01 23:07:13 +00:00
Leo Vasanko
52ecbc3d36 Search filtering 2023-11-01 23:00:59 +00:00
Leo Vasanko
042f1b7f42 cista.util submodule 2023-11-01 21:29:34 +00:00
Leo Vasanko
d27cb2133a Rewrite folder selection. Needs better error handling still. 2023-11-01 21:29:13 +00:00
Leo Vasanko
a8ea43194d Add settings to use npm run dev server with cista -l :8000 backend 2023-11-01 21:12:47 +00:00
Leo Vasanko
07fe7448cc Update front build. 2023-11-01 20:16:37 +00:00
Leo Vasanko
783af44e26 Ruff 2023-11-01 19:58:59 +00:00
Leo Vasanko
0d6180e8a4 Add charset=UTF-8 2023-11-01 17:32:48 +00:00
Leo Vasanko
bdc0bbd44f Implemented HTTP caching and updates on wwwroot, much faster page loads. 2023-11-01 17:08:05 +00:00
Leo Vasanko
ba36eaec1b Allow multiple commands on control socket without disconnecting. 2023-11-01 14:57:54 +00:00
Leo Vasanko
a435a30c88 Realtime updates of wwwroot files when --dev is used. 2023-11-01 14:53:57 +00:00
Leo Vasanko
742b05ed66 Faster wwwroot serving, uses RAM cache of brotli compressed data for all assets. 2023-11-01 14:40:08 +00:00
Leo Vasanko
a26dc42d88 Fix control message decoding. 2023-11-01 14:12:06 +00:00
Leo Vasanko
9002afbc7e Merged something 2023-11-01 14:03:17 +00:00
Leo Vasanko
acdd776b92 Formatting and fix Internal Server Error on upload 2023-10-28 20:20:34 +00:00
b3fd9637eb Login, Download and visuals update 2023-10-28 04:13:01 -05:00
2b72508206 Search Bar UI update 2023-10-27 15:32:21 +00:00
Leo Vasanko
8cc3ed1a04 Human-readable sizes 2023-10-27 08:28:03 +03:00
Leo Vasanko
0d186726b5 Prettier file listing, using browser instead of viewer for file display (for now), sorting improved, modified timestamps improved. 2023-10-27 07:53:30 +03:00
Leo Vasanko
63bbe84859 Provide file/dir id from server. The value will stay the same if the file is renamed/moved, but will change if a file is replaced by another with the same name. 2023-10-27 07:51:51 +03:00
Leo Vasanko
202f28ff15 Add esbuild dependency 2023-10-27 04:34:27 +03:00
41772e6c18 Fix get files #build_commit 2023-10-26 10:01:54 -05:00
e52379d515 Fix get files #build_commit 2023-10-26 09:48:12 -05:00
74987898c9 Fix get files #build_commit 2023-10-26 09:34:24 -05:00
859d312913 Fix get files 2023-10-26 09:14:24 -05:00
4bc9cf4534 Update endpoints and URL bases configuration 2023-10-26 09:06:07 -05:00
754d779069 Test frontend #only_compile 2023-10-26 00:10:52 -05:00
367e4ba0ea Test frontend #only_compile 2023-10-26 00:08:06 -05:00
c2e9a4af05 Test frontend #only_compile 2023-10-26 00:06:45 -05:00
6cdc37a172 Test frontend #only_compile 2023-10-26 00:05:11 -05:00
19699564c2 Test frontend #only_compile 2023-10-26 00:02:45 -05:00
7baf8b3f9b Test frontend #only_compile 2023-10-25 23:55:50 -05:00
47329ac04e Test frontend #only_compile 2023-10-25 23:46:21 -05:00
f4013d1196 Test frontend #only_compile 2023-10-25 23:34:20 -05:00
3672156b5e Test frontend #only_compile 2023-10-25 23:31:13 -05:00
f2b37852da Test frontend #only_compile 2023-10-25 21:56:45 -05:00
708e54d080 Test frontend #only_compile 2023-10-25 21:52:08 -05:00
d051265f40 Test frontend #only_compile 2023-10-25 21:47:45 -05:00
5cf133465e Test frontend #only_compile 2023-10-25 21:40:44 -05:00
1c91bf2e87 Test frontend #only_compile 2023-10-25 21:26:37 -05:00
9cd6f83bec Initialize VUE project with WS connection, main pages, and various small features 2023-10-25 18:43:44 -05:00
115 changed files with 1490 additions and 1876 deletions

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

241
cista-front/public/old-index.html Executable file
View 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>

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 158 B

After

Width:  |  Height:  |  Size: 158 B

View File

Before

Width:  |  Height:  |  Size: 168 B

After

Width:  |  Height:  |  Size: 168 B

View File

Before

Width:  |  Height:  |  Size: 388 B

After

Width:  |  Height:  |  Size: 388 B

View File

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 128 B

View File

Before

Width:  |  Height:  |  Size: 126 B

After

Width:  |  Height:  |  Size: 126 B

View File

Before

Width:  |  Height:  |  Size: 158 B

After

Width:  |  Height:  |  Size: 158 B

View File

Before

Width:  |  Height:  |  Size: 208 B

After

Width:  |  Height:  |  Size: 208 B

View File

Before

Width:  |  Height:  |  Size: 563 B

After

Width:  |  Height:  |  Size: 563 B

View File

Before

Width:  |  Height:  |  Size: 212 B

After

Width:  |  Height:  |  Size: 212 B

View File

Before

Width:  |  Height:  |  Size: 293 B

After

Width:  |  Height:  |  Size: 293 B

View File

Before

Width:  |  Height:  |  Size: 310 B

After

Width:  |  Height:  |  Size: 310 B

View File

Before

Width:  |  Height:  |  Size: 193 B

After

Width:  |  Height:  |  Size: 193 B

View File

Before

Width:  |  Height:  |  Size: 278 B

After

Width:  |  Height:  |  Size: 278 B

View File

Before

Width:  |  Height:  |  Size: 711 B

After

Width:  |  Height:  |  Size: 711 B

View File

Before

Width:  |  Height:  |  Size: 365 B

After

Width:  |  Height:  |  Size: 365 B

View File

Before

Width:  |  Height:  |  Size: 783 B

After

Width:  |  Height:  |  Size: 783 B

View File

Before

Width:  |  Height:  |  Size: 382 B

After

Width:  |  Height:  |  Size: 382 B

View File

Before

Width:  |  Height:  |  Size: 200 B

After

Width:  |  Height:  |  Size: 200 B

View File

Before

Width:  |  Height:  |  Size: 698 B

After

Width:  |  Height:  |  Size: 698 B

View File

Before

Width:  |  Height:  |  Size: 156 B

After

Width:  |  Height:  |  Size: 156 B

View File

Before

Width:  |  Height:  |  Size: 416 B

After

Width:  |  Height:  |  Size: 416 B

View File

Before

Width:  |  Height:  |  Size: 517 B

After

Width:  |  Height:  |  Size: 517 B

View File

Before

Width:  |  Height:  |  Size: 257 B

After

Width:  |  Height:  |  Size: 257 B

View File

Before

Width:  |  Height:  |  Size: 297 B

After

Width:  |  Height:  |  Size: 297 B

View File

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 312 B

View File

Before

Width:  |  Height:  |  Size: 109 B

After

Width:  |  Height:  |  Size: 109 B

View File

Before

Width:  |  Height:  |  Size: 587 B

After

Width:  |  Height:  |  Size: 587 B

View File

Before

Width:  |  Height:  |  Size: 269 B

After

Width:  |  Height:  |  Size: 269 B

View File

Before

Width:  |  Height:  |  Size: 106 B

After

Width:  |  Height:  |  Size: 106 B

View File

Before

Width:  |  Height:  |  Size: 393 B

After

Width:  |  Height:  |  Size: 393 B

View File

Before

Width:  |  Height:  |  Size: 94 B

After

Width:  |  Height:  |  Size: 94 B

View File

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 229 B

View File

Before

Width:  |  Height:  |  Size: 108 B

After

Width:  |  Height:  |  Size: 108 B

View File

Before

Width:  |  Height:  |  Size: 407 B

After

Width:  |  Height:  |  Size: 407 B

View File

Before

Width:  |  Height:  |  Size: 887 B

After

Width:  |  Height:  |  Size: 887 B

View File

Before

Width:  |  Height:  |  Size: 908 B

After

Width:  |  Height:  |  Size: 908 B

View File

Before

Width:  |  Height:  |  Size: 417 B

After

Width:  |  Height:  |  Size: 417 B

View File

Before

Width:  |  Height:  |  Size: 554 B

After

Width:  |  Height:  |  Size: 554 B

View File

Before

Width:  |  Height:  |  Size: 552 B

After

Width:  |  Height:  |  Size: 552 B

View File

Before

Width:  |  Height:  |  Size: 114 B

After

Width:  |  Height:  |  Size: 114 B

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 91 B

After

Width:  |  Height:  |  Size: 91 B

View File

Before

Width:  |  Height:  |  Size: 647 B

After

Width:  |  Height:  |  Size: 647 B

View File

Before

Width:  |  Height:  |  Size: 95 B

After

Width:  |  Height:  |  Size: 95 B

View File

Before

Width:  |  Height:  |  Size: 208 B

After

Width:  |  Height:  |  Size: 208 B

View File

Before

Width:  |  Height:  |  Size: 104 B

After

Width:  |  Height:  |  Size: 104 B

View File

Before

Width:  |  Height:  |  Size: 508 B

After

Width:  |  Height:  |  Size: 508 B

View File

Before

Width:  |  Height:  |  Size: 1009 B

After

Width:  |  Height:  |  Size: 1009 B

View File

Before

Width:  |  Height:  |  Size: 278 B

After

Width:  |  Height:  |  Size: 278 B

View File

Before

Width:  |  Height:  |  Size: 753 B

After

Width:  |  Height:  |  Size: 753 B

View File

Before

Width:  |  Height:  |  Size: 353 B

After

Width:  |  Height:  |  Size: 353 B

View File

Before

Width:  |  Height:  |  Size: 542 B

After

Width:  |  Height:  |  Size: 542 B

View File

Before

Width:  |  Height:  |  Size: 292 B

After

Width:  |  Height:  |  Size: 292 B

View File

Before

Width:  |  Height:  |  Size: 621 B

After

Width:  |  Height:  |  Size: 621 B

View File

Before

Width:  |  Height:  |  Size: 517 B

After

Width:  |  Height:  |  Size: 517 B

View File

Before

Width:  |  Height:  |  Size: 289 B

After

Width:  |  Height:  |  Size: 289 B

View File

Before

Width:  |  Height:  |  Size: 498 B

After

Width:  |  Height:  |  Size: 498 B

View File

Before

Width:  |  Height:  |  Size: 464 B

After

Width:  |  Height:  |  Size: 464 B

View File

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

View File

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

View File

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

View 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>

View 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>

View File

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

View File

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

View File

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

View 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>

View 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>

View File

@@ -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')

View File

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

View 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)
}
}

View 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

View 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
}
}
})

View File

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

View 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>

View File

@@ -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: {

View File

@@ -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):

View File

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

View File

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

View File

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

View File

@@ -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] = {}

View File

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

View File

@@ -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):

Some files were not shown because too many files have changed in this diff Show More