Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7077b21159 | ||
|
|
938c5ca657 | ||
|
|
e0aef07783 | ||
|
|
36826a83c1 | ||
|
|
6880f82c19 | ||
|
|
5dd1bd9bdc | ||
|
|
41e8c78ecd | ||
|
|
dc4bb494f3 | ||
|
|
9b58b887b4 | ||
|
|
07848907f3 | ||
|
|
7a08f7cbe2 | ||
|
|
dd37238510 | ||
|
|
c8d5f335b1 | ||
|
|
bb80b3ee54 | ||
|
|
06d860c601 | ||
|
|
c321de13fd | ||
|
|
278e8303c4 |
67
README.md
67
README.md
@@ -1,19 +1,23 @@
|
|||||||
# Web File Storage
|
# Web File Storage
|
||||||
|
|
||||||
Run directly from repository with Hatch (or use pip install as usual):
|
The Python package installs a `cista` executable. Use `hatch shell` to initiate and install in a virtual environment, or `pip install` it on your system. Alternatively `hatch run cista` may be used to skip the shell step but stay virtual. `pip install hatch` first if needed.
|
||||||
|
|
||||||
```sh
|
|
||||||
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).
|
|
||||||
|
|
||||||
Create your user account:
|
Create your user account:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
hatch run cista --user admin --privileged
|
cista --user admin --privileged
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Running the server
|
||||||
|
|
||||||
|
Serve your files on localhost:8000:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cista -l :8000 /path/to/files
|
||||||
|
```
|
||||||
|
|
||||||
|
The Git repository does not contain a frontend build, so you should first do that...
|
||||||
|
|
||||||
## Build frontend
|
## Build frontend
|
||||||
|
|
||||||
Frontend needs to be built before using and after any frontend changes:
|
Frontend needs to be built before using and after any frontend changes:
|
||||||
@@ -25,3 +29,50 @@ npm run build
|
|||||||
```
|
```
|
||||||
|
|
||||||
This will place the front in `cista/wwwroot` from where the backend server delivers it, and that also gets included in the Python package built via `hatch build`.
|
This will place the front in `cista/wwwroot` from where the backend server delivers it, and that also gets included in the Python package built via `hatch build`.
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
For rapid turnaround during development, you should run `npm run dev` Vite development server on the Vue frontend. While that is running, start the backend on another terminal `hatch run cista --dev -l :8000` and connect to the frontend.
|
||||||
|
|
||||||
|
The backend and the frontend will each reload automatically at any code or config changes.
|
||||||
|
|
||||||
|
## System deployment
|
||||||
|
|
||||||
|
Clone the repository to `/srv/cista/cista-storage` or other suitable location accessible to the storage user account you plan to use. `sudo -u storage -s` and build the frontend if you hadn't already.
|
||||||
|
|
||||||
|
Create **/etc/systemd/system/cista@.service**:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Cista storage %i
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=storage
|
||||||
|
WorkingDirectory=/srv/cista/cista-storage
|
||||||
|
ExecStart=hatch run cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/@%i/
|
||||||
|
TimeoutStopSec=2
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
This assumes you may want to run multiple separate storages, each having their files under `/media/storage/<domain>` and configuration under `/srv/cista/<domain>/`. Instead of numeric ports, we use UNIX sockets for convenience.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now cista@foo.example.com
|
||||||
|
systemctl enable --now cista@bar.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Exposing this publicly online is the most convenient using the [Caddy](https://caddyserver.com/) web server but you can of course use Nginx or others as well. Or even run the server with `-l domain.example.com` given TLS certificates in the config folder.
|
||||||
|
|
||||||
|
**/etc/caddy/Caddyfile**:
|
||||||
|
|
||||||
|
```Caddyfile
|
||||||
|
foo.example.com, bar.example.com {
|
||||||
|
reverse_proxy unix//srv/cista/{host}/socket
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Using the `{host}` placeholder we can just put all the domains on the same block. That's the full server configuration you need. `systemctl enable --now caddy` or `systemctl restart caddy` for the config to take effect.
|
||||||
|
|||||||
13
cista/api.py
13
cista/api.py
@@ -37,16 +37,23 @@ 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
|
||||||
data = None
|
while True:
|
||||||
while pos < req.end and (data := await ws.recv()) and isinstance(data, bytes):
|
data = await ws.recv()
|
||||||
|
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}")
|
||||||
# Report success
|
# Report success
|
||||||
res = StatusMsg(status="ack", req=req)
|
res = StatusMsg(status="ack", req=req)
|
||||||
print("ack", res)
|
|
||||||
await asend(ws, res)
|
await asend(ws, res)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,11 @@ 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)
|
||||||
os.lseek(self.fd, pos, os.SEEK_SET)
|
if buffer:
|
||||||
os.write(self.fd, buffer)
|
os.lseek(self.fd, pos, os.SEEK_SET)
|
||||||
|
os.write(self.fd, buffer)
|
||||||
|
|
||||||
def __getitem__(self, slice):
|
def __getitem__(self, slice):
|
||||||
if self.fd is None:
|
if self.fd is None:
|
||||||
|
|||||||
@@ -149,15 +149,3 @@ class Space(msgspec.Struct):
|
|||||||
free: int
|
free: int
|
||||||
usage: int
|
usage: int
|
||||||
storage: int
|
storage: int
|
||||||
|
|
||||||
|
|
||||||
def make_dir_data(root):
|
|
||||||
if len(root) == 3:
|
|
||||||
return FileEntry(*root)
|
|
||||||
id_, size, mtime, listing = root
|
|
||||||
converted = {}
|
|
||||||
for name, data in listing.items():
|
|
||||||
converted[name] = make_dir_data(data)
|
|
||||||
sz = sum(x.size for x in converted.values())
|
|
||||||
mt = max(x.mtime for x in converted.values())
|
|
||||||
return DirEntry(id_, sz, max(mt, mtime), converted)
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path, PurePath
|
from pathlib import Path
|
||||||
|
|
||||||
from sanic import Sanic
|
from sanic import Sanic
|
||||||
|
|
||||||
@@ -15,7 +15,6 @@ def run(*, dev=False):
|
|||||||
# Silence Sanic's warning about running in production rather than debug
|
# Silence Sanic's warning about running in production rather than debug
|
||||||
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
|
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
|
||||||
confdir = config.conffile.parent
|
confdir = config.conffile.parent
|
||||||
wwwroot = PurePath(__file__).parent / "wwwroot"
|
|
||||||
if opts.get("ssl"):
|
if opts.get("ssl"):
|
||||||
# Run plain HTTP redirect/acme server on port 80
|
# Run plain HTTP redirect/acme server on port 80
|
||||||
server80.app.prepare(port=80, motd=False)
|
server80.app.prepare(port=80, motd=False)
|
||||||
@@ -27,7 +26,7 @@ def run(*, dev=False):
|
|||||||
motd=False,
|
motd=False,
|
||||||
dev=dev,
|
dev=dev,
|
||||||
auto_reload=dev,
|
auto_reload=dev,
|
||||||
reload_dir={confdir, wwwroot},
|
reload_dir={confdir},
|
||||||
access_log=True,
|
access_log=True,
|
||||||
) # type: ignore
|
) # type: ignore
|
||||||
if dev:
|
if dev:
|
||||||
|
|||||||
@@ -110,26 +110,6 @@ class State:
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
del self._listing[self._slice(relpath)]
|
del self._listing[self._slice(relpath)]
|
||||||
|
|
||||||
def _index(self, rel: PurePosixPath):
|
|
||||||
idx = 0
|
|
||||||
ret = []
|
|
||||||
|
|
||||||
def _dir(self, idx: int):
|
|
||||||
level = self._listing[idx].level + 1
|
|
||||||
end = len(self._listing)
|
|
||||||
idx += 1
|
|
||||||
ret = []
|
|
||||||
while idx < end and (r := self._listing[idx]).level >= level:
|
|
||||||
if r.level == level:
|
|
||||||
ret.append(idx)
|
|
||||||
return ret, idx
|
|
||||||
|
|
||||||
def update(self, rel: PurePosixPath, value: FileEntry):
|
|
||||||
begin = 0
|
|
||||||
parents = []
|
|
||||||
while self._listing[begin].level < len(rel.parts):
|
|
||||||
parents.append(begin)
|
|
||||||
|
|
||||||
|
|
||||||
state = State()
|
state = State()
|
||||||
rootpath: Path = None # type: ignore
|
rootpath: Path = None # type: ignore
|
||||||
@@ -160,8 +140,8 @@ def watcher_thread(loop):
|
|||||||
state.root = new
|
state.root = new
|
||||||
broadcast(format_update(old, new), loop)
|
broadcast(format_update(old, new), loop)
|
||||||
|
|
||||||
# The watching is not entirely reliable, so do a full refresh every minute
|
# The watching is not entirely reliable, so do a full refresh every 30 seconds
|
||||||
refreshdl = time.monotonic() + 60.0
|
refreshdl = time.monotonic() + 30.0
|
||||||
|
|
||||||
for event in i.event_gen():
|
for event in i.event_gen():
|
||||||
if quit:
|
if quit:
|
||||||
@@ -337,7 +317,7 @@ async def abroadcast(msg):
|
|||||||
|
|
||||||
async def start(app, loop):
|
async def start(app, loop):
|
||||||
config.load_config()
|
config.load_config()
|
||||||
use_inotify = False and sys.platform == "linux"
|
use_inotify = sys.platform == "linux"
|
||||||
app.ctx.watcher = threading.Thread(
|
app.ctx.watcher = threading.Thread(
|
||||||
target=watcher_thread if use_inotify else watcher_thread_poll,
|
target=watcher_thread if use_inotify else watcher_thread_poll,
|
||||||
args=[loop],
|
args=[loop],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ 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 { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
||||||
import { useDocumentStore } from '@/stores/documents'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import Router from '@/router/index'
|
import Router from '@/router/index'
|
||||||
@@ -27,7 +27,7 @@ interface Path {
|
|||||||
pathList: string[]
|
pathList: string[]
|
||||||
query: string
|
query: string
|
||||||
}
|
}
|
||||||
const documentStore = useDocumentStore()
|
const store = useMainStore()
|
||||||
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).split('//')
|
||||||
const pathList = p[0].split('/').filter(value => value !== '')
|
const pathList = p[0].split('/').filter(value => value !== '')
|
||||||
@@ -39,18 +39,16 @@ const path: ComputedRef<Path> = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || documentStore.server.name || 'Cista Storage'
|
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || store.server.name || 'Cista Storage'
|
||||||
})
|
})
|
||||||
onMounted(loadSession)
|
onMounted(loadSession)
|
||||||
onMounted(watchConnect)
|
onMounted(watchConnect)
|
||||||
onUnmounted(watchDisconnect)
|
onUnmounted(watchDisconnect)
|
||||||
// Update human-readable x seconds ago messages from mtimes
|
|
||||||
setInterval(documentStore.updateModified, 1000)
|
|
||||||
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 fileExplorer = store.fileExplorer as any
|
||||||
if (!fileExplorer) return
|
if (!fileExplorer) return
|
||||||
const c = fileExplorer.isCursor()
|
const c = fileExplorer.isCursor()
|
||||||
const keyup = event.type === 'keyup'
|
const keyup = event.type === 'keyup'
|
||||||
@@ -126,3 +124,4 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
export type { Path }
|
export type { Path }
|
||||||
</script>
|
</script>
|
||||||
|
@/stores/main
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
aria-label="Breadcrumb"
|
aria-label="Breadcrumb"
|
||||||
@keyup.left.stop="move(-1)"
|
@keyup.left.stop="move(-1)"
|
||||||
@keyup.right.stop="move(1)"
|
@keyup.right.stop="move(1)"
|
||||||
@focus="move(0)"
|
@keyup.enter="move(0)"
|
||||||
>
|
>
|
||||||
<a href="#/"
|
<a href="#/"
|
||||||
:ref="el => setLinkRef(0, el)"
|
:ref="el => setLinkRef(0, el)"
|
||||||
@@ -48,9 +48,11 @@ const navigate = (index: number) => {
|
|||||||
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
|
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
|
||||||
const url = `/${longest.value.slice(0, index).join('/')}/`
|
const url = `/${longest.value.slice(0, index).join('/')}/`
|
||||||
const here = `/${longest.value.join('/')}/`
|
const here = `/${longest.value.join('/')}/`
|
||||||
|
const current = decodeURIComponent(location.hash.slice(1).split('//')[0])
|
||||||
|
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
|
if (here.startsWith(current)) router.replace(u)
|
||||||
|
else router.push(u)
|
||||||
link.focus()
|
link.focus()
|
||||||
if (here.startsWith(location.hash.slice(1))) router.replace(url)
|
|
||||||
else router.push(url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const move = (dir: number) => {
|
const move = (dir: number) => {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<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>
|
||||||
<th class="sortcolumn" :class="{ sortactive: sort === 'name' }" @click="toggleSort('name')">Name</th>
|
<th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
|
||||||
<th class="sortcolumn modified right" :class="{ sortactive: sort === 'modified' }" @click="toggleSort('modified')">Modified</th>
|
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
|
||||||
<th class="sortcolumn size right" :class="{ sortactive: sort === 'size' }" @click="toggleSort('size')">Size</th>
|
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
|
||||||
<th class="menu"></th>
|
<th class="menu"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -17,11 +17,11 @@
|
|||||||
<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 />
|
<FileModified :doc=editing :key=nowkey />
|
||||||
<FileSize :doc=editing />
|
<FileSize :doc=editing />
|
||||||
<td class="menu"></td>
|
<td class="menu"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="(doc, index) in sortedDocuments" :key="doc.key">
|
<template v-for="(doc, index) in documents" :key="doc.key">
|
||||||
<tr class="folder-change" v-if="showFolderBreadcrumb(index)">
|
<tr class="folder-change" v-if="showFolderBreadcrumb(index)">
|
||||||
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
|
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -36,11 +36,11 @@
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
:checked="documentStore.selected.has(doc.key)"
|
:checked="store.selected.has(doc.key)"
|
||||||
@change="
|
@change="
|
||||||
($event.target as HTMLInputElement).checked
|
($event.target as HTMLInputElement).checked
|
||||||
? documentStore.selected.add(doc.key)
|
? store.selected.add(doc.key)
|
||||||
: documentStore.selected.delete(doc.key)
|
: store.selected.delete(doc.key)
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a
|
<a
|
||||||
:href="url_for(doc)"
|
:href="doc.url"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@contextmenu.prevent
|
@contextmenu.prevent
|
||||||
@focus.stop="cursor = doc"
|
@focus.stop="cursor = doc"
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
<button v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
<button v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<FileModified :doc=doc />
|
<FileModified :doc=doc :key=nowkey />
|
||||||
<FileSize :doc=doc />
|
<FileSize :doc=doc />
|
||||||
<td class="menu">
|
<td class="menu">
|
||||||
<button tabindex="-1" @click.stop="contextMenu($event, doc)">⋮</button>
|
<button tabindex="-1" @click.stop="contextMenu($event, doc)">⋮</button>
|
||||||
@@ -79,28 +79,26 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watchEffect } from 'vue'
|
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||||
import { useDocumentStore } from '@/stores/documents'
|
import { useMainStore } from '@/stores/main'
|
||||||
import type { Document } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import FileRenameInput from './FileRenameInput.vue'
|
import FileRenameInput from './FileRenameInput.vue'
|
||||||
import { connect, controlUrl } from '@/repositories/WS'
|
import { connect, controlUrl } from '@/repositories/WS'
|
||||||
import { collator, formatSize, formatUnixDate } from '@/utils'
|
import { formatSize } from '@/utils'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
|
import type { SortOrder } from '@/utils/docsort'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
documents: Document[]
|
documents: Doc[]
|
||||||
}>()
|
}>()
|
||||||
const documentStore = useDocumentStore()
|
const store = useMainStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const url_for = (doc: Document) => {
|
const cursor = shallowRef<Doc | null>(null)
|
||||||
const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
|
||||||
return doc.dir ? `#/${p}/` : `/files/${p}`
|
|
||||||
}
|
|
||||||
const cursor = ref<Document | null>(null)
|
|
||||||
// File rename
|
// File rename
|
||||||
const editing = ref<Document | null>(null)
|
const editing = shallowRef<Doc | null>(null)
|
||||||
const rename = (doc: Document, newName: string) => {
|
const rename = (doc: Doc, newName: string) => {
|
||||||
const oldName = doc.name
|
const oldName = doc.name
|
||||||
const control = connect(controlUrl, {
|
const control = connect(controlUrl, {
|
||||||
message(ev: MessageEvent) {
|
message(ev: MessageEvent) {
|
||||||
@@ -124,35 +122,25 @@ const rename = (doc: Document, newName: string) => {
|
|||||||
}
|
}
|
||||||
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 = Math.floor(Date.now() / 1000)
|
||||||
editing.value = {
|
editing.value = new Doc({
|
||||||
loc: loc.value,
|
loc: loc.value,
|
||||||
key: 'new',
|
key: 'new',
|
||||||
name: 'New Folder',
|
name: 'New Folder',
|
||||||
dir: true,
|
dir: true,
|
||||||
mtime: now,
|
mtime: now,
|
||||||
size: 0,
|
size: 0,
|
||||||
sizedisp: formatSize(0),
|
})
|
||||||
modified: formatUnixDate(now),
|
|
||||||
haystack: '',
|
|
||||||
}
|
|
||||||
console.log("New")
|
|
||||||
},
|
},
|
||||||
toggleSelectAll() {
|
toggleSelectAll() {
|
||||||
console.log('Select')
|
console.log('Select')
|
||||||
allSelected.value = !allSelected.value
|
allSelected.value = !allSelected.value
|
||||||
},
|
},
|
||||||
toggleSortColumn(column: number) {
|
toggleSortColumn(column: number) {
|
||||||
const columns = ['', 'name', 'modified', 'size', '']
|
const order = ['', 'name', 'modified', 'size', ''][column]
|
||||||
toggleSort(columns[column])
|
if (order) store.toggleSort(order as SortOrder)
|
||||||
},
|
},
|
||||||
isCursor() {
|
isCursor() {
|
||||||
return cursor.value !== null && editing.value === null
|
return cursor.value !== null && editing.value === null
|
||||||
@@ -163,36 +151,36 @@ defineExpose({
|
|||||||
cursorSelect() {
|
cursorSelect() {
|
||||||
const doc = cursor.value
|
const doc = cursor.value
|
||||||
if (!doc) return
|
if (!doc) return
|
||||||
if (documentStore.selected.has(doc.key)) {
|
if (store.selected.has(doc.key)) {
|
||||||
documentStore.selected.delete(doc.key)
|
store.selected.delete(doc.key)
|
||||||
} else {
|
} else {
|
||||||
documentStore.selected.add(doc.key)
|
store.selected.add(doc.key)
|
||||||
}
|
}
|
||||||
this.cursorMove(1)
|
this.cursorMove(1)
|
||||||
},
|
},
|
||||||
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 docs = props.documents
|
||||||
if (documents.length === 0) {
|
if (docs.length === 0) {
|
||||||
cursor.value = null
|
cursor.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const N = documents.length
|
const N = docs.length
|
||||||
const mod = (a: number, b: number) => ((a % b) + b) % b
|
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||||
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
const index =
|
const index =
|
||||||
cursor.value !== null ? documents.indexOf(cursor.value) : documents.length
|
cursor.value !== null ? docs.indexOf(cursor.value) : docs.length
|
||||||
const moveto = increment(index, d)
|
const moveto = increment(index, d)
|
||||||
cursor.value = documents[moveto] ?? null
|
cursor.value = docs[moveto] ?? null
|
||||||
const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null
|
const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null
|
||||||
if (select) {
|
if (select) {
|
||||||
// Go forwards, possibly wrapping over the end; the last entry is not toggled
|
// Go forwards, possibly wrapping over the end; the last entry is not toggled
|
||||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
||||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||||
if (p === N) continue
|
if (p === N) continue
|
||||||
const key = documents[p].key
|
const key = docs[p].key
|
||||||
if (documentStore.selected.has(key)) documentStore.selected.delete(key)
|
if (store.selected.has(key)) store.selected.delete(key)
|
||||||
else documentStore.selected.add(key)
|
else store.selected.add(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -229,7 +217,14 @@ watchEffect(() => {
|
|||||||
focusBreadcrumb()
|
focusBreadcrumb()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const mkdir = (doc: Document, name: string) => {
|
let nowkey = ref(0)
|
||||||
|
let modifiedTimer: any = null
|
||||||
|
const updateModified = () => {
|
||||||
|
nowkey.value = Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
onMounted(() => { updateModified(); modifiedTimer = setInterval(updateModified, 1000) })
|
||||||
|
onUnmounted(() => { clearInterval(modifiedTimer) })
|
||||||
|
const mkdir = (doc: Doc, name: string) => {
|
||||||
const control = connect(controlUrl, {
|
const control = connect(controlUrl, {
|
||||||
open() {
|
open() {
|
||||||
control.send(
|
control.send(
|
||||||
@@ -246,34 +241,24 @@ const mkdir = (doc: Document, name: string) => {
|
|||||||
editing.value = null
|
editing.value = null
|
||||||
} else {
|
} else {
|
||||||
console.log('mkdir', msg)
|
console.log('mkdir', msg)
|
||||||
router.push(doc.loc ? `/${doc.loc}/${name}/` : `/${name}/`)
|
router.push(doc.urlrouter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
doc.name = name // We should get an update from watch but this is quicker
|
// We should get an update from watch but this is quicker
|
||||||
|
doc.name = name
|
||||||
|
doc.key = crypto.randomUUID()
|
||||||
}
|
}
|
||||||
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
// Column sort
|
const docs = props.documents
|
||||||
const toggleSort = (name: string) => {
|
const docloc = docs[i].loc
|
||||||
sort.value = sort.value === name ? '' : name
|
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
|
||||||
}
|
|
||||||
const sort = ref<string>('')
|
|
||||||
const sortCompare = {
|
|
||||||
name: (a: Document, b: Document) => collator.compare(a.name, b.name),
|
|
||||||
modified: (a: Document, b: Document) => b.mtime - a.mtime,
|
|
||||||
size: (a: Document, b: Document) => b.size - a.size
|
|
||||||
}
|
|
||||||
const sorted = (documents: Document[]) => {
|
|
||||||
const cmp = sortCompare[sort.value as keyof typeof sortCompare]
|
|
||||||
const sorted = [...documents]
|
|
||||||
if (cmp) sorted.sort(cmp)
|
|
||||||
return sorted
|
|
||||||
}
|
}
|
||||||
const selectionIndeterminate = computed({
|
const selectionIndeterminate = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
return (
|
return (
|
||||||
props.documents.length > 0 &&
|
props.documents.length > 0 &&
|
||||||
props.documents.some((doc: Document) => documentStore.selected.has(doc.key)) &&
|
props.documents.some((doc: Doc) => store.selected.has(doc.key)) &&
|
||||||
!allSelected.value
|
!allSelected.value
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -284,16 +269,16 @@ const allSelected = computed({
|
|||||||
get: () => {
|
get: () => {
|
||||||
return (
|
return (
|
||||||
props.documents.length > 0 &&
|
props.documents.length > 0 &&
|
||||||
props.documents.every((doc: Document) => documentStore.selected.has(doc.key))
|
props.documents.every((doc: Doc) => store.selected.has(doc.key))
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
set: (value: boolean) => {
|
set: (value: boolean) => {
|
||||||
console.log('Setting allSelected', value)
|
console.log('Setting allSelected', value)
|
||||||
for (const doc of props.documents) {
|
for (const doc of props.documents) {
|
||||||
if (value) {
|
if (value) {
|
||||||
documentStore.selected.add(doc.key)
|
store.selected.add(doc.key)
|
||||||
} else {
|
} else {
|
||||||
documentStore.selected.delete(doc.key)
|
store.selected.delete(doc.key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,9 +286,13 @@ const allSelected = computed({
|
|||||||
|
|
||||||
const loc = computed(() => props.path.join('/'))
|
const loc = computed(() => props.path.join('/'))
|
||||||
|
|
||||||
const contextMenu = (ev: Event, doc: Document) => {
|
const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||||
cursor.value = doc
|
cursor.value = doc
|
||||||
console.log('Context menu', ev, doc)
|
ContextMenu.showContextMenu({
|
||||||
|
x: ev.x, y: ev.y, items: [
|
||||||
|
{ label: 'Rename', onClick: () => { editing.value = doc } },
|
||||||
|
],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -451,3 +440,4 @@ tbody .selection input {
|
|||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@/stores/main
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Document } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const datetime = computed(() =>
|
const datetime = computed(() =>
|
||||||
@@ -17,6 +17,6 @@ const tooltip = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
doc: Document
|
doc: Doc
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Document } from '@/repositories/Document'
|
import { Doc } 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: Doc
|
||||||
rename: (doc: Document, newName: string) => void
|
rename: (doc: Doc, newName: string) => void
|
||||||
exit: () => void
|
exit: () => void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Document } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const sizeClass = computed(() => {
|
const sizeClass = computed(() => {
|
||||||
@@ -12,7 +12,7 @@ const sizeClass = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
doc: Document
|
doc: Doc
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav class="headermain">
|
<nav class="headermain">
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<template v-if="documentStore.error">
|
<template v-if="store.error">
|
||||||
<div class="error-message" @click="documentStore.error = ''">{{ documentStore.error }}</div>
|
<div class="error-message" @click="store.error = ''">{{ store.error }}</div>
|
||||||
<div class="smallgap"></div>
|
<div class="smallgap"></div>
|
||||||
</template>
|
</template>
|
||||||
<UploadButton :path="props.path" />
|
<UploadButton :path="props.path" />
|
||||||
<SvgButton
|
<SvgButton
|
||||||
name="create-folder"
|
name="create-folder"
|
||||||
data-tooltip="New folder"
|
data-tooltip="New folder"
|
||||||
@click="() => documentStore.fileExplorer!.newFolder()"
|
@click="() => store.fileExplorer!.newFolder()"
|
||||||
/>
|
/>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
<div class="spacer smallgap"></div>
|
<div class="spacer smallgap"></div>
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
ref="search"
|
ref="search"
|
||||||
type="search"
|
type="search"
|
||||||
:value="query"
|
:value="query"
|
||||||
@blur="ev => { if (!query) closeSearch(ev) }"
|
|
||||||
@input="updateSearch"
|
@input="updateSearch"
|
||||||
placeholder="Search words"
|
placeholder="Search words"
|
||||||
class="margin-input"
|
class="margin-input"
|
||||||
@@ -32,15 +31,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useDocumentStore } from '@/stores/documents'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { ref, nextTick, watchEffect } from 'vue'
|
import { ref, nextTick, watchEffect } from 'vue'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
|
|
||||||
const documentStore = useDocumentStore()
|
const store = useMainStore()
|
||||||
const showSearchInput = ref<boolean>(false)
|
const showSearchInput = ref<boolean>(false)
|
||||||
const search = ref<HTMLInputElement | null>()
|
const search = ref<HTMLInputElement | null>()
|
||||||
const searchButton = ref<HTMLButtonElement | null>()
|
const searchButton = ref<HTMLButtonElement | null>()
|
||||||
|
const props = defineProps<{
|
||||||
|
path: Array<string>
|
||||||
|
query: string
|
||||||
|
}>()
|
||||||
|
|
||||||
const closeSearch = (ev: Event) => {
|
const closeSearch = (ev: Event) => {
|
||||||
if (!showSearchInput.value) return // Already closing
|
if (!showSearchInput.value) return // Already closing
|
||||||
@@ -54,9 +57,9 @@ const updateSearch = (ev: Event) => {
|
|||||||
let p = props.path.join('/')
|
let p = props.path.join('/')
|
||||||
p = p ? `/${p}` : ''
|
p = p ? `/${p}` : ''
|
||||||
const url = q ? `${p}//${q}` : (p || '/')
|
const url = q ? `${p}//${q}` : (p || '/')
|
||||||
console.log("Update search", url)
|
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
if (!props.query && q) router.push(url)
|
if (!props.query && q) router.push(u)
|
||||||
else router.replace(url)
|
else router.replace(u)
|
||||||
}
|
}
|
||||||
const toggleSearchInput = (ev: Event) => {
|
const toggleSearchInput = (ev: Event) => {
|
||||||
showSearchInput.value = !showSearchInput.value
|
showSearchInput.value = !showSearchInput.value
|
||||||
@@ -72,10 +75,10 @@ watchEffect(() => {
|
|||||||
const settingsMenu = (e: Event) => {
|
const settingsMenu = (e: Event) => {
|
||||||
// show the context menu
|
// show the context menu
|
||||||
const items = []
|
const items = []
|
||||||
if (documentStore.user.isLoggedIn) {
|
if (store.user.isLoggedIn) {
|
||||||
items.push({ label: `Logout ${documentStore.user.username ?? ''}`, onClick: () => documentStore.logout() })
|
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
|
||||||
} else {
|
} else {
|
||||||
items.push({ label: 'Login', onClick: () => documentStore.loginDialog() })
|
items.push({ label: 'Login', onClick: () => store.loginDialog() })
|
||||||
}
|
}
|
||||||
ContextMenu.showContextMenu({
|
ContextMenu.showContextMenu({
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -83,11 +86,6 @@ const settingsMenu = (e: Event) => {
|
|||||||
items,
|
items,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const props = defineProps<{
|
|
||||||
path: Array<string>
|
|
||||||
query: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
toggleSearchInput,
|
toggleSearchInput,
|
||||||
closeSearch,
|
closeSearch,
|
||||||
@@ -116,3 +114,4 @@ input[type='search'] {
|
|||||||
max-width: 30vw;
|
max-width: 30vw;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@/stores/main
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<template v-if="documentStore.selected.size">
|
<template v-if="store.selected.size">
|
||||||
<div class="smallgap"></div>
|
<div class="smallgap"></div>
|
||||||
<p class="select-text">{{ documentStore.selected.size }} selected ➤</p>
|
<p class="select-text">{{ store.selected.size }} selected ➤</p>
|
||||||
<SvgButton name="download" data-tooltip="Download" @click="download" />
|
<SvgButton name="download" data-tooltip="Download" @click="download" />
|
||||||
<SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" />
|
<SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" />
|
||||||
<SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" />
|
<SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" />
|
||||||
<SvgButton name="trash" data-tooltip="Delete ⚠️" @click="op('rm')" />
|
<SvgButton name="trash" data-tooltip="Delete ⚠️" @click="op('rm')" />
|
||||||
<button class="action-button unselect" data-tooltip="Unselect all" @click="documentStore.selected.clear()">❌</button>
|
<button class="action-button unselect" data-tooltip="Unselect all" @click="store.selected.clear()">❌</button>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {connect, controlUrl} from '@/repositories/WS'
|
import {connect, controlUrl} from '@/repositories/WS'
|
||||||
import { useDocumentStore } from '@/stores/documents'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { SelectedItems } from '@/repositories/Document'
|
import type { SelectedItems } from '@/repositories/Document'
|
||||||
|
|
||||||
const documentStore = useDocumentStore()
|
const store = useMainStore()
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
})
|
})
|
||||||
|
|
||||||
const dst = computed(() => props.path!.join('/'))
|
const dst = computed(() => props.path!.join('/'))
|
||||||
const op = (op: string, dst?: string) => {
|
const op = (op: string, dst?: string) => {
|
||||||
const sel = documentStore.selectedFiles
|
const sel = store.selectedFiles
|
||||||
const msg = {
|
const msg = {
|
||||||
op,
|
op,
|
||||||
sel: sel.keys.map(key => {
|
sel: sel.keys.map(key => {
|
||||||
@@ -38,12 +38,12 @@ const op = (op: string, dst?: string) => {
|
|||||||
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)
|
||||||
documentStore.error = res.error.message
|
store.error = res.error.message
|
||||||
return
|
return
|
||||||
} else if (res.status === 'ack') {
|
} else if (res.status === 'ack') {
|
||||||
console.log('Control ack OK', res)
|
console.log('Control ack OK', res)
|
||||||
control.close()
|
control.close()
|
||||||
documentStore.selected.clear()
|
store.selected.clear()
|
||||||
return
|
return
|
||||||
} else console.log('Unknown control response', msg, res)
|
} else console.log('Unknown control response', msg, res)
|
||||||
}
|
}
|
||||||
@@ -108,17 +108,17 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
|||||||
}
|
}
|
||||||
|
|
||||||
const download = async () => {
|
const download = async () => {
|
||||||
const sel = documentStore.selectedFiles
|
const sel = store.selectedFiles
|
||||||
console.log('Download', sel)
|
console.log('Download', sel)
|
||||||
if (sel.keys.length === 0) {
|
if (sel.keys.length === 0) {
|
||||||
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
|
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
|
||||||
documentStore.selected.clear()
|
store.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 files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
|
||||||
if (files.length === 1) {
|
if (files.length === 1) {
|
||||||
documentStore.selected.clear()
|
store.selected.clear()
|
||||||
return linkdl(`/files/${files[0][1]}`)
|
return linkdl(`/files/${files[0][1]}`)
|
||||||
}
|
}
|
||||||
// Use FileSystem API if multiple files and the browser supports it
|
// Use FileSystem API if multiple files and the browser supports it
|
||||||
@@ -130,7 +130,7 @@ const download = async () => {
|
|||||||
mode: 'readwrite'
|
mode: 'readwrite'
|
||||||
})
|
})
|
||||||
filesystemdl(sel, handle).then(() => {
|
filesystemdl(sel, handle).then(() => {
|
||||||
documentStore.selected.clear()
|
store.selected.clear()
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -140,7 +140,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'
|
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
|
||||||
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
||||||
documentStore.selected.clear()
|
store.selected.clear()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -152,3 +152,4 @@ const download = async () => {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@/stores/main
|
||||||
|
|||||||
@@ -39,10 +39,10 @@
|
|||||||
import { reactive, ref } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
import { loginUser } from '@/repositories/User'
|
import { loginUser } from '@/repositories/User'
|
||||||
import type { ISimpleError } from '@/repositories/Client'
|
import type { ISimpleError } from '@/repositories/Client'
|
||||||
import { useDocumentStore } from '@/stores/documents'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
|
||||||
const confirmLoading = ref<boolean>(false)
|
const confirmLoading = ref<boolean>(false)
|
||||||
const store = useDocumentStore()
|
const store = useMainStore()
|
||||||
|
|
||||||
const loginForm = reactive({
|
const loginForm = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
@@ -99,3 +99,4 @@ const login = async () => {
|
|||||||
height: 1em;
|
height: 1em;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@/stores/main
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { connect, uploadUrl } from '@/repositories/WS';
|
import { connect, uploadUrl } from '@/repositories/WS';
|
||||||
import { useDocumentStore } from '@/stores/documents'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { collator } from '@/utils';
|
import { collator } from '@/utils';
|
||||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
|
|
||||||
const fileInput = ref()
|
const fileInput = ref()
|
||||||
const folderInput = ref()
|
const folderInput = ref()
|
||||||
const documentStore = useDocumentStore()
|
const store = useMainStore()
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
})
|
})
|
||||||
@@ -16,13 +16,51 @@ type CloudFile = {
|
|||||||
cloudName: string
|
cloudName: string
|
||||||
cloudPos: number
|
cloudPos: number
|
||||||
}
|
}
|
||||||
|
function pasteHandler(event: ClipboardEvent) {
|
||||||
|
const items = Array.from(event.clipboardData?.items ?? [])
|
||||||
|
const infiles = [] as File[]
|
||||||
|
const dirs = [] as FileSystemDirectoryEntry[]
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.kind !== 'file') continue
|
||||||
|
const entry = item.webkitGetAsEntry()
|
||||||
|
if (entry?.isFile) {
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (file) infiles.push(file)
|
||||||
|
} else if (entry?.isDirectory) {
|
||||||
|
dirs.push(entry as FileSystemDirectoryEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (infiles.length || dirs.length) {
|
||||||
|
event.preventDefault()
|
||||||
|
uploadFiles(infiles)
|
||||||
|
for (const entry of dirs) pasteDirectory(entry, `${props.path!.join('/')}/${entry.name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
|
||||||
|
const reader = entry.createReader()
|
||||||
|
const entries = await new Promise<any[]>(resolve => reader.readEntries(resolve))
|
||||||
|
const cloudfiles = [] as CloudFile[]
|
||||||
|
for (const entry of entries) {
|
||||||
|
const cloudName = `${loc}/${entry.name}`
|
||||||
|
if (entry.isFile) {
|
||||||
|
const file = await new Promise(resolve => entry.file(resolve)) as File
|
||||||
|
cloudfiles.push({file, cloudName, cloudPos: 0})
|
||||||
|
} else if (entry.isDirectory) {
|
||||||
|
await pasteDirectory(entry, cloudName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cloudfiles.length) uploadCloudFiles(cloudfiles)
|
||||||
|
}
|
||||||
function uploadHandler(event: Event) {
|
function uploadHandler(event: Event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const infiles = Array.from(event.dataTransfer?.files || event.target.files) as File[]
|
const input = event.target as HTMLInputElement | null
|
||||||
if (!infiles.length) return
|
const infiles = Array.from((input ?? (event as DragEvent).dataTransfer)?.files ?? []) as File[]
|
||||||
|
if (input) input.value = ''
|
||||||
|
if (infiles.length) uploadFiles(infiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadFiles = (infiles: File[]) => {
|
||||||
const loc = props.path!.join('/')
|
const loc = props.path!.join('/')
|
||||||
let files = []
|
let files = []
|
||||||
for (const file of infiles) {
|
for (const file of infiles) {
|
||||||
@@ -32,9 +70,12 @@ function uploadHandler(event: Event) {
|
|||||||
cloudPos: 0,
|
cloudPos: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
uploadCloudFiles(files)
|
||||||
|
}
|
||||||
|
const uploadCloudFiles = (files: CloudFile[]) => {
|
||||||
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
|
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
|
||||||
if (dotfiles.length) {
|
if (dotfiles.length) {
|
||||||
documentStore.error = "Won't upload dotfiles"
|
store.error = "Won't upload dotfiles"
|
||||||
console.log("Dotfiles omitted", dotfiles)
|
console.log("Dotfiles omitted", dotfiles)
|
||||||
files = files.filter(f => !f.cloudName.includes('/.'))
|
files = files.filter(f => !f.cloudName.includes('/.'))
|
||||||
}
|
}
|
||||||
@@ -76,7 +117,7 @@ const speed = computed(() => {
|
|||||||
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
|
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
|
||||||
return s // "Current speed"
|
return s // "Current speed"
|
||||||
})
|
})
|
||||||
const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 100 ? 1 : 0) + '\u202FMB/s': 'stalled')
|
const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s': 'stalled')
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (Date.now() - uprogress.tlast > 3000) {
|
if (Date.now() - uprogress.tlast > 3000) {
|
||||||
// Reset
|
// Reset
|
||||||
@@ -130,13 +171,13 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
|
|||||||
open(ev: Event) { resolve(ws) },
|
open(ev: Event) { resolve(ws) },
|
||||||
error(ev: Event) {
|
error(ev: Event) {
|
||||||
console.error('Upload socket error', ev)
|
console.error('Upload socket error', ev)
|
||||||
documentStore.error = 'Upload socket error'
|
store.error = 'Upload socket error'
|
||||||
},
|
},
|
||||||
message(ev: MessageEvent) {
|
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('Upload socket error', res.error)
|
console.error('Upload socket error', res.error)
|
||||||
documentStore.error = res.error.message
|
store.error = res.error.message
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (res.status === 'ack') {
|
if (res.status === 'ack') {
|
||||||
@@ -165,10 +206,6 @@ const worker = async () => {
|
|||||||
const ws = await WSCreate()
|
const ws = await WSCreate()
|
||||||
while (upqueue.length) {
|
while (upqueue.length) {
|
||||||
const f = upqueue[0]
|
const f = upqueue[0]
|
||||||
if (f.cloudPos === f.file.size) {
|
|
||||||
upqueue.shift()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const start = f.cloudPos
|
const start = f.cloudPos
|
||||||
const end = Math.min(f.file.size, start + (1<<20))
|
const end = Math.min(f.file.size, start + (1<<20))
|
||||||
const control = { name: f.cloudName, size: f.file.size, start, end }
|
const control = { name: f.cloudName, size: f.file.size, start, end }
|
||||||
@@ -179,6 +216,7 @@ const worker = async () => {
|
|||||||
ws.sendMsg(control)
|
ws.sendMsg(control)
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await ws.sendData(data)
|
await ws.sendData(data)
|
||||||
|
if (f.cloudPos === f.file.size) upqueue.shift()
|
||||||
}
|
}
|
||||||
if (upqueue.length) startWorker()
|
if (upqueue.length) startWorker()
|
||||||
uprogress.status = "idle"
|
uprogress.status = "idle"
|
||||||
@@ -196,8 +234,10 @@ onMounted(() => {
|
|||||||
// Need to prevent both to prevent browser from opening the file
|
// Need to prevent both to prevent browser from opening the file
|
||||||
addEventListener('dragover', uploadHandler)
|
addEventListener('dragover', uploadHandler)
|
||||||
addEventListener('drop', uploadHandler)
|
addEventListener('drop', uploadHandler)
|
||||||
|
addEventListener('paste', pasteHandler)
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
removeEventListener('paste', pasteHandler)
|
||||||
removeEventListener('dragover', uploadHandler)
|
removeEventListener('dragover', uploadHandler)
|
||||||
removeEventListener('drop', uploadHandler)
|
removeEventListener('drop', uploadHandler)
|
||||||
})
|
})
|
||||||
@@ -219,7 +259,7 @@ onUnmounted(() => {
|
|||||||
{{ (uprogress.filepos / uprogress.filesize * 100).toFixed(0) + '\u202F%' }}
|
{{ (uprogress.filepos / uprogress.filesize * 100).toFixed(0) + '\u202F%' }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="position" v-if="uprogress.filesize > 1e7">
|
<span class="position" v-if="uprogress.total > 1e7">
|
||||||
{{ (uprogress.uploaded / 1e6).toFixed(0) + '\u202F/\u202F' + (uprogress.total / 1e6).toFixed(0) + '\u202FMB' }}
|
{{ (uprogress.uploaded / 1e6).toFixed(0) + '\u202F/\u202F' + (uprogress.total / 1e6).toFixed(0) + '\u202FMB' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="speed">{{ speeddisp }}</span>
|
<span class="speed">{{ speeddisp }}</span>
|
||||||
@@ -262,3 +302,4 @@ span {
|
|||||||
.position { min-width: 4em }
|
.position { min-width: 4em }
|
||||||
.speed { min-width: 4em }
|
.speed { min-width: 4em }
|
||||||
</style>
|
</style>
|
||||||
|
@/stores/main
|
||||||
|
|||||||
@@ -1,17 +1,42 @@
|
|||||||
|
import { formatSize, formatUnixDate, haystackFormat } from "@/utils"
|
||||||
|
|
||||||
export type FUID = string
|
export type FUID = string
|
||||||
|
|
||||||
export type Document = {
|
export type DocProps = {
|
||||||
loc: string
|
loc: string
|
||||||
name: string
|
name: string
|
||||||
key: FUID
|
key: FUID
|
||||||
size: number
|
size: number
|
||||||
sizedisp: string
|
|
||||||
mtime: number
|
mtime: number
|
||||||
modified: string
|
|
||||||
haystack: string
|
|
||||||
dir: boolean
|
dir: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class Doc {
|
||||||
|
private _name: string = ""
|
||||||
|
public loc: string = ""
|
||||||
|
public key: FUID = ""
|
||||||
|
public size: number = 0
|
||||||
|
public mtime: number = 0
|
||||||
|
public haystack: string = ""
|
||||||
|
public dir: boolean = false
|
||||||
|
|
||||||
|
constructor(props: Partial<DocProps> = {}) { Object.assign(this, props) }
|
||||||
|
get name() { return this._name }
|
||||||
|
set name(name: string) {
|
||||||
|
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
||||||
|
this._name = name
|
||||||
|
this.haystack = haystackFormat(name)
|
||||||
|
}
|
||||||
|
get sizedisp(): string { return formatSize(this.size) }
|
||||||
|
get modified(): string { return formatUnixDate(this.mtime) }
|
||||||
|
get url(): string {
|
||||||
|
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
||||||
|
return this.dir ? '/#/' + `${p}/`.replaceAll('#', '%23') : `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
|
}
|
||||||
|
get urlrouter(): string {
|
||||||
|
return this.url.replace(/^\/#/, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
export type errorEvent = {
|
export type errorEvent = {
|
||||||
error: {
|
error: {
|
||||||
code: number
|
code: number
|
||||||
@@ -36,7 +61,7 @@ export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>
|
|||||||
// Helper structure for selections
|
// Helper structure for selections
|
||||||
export interface SelectedItems {
|
export interface SelectedItems {
|
||||||
keys: FUID[]
|
keys: FUID[]
|
||||||
docs: Record<FUID, Document>
|
docs: Record<FUID, Doc>
|
||||||
recursive: Array<[string, string, Document]>
|
recursive: Array<[string, string, Doc]>
|
||||||
missing: Set<FUID>
|
missing: Set<FUID>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useDocumentStore } from "@/stores/documents"
|
import { useMainStore } from "@/stores/main"
|
||||||
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
|
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
|
||||||
|
|
||||||
export const controlUrl = '/api/control'
|
export const controlUrl = '/api/control'
|
||||||
@@ -6,22 +6,26 @@ export const uploadUrl = '/api/upload'
|
|||||||
export const watchUrl = '/api/watch'
|
export const watchUrl = '/api/watch'
|
||||||
|
|
||||||
let tree = [] as FileEntry[]
|
let tree = [] as FileEntry[]
|
||||||
let reconnectDuration = 500
|
let reconnDelay = 500
|
||||||
let wsWatch = null as WebSocket | null
|
let wsWatch = null as WebSocket | null
|
||||||
|
|
||||||
export const loadSession = () => {
|
export const loadSession = () => {
|
||||||
const store = useDocumentStore()
|
const s = localStorage['cista-files']
|
||||||
|
if (!s) return false
|
||||||
|
const store = useMainStore()
|
||||||
try {
|
try {
|
||||||
tree = JSON.parse(sessionStorage["cista-files"])
|
tree = JSON.parse(s)
|
||||||
store.updateRoot(tree)
|
store.updateRoot(tree)
|
||||||
|
console.log(`Loaded session with ${tree.length} items cached`)
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log("Loading session failed", error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveSession = () => {
|
const saveSession = () => {
|
||||||
sessionStorage["cista-files"] = JSON.stringify(tree)
|
localStorage["cista-files"] = JSON.stringify(tree)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => {
|
export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => {
|
||||||
@@ -35,7 +39,7 @@ export const watchConnect = () => {
|
|||||||
clearTimeout(watchTimeout)
|
clearTimeout(watchTimeout)
|
||||||
watchTimeout = null
|
watchTimeout = null
|
||||||
}
|
}
|
||||||
const store = useDocumentStore()
|
const store = useMainStore()
|
||||||
if (store.error !== 'Reconnecting...') store.error = 'Connecting...'
|
if (store.error !== 'Reconnecting...') store.error = 'Connecting...'
|
||||||
console.log(store.error)
|
console.log(store.error)
|
||||||
|
|
||||||
@@ -59,7 +63,7 @@ export const watchConnect = () => {
|
|||||||
console.log('Connected to backend', msg)
|
console.log('Connected to backend', msg)
|
||||||
store.server = msg.server
|
store.server = msg.server
|
||||||
store.connected = true
|
store.connected = true
|
||||||
reconnectDuration = 500
|
reconnDelay = 500
|
||||||
store.error = ''
|
store.error = ''
|
||||||
if (msg.user) store.login(msg.user.username, msg.user.privileged)
|
if (msg.user) store.login(msg.user.username, msg.user.privileged)
|
||||||
else if (store.isUserLogged) store.logout()
|
else if (store.isUserLogged) store.logout()
|
||||||
@@ -77,16 +81,16 @@ export const watchDisconnect = () => {
|
|||||||
let watchTimeout: any = null
|
let watchTimeout: any = null
|
||||||
|
|
||||||
const watchReconnect = (event: MessageEvent) => {
|
const watchReconnect = (event: MessageEvent) => {
|
||||||
const store = useDocumentStore()
|
const store = useMainStore()
|
||||||
if (store.connected) {
|
if (store.connected) {
|
||||||
console.warn("Disconnected from server", event)
|
console.warn("Disconnected from server", event)
|
||||||
store.connected = false
|
store.connected = false
|
||||||
store.error = 'Reconnecting...'
|
store.error = 'Reconnecting...'
|
||||||
}
|
}
|
||||||
reconnectDuration = Math.min(5000, reconnectDuration + 500)
|
reconnDelay = Math.min(5000, reconnDelay + 500)
|
||||||
// The server closes the websocket after errors, so we need to reopen it
|
// The server closes the websocket after errors, so we need to reopen it
|
||||||
if (watchTimeout !== null) clearTimeout(watchTimeout)
|
if (watchTimeout !== null) clearTimeout(watchTimeout)
|
||||||
watchTimeout = setTimeout(watchConnect, reconnectDuration)
|
watchTimeout = setTimeout(watchConnect, reconnDelay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -110,7 +114,7 @@ const handleWatchMessage = (event: MessageEvent) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleRootMessage({ root }: { root: FileEntry[] }) {
|
function handleRootMessage({ root }: { root: FileEntry[] }) {
|
||||||
const store = useDocumentStore()
|
const store = useMainStore()
|
||||||
console.log('Watch root', root)
|
console.log('Watch root', root)
|
||||||
store.updateRoot(root)
|
store.updateRoot(root)
|
||||||
tree = root
|
tree = root
|
||||||
@@ -118,7 +122,7 @@ function handleRootMessage({ root }: { root: FileEntry[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
||||||
const store = useDocumentStore()
|
const store = useMainStore()
|
||||||
const update = updateData.update
|
const update = updateData.update
|
||||||
console.log('Watch update', update)
|
console.log('Watch update', update)
|
||||||
if (!tree) return console.error('Watch update before root')
|
if (!tree) return console.error('Watch update before root')
|
||||||
@@ -142,7 +146,7 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleError(msg: errorEvent) {
|
function handleError(msg: errorEvent) {
|
||||||
const store = useDocumentStore()
|
const store = useMainStore()
|
||||||
if (msg.error.code === 401) {
|
if (msg.error.code === 401) {
|
||||||
store.user.isOpenLoginModal = true
|
store.user.isOpenLoginModal = true
|
||||||
store.user.isLoggedIn = false
|
store.user.isLoggedIn = false
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import type { Document, FileEntry, FUID, SelectedItems } from '@/repositories/Document'
|
import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
|
||||||
import { formatSize, formatUnixDate, haystackFormat } from '@/utils'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { collator } from '@/utils'
|
import { collator } from '@/utils'
|
||||||
import { logoutUser } from '@/repositories/User'
|
import { logoutUser } from '@/repositories/User'
|
||||||
import { watchConnect } from '@/repositories/WS'
|
import { watchConnect } from '@/repositories/WS'
|
||||||
|
import { shallowRef } from 'vue'
|
||||||
|
import { sorted, type SortOrder } from '@/utils/docsort'
|
||||||
|
|
||||||
type FileData = { id: string; mtime: number; size: number; dir: DirectoryData }
|
|
||||||
type DirectoryData = {
|
|
||||||
[filename: string]: FileData
|
|
||||||
}
|
|
||||||
type User = {
|
type User = {
|
||||||
username: string
|
username: string
|
||||||
privileged: boolean
|
privileged: boolean
|
||||||
@@ -16,15 +14,20 @@ type User = {
|
|||||||
isLoggedIn: boolean
|
isLoggedIn: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDocumentStore = defineStore({
|
export const useMainStore = defineStore({
|
||||||
id: 'documents',
|
id: 'main',
|
||||||
state: () => ({
|
state: () => ({
|
||||||
document: [] as Document[],
|
document: shallowRef<Doc[]>([]),
|
||||||
selected: new Set<FUID>(),
|
selected: new Set<FUID>(),
|
||||||
|
query: '' as string,
|
||||||
fileExplorer: null as any,
|
fileExplorer: null as any,
|
||||||
error: '' as string,
|
error: '' as string,
|
||||||
connected: false,
|
connected: false,
|
||||||
server: {} as Record<string, any>,
|
server: {} as Record<string, any>,
|
||||||
|
prefs: {
|
||||||
|
sortListing: '' as SortOrder,
|
||||||
|
sortFiltered: '' as SortOrder,
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
username: '',
|
username: '',
|
||||||
privileged: false,
|
privileged: false,
|
||||||
@@ -32,29 +35,26 @@ export const useDocumentStore = defineStore({
|
|||||||
isOpenLoginModal: false
|
isOpenLoginModal: false
|
||||||
} as User
|
} as User
|
||||||
}),
|
}),
|
||||||
|
persist: {
|
||||||
|
paths: ['prefs'],
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
updateRoot(root: FileEntry[]) {
|
updateRoot(root: FileEntry[]) {
|
||||||
const docs = []
|
const docs = []
|
||||||
let loc = [] as string[]
|
let loc = [] as string[]
|
||||||
for (const [level, name, key, mtime, size, isfile] of root) {
|
for (const [level, name, key, mtime, size, isfile] of root) {
|
||||||
loc = loc.slice(0, level - 1)
|
loc = loc.slice(0, level - 1)
|
||||||
docs.push({
|
docs.push(new Doc({
|
||||||
name,
|
name,
|
||||||
loc: level ? loc.join('/') : '/',
|
loc: level ? loc.join('/') : '/',
|
||||||
key,
|
key,
|
||||||
size,
|
size,
|
||||||
sizedisp: formatSize(size),
|
|
||||||
mtime,
|
mtime,
|
||||||
modified: formatUnixDate(mtime),
|
|
||||||
haystack: haystackFormat(name),
|
|
||||||
dir: !isfile,
|
dir: !isfile,
|
||||||
})
|
}))
|
||||||
loc.push(name)
|
loc.push(name)
|
||||||
}
|
}
|
||||||
this.document = docs as Document[]
|
this.document = docs
|
||||||
},
|
|
||||||
updateModified() {
|
|
||||||
for (const doc of this.document) doc.modified = formatUnixDate(doc.mtime)
|
|
||||||
},
|
},
|
||||||
login(username: string, privileged: boolean) {
|
login(username: string, privileged: boolean) {
|
||||||
this.user.username = username
|
this.user.username = username
|
||||||
@@ -70,23 +70,18 @@ export const useDocumentStore = defineStore({
|
|||||||
console.log("Logout")
|
console.log("Logout")
|
||||||
await logoutUser()
|
await logoutUser()
|
||||||
this.$reset()
|
this.$reset()
|
||||||
|
localStorage.clear()
|
||||||
history.go() // Reload page
|
history.go() // Reload page
|
||||||
}
|
},
|
||||||
|
toggleSort(name: SortOrder) {
|
||||||
|
if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name
|
||||||
|
else this.prefs.sortListing = this.prefs.sortListing === name ? '' : name
|
||||||
|
},
|
||||||
},
|
},
|
||||||
getters: {
|
getters: {
|
||||||
isUserLogged(): boolean {
|
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
|
||||||
return this.user.isLoggedIn
|
isUserLogged(): boolean { return this.user.isLoggedIn },
|
||||||
},
|
recentDocuments(): Doc[] { return sorted(this.document, 'modified') },
|
||||||
recentDocuments(): Document[] {
|
|
||||||
const ret = [...this.document]
|
|
||||||
ret.sort((a, b) => b.mtime - a.mtime)
|
|
||||||
return ret
|
|
||||||
},
|
|
||||||
largeDocuments(): Document[] {
|
|
||||||
const ret = [...this.document]
|
|
||||||
ret.sort((a, b) => b.size - a.size)
|
|
||||||
return ret
|
|
||||||
},
|
|
||||||
selectedFiles(): SelectedItems {
|
selectedFiles(): SelectedItems {
|
||||||
const selected = this.selected
|
const selected = this.selected
|
||||||
const found = new Set<FUID>()
|
const found = new Set<FUID>()
|
||||||
@@ -107,7 +102,7 @@ export const useDocumentStore = defineStore({
|
|||||||
for (const key of selected) if (!found.has(key)) ret.missing.add(key)
|
for (const key of selected) if (!found.has(key)) ret.missing.add(key)
|
||||||
// Build a flat list including contents recursively
|
// Build a flat list including contents recursively
|
||||||
const relnames = new Set<string>()
|
const relnames = new Set<string>()
|
||||||
function add(rel: string, full: string, doc: Document) {
|
function add(rel: string, full: string, doc: Doc) {
|
||||||
if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`)
|
if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`)
|
||||||
relnames.add(rel)
|
relnames.add(rel)
|
||||||
ret.recursive.push([rel, full, doc])
|
ret.recursive.push([rel, full, doc])
|
||||||
15
frontend/src/utils/docsort.ts
Normal file
15
frontend/src/utils/docsort.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Doc } from '@/repositories/Document'
|
||||||
|
import { collator } from '@/utils'
|
||||||
|
|
||||||
|
export const ordering = {
|
||||||
|
name: (a: Doc, b: Doc) => collator.compare(a.name, b.name),
|
||||||
|
modified: (a: Doc, b: Doc) => b.mtime - a.mtime,
|
||||||
|
size: (a: Doc, b: Doc) => b.size - a.size
|
||||||
|
}
|
||||||
|
export type SortOrder = keyof typeof ordering | ''
|
||||||
|
export const sorted = (documents: Doc[], order: SortOrder) => {
|
||||||
|
if (!order) return documents
|
||||||
|
const sorted = [...documents]
|
||||||
|
sorted.sort(ordering[order])
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
@@ -86,7 +86,7 @@ export function haystackFormat(str: string) {
|
|||||||
// Preformat search string for faster search
|
// Preformat search string for faster search
|
||||||
export function needleFormat(query: string) {
|
export function needleFormat(query: string) {
|
||||||
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||||
return {based, words: based.split(/\W+/)}
|
return {based, words: based.split(/\s+/)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test if haystack includes needle
|
// Test if haystack includes needle
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watchEffect, ref, computed } from 'vue'
|
import { watchEffect, ref, computed } from 'vue'
|
||||||
import { useDocumentStore } from '@/stores/documents'
|
import { useMainStore } from '@/stores/main'
|
||||||
import Router from '@/router/index'
|
import Router from '@/router/index'
|
||||||
import { needleFormat, localeIncludes, collator } from '@/utils';
|
import { needleFormat, localeIncludes, collator } from '@/utils';
|
||||||
|
import { sorted } from '@/utils/docsort';
|
||||||
|
|
||||||
const documentStore = useDocumentStore()
|
const store = useMainStore()
|
||||||
const fileExplorer = ref()
|
const fileExplorer = ref()
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
@@ -24,19 +25,25 @@ const documents = computed(() => {
|
|||||||
const loc = props.path.join('/')
|
const loc = props.path.join('/')
|
||||||
const query = props.query
|
const query = props.query
|
||||||
// List the current location
|
// List the current location
|
||||||
if (!query) return documentStore.document.filter(doc => doc.loc === loc)
|
if (!query) return sorted(
|
||||||
|
store.document.filter(doc => doc.loc === loc),
|
||||||
|
store.prefs.sortListing,
|
||||||
|
)
|
||||||
// Find up to 100 newest documents that match the search
|
// Find up to 100 newest documents that match the search
|
||||||
const needle = needleFormat(query)
|
const needle = needleFormat(query)
|
||||||
let limit = 100
|
let limit = 100
|
||||||
let docs = []
|
let docs = []
|
||||||
for (const doc of documentStore.recentDocuments) {
|
for (const doc of store.recentDocuments) {
|
||||||
if (localeIncludes(doc.haystack, needle)) {
|
if (localeIncludes(doc.haystack, needle)) {
|
||||||
docs.push(doc)
|
docs.push(doc)
|
||||||
if (--limit === 0) break
|
if (--limit === 0) break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Organize by folder, by relevance
|
|
||||||
const locsub = loc + '/'
|
const locsub = loc + '/'
|
||||||
|
// Custom sort override in effect?
|
||||||
|
const order = store.prefs.sortFiltered
|
||||||
|
if (order) return sorted(docs, order)
|
||||||
|
// Sort by relevance - current folder, then subfolders, then others
|
||||||
docs.sort((a, b) => (
|
docs.sort((a, b) => (
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
(b.loc === loc) - (a.loc === loc) ||
|
(b.loc === loc) - (a.loc === loc) ||
|
||||||
@@ -53,6 +60,7 @@ const documents = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
documentStore.fileExplorer = fileExplorer.value
|
store.fileExplorer = fileExplorer.value
|
||||||
|
store.query = props.query
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = ""
|
Homepage = "https://git.zi.fi/Vasanko/cista-storage"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
cista = "cista.__main__:main"
|
cista = "cista.__main__:main"
|
||||||
@@ -40,20 +40,18 @@ dev = [
|
|||||||
"ruff",
|
"ruff",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.hatchling]
|
|
||||||
# Build frontend
|
|
||||||
pre_build = "npm run build --prefix cista-front"
|
|
||||||
|
|
||||||
[tool.hatch.version]
|
[tool.hatch.version]
|
||||||
source = "vcs"
|
source = "vcs"
|
||||||
|
|
||||||
[tool.hatch.build]
|
[tool.hatch.build]
|
||||||
|
artifacts = ["cista/wwwroot"]
|
||||||
|
hooks.custom.path = "scripts/build-frontend.py"
|
||||||
hooks.vcs.version-file = "cista/_version.py"
|
hooks.vcs.version-file = "cista/_version.py"
|
||||||
hooks.vcs.template = """
|
hooks.vcs.template = """
|
||||||
# This file is automatically generated by hatch build.
|
# This file is automatically generated by hatch build.
|
||||||
__version__ = {version!r}
|
__version__ = {version!r}
|
||||||
"""
|
"""
|
||||||
|
only-packages = true
|
||||||
targets.sdist.include = [
|
targets.sdist.include = [
|
||||||
"/cista",
|
"/cista",
|
||||||
]
|
]
|
||||||
|
|||||||
12
scripts/build-frontend.py
Normal file
12
scripts/build-frontend.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# noqa: INP001
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
|
|
||||||
|
|
||||||
|
class CustomBuildHook(BuildHookInterface):
|
||||||
|
def initialize(self, version, build_data):
|
||||||
|
super().initialize(version, build_data)
|
||||||
|
print("Building Cista frontend...")
|
||||||
|
subprocess.run("npm install --prefix frontend".split(" "), check=True) # noqa: S603
|
||||||
|
subprocess.run("npm run build --prefix frontend".split(" "), check=True) # noqa: S603
|
||||||
Reference in New Issue
Block a user