Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c40c245ce6 | ||
|
|
1fdd00b833 | ||
|
|
520a9dff47 | ||
|
|
c5c65d136a | ||
|
|
61f9026e23 | ||
|
|
3e50149d4d | ||
|
|
7077b21159 | ||
|
|
938c5ca657 | ||
|
|
e0aef07783 | ||
|
|
36826a83c1 | ||
|
|
6880f82c19 | ||
|
|
5dd1bd9bdc | ||
|
|
41e8c78ecd | ||
|
|
dc4bb494f3 | ||
|
|
9b58b887b4 | ||
|
|
07848907f3 | ||
|
|
7a08f7cbe2 | ||
|
|
dd37238510 | ||
|
|
c8d5f335b1 | ||
|
|
bb80b3ee54 | ||
|
|
06d860c601 | ||
|
|
c321de13fd | ||
|
|
278e8303c4 |
115
README.md
115
README.md
@@ -1,27 +1,116 @@
|
||||
# Web File Storage
|
||||
# Cista Web Storage
|
||||
|
||||
Run directly from repository with Hatch (or use pip install as usual):
|
||||
<img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.jpg" align=right width=250>
|
||||
|
||||
```sh
|
||||
hatch run cista -l :3000 /path/to/files
|
||||
Cista takes its name from the ancient cistae, metal containers used by Greeks and Egyptians to safeguard valuable items. This modern application provides a browser interface for secure and accessible file storage, echoing the trust and reliability of its historical namesake.
|
||||
|
||||
This is a cutting-edge **file and document server** designed for speed, efficiency, and unparalleled ease of use. Experience **lightning-fast browsing**, thanks to the file list maintained directly in your browser and updated from server filesystem events, coupled with our highly optimized code. Fully **keyboard-navigable** and with a responsive layout, Cista flawlessly adapts to your devices, providing a seamless experience wherever you are. Our powerful **instant search** means you're always just a few keystrokes away from finding exactly what you need. Press **1/2/3** to switch ordering, navigate with all four arrow keys (+Shift to select). Or click your way around on **breadcrumbs that remember where you were**.
|
||||
|
||||
The Cista project started as an inevitable remake of [Droppy](https://github.com/droppyjs/droppy) which we used and loved despite its numerous bugs. Cista Storage stands out in handling even the most exotic filenames, ensuring a smooth experience where others falter.
|
||||
|
||||
All of this is wrapped in an intuitive interface with automatic light and dark themes, making Cista Storage the ideal choice for anyone seeking a reliable, versatile, and quick file storage solution. Quickly setup your own Cista where your files are just a click away, safe, and always accessible.
|
||||
|
||||
Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and perhaps upload something...
|
||||
|
||||
|
||||
## Getting Started
|
||||
### Installation
|
||||
|
||||
To install the cista application, use:
|
||||
|
||||
```fish
|
||||
pip install cista
|
||||
```
|
||||
|
||||
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).
|
||||
Note: Some Linux distributions might need `--break-system-packages` to install Python packages, which are safely installed in the user's home folder. As an alternative to avoid installation, run it with command `pipx run cista`
|
||||
|
||||
Create your user account:
|
||||
### Running the Server
|
||||
|
||||
```sh
|
||||
hatch run cista --user admin --privileged
|
||||
Create an account: (or run a public server without authentication)
|
||||
```fish
|
||||
cista --user yourname --privileged
|
||||
```
|
||||
|
||||
## Build frontend
|
||||
Serve your files at http://localhost:8000:
|
||||
```fish
|
||||
cista -l :8000 /path/to/files
|
||||
```
|
||||
|
||||
Frontend needs to be built before using and after any frontend changes:
|
||||
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
|
||||
|
||||
```sh
|
||||
### Internet Access
|
||||
|
||||
To use your own TLS certificates, place them in the config folder and run:
|
||||
|
||||
```fish
|
||||
cista -l cista.example.com
|
||||
```
|
||||
|
||||
Most admins instead find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address. Caddy configuration **/etc/caddy/Caddyfile** is dead simple:
|
||||
|
||||
```Caddyfile
|
||||
cista.example.com {
|
||||
reverse_proxy :8000
|
||||
}
|
||||
```
|
||||
|
||||
## Development setup
|
||||
|
||||
For rapid development, we use the Vite development server for the Vue frontend, while running the backend on port 8000 that Vite proxies backend requests to. Each server live reloads whenever its code or configuration are modified.
|
||||
|
||||
```fish
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
npm run dev
|
||||
```
|
||||
|
||||
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`.
|
||||
Concurrently, start the backend on another terminal:
|
||||
|
||||
```fish
|
||||
hatch shell
|
||||
pip install -e '.[dev]'
|
||||
cista --dev -l :8000 /path/to/files
|
||||
```
|
||||
|
||||
We use `hatch shell` for installing on a virtual environment, to not disturb the rest of the system with our hacking.
|
||||
|
||||
Vue is used to build files in `cista/wwwroot`, included prebuilt in the Python package. Running `hatch build` builds the frontend and creates a NodeJS-independent Python package.
|
||||
|
||||
## System Deployment
|
||||
|
||||
This setup allows easy addition of storages, each with its own domain, configuration, and files.
|
||||
|
||||
Assuming a restricted user account **storage** for serving files and that cista is installed system-wide or on this account (check with `sudo -u storage -s`). Alternatively, use `pipx run cista` or `hatch run cista` as the ExecStart command.
|
||||
|
||||
Create **/etc/systemd/system/cista@.service**:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Cista storage %i
|
||||
|
||||
[Service]
|
||||
User=storage
|
||||
ExecStart=cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/@%i/
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
This setup supports multiple storages, each under `/media/storage/<domain>` for files and `/srv/cista/<domain>/` for configuration. UNIX sockets are used instead of numeric ports for convenience.
|
||||
|
||||
```fish
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now cista@foo.example.com
|
||||
systemctl enable --now cista@bar.example.com
|
||||
```
|
||||
|
||||
Public exposure is easiest using the Caddy web server, but Nginx or others also work. Run the server with -l domain.example.com if you have TLS certificates in the config folder.
|
||||
|
||||
**/etc/caddy/Caddyfile**:
|
||||
|
||||
```Caddyfile
|
||||
foo.example.com, bar.example.com {
|
||||
reverse_proxy unix//srv/cista/{host}/socket
|
||||
}
|
||||
```
|
||||
|
||||
@@ -62,12 +62,15 @@ def _main():
|
||||
_confdir(args)
|
||||
exists = config.conffile.exists()
|
||||
import_droppy = args["--import-droppy"]
|
||||
necessary_opts = exists or import_droppy or path and listen
|
||||
necessary_opts = exists or import_droppy or path
|
||||
if not necessary_opts:
|
||||
# Maybe run without arguments
|
||||
print(doc)
|
||||
print(
|
||||
"No config file found! Get started with:\n cista -l :8000 /path/to/files, or\n cista -l example.com --import-droppy # Uses Droppy files\n",
|
||||
"No config file found! Get started with one of:\n"
|
||||
" cista --user yourname --privileged\n"
|
||||
" cista --import-droppy\n"
|
||||
" cista -l :8000 /path/to/files\n"
|
||||
)
|
||||
return 1
|
||||
settings = {}
|
||||
@@ -79,8 +82,15 @@ def _main():
|
||||
settings = droppy.readconf()
|
||||
if path:
|
||||
settings["path"] = path
|
||||
elif not exists:
|
||||
settings["path"] = Path.home() / "Downloads"
|
||||
if listen:
|
||||
settings["listen"] = listen
|
||||
elif not exists:
|
||||
settings["listen"] = ":8000"
|
||||
if not exists and not import_droppy:
|
||||
# We have no users, so make it public
|
||||
settings["public"] = True
|
||||
operation = config.update_config(settings)
|
||||
print(f"Config {operation}: {config.conffile}")
|
||||
# Prepare to serve
|
||||
@@ -112,11 +122,23 @@ def _confdir(args):
|
||||
|
||||
def _user(args):
|
||||
_confdir(args)
|
||||
config.load_config()
|
||||
if config.conffile.exists():
|
||||
config.load_config()
|
||||
operation = False
|
||||
else:
|
||||
# Defaults for new config when user is created
|
||||
operation = config.update_config(
|
||||
{
|
||||
"listen": ":8000",
|
||||
"path": Path.home() / "Downloads",
|
||||
"public": False,
|
||||
}
|
||||
)
|
||||
print(f"Config {operation}: {config.conffile}\n")
|
||||
|
||||
name = args["--user"]
|
||||
if not name or not name.isidentifier():
|
||||
raise ValueError("Invalid username")
|
||||
config.load_config()
|
||||
u = config.config.users.get(name)
|
||||
info = f"User {name}" if u else f"New user {name}"
|
||||
changes = {}
|
||||
@@ -128,12 +150,17 @@ def _user(args):
|
||||
info += " (admin)" if oldadmin else ""
|
||||
if args["--password"] or not u:
|
||||
changes["password"] = pw = pwgen.generate()
|
||||
info += f"\n Password: {pw}"
|
||||
res = config.update_user(args["--user"], changes)
|
||||
info += f"\n Password: {pw}\n"
|
||||
res = config.update_user(name, changes)
|
||||
print(info)
|
||||
if res == "read":
|
||||
print(" No changes")
|
||||
|
||||
if operation == "created":
|
||||
print(
|
||||
"Now you can run the server:\n cista # defaults set: -l :8000 ~/Downloads\n"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
13
cista/api.py
13
cista/api.py
@@ -37,16 +37,23 @@ async def upload(req, ws):
|
||||
)
|
||||
req = msgspec.json.decode(text, type=FileRange)
|
||||
pos = req.start
|
||||
data = None
|
||||
while pos < req.end and (data := await ws.recv()) and isinstance(data, bytes):
|
||||
while True:
|
||||
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))
|
||||
pos += typing.cast(int, sentsize)
|
||||
if pos >= req.end:
|
||||
break
|
||||
if pos != req.end:
|
||||
d = f"{len(data)} bytes" if isinstance(data, bytes) else data
|
||||
raise ValueError(f"Expected {req.end - pos} more bytes, got {d}")
|
||||
# Report success
|
||||
res = StatusMsg(status="ack", req=req)
|
||||
print("ack", res)
|
||||
await asend(ws, res)
|
||||
|
||||
|
||||
|
||||
19
cista/app.py
19
cista/app.py
@@ -11,7 +11,7 @@ import brotli
|
||||
import sanic.helpers
|
||||
from blake3 import blake3
|
||||
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
|
||||
|
||||
@@ -120,10 +120,19 @@ def _load_wwwroot(www):
|
||||
br = False
|
||||
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,
|
||||
msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
|
||||
if not www:
|
||||
logging.warning(msg)
|
||||
if not app.debug:
|
||||
msg = "Web frontend missing. Cista installation is broken.\n"
|
||||
wwwnew[""] = (
|
||||
msg.encode(),
|
||||
False,
|
||||
{
|
||||
"etag": "error",
|
||||
"content-type": "text/plain",
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
)
|
||||
return wwwnew
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import sys
|
||||
from functools import wraps
|
||||
from hashlib import sha256
|
||||
from pathlib import Path, PurePath
|
||||
@@ -90,6 +91,8 @@ def config_update(modify):
|
||||
return "read"
|
||||
f.write(new)
|
||||
f.close()
|
||||
if sys.platform == "win32":
|
||||
conffile.unlink() # Windows doesn't support atomic replace
|
||||
tmpname.rename(conffile) # Atomic replace
|
||||
except:
|
||||
f.close()
|
||||
@@ -135,7 +138,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
|
||||
# Encode into dict, update values with new, convert to Config
|
||||
try:
|
||||
u = conf.users[name].__copy__()
|
||||
except KeyError:
|
||||
except (KeyError, AttributeError):
|
||||
u = User()
|
||||
if "password" in changes:
|
||||
from . import auth
|
||||
@@ -144,7 +147,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
|
||||
del changes["password"]
|
||||
udict = msgspec.to_builtins(u, enc_hook=enc_hook)
|
||||
udict.update(changes)
|
||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook) if conf else {"users": {}}
|
||||
settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook)
|
||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||
|
||||
|
||||
@@ -34,9 +34,11 @@ class File:
|
||||
self.open_rw()
|
||||
assert self.fd is not None
|
||||
if file_size is not None:
|
||||
assert pos + len(buffer) <= file_size
|
||||
os.ftruncate(self.fd, file_size)
|
||||
os.lseek(self.fd, pos, os.SEEK_SET)
|
||||
os.write(self.fd, buffer)
|
||||
if buffer:
|
||||
os.lseek(self.fd, pos, os.SEEK_SET)
|
||||
os.write(self.fd, buffer)
|
||||
|
||||
def __getitem__(self, slice):
|
||||
if self.fd is None:
|
||||
|
||||
@@ -149,15 +149,3 @@ class Space(msgspec.Struct):
|
||||
free: int
|
||||
usage: 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 re
|
||||
from pathlib import Path, PurePath
|
||||
from pathlib import Path
|
||||
|
||||
from sanic import Sanic
|
||||
|
||||
@@ -15,7 +15,6 @@ def run(*, dev=False):
|
||||
# Silence Sanic's warning about running in production rather than debug
|
||||
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
|
||||
confdir = config.conffile.parent
|
||||
wwwroot = PurePath(__file__).parent / "wwwroot"
|
||||
if opts.get("ssl"):
|
||||
# Run plain HTTP redirect/acme server on port 80
|
||||
server80.app.prepare(port=80, motd=False)
|
||||
@@ -27,7 +26,7 @@ def run(*, dev=False):
|
||||
motd=False,
|
||||
dev=dev,
|
||||
auto_reload=dev,
|
||||
reload_dir={confdir, wwwroot},
|
||||
reload_dir={confdir},
|
||||
access_log=True,
|
||||
) # type: ignore
|
||||
if dev:
|
||||
|
||||
@@ -110,26 +110,6 @@ class State:
|
||||
with self.lock:
|
||||
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()
|
||||
rootpath: Path = None # type: ignore
|
||||
@@ -149,7 +129,7 @@ def watcher_thread(loop):
|
||||
global rootpath
|
||||
import inotify.adapters
|
||||
|
||||
while True:
|
||||
while not quit:
|
||||
rootpath = config.config.path
|
||||
i = inotify.adapters.InotifyTree(rootpath.as_posix())
|
||||
# Initialize the tree from filesystem
|
||||
@@ -160,8 +140,8 @@ def watcher_thread(loop):
|
||||
state.root = new
|
||||
broadcast(format_update(old, new), loop)
|
||||
|
||||
# The watching is not entirely reliable, so do a full refresh every minute
|
||||
refreshdl = time.monotonic() + 60.0
|
||||
# The watching is not entirely reliable, so do a full refresh every 30 seconds
|
||||
refreshdl = time.monotonic() + 30.0
|
||||
|
||||
for event in i.event_gen():
|
||||
if quit:
|
||||
@@ -238,11 +218,15 @@ def _walk(rel: PurePosixPath, isfile: int, st: stat_result) -> list[FileEntry]:
|
||||
try:
|
||||
li = []
|
||||
for f in path.iterdir():
|
||||
if quit:
|
||||
raise SystemExit("quit")
|
||||
if f.name.startswith("."):
|
||||
continue # No dotfiles
|
||||
s = f.stat()
|
||||
li.append((int(not stat.S_ISDIR(s.st_mode)), f.name, s))
|
||||
for [isfile, name, s] in humansorted(li):
|
||||
if quit:
|
||||
raise SystemExit("quit")
|
||||
subtree = _walk(rel / name, isfile, s)
|
||||
child = subtree[0]
|
||||
entry.mtime = max(entry.mtime, child.mtime)
|
||||
@@ -337,7 +321,7 @@ async def abroadcast(msg):
|
||||
|
||||
async def start(app, loop):
|
||||
config.load_config()
|
||||
use_inotify = False and sys.platform == "linux"
|
||||
use_inotify = sys.platform == "linux"
|
||||
app.ctx.watcher = threading.Thread(
|
||||
target=watcher_thread if use_inotify else watcher_thread_poll,
|
||||
args=[loop],
|
||||
|
||||
BIN
docs/cista.jpg
Normal file
BIN
docs/cista.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@@ -4,7 +4,7 @@
|
||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
||||
<HeaderSelected :path="path.pathList" />
|
||||
</HeaderMain>
|
||||
<BreadCrumb :path="path.pathList" tabindex="-1"/>
|
||||
<BreadCrumb :path="path.pathList" primary />
|
||||
</header>
|
||||
<main>
|
||||
<RouterView :path="path.pathList" :query="path.query" />
|
||||
@@ -17,7 +17,7 @@ import type { ComputedRef } from 'vue'
|
||||
import type HeaderMain from '@/components/HeaderMain.vue'
|
||||
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import Router from '@/router/index'
|
||||
@@ -27,7 +27,7 @@ interface Path {
|
||||
pathList: string[]
|
||||
query: string
|
||||
}
|
||||
const documentStore = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
const path: ComputedRef<Path> = computed(() => {
|
||||
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
|
||||
const pathList = p[0].split('/').filter(value => value !== '')
|
||||
@@ -39,20 +39,19 @@ const path: ComputedRef<Path> = computed(() => {
|
||||
}
|
||||
})
|
||||
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(watchConnect)
|
||||
onUnmounted(watchDisconnect)
|
||||
// Update human-readable x seconds ago messages from mtimes
|
||||
setInterval(documentStore.updateModified, 1000)
|
||||
const headerMain = ref<typeof HeaderMain | null>(null)
|
||||
let vert = 0
|
||||
let timer: any = null
|
||||
const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
const fileExplorer = documentStore.fileExplorer as any
|
||||
const fileExplorer = store.fileExplorer as any
|
||||
if (!fileExplorer) return
|
||||
const c = fileExplorer.isCursor()
|
||||
const input = (event.target as HTMLElement).tagName === 'INPUT'
|
||||
const keyup = event.type === 'keyup'
|
||||
if (event.repeat) {
|
||||
if (
|
||||
@@ -72,13 +71,21 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
||||
headerMain.value!.toggleSearchInput()
|
||||
}
|
||||
// Search also on / (UNIX style)
|
||||
else if (keyup && !input && event.key === '/') {
|
||||
headerMain.value!.toggleSearchInput()
|
||||
}
|
||||
// Globally close search on Escape
|
||||
else if (keyup && event.key === 'Escape') {
|
||||
headerMain.value!.closeSearch(event)
|
||||
}
|
||||
// Select all (toggle); keydown to prevent builtin
|
||||
else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
|
||||
fileExplorer.toggleSelectAll()
|
||||
}
|
||||
// Keys 1-3 to sort columns
|
||||
else if (
|
||||
c &&
|
||||
!input &&
|
||||
keyup &&
|
||||
(event.key === '1' || event.key === '2' || event.key === '3')
|
||||
) {
|
||||
@@ -126,3 +133,4 @@ onUnmounted(() => {
|
||||
})
|
||||
export type { Path }
|
||||
</script>
|
||||
@/stores/main
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
aria-label="Breadcrumb"
|
||||
@keyup.left.stop="move(-1)"
|
||||
@keyup.right.stop="move(1)"
|
||||
@focus="move(0)"
|
||||
@keyup.enter="move(0)"
|
||||
@focus=focusCurrent
|
||||
tabindex=0
|
||||
>
|
||||
<a href="#/"
|
||||
:ref="el => setLinkRef(0, el)"
|
||||
@@ -26,7 +28,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import home from '@/assets/svg/home.svg'
|
||||
import { onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -37,20 +39,33 @@ onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home
|
||||
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
primary?: boolean
|
||||
}>()
|
||||
|
||||
const longest = ref<Array<string>>([])
|
||||
|
||||
const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined
|
||||
|
||||
const focusCurrent = () => {
|
||||
nextTick(() => {
|
||||
const index = props.path.length
|
||||
if (index < links.length) links[index].focus()
|
||||
})
|
||||
}
|
||||
|
||||
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 url = index ? `/${longest.value.slice(0, index).join('/')}/` : '/'
|
||||
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
|
||||
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0])
|
||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
// Clicking on current link clears the rest of the path and adds new history
|
||||
if (isCurrent(index)) { longest.value.splice(index); router.push(u) }
|
||||
// Moving along breadcrumbs doesn't create new history
|
||||
else if (long.startsWith(browser)) router.replace(u)
|
||||
// Nornal navigation from elsewhere (e.g. search result breadcrumbs)
|
||||
else router.push(u)
|
||||
}
|
||||
|
||||
const move = (dir: number) => {
|
||||
@@ -62,13 +77,16 @@ const move = (dir: number) => {
|
||||
watchEffect(() => {
|
||||
const longcut = longest.value.slice(0, props.path.length)
|
||||
const same = longcut.every((value, index) => value === props.path[index])
|
||||
// Navigated out of previous path, reset longest to current
|
||||
if (!same) longest.value = props.path
|
||||
else if (props.path.length > longcut.length) {
|
||||
longest.value = longcut.concat(props.path.slice(longcut.length))
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (links.length) navigate(props.path.length)
|
||||
// If needed, focus primary navigation to new location
|
||||
if (props.primary) nextTick(() => {
|
||||
const act = document.activeElement as HTMLElement
|
||||
if (!act || [...links, document.body].includes(act)) focusCurrent()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<th class="selection">
|
||||
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
|
||||
</th>
|
||||
<th class="sortcolumn" :class="{ sortactive: sort === 'name' }" @click="toggleSort('name')">Name</th>
|
||||
<th class="sortcolumn modified right" :class="{ sortactive: sort === 'modified' }" @click="toggleSort('modified')">Modified</th>
|
||||
<th class="sortcolumn size right" :class="{ sortactive: sort === 'size' }" @click="toggleSort('size')">Size</th>
|
||||
<th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
|
||||
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
|
||||
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
|
||||
<th class="menu"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -17,11 +17,11 @@
|
||||
<td class="name">
|
||||
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
|
||||
</td>
|
||||
<FileModified :doc=editing />
|
||||
<FileModified :doc=editing :key=nowkey />
|
||||
<FileSize :doc=editing />
|
||||
<td class="menu"></td>
|
||||
</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)">
|
||||
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
|
||||
</tr>
|
||||
@@ -36,11 +36,11 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
tabindex="-1"
|
||||
:checked="documentStore.selected.has(doc.key)"
|
||||
:checked="store.selected.has(doc.key)"
|
||||
@change="
|
||||
($event.target as HTMLInputElement).checked
|
||||
? documentStore.selected.add(doc.key)
|
||||
: documentStore.selected.delete(doc.key)
|
||||
? store.selected.add(doc.key)
|
||||
: store.selected.delete(doc.key)
|
||||
"
|
||||
/>
|
||||
</td>
|
||||
@@ -50,7 +50,7 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<a
|
||||
:href="url_for(doc)"
|
||||
:href="doc.url"
|
||||
tabindex="-1"
|
||||
@contextmenu.prevent
|
||||
@focus.stop="cursor = doc"
|
||||
@@ -58,13 +58,13 @@
|
||||
@keyup.right.stop="ev => { if (doc.dir) (ev.target as HTMLElement).click() }"
|
||||
>{{ doc.name }}</a
|
||||
>
|
||||
<button v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||
<button tabindex=-1 v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||
</template>
|
||||
</td>
|
||||
<FileModified :doc=doc />
|
||||
<FileModified :doc=doc :key=nowkey />
|
||||
<FileSize :doc=doc />
|
||||
<td class="menu">
|
||||
<button tabindex="-1" @click.stop="contextMenu($event, doc)">⋮</button>
|
||||
<button tabindex=-1 @click.stop="contextMenu($event, doc)">⋮</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -79,28 +79,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watchEffect } from 'vue'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import type { Document } from '@/repositories/Document'
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import FileRenameInput from './FileRenameInput.vue'
|
||||
import { connect, controlUrl } from '@/repositories/WS'
|
||||
import { collator, formatSize, formatUnixDate } from '@/utils'
|
||||
import { formatSize } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import type { SortOrder } from '@/utils/docsort'
|
||||
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
documents: Document[]
|
||||
documents: Doc[]
|
||||
}>()
|
||||
const documentStore = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
const router = useRouter()
|
||||
const url_for = (doc: Document) => {
|
||||
const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
return doc.dir ? `#/${p}/` : `/files/${p}`
|
||||
}
|
||||
const cursor = ref<Document | null>(null)
|
||||
const cursor = shallowRef<Doc | null>(null)
|
||||
// File rename
|
||||
const editing = ref<Document | null>(null)
|
||||
const rename = (doc: Document, newName: string) => {
|
||||
const editing = shallowRef<Doc | null>(null)
|
||||
const rename = (doc: Doc, newName: string) => {
|
||||
const oldName = doc.name
|
||||
const control = connect(controlUrl, {
|
||||
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
|
||||
}
|
||||
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({
|
||||
newFolder() {
|
||||
const now = Date.now() / 1000
|
||||
editing.value = {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
editing.value = new Doc({
|
||||
loc: loc.value,
|
||||
key: 'new',
|
||||
name: 'New Folder',
|
||||
dir: true,
|
||||
mtime: now,
|
||||
size: 0,
|
||||
sizedisp: formatSize(0),
|
||||
modified: formatUnixDate(now),
|
||||
haystack: '',
|
||||
}
|
||||
console.log("New")
|
||||
})
|
||||
},
|
||||
toggleSelectAll() {
|
||||
console.log('Select')
|
||||
allSelected.value = !allSelected.value
|
||||
},
|
||||
toggleSortColumn(column: number) {
|
||||
const columns = ['', 'name', 'modified', 'size', '']
|
||||
toggleSort(columns[column])
|
||||
const order = ['', 'name', 'modified', 'size', ''][column]
|
||||
if (order) store.toggleSort(order as SortOrder)
|
||||
},
|
||||
isCursor() {
|
||||
return cursor.value !== null && editing.value === null
|
||||
@@ -163,36 +151,36 @@ defineExpose({
|
||||
cursorSelect() {
|
||||
const doc = cursor.value
|
||||
if (!doc) return
|
||||
if (documentStore.selected.has(doc.key)) {
|
||||
documentStore.selected.delete(doc.key)
|
||||
if (store.selected.has(doc.key)) {
|
||||
store.selected.delete(doc.key)
|
||||
} else {
|
||||
documentStore.selected.add(doc.key)
|
||||
store.selected.add(doc.key)
|
||||
}
|
||||
this.cursorMove(1)
|
||||
},
|
||||
cursorMove(d: number, select = false) {
|
||||
// Move cursor up or down (keyboard navigation)
|
||||
const documents = sortedDocuments.value
|
||||
if (documents.length === 0) {
|
||||
const docs = props.documents
|
||||
if (docs.length === 0) {
|
||||
cursor.value = null
|
||||
return
|
||||
}
|
||||
const N = documents.length
|
||||
const N = docs.length
|
||||
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||
const index =
|
||||
cursor.value !== null ? documents.indexOf(cursor.value) : documents.length
|
||||
cursor.value !== null ? docs.indexOf(cursor.value) : docs.length
|
||||
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
|
||||
if (select) {
|
||||
// Go forwards, possibly wrapping over the end; the last entry is not toggled
|
||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||
if (p === N) continue
|
||||
const key = documents[p].key
|
||||
if (documentStore.selected.has(key)) documentStore.selected.delete(key)
|
||||
else documentStore.selected.add(key)
|
||||
const key = docs[p].key
|
||||
if (store.selected.has(key)) store.selected.delete(key)
|
||||
else store.selected.add(key)
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
@@ -229,7 +217,14 @@ watchEffect(() => {
|
||||
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, {
|
||||
open() {
|
||||
control.send(
|
||||
@@ -246,34 +241,24 @@ const mkdir = (doc: Document, name: string) => {
|
||||
editing.value = null
|
||||
} else {
|
||||
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()
|
||||
}
|
||||
|
||||
// Column sort
|
||||
const toggleSort = (name: string) => {
|
||||
sort.value = sort.value === name ? '' : name
|
||||
}
|
||||
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 showFolderBreadcrumb = (i: number) => {
|
||||
const docs = props.documents
|
||||
const docloc = docs[i].loc
|
||||
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
|
||||
}
|
||||
const selectionIndeterminate = computed({
|
||||
get: () => {
|
||||
return (
|
||||
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
|
||||
)
|
||||
},
|
||||
@@ -284,16 +269,16 @@ const allSelected = computed({
|
||||
get: () => {
|
||||
return (
|
||||
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) => {
|
||||
console.log('Setting allSelected', value)
|
||||
for (const doc of props.documents) {
|
||||
if (value) {
|
||||
documentStore.selected.add(doc.key)
|
||||
store.selected.add(doc.key)
|
||||
} 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 contextMenu = (ev: Event, doc: Document) => {
|
||||
const contextMenu = (ev: MouseEvent, doc: 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>
|
||||
|
||||
@@ -451,3 +440,4 @@ tbody .selection input {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@/stores/main
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Document } from '@/repositories/Document'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const datetime = computed(() =>
|
||||
@@ -17,6 +17,6 @@ const tooltip = computed(() =>
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Document
|
||||
doc: Doc
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Document } from '@/repositories/Document'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
@@ -28,8 +28,8 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Document
|
||||
rename: (doc: Document, newName: string) => void
|
||||
doc: Doc
|
||||
rename: (doc: Doc, newName: string) => void
|
||||
exit: () => void
|
||||
}>()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Document } from '@/repositories/Document'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
@@ -12,7 +12,7 @@ const sizeClass = computed(() => {
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Document
|
||||
doc: Doc
|
||||
}>()
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<nav class="headermain">
|
||||
<div class="buttons">
|
||||
<template v-if="documentStore.error">
|
||||
<div class="error-message" @click="documentStore.error = ''">{{ documentStore.error }}</div>
|
||||
<template v-if="store.error">
|
||||
<div class="error-message" @click="store.error = ''">{{ store.error }}</div>
|
||||
<div class="smallgap"></div>
|
||||
</template>
|
||||
<UploadButton :path="props.path" />
|
||||
<SvgButton
|
||||
name="create-folder"
|
||||
data-tooltip="New folder"
|
||||
@click="() => documentStore.fileExplorer!.newFolder()"
|
||||
@click="() => store.fileExplorer!.newFolder()"
|
||||
/>
|
||||
<slot></slot>
|
||||
<div class="spacer smallgap"></div>
|
||||
@@ -18,11 +18,9 @@
|
||||
ref="search"
|
||||
type="search"
|
||||
:value="query"
|
||||
@blur="ev => { if (!query) closeSearch(ev) }"
|
||||
@input="updateSearch"
|
||||
placeholder="Search words"
|
||||
class="margin-input"
|
||||
@keyup.escape="closeSearch"
|
||||
/>
|
||||
</template>
|
||||
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
|
||||
@@ -32,15 +30,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { ref, nextTick, watchEffect } from 'vue'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import router from '@/router';
|
||||
|
||||
const documentStore = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
const showSearchInput = ref<boolean>(false)
|
||||
const search = ref<HTMLInputElement | null>()
|
||||
const searchButton = ref<HTMLButtonElement | null>()
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
query: string
|
||||
}>()
|
||||
|
||||
const closeSearch = (ev: Event) => {
|
||||
if (!showSearchInput.value) return // Already closing
|
||||
@@ -54,9 +56,9 @@ const updateSearch = (ev: Event) => {
|
||||
let p = props.path.join('/')
|
||||
p = p ? `/${p}` : ''
|
||||
const url = q ? `${p}//${q}` : (p || '/')
|
||||
console.log("Update search", url)
|
||||
if (!props.query && q) router.push(url)
|
||||
else router.replace(url)
|
||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
if (!props.query && q) router.push(u)
|
||||
else router.replace(u)
|
||||
}
|
||||
const toggleSearchInput = (ev: Event) => {
|
||||
showSearchInput.value = !showSearchInput.value
|
||||
@@ -72,10 +74,10 @@ watchEffect(() => {
|
||||
const settingsMenu = (e: Event) => {
|
||||
// show the context menu
|
||||
const items = []
|
||||
if (documentStore.user.isLoggedIn) {
|
||||
items.push({ label: `Logout ${documentStore.user.username ?? ''}`, onClick: () => documentStore.logout() })
|
||||
if (store.user.isLoggedIn) {
|
||||
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
|
||||
} else {
|
||||
items.push({ label: 'Login', onClick: () => documentStore.loginDialog() })
|
||||
items.push({ label: 'Login', onClick: () => store.loginDialog() })
|
||||
}
|
||||
ContextMenu.showContextMenu({
|
||||
// @ts-ignore
|
||||
@@ -83,11 +85,6 @@ const settingsMenu = (e: Event) => {
|
||||
items,
|
||||
})
|
||||
}
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
query: string
|
||||
}>()
|
||||
|
||||
defineExpose({
|
||||
toggleSearchInput,
|
||||
closeSearch,
|
||||
@@ -116,3 +113,4 @@ input[type='search'] {
|
||||
max-width: 30vw;
|
||||
}
|
||||
</style>
|
||||
@/stores/main
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
<template>
|
||||
<template v-if="documentStore.selected.size">
|
||||
<template v-if="store.selected.size">
|
||||
<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="copy" data-tooltip="Copy here" @click="op('cp', dst)" />
|
||||
<SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" />
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {connect, controlUrl} from '@/repositories/WS'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { computed } from 'vue'
|
||||
import type { SelectedItems } from '@/repositories/Document'
|
||||
|
||||
const documentStore = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
const props = defineProps({
|
||||
path: Array<string>
|
||||
})
|
||||
|
||||
const dst = computed(() => props.path!.join('/'))
|
||||
const op = (op: string, dst?: string) => {
|
||||
const sel = documentStore.selectedFiles
|
||||
const sel = store.selectedFiles
|
||||
const msg = {
|
||||
op,
|
||||
sel: sel.keys.map(key => {
|
||||
@@ -38,12 +38,12 @@ const op = (op: string, dst?: string) => {
|
||||
const res = JSON.parse(ev.data)
|
||||
if ('error' in res) {
|
||||
console.error('Control socket error', msg, res.error)
|
||||
documentStore.error = res.error.message
|
||||
store.error = res.error.message
|
||||
return
|
||||
} else if (res.status === 'ack') {
|
||||
console.log('Control ack OK', res)
|
||||
control.close()
|
||||
documentStore.selected.clear()
|
||||
store.selected.clear()
|
||||
return
|
||||
} else console.log('Unknown control response', msg, res)
|
||||
}
|
||||
@@ -108,17 +108,17 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
const sel = documentStore.selectedFiles
|
||||
const sel = store.selectedFiles
|
||||
console.log('Download', sel)
|
||||
if (sel.keys.length === 0) {
|
||||
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
|
||||
documentStore.selected.clear()
|
||||
store.selected.clear()
|
||||
return
|
||||
}
|
||||
// Plain old a href download if only one file (ignoring any folders)
|
||||
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
|
||||
if (files.length === 1) {
|
||||
documentStore.selected.clear()
|
||||
store.selected.clear()
|
||||
return linkdl(`/files/${files[0][1]}`)
|
||||
}
|
||||
// Use FileSystem API if multiple files and the browser supports it
|
||||
@@ -130,7 +130,7 @@ const download = async () => {
|
||||
mode: 'readwrite'
|
||||
})
|
||||
filesystemdl(sel, handle).then(() => {
|
||||
documentStore.selected.clear()
|
||||
store.selected.clear()
|
||||
})
|
||||
return
|
||||
} catch (e) {
|
||||
@@ -140,7 +140,7 @@ const download = async () => {
|
||||
// Otherwise, zip and download
|
||||
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
|
||||
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
||||
documentStore.selected.clear()
|
||||
store.selected.clear()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -152,3 +152,4 @@ const download = async () => {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@/stores/main
|
||||
|
||||
@@ -39,10 +39,10 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
import { loginUser } from '@/repositories/User'
|
||||
import type { ISimpleError } from '@/repositories/Client'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
|
||||
const confirmLoading = ref<boolean>(false)
|
||||
const store = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
@@ -99,3 +99,4 @@ const login = async () => {
|
||||
height: 1em;
|
||||
}
|
||||
</style>
|
||||
@/stores/main
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { connect, uploadUrl } from '@/repositories/WS';
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { collator } from '@/utils';
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
|
||||
const fileInput = ref()
|
||||
const folderInput = ref()
|
||||
const documentStore = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
const props = defineProps({
|
||||
path: Array<string>
|
||||
})
|
||||
@@ -16,13 +16,51 @@ type CloudFile = {
|
||||
cloudName: string
|
||||
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) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
// @ts-ignore
|
||||
const infiles = Array.from(event.dataTransfer?.files || event.target.files) as File[]
|
||||
if (!infiles.length) return
|
||||
const input = event.target as HTMLInputElement | null
|
||||
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('/')
|
||||
let files = []
|
||||
for (const file of infiles) {
|
||||
@@ -32,9 +70,12 @@ function uploadHandler(event: Event) {
|
||||
cloudPos: 0,
|
||||
})
|
||||
}
|
||||
uploadCloudFiles(files)
|
||||
}
|
||||
const uploadCloudFiles = (files: CloudFile[]) => {
|
||||
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
|
||||
if (dotfiles.length) {
|
||||
documentStore.error = "Won't upload dotfiles"
|
||||
store.error = "Won't upload dotfiles"
|
||||
console.log("Dotfiles omitted", dotfiles)
|
||||
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
|
||||
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(() => {
|
||||
if (Date.now() - uprogress.tlast > 3000) {
|
||||
// Reset
|
||||
@@ -130,13 +171,13 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
|
||||
open(ev: Event) { resolve(ws) },
|
||||
error(ev: Event) {
|
||||
console.error('Upload socket error', ev)
|
||||
documentStore.error = 'Upload socket error'
|
||||
store.error = 'Upload socket error'
|
||||
},
|
||||
message(ev: MessageEvent) {
|
||||
const res = JSON.parse(ev!.data)
|
||||
if ('error' in res) {
|
||||
console.error('Upload socket error', res.error)
|
||||
documentStore.error = res.error.message
|
||||
store.error = res.error.message
|
||||
return
|
||||
}
|
||||
if (res.status === 'ack') {
|
||||
@@ -165,10 +206,6 @@ const worker = async () => {
|
||||
const ws = await WSCreate()
|
||||
while (upqueue.length) {
|
||||
const f = upqueue[0]
|
||||
if (f.cloudPos === f.file.size) {
|
||||
upqueue.shift()
|
||||
continue
|
||||
}
|
||||
const start = f.cloudPos
|
||||
const end = Math.min(f.file.size, start + (1<<20))
|
||||
const control = { name: f.cloudName, size: f.file.size, start, end }
|
||||
@@ -179,6 +216,7 @@ const worker = async () => {
|
||||
ws.sendMsg(control)
|
||||
// @ts-ignore
|
||||
await ws.sendData(data)
|
||||
if (f.cloudPos === f.file.size) upqueue.shift()
|
||||
}
|
||||
if (upqueue.length) startWorker()
|
||||
uprogress.status = "idle"
|
||||
@@ -196,8 +234,10 @@ onMounted(() => {
|
||||
// Need to prevent both to prevent browser from opening the file
|
||||
addEventListener('dragover', uploadHandler)
|
||||
addEventListener('drop', uploadHandler)
|
||||
addEventListener('paste', pasteHandler)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
removeEventListener('paste', pasteHandler)
|
||||
removeEventListener('dragover', uploadHandler)
|
||||
removeEventListener('drop', uploadHandler)
|
||||
})
|
||||
@@ -219,7 +259,7 @@ onUnmounted(() => {
|
||||
{{ (uprogress.filepos / uprogress.filesize * 100).toFixed(0) + '\u202F%' }}
|
||||
</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' }}
|
||||
</span>
|
||||
<span class="speed">{{ speeddisp }}</span>
|
||||
@@ -262,3 +302,4 @@ span {
|
||||
.position { min-width: 4em }
|
||||
.speed { min-width: 4em }
|
||||
</style>
|
||||
@/stores/main
|
||||
|
||||
@@ -1,17 +1,42 @@
|
||||
import { formatSize, formatUnixDate, haystackFormat } from "@/utils"
|
||||
|
||||
export type FUID = string
|
||||
|
||||
export type Document = {
|
||||
export type DocProps = {
|
||||
loc: string
|
||||
name: string
|
||||
key: FUID
|
||||
size: number
|
||||
sizedisp: string
|
||||
mtime: number
|
||||
modified: string
|
||||
haystack: string
|
||||
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 = {
|
||||
error: {
|
||||
code: number
|
||||
@@ -36,7 +61,7 @@ export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>
|
||||
// Helper structure for selections
|
||||
export interface SelectedItems {
|
||||
keys: FUID[]
|
||||
docs: Record<FUID, Document>
|
||||
recursive: Array<[string, string, Document]>
|
||||
docs: Record<FUID, Doc>
|
||||
recursive: Array<[string, string, Doc]>
|
||||
missing: Set<FUID>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDocumentStore } from "@/stores/documents"
|
||||
import { useMainStore } from "@/stores/main"
|
||||
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
|
||||
|
||||
export const controlUrl = '/api/control'
|
||||
@@ -6,22 +6,26 @@ export const uploadUrl = '/api/upload'
|
||||
export const watchUrl = '/api/watch'
|
||||
|
||||
let tree = [] as FileEntry[]
|
||||
let reconnectDuration = 500
|
||||
let reconnDelay = 500
|
||||
let wsWatch = null as WebSocket | null
|
||||
|
||||
export const loadSession = () => {
|
||||
const store = useDocumentStore()
|
||||
const s = localStorage['cista-files']
|
||||
if (!s) return false
|
||||
const store = useMainStore()
|
||||
try {
|
||||
tree = JSON.parse(sessionStorage["cista-files"])
|
||||
tree = JSON.parse(s)
|
||||
store.updateRoot(tree)
|
||||
console.log(`Loaded session with ${tree.length} items cached`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.log("Loading session failed", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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>>) => {
|
||||
@@ -35,7 +39,7 @@ export const watchConnect = () => {
|
||||
clearTimeout(watchTimeout)
|
||||
watchTimeout = null
|
||||
}
|
||||
const store = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
if (store.error !== 'Reconnecting...') store.error = 'Connecting...'
|
||||
console.log(store.error)
|
||||
|
||||
@@ -59,7 +63,7 @@ export const watchConnect = () => {
|
||||
console.log('Connected to backend', msg)
|
||||
store.server = msg.server
|
||||
store.connected = true
|
||||
reconnectDuration = 500
|
||||
reconnDelay = 500
|
||||
store.error = ''
|
||||
if (msg.user) store.login(msg.user.username, msg.user.privileged)
|
||||
else if (store.isUserLogged) store.logout()
|
||||
@@ -77,16 +81,16 @@ export const watchDisconnect = () => {
|
||||
let watchTimeout: any = null
|
||||
|
||||
const watchReconnect = (event: MessageEvent) => {
|
||||
const store = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
if (store.connected) {
|
||||
console.warn("Disconnected from server", event)
|
||||
store.connected = false
|
||||
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
|
||||
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[] }) {
|
||||
const store = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
console.log('Watch root', root)
|
||||
store.updateRoot(root)
|
||||
tree = root
|
||||
@@ -118,7 +122,7 @@ function handleRootMessage({ root }: { root: FileEntry[] }) {
|
||||
}
|
||||
|
||||
function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
||||
const store = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
const update = updateData.update
|
||||
console.log('Watch update', update)
|
||||
if (!tree) return console.error('Watch update before root')
|
||||
@@ -142,7 +146,7 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
||||
}
|
||||
|
||||
function handleError(msg: errorEvent) {
|
||||
const store = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
if (msg.error.code === 401) {
|
||||
store.user.isOpenLoginModal = true
|
||||
store.user.isLoggedIn = false
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { Document, FileEntry, FUID, SelectedItems } from '@/repositories/Document'
|
||||
import { formatSize, formatUnixDate, haystackFormat } from '@/utils'
|
||||
import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { defineStore } from 'pinia'
|
||||
import { collator } from '@/utils'
|
||||
import { logoutUser } from '@/repositories/User'
|
||||
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 = {
|
||||
username: string
|
||||
privileged: boolean
|
||||
@@ -16,15 +14,20 @@ type User = {
|
||||
isLoggedIn: boolean
|
||||
}
|
||||
|
||||
export const useDocumentStore = defineStore({
|
||||
id: 'documents',
|
||||
export const useMainStore = defineStore({
|
||||
id: 'main',
|
||||
state: () => ({
|
||||
document: [] as Document[],
|
||||
document: shallowRef<Doc[]>([]),
|
||||
selected: new Set<FUID>(),
|
||||
query: '' as string,
|
||||
fileExplorer: null as any,
|
||||
error: '' as string,
|
||||
connected: false,
|
||||
server: {} as Record<string, any>,
|
||||
prefs: {
|
||||
sortListing: '' as SortOrder,
|
||||
sortFiltered: '' as SortOrder,
|
||||
},
|
||||
user: {
|
||||
username: '',
|
||||
privileged: false,
|
||||
@@ -32,29 +35,26 @@ export const useDocumentStore = defineStore({
|
||||
isOpenLoginModal: false
|
||||
} as User
|
||||
}),
|
||||
persist: {
|
||||
paths: ['prefs'],
|
||||
},
|
||||
actions: {
|
||||
updateRoot(root: FileEntry[]) {
|
||||
const docs = []
|
||||
let loc = [] as string[]
|
||||
for (const [level, name, key, mtime, size, isfile] of root) {
|
||||
loc = loc.slice(0, level - 1)
|
||||
docs.push({
|
||||
docs.push(new Doc({
|
||||
name,
|
||||
loc: level ? loc.join('/') : '/',
|
||||
key,
|
||||
size,
|
||||
sizedisp: formatSize(size),
|
||||
mtime,
|
||||
modified: formatUnixDate(mtime),
|
||||
haystack: haystackFormat(name),
|
||||
dir: !isfile,
|
||||
})
|
||||
}))
|
||||
loc.push(name)
|
||||
}
|
||||
this.document = docs as Document[]
|
||||
},
|
||||
updateModified() {
|
||||
for (const doc of this.document) doc.modified = formatUnixDate(doc.mtime)
|
||||
this.document = docs
|
||||
},
|
||||
login(username: string, privileged: boolean) {
|
||||
this.user.username = username
|
||||
@@ -70,23 +70,18 @@ export const useDocumentStore = defineStore({
|
||||
console.log("Logout")
|
||||
await logoutUser()
|
||||
this.$reset()
|
||||
localStorage.clear()
|
||||
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: {
|
||||
isUserLogged(): boolean {
|
||||
return this.user.isLoggedIn
|
||||
},
|
||||
recentDocuments(): Document[] {
|
||||
const ret = [...this.document]
|
||||
ret.sort((a, b) => b.mtime - a.mtime)
|
||||
return ret
|
||||
},
|
||||
largeDocuments(): Document[] {
|
||||
const ret = [...this.document]
|
||||
ret.sort((a, b) => b.size - a.size)
|
||||
return ret
|
||||
},
|
||||
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
|
||||
isUserLogged(): boolean { return this.user.isLoggedIn },
|
||||
recentDocuments(): Doc[] { return sorted(this.document, 'modified') },
|
||||
selectedFiles(): SelectedItems {
|
||||
const selected = this.selected
|
||||
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)
|
||||
// Build a flat list including contents recursively
|
||||
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}`)
|
||||
relnames.add(rel)
|
||||
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
|
||||
export function needleFormat(query: string) {
|
||||
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
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watchEffect, ref, computed } from 'vue'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import Router from '@/router/index'
|
||||
import { needleFormat, localeIncludes, collator } from '@/utils';
|
||||
import { sorted } from '@/utils/docsort';
|
||||
|
||||
const documentStore = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
const fileExplorer = ref()
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
@@ -24,19 +25,25 @@ const documents = computed(() => {
|
||||
const loc = props.path.join('/')
|
||||
const query = props.query
|
||||
// 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
|
||||
const needle = needleFormat(query)
|
||||
let limit = 100
|
||||
let docs = []
|
||||
for (const doc of documentStore.recentDocuments) {
|
||||
for (const doc of store.recentDocuments) {
|
||||
if (localeIncludes(doc.haystack, needle)) {
|
||||
docs.push(doc)
|
||||
if (--limit === 0) break
|
||||
}
|
||||
}
|
||||
// Organize by folder, by relevance
|
||||
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) => (
|
||||
// @ts-ignore
|
||||
(b.loc === loc) - (a.loc === loc) ||
|
||||
@@ -53,6 +60,7 @@ const documents = computed(() => {
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
documentStore.fileExplorer = fileExplorer.value
|
||||
store.fileExplorer = fileExplorer.value
|
||||
store.query = props.query
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -29,7 +29,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = ""
|
||||
Homepage = "https://git.zi.fi/Vasanko/cista-storage"
|
||||
|
||||
[project.scripts]
|
||||
cista = "cista.__main__:main"
|
||||
@@ -40,20 +40,18 @@ dev = [
|
||||
"ruff",
|
||||
]
|
||||
|
||||
[tool.hatchling]
|
||||
# Build frontend
|
||||
pre_build = "npm run build --prefix cista-front"
|
||||
|
||||
[tool.hatch.version]
|
||||
source = "vcs"
|
||||
|
||||
[tool.hatch.build]
|
||||
artifacts = ["cista/wwwroot"]
|
||||
hooks.custom.path = "scripts/build-frontend.py"
|
||||
hooks.vcs.version-file = "cista/_version.py"
|
||||
hooks.vcs.template = """
|
||||
# This file is automatically generated by hatch build.
|
||||
__version__ = {version!r}
|
||||
"""
|
||||
|
||||
only-packages = true
|
||||
targets.sdist.include = [
|
||||
"/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