45 Commits

Author SHA1 Message Date
Leo Vasanko
8dda230510 Add PIL dependency 2023-11-18 11:40:34 -08:00
Leo Vasanko
696e3ab568 Implement media preview thumbnails for Gallery 2023-11-18 11:38:25 -08:00
Leo Vasanko
85ac12ad33 Fix empty folder / folder missing for empty folders at root 2023-11-18 10:22:07 -08:00
Leo Vasanko
e56cc47105 Large number of keyboard navigation and other fixes. 2023-11-18 10:15:13 -08:00
Leo Vasanko
ebbd96bc94 Global shortcut keys tuning. 2023-11-18 07:56:53 -08:00
Leo Vasanko
a9b6d04361 Remove defineProps imports (it is a compiler macro now). 2023-11-18 07:47:55 -08:00
Leo Vasanko
5808fe17ad Fix cursor handling 2023-11-18 07:47:34 -08:00
Leo Vasanko
671359e327 Search placeholder 2023-11-18 07:39:08 -08:00
Leo Vasanko
ba9495eb65 File explorer CSS, avoid modified/size being cut 2023-11-18 07:33:50 -08:00
Leo Vasanko
de482afd60 Improved breadcrumb scaling 2023-11-18 07:00:55 -08:00
Leo Vasanko
a547052e29 Added header eye button for gallery toggle 2023-11-18 07:00:46 -08:00
Leo Vasanko
07c2ff4c15 Keyboard sort by 1-2-3 supplemented by the key left of them for default sort. 2023-11-18 06:32:25 -08:00
Leo Vasanko
e20b04189f Refactoring cursor to be stored in store as key only. A few issues remain. 2023-11-17 19:44:18 -08:00
Leo Vasanko
8da141744e Implemented Gallery view for media files. 2023-11-17 18:32:24 -08:00
Leo Vasanko
11887edde3 Skip any symlinks while scanning. Stats on how long a scan took. 2023-11-17 17:49:35 -08:00
Leo Vasanko
034c6fdea9 Fixed header and breadcrumb layout and issues. 2023-11-17 16:16:53 -08:00
Leo Vasanko
c5083f0f2b Correct error page rendering via Sanic. 2023-11-17 09:20:14 -08:00
Leo Vasanko
f8a9197474 Tidying up log, correct HTTP status for error pages. 2023-11-16 17:10:18 -08:00
Leo Vasanko
5285cb2fb5 Watching cleanup 2023-11-16 09:17:50 -08:00
Leo Vasanko
b6b387d09b Code cleanup and bugfixes:
- Resolve threading deadlock when multiple watch request arrived at the same moment.
- Implement more graceful server exit.
- Reduce excessive logging.
- Fix unix socket clearing; until Sanic starts accepting Path for unix socket name.
2023-11-16 07:09:57 -08:00
Leo Vasanko
669762dfe7 Update documentation README 2023-11-16 07:07:52 -08:00
Leo Vasanko
51fd07d4fa Update frontend/README.md 2023-11-14 23:50:17 +00:00
Leo Vasanko
c40c245ce6 CLI simplification, better diagnostic for missing wwwroot, docs. 2023-11-14 11:05:04 -08:00
Leo Vasanko
1fdd00b833 Updated installation and README 2023-11-14 08:00:21 -08:00
Leo Vasanko
520a9dff47 Keyboard nav: 1/2/3 globally toggle table ordering. Esc closes search, slash also toggles search like Ctrl/Cmd+F does. 2023-11-14 05:55:55 -08:00
Leo Vasanko
c5c65d136a BreadCrumb & keyboard navigation improvements. 2023-11-14 05:43:01 -08:00
Leo Vasanko
61f9026e23 Attempt to fix config handling on Windows 2023-11-13 16:31:35 -08:00
Leo Vasanko
3e50149d4d Add more quit points for watching thread. 2023-11-13 16:28:53 -08:00
Leo Vasanko
7077b21159 Add frontend build to Python packaging. Remove dead code, cleanup. 2023-11-13 16:19:33 -08:00
Leo Vasanko
938c5ca657 Add project URL 2023-11-13 14:59:22 -08:00
Leo Vasanko
e0aef07783 Update README 2023-11-13 14:49:08 -08:00
Leo Vasanko
36826a83c1 Remember sort order 2023-11-13 14:15:28 -08:00
Leo Vasanko
6880f82c19 Add file context menu (only rename for now). 2023-11-13 10:09:12 -08:00
Leo Vasanko
5dd1bd9bdc Add missing file 2023-11-13 09:55:32 -08:00
Leo Vasanko
41e8c78ecd Refactoring Document storage (#5)
- Major refactoring that makes Doc a class with properties
- Data made only shallow reactive, for a good speedup of initial load
- Minor bugfixes and UX improvements along the way
- Fixed handling of hash and question marks in URLs (was confusing Vue Router)
- Search made stricter to find good results (not ignore all punctuation)

Reviewed-on: #5
2023-11-13 17:52:57 +00:00
Leo Vasanko
dc4bb494f3 Use localStoragerather than sessionStorage for cache. Rename variable. 2023-11-13 13:04:39 +00:00
Leo Vasanko
9b58b887b4 Log messages on session loading 2023-11-13 12:17:47 +00:00
Leo Vasanko
07848907f3 Typing error 2023-11-13 12:11:02 +00:00
Leo Vasanko
7a08f7cbe2 Pasteing files and folders to upload. 2023-11-13 03:39:10 -08:00
Leo Vasanko
dd37238510 Update modified immediately when entering a folder 2023-11-13 02:19:13 -08:00
Leo Vasanko
c8d5f335b1 Fix upload of zero-sized files. 2023-11-13 02:13:11 -08:00
Leo Vasanko
bb80b3ee54 Clear file upload input to allow re-uploading the same item. 2023-11-13 01:38:22 -08:00
Leo Vasanko
06d860c601 Only update time-ago modified field on current folder (optimization, full update was slow for large storages). 2023-11-13 00:52:03 -08:00
Leo Vasanko
c321de13fd Don't reload backend on wwwroot changes. 2023-11-13 00:48:45 -08:00
Leo Vasanko
278e8303c4 Upload manager UI fix/tuning. 2023-11-13 00:37:56 -08:00
39 changed files with 1474 additions and 618 deletions

117
README.md
View File

@@ -1,27 +1,118 @@
# 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.webp" align=left 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 but different (sub)domains.
`/etc/caddy/Caddyfile`:
```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 avoid disturbing 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.
`/etc/caddy/Caddyfile`:
```Caddyfile
foo.example.com, bar.example.com {
reverse_proxy unix//srv/cista/{host}/socket
}
```

View File

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

View File

@@ -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)
@@ -104,13 +111,24 @@ async def watch(req, ws):
)
uuid = token_bytes(16)
try:
with watching.state.lock:
q = watching.pubsub[uuid] = asyncio.Queue()
# Init with disk usage and full tree
await ws.send(watching.format_space(watching.state.space))
await ws.send(watching.format_root(watching.state.root))
q, space, root = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, subscribe, uuid, ws
)
await ws.send(space)
await ws.send(root)
# Send updates
while True:
await ws.send(await q.get())
finally:
del watching.pubsub[uuid]
def subscribe(uuid, ws):
with watching.state.lock:
q = watching.pubsub[uuid] = asyncio.Queue()
# Init with disk usage and full tree
return (
q,
watching.format_space(watching.state.space),
watching.format_root(watching.state.root),
)

View File

@@ -1,6 +1,7 @@
import asyncio
import datetime
import mimetypes
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path, PurePath, PurePosixPath
from stat import S_IFDIR, S_IFREG
@@ -11,11 +12,11 @@ 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.log import logging
from sanic.exceptions import Forbidden, NotFound
from sanic.log import logger
from stream_zip import ZIP_AUTO, stream_zip
from cista import auth, config, session, watching
from cista import auth, config, preview, session, watching
from cista.api import bp
from cista.util.apphelpers import handle_sanic_exception
@@ -24,6 +25,7 @@ sanic.helpers._ENTITY_HEADERS = frozenset()
app = Sanic("cista", strict_slashes=True)
app.blueprint(auth.bp)
app.blueprint(preview.bp)
app.blueprint(bp)
app.exception(Exception)(handle_sanic_exception)
@@ -31,14 +33,15 @@ app.exception(Exception)(handle_sanic_exception)
@app.before_server_start
async def main_start(app, loop):
config.load_config()
await watching.start(app, loop)
app.ctx.threadexec = ThreadPoolExecutor(
max_workers=8, thread_name_prefix="cista-ioworker"
)
await watching.start(app, loop)
@app.after_server_stop
async def main_stop(app, loop):
quit.set()
await watching.stop(app, loop)
app.ctx.threadexec.shutdown()
@@ -120,10 +123,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:
logger.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
@@ -132,7 +144,7 @@ def _load_wwwroot(www):
async def start(app):
await load_wwwroot(app)
if app.debug:
app.add_task(refresh_wwwroot())
app.add_task(refresh_wwwroot(), name="refresh_wwwroot")
async def load_wwwroot(app):
@@ -142,27 +154,31 @@ async def load_wwwroot(app):
)
quit = threading.Event()
async def refresh_wwwroot():
while True:
await asyncio.sleep(0.5)
try:
wwwold = www
await load_wwwroot(app)
changes = ""
for name in sorted(www):
attr = www[name]
if wwwold.get(name) == attr:
continue
headers = attr[2]
changes += f"{headers['last-modified']} {headers['etag']} /{name}\n"
for name in sorted(set(wwwold) - set(www)):
changes += f"Deleted /{name}\n"
if changes:
print(f"Updated wwwroot:\n{changes}", end="", flush=True)
except Exception as e:
print("Error loading wwwroot", e)
if not app.debug:
return
try:
while not quit.is_set():
try:
wwwold = www
await load_wwwroot(app)
changes = ""
for name in sorted(www):
attr = www[name]
if wwwold.get(name) == attr:
continue
headers = attr[2]
changes += f"{headers['last-modified']} {headers['etag']} /{name}\n"
for name in sorted(set(wwwold) - set(www)):
changes += f"Deleted /{name}\n"
if changes:
print(f"Updated wwwroot:\n{changes}", end="", flush=True)
except Exception as e:
print(f"Error loading wwwroot: {e!r}")
await asyncio.sleep(0.5)
except asyncio.CancelledError:
pass
@app.route("/<path:path>", methods=["GET", "HEAD"])
@@ -242,7 +258,7 @@ async def zip_download(req, keys, zipfile, ext):
for chunk in stream_zip(local_files(files)):
asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result()
except Exception:
logging.exception("Error streaming ZIP")
logger.exception("Error streaming ZIP")
raise
finally:
asyncio.run_coroutine_threadsafe(queue.put(None), loop)

View File

@@ -71,7 +71,7 @@ def verify(request, *, privileged=False):
raise Forbidden("Access Forbidden: Only for privileged users", quiet=True)
elif config.config.public or request.ctx.user:
return
raise Unauthorized("Login required", "cookie", quiet=True)
raise Unauthorized(f"Login required for {request.path}", "cookie", quiet=True)
bp = Blueprint("auth")

View File

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

View File

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

40
cista/preview.py Normal file
View File

@@ -0,0 +1,40 @@
import asyncio
import io
from pathlib import PurePosixPath
from urllib.parse import unquote
from PIL import Image
from sanic import Blueprint, raw
from sanic.exceptions import Forbidden, NotFound
from cista import config
from cista.util.filename import sanitize
bp = Blueprint("preview", url_prefix="/preview")
@bp.get("/<path:path>")
async def preview(req, path):
"""Preview a file"""
width = int(req.query_string) if req.query_string else 768
rel = PurePosixPath(sanitize(unquote(path)))
path = config.config.path / rel
if not path.is_file():
raise NotFound("File not found")
size = path.lstat().st_size
if size > 20 * 10**6:
raise Forbidden("File too large")
img = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, process_image, path, width
)
return raw(img, content_type="image/webp")
def process_image(path, maxsize):
img = Image.open(path)
w, h = img.size
img.thumbnail((min(w, maxsize), min(h, maxsize)))
# Save as webp
imgdata = io.BytesIO()
img.save(imgdata, format="webp", quality=70, method=6)
return imgdata.getvalue()

View File

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

View File

@@ -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:
@@ -52,7 +51,7 @@ def parse_listen(listen):
raise ValueError(
f"Directory for unix socket does not exist: {unix.parent}/",
)
return "http://localhost", {"unix": unix}
return "http://localhost", {"unix": unix.as_posix()}
if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
try:

View File

@@ -21,7 +21,6 @@ def jres(data, **kwargs):
async def handle_sanic_exception(request, e):
logger.exception(e)
context, code = {}, 500
message = str(e)
if isinstance(e, SanicException):
@@ -42,7 +41,7 @@ async def handle_sanic_exception(request, e):
res.cookies.add_cookie("message", message, max_age=5)
return res
# Otherwise use Sanic's default error page
return errorpages.HTMLRenderer(request, e, debug=request.app.debug).full()
return errorpages.HTMLRenderer(request, e, debug=request.app.debug).render()
def websocket_wrapper(handler):
@@ -54,13 +53,14 @@ def websocket_wrapper(handler):
auth.verify(request)
await handler(request, ws, *args, **kwargs)
except Exception as e:
logger.exception(e)
context, code, message = {}, 500, str(e) or "Internal Server Error"
if isinstance(e, SanicException):
context = e.context or {}
code = e.status_code
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
if not getattr(e, "quiet", False):
logger.exception(f"{code} {e!r}")
raise
return wrapper

View File

@@ -1,15 +1,16 @@
import asyncio
import shutil
import stat
import sys
import threading
import time
from contextlib import suppress
from os import stat_result
from pathlib import Path, PurePosixPath
from stat import S_ISDIR, S_ISREG
import msgspec
from natsort import humansorted, natsort_keygen, ns
from sanic.log import logging
from sanic.log import logger
from cista import config
from cista.fileio import fuid
@@ -110,155 +111,73 @@ 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
quit = False
modified_flags = (
"IN_CREATE",
"IN_DELETE",
"IN_DELETE_SELF",
"IN_MODIFY",
"IN_MOVE_SELF",
"IN_MOVED_FROM",
"IN_MOVED_TO",
)
quit = threading.Event()
## Filesystem scanning
def watcher_thread(loop):
global rootpath
import inotify.adapters
while True:
rootpath = config.config.path
i = inotify.adapters.InotifyTree(rootpath.as_posix())
# Initialize the tree from filesystem
new = walk()
with state.lock:
old = state.root
if old != new:
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
for event in i.event_gen():
if quit:
return
# Disk usage update
du = shutil.disk_usage(rootpath)
space = Space(*du, storage=state.root[0].size)
if space != state.space:
state.space = space
broadcast(format_space(space), loop)
break
# Do a full refresh?
if time.monotonic() > refreshdl:
break
if event is None:
continue
_, flags, path, filename = event
if not any(f in modified_flags for f in flags):
continue
# Update modified path
path = PurePosixPath(path) / filename
try:
update(path.relative_to(rootpath), loop)
except Exception as e:
print("Watching error", e, path, rootpath)
raise
i = None # Free the inotify object
def watcher_thread_poll(loop):
global rootpath
while not quit:
rootpath = config.config.path
new = walk()
with state.lock:
old = state.root
if old != new:
state.root = new
broadcast(format_update(old, new), loop)
# Disk usage update
du = shutil.disk_usage(rootpath)
space = Space(*du, storage=state.root[0].size)
if space != state.space:
state.space = space
broadcast(format_space(space), loop)
time.sleep(2.0)
def walk(rel=PurePosixPath()) -> list[FileEntry]: # noqa: B008
path = rootpath / rel
try:
st = path.stat()
except OSError:
return []
return _walk(rel, int(not stat.S_ISDIR(st.st_mode)), st)
def _walk(rel: PurePosixPath, isfile: int, st: stat_result) -> list[FileEntry]:
entry = FileEntry(
level=len(rel.parts),
name=rel.name,
key=fuid(st),
mtime=int(st.st_mtime),
size=st.st_size if isfile else 0,
isfile=isfile,
)
if isfile:
return [entry]
ret = [entry]
def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]:
path = rootpath / rel
ret = []
try:
st = stat or path.stat()
isfile = not S_ISDIR(st.st_mode)
entry = FileEntry(
level=len(rel.parts),
name=rel.name,
key=fuid(st),
mtime=int(st.st_mtime),
size=st.st_size if isfile else 0,
isfile=isfile,
)
if isfile:
return [entry]
# Walk all entries of the directory
ret = [entry]
li = []
for f in path.iterdir():
if quit.is_set():
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):
subtree = _walk(rel / name, isfile, s)
child = subtree[0]
with suppress(FileNotFoundError):
s = f.lstat()
isfile = S_ISREG(s.st_mode)
isdir = S_ISDIR(s.st_mode)
if not isfile and not isdir:
continue
li.append((int(isfile), f.name, s))
# Build the tree as a list of FileEntries
for [_, name, s] in humansorted(li):
sub = walk(rel / name, stat=s)
ret.extend(sub)
child = sub[0]
entry.mtime = max(entry.mtime, child.mtime)
entry.size += child.size
ret.extend(subtree)
except FileNotFoundError:
pass # Things may be rapidly in motion
except OSError as e:
print("OS error walking path", path, e)
if e.errno == 13: # Permission denied
pass
logger.error(f"Watching {path=}: {e!r}")
return ret
def update(relpath: PurePosixPath, loop):
"""Called by inotify updates, check the filesystem and broadcast any changes."""
if rootpath is None or relpath is None:
print("ERROR", rootpath, relpath)
def update_root(loop):
"""Full filesystem scan"""
new = walk(PurePosixPath())
with state.lock:
old = state.root
if old != new:
state.root = new
broadcast(format_update(old, new), loop)
def update_path(relpath: PurePosixPath, loop):
"""Called on FS updates, check the filesystem and broadcast any changes."""
new = walk(relpath)
with state.lock:
old = state[relpath]
@@ -272,6 +191,22 @@ def update(relpath: PurePosixPath, loop):
broadcast(format_update(old, state.root), loop)
def update_space(loop):
"""Called periodically to update the disk usage."""
du = shutil.disk_usage(rootpath)
space = Space(*du, storage=state.root[0].size)
# Update only on difference above 1 MB
tol = 10**6
old = msgspec.structs.astuple(state.space)
new = msgspec.structs.astuple(space)
if any(abs(o - n) > tol for o, n in zip(old, new, strict=True)):
state.space = space
broadcast(format_space(space), loop)
## Messaging
def format_update(old, new):
# Make keep/del/insert diff until one of the lists ends
oidx, nidx = 0, 0
@@ -332,20 +267,77 @@ async def abroadcast(msg):
queue.put_nowait(msg)
except Exception:
# Log because asyncio would silently eat the error
logging.exception("Broadcast error")
logger.exception("Broadcast error")
## Watcher thread
def watcher_inotify(loop):
"""Inotify watcher thread (Linux only)"""
import inotify.adapters
modified_flags = (
"IN_CREATE",
"IN_DELETE",
"IN_DELETE_SELF",
"IN_MODIFY",
"IN_MOVE_SELF",
"IN_MOVED_FROM",
"IN_MOVED_TO",
)
while not quit.is_set():
i = inotify.adapters.InotifyTree(rootpath.as_posix())
# Initialize the tree from filesystem
update_root(loop)
trefresh = time.monotonic() + 30.0
tspace = time.monotonic() + 5.0
# Watch for changes (frequent wakeups needed for quiting)
for event in i.event_gen(timeout_s=0.1):
if quit.is_set():
break
t = time.monotonic()
# The watching is not entirely reliable, so do a full refresh every 30 seconds
if t >= trefresh:
break
# Disk usage update
if t >= tspace:
tspace = time.monotonic() + 5.0
update_space(loop)
# Inotify event, update the tree
if event and any(f in modified_flags for f in event[1]):
# Update modified path
update_path(PurePosixPath(event[2]) / event[3], loop)
del i # Free the inotify object
def watcher_poll(loop):
"""Polling version of the watcher thread."""
while not quit.is_set():
t0 = time.perf_counter()
update_root(loop)
update_space(loop)
dur = time.perf_counter() - t0
if dur > 1.0:
logger.debug(f"Reading the full file list took {dur:.1f}s")
quit.wait(0.1 + 8 * dur)
async def start(app, loop):
global rootpath
config.load_config()
use_inotify = False and sys.platform == "linux"
rootpath = config.config.path
use_inotify = sys.platform == "linux"
app.ctx.watcher = threading.Thread(
target=watcher_thread if use_inotify else watcher_thread_poll,
target=watcher_inotify if use_inotify else watcher_poll,
args=[loop],
# Descriptive name for system monitoring
name=f"cista-watcher {rootpath}",
)
app.ctx.watcher.start()
async def stop(app, loop):
global quit
quit = True
quit.set()
app.ctx.watcher.join()

BIN
docs/cista.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

View File

@@ -1,6 +1,6 @@
# cista-front
# Cista Vue Frontend
This template should help get you started developing with Vue 3 in Vite.
The frontend is a Single-Page App implemented with Vue 3. Development uses the Vite server together with the main Python backend, but in production the latter also serves the prebuilt frontend files.
## Recommended IDE Setup
@@ -17,24 +17,30 @@ If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has a
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration
## Hot-Reload for Development
See [Vite Configuration Reference](https://vitejs.dev/config/).
### Run the backend
## Project Setup
```sh
npm install
```fish
hatch shell
cista --dev -l :8000
```
### Compile and Hot-Reload for Development
### And the Vite server (in another terminal)
```sh
```fish
cd frontend
npm install
npm run dev
```
Browse to Vite, which will proxy API requests to port 8000. Both servers live reload changes.
### Type-Check, Compile and Minify for Production
```sh
This is also called by `hatch build` during Python packaging:
```fish
npm run build
```

View File

@@ -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,17 +17,18 @@ 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'
import type { SortOrder } from './utils/docsort'
interface Path {
path: string
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,50 +40,68 @@ 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 (
event.key === 'ArrowUp' ||
event.key === 'ArrowDown' ||
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
(c && event.code === 'Space')
) {
event.preventDefault()
if (!input) event.preventDefault()
}
return
}
//console.log("key pressed", event)
// For up/down implement custom fast repeat
if (event.key === 'ArrowUp') vert = keyup ? 0 : event.altKey ? -10 : -1
else if (event.key === 'ArrowDown') vert = keyup ? 0 : event.altKey ? 10 : 1
/// Long if-else machina for all keys we handle here
let arrow = ''
if (event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase()
// Find: process on keydown so that we can bypass the built-in search hotkey
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
headerMain.value!.toggleSearchInput()
}
// Select all (toggle); keydown to prevent builtin
// Search also on / (UNIX style)
else if (keyup && !input && event.key === '/') {
headerMain.value!.toggleSearchInput()
}
// Globally close search, clear errors on Escape
else if (keyup && event.key === 'Escape') {
store.error = ''
headerMain.value!.closeSearch(event)
store.focusBreadcrumb()
}
else if (!input && keyup && event.key === 'Backspace') {
Router.back()
}
// Select all (toggle); keydown to precede and prevent builtin
else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
fileExplorer.toggleSelectAll()
}
// Keys 1-3 to sort columns
// G toggles Gallery
else if (!input && keyup && event.key === 'g') {
store.prefs.gallery = !store.prefs.gallery
}
// Keys Backquote-1-2-3 to sort columns
else if (
c &&
!input &&
keyup &&
(event.key === '1' || event.key === '2' || event.key === '3')
(event.code === 'Backquote' || event.key === '1' || event.key === '2' || event.key === '3')
) {
fileExplorer.toggleSortColumn(+event.key)
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
}
// Rename
else if (c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
@@ -92,28 +111,26 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
else if (c && event.code === 'Space') {
if (keyup && !event.altKey && !event.ctrlKey)
fileExplorer.cursorSelect()
} else return
event.preventDefault()
if (!vert) {
if (timer) {
clearTimeout(timer) // Good for either timeout or interval
timer = null
}
return
}
if (!timer) {
else return
/// We are handling this!
event.preventDefault()
if (timer) {
clearTimeout(timer) // Good for either timeout or interval
timer = null
}
let f: any
switch (arrow) {
case 'up': f = () => fileExplorer.up(event); break
case 'down': f = () => fileExplorer.down(event); break
case 'left': f = () => fileExplorer.left(event); break
case 'right': f = () => fileExplorer.right(event); break
}
if (f && !keyup) {
// Initial move, then t0 delay until repeats at tr intervals
const select = event.shiftKey
fileExplorer.cursorMove(vert, select)
const t0 = 200,
tr = 30
timer = setTimeout(
() =>
(timer = setInterval(() => {
fileExplorer.cursorMove(vert, select)
}, tr)),
t0 - tr
)
const t0 = 200, tr = event.altKey ? 20 : 100
f()
timer = setTimeout(() => { timer = setInterval(f, tr) }, t0 - tr)
}
}
onMounted(() => {

View File

@@ -14,7 +14,7 @@
/* The following are overridden by responsive layouts */
--root-font-size: 1rem;
--header-font-size: 1rem;
--header-height: calc(6.5 * var(--header-font-size));
--header-height: 4rem;
}
@media (prefers-color-scheme: dark) {
:root {
@@ -37,12 +37,6 @@
:root {
--root-font-size: calc(8px + 8 * 100vw / 1000);
}
header .buttons:has(input[type='search']) > div {
display: none;
}
header .buttons > div:has(input[type='search']) {
display: inherit;
}
}
@media screen and (min-width: 2000px) {
:root {
@@ -54,6 +48,7 @@
:root {
--header-font-size: calc(10px + 10 * 100vh / 600); /* 20px at 600px height */
--root-font-size: 0.8rem;
--header-height: 2rem;
}
header .breadcrumb > * {
padding-top: calc(8 + 8 * 100vh / 600) !important;
@@ -78,17 +73,13 @@
}
header {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: end;
}
header .breadcrumb {
flex-shrink: 1;
}
header .breadcrumb > * {
flex-shrink: 1;
padding-top: 1rem !important;
padding-bottom: 1rem !important;
header .headermain { order: 1; }
header .breadcrumb { align-self: stretch; }
header .action-button {
width: 2em;
height: 2em;
}
}
@media print {
@@ -101,7 +92,7 @@
nav,
.menu,
.rename-button {
display: none;
display: none !important;
}
.breadcrumb > a {
color: black !important;
@@ -122,6 +113,7 @@
padding-bottom: 0 !important;
}
thead tr {
font-size: 1rem !important;
position: static !important;
background: none !important;
border-bottom: 1pt solid black !important;
@@ -142,6 +134,9 @@
left: 0;
}
}
* {
box-sizing: border-box;
}
html {
font-size: var(--root-font-size);
overflow: hidden;

View File

@@ -2,14 +2,18 @@
<nav
class="breadcrumb"
aria-label="Breadcrumb"
@keyup.left.stop="move(-1)"
@keyup.right.stop="move(1)"
@focus="move(0)"
@keydown.left.stop="move(-1)"
@keydown.right.stop="move(1)"
@keyup.enter="move(0)"
@focus=focusCurrent
tabindex=0
>
<a href="#/"
:ref="el => setLinkRef(0, el)"
:class="{ current: !!isCurrent(0) }"
:aria-current="isCurrent(0)"
@click.prevent="navigate(0)"
title="/"
>
<component :is="home" />
</a>
@@ -19,6 +23,7 @@
:aria-current="isCurrent(index + 1)"
@click.prevent="navigate(index + 1)"
:ref="el => setLinkRef(index + 1, el)"
:title="`/${longest.slice(0, index + 1).join('/')}`"
>{{ location }}</a>
</template>
</nav>
@@ -26,7 +31,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 +42,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 +80,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>
@@ -83,31 +104,35 @@ watchEffect(() => {
--breadcrumb-transtime: 0.3s;
}
.breadcrumb {
flex: 1 1 auto;
display: flex;
list-style: none;
min-width: 20%;
max-width: 100%;
min-height: 2em;
margin: 0;
padding: 0 1em 0 0;
}
.breadcrumb > a {
flex: 0 4 auto;
display: flex;
align-items: center;
margin: 0 -0.5em 0 -0.5em;
padding: 0;
max-width: 8em;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
height: 1.5em;
color: var(--breadcrumb-color);
padding: 0.3em 1.5em;
clip-path: polygon(0 0, 1em 50%, 0 100%, 100% 100%, 100% 0, 0 0);
transition: all var(--breadcrumb-transtime);
}
.breadcrumb a:first-child {
margin-left: 0;
padding-left: .2em;
.breadcrumb > a:first-child {
flex: 0 0 auto;
padding-left: 1.5em;
padding-right: 1.7em;
clip-path: none;
}
.breadcrumb a:last-child {
max-width: none;
.breadcrumb > a:last-child {
clip-path: polygon(
0 0,
calc(100% - 1em) 0,
@@ -118,7 +143,7 @@ watchEffect(() => {
0 0
);
}
.breadcrumb a:only-child {
.breadcrumb > a:only-child {
clip-path: polygon(
0 0,
calc(100% - 1em) 0,
@@ -130,9 +155,9 @@ watchEffect(() => {
}
.breadcrumb svg {
/* FIXME: Custom positioning to align it well; needs proper solution */
padding-left: 0.8em;
width: 1.3em;
height: 1.3em;
margin: -.5em;
fill: var(--breadcrumb-color);
transition: fill var(--breadcrumb-transtime);
}
@@ -152,6 +177,6 @@ watchEffect(() => {
}
.breadcrumb a:hover { color: var(--breadcrumb-hover-color) }
.breadcrumb a:hover svg { fill: var(--breadcrumb-hover-color) }
.breadcrumb a.current { color: var(--accent-color) }
.breadcrumb a.current { color: var(--accent-color); max-width: none; flex: 0 1 auto; }
.breadcrumb a.current svg { fill: var(--accent-color) }
</style>

View File

@@ -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,30 +17,30 @@
<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>
<tr
:id="`file-${doc.key}`"
:class="{ file: !doc.dir, folder: doc.dir, cursor: cursor === doc }"
@click="cursor = cursor === doc ? null : doc"
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }"
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
@contextmenu.prevent="contextMenu($event, doc)"
>
<td class="selection" @click.up.stop="cursor = cursor === doc ? doc : null">
<td class="selection" @click.up.stop="store.cursor = store.cursor === doc.key ? doc.key : ''">
<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>
@@ -49,22 +49,16 @@
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
</template>
<template v-else>
<a
:href="url_for(doc)"
tabindex="-1"
@contextmenu.prevent
@focus.stop="cursor = doc"
@keyup.left="router.back()"
@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>
<a :href=doc.url tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
{{ doc.name }}
</a>
<button tabindex=-1 v-if="store.cursor == doc.key" 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>
@@ -75,32 +69,29 @@
</tr>
</tbody>
</table>
<div v-else class="empty-container">Nothing to see here</div>
</template>
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
import { useDocumentStore } from '@/stores/documents'
import type { Document } 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'
import type SvgButtonVue from './SvgButton.vue'
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)
// 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,75 +115,69 @@ 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])
},
isCursor() {
return cursor.value !== null && editing.value === null
return store.cursor && editing.value === null
},
cursorRename() {
editing.value = cursor.value
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
},
cursorSelect() {
const doc = cursor.value
if (!doc) return
if (documentStore.selected.has(doc.key)) {
documentStore.selected.delete(doc.key)
const key = store.cursor
if (!key) return
if (store.selected.has(key)) {
store.selected.delete(key)
} else {
documentStore.selected.add(doc.key)
store.selected.add(key)
}
this.cursorMove(1)
this.cursorMove(1, null)
},
cursorMove(d: number, select = false) {
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
left(ev: KeyboardEvent) { router.back() },
right(ev: KeyboardEvent) {
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
if (a) a.click()
},
cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey
// Move cursor up or down (keyboard navigation)
const documents = sortedDocuments.value
if (documents.length === 0) {
cursor.value = null
const docs = props.documents
if (docs.length === 0) {
store.cursor = ''
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
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : docs.length
const moveto = increment(index, d)
cursor.value = documents[moveto] ?? null
const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null
store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
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
@@ -214,22 +199,29 @@ const focusBreadcrumb = () => {
let scrolltimer: any = null
let scrolltr: any = null
watchEffect(() => {
if (cursor.value && cursor.value !== editing.value) editing.value = null
if (editing.value) cursor.value = editing.value
if (cursor.value) {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value?.key
if (store.cursor) {
const a = document.querySelector(
`#file-${cursor.value.key} .name a`
`#file-${store.cursor} .name a`
) as HTMLAnchorElement | null
if (a) a.focus()
}
})
watchEffect(() => {
if (!props.documents.length && cursor.value) {
cursor.value = null
if (!props.documents.length && store.cursor) {
store.cursor = ''
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 +238,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 +266,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 +283,13 @@ const allSelected = computed({
const loc = computed(() => props.path.join('/'))
const contextMenu = (ev: Event, doc: Document) => {
cursor.value = doc
console.log('Context menu', ev, doc)
const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key
ContextMenu.showContextMenu({
x: ev.x, y: ev.y, items: [
{ label: 'Rename', onClick: () => { editing.value = doc } },
],
})
}
</script>
@@ -321,29 +307,36 @@ tbody tr {
position: relative;
z-index: auto;
}
table thead input[type='checkbox'] {
table thead .selection input[type='checkbox'] {
position: inherit;
width: 1em;
height: 1em;
padding: 0.5rem 0.5em;
width: 1rem;
height: 1rem;
padding: 0;
margin: auto;
}
table tbody input[type='checkbox'] {
table tbody .selection input[type='checkbox'] {
width: 2rem;
height: 2rem;
}
table .selection {
width: 2rem;
width: 3rem;
text-align: center;
text-overflow: clip;
padding: 0;
}
table .selection input {
margin: auto;
}
table .modified {
width: 9em;
width: 10rem;
text-overflow: clip;
}
table .size {
width: 5em;
width: 7rem;
text-overflow: clip;
}
table .menu {
width: 1rem;
width: 2rem;
}
tbody td {
font-size: 1.2rem;
@@ -378,7 +371,7 @@ table td {
}
}
thead tr {
font-size: var(--header-font-size);
font-size: 0.8rem;
background: linear-gradient(to bottom, #eee, #fff 30%, #ddd);
color: #000;
box-shadow: 0 0 .2rem black;
@@ -399,9 +392,11 @@ tbody tr.cursor {
padding-right: 1.5rem;
}
.sortcolumn::after {
font-size: 1rem;
content: '▸';
color: #888;
margin-left: 0.5em;
margin-left: 0.5rem;
margin-top: -.2rem;
position: absolute;
transition: all var(--transition-time) linear;
}
@@ -451,3 +446,4 @@ tbody .selection input {
color: #888;
}
</style>
@/stores/main

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,254 @@
<template>
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
<GalleryFigure v-for="(doc, index) in documents" :key="doc.key" :doc="doc" :index="index">
<BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" v-if="showFolderBreadcrumb(index)" class="folder-change"/>
</GalleryFigure>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue'
import { connect, controlUrl } from '@/repositories/WS'
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: Doc[]
}>()
const store = useMainStore()
const router = useRouter()
// File rename
const editing = shallowRef<Doc | null>(null)
const rename = (doc: Doc, newName: string) => {
const oldName = doc.name
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Rename failed', msg.error.message, msg.error)
doc.name = oldName
} else {
console.log('Rename succeeded', msg)
}
}
})
control.onopen = () => {
control.send(
JSON.stringify({
op: 'rename',
path: `${doc.loc}/${oldName}`,
to: newName
})
)
}
doc.name = newName // We should get an update from watch but this is quicker
}
const gallery = ref<HTMLElement>()
const columns = computed(() => {
if (!gallery.value) return 1
return getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
})
defineExpose({
newFolder() {
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,
})
},
toggleSelectAll() {
console.log('Select')
allSelected.value = !allSelected.value
},
toggleSortColumn(column: number) {
const order = ['', 'name', 'modified', 'size', ''][column]
if (order) store.toggleSort(order as SortOrder)
},
isCursor() {
return store.cursor && editing.value === null
},
cursorRename() {
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
},
cursorSelect() {
const key = store.cursor
if (!key) return
if (store.selected.has(key)) {
store.selected.delete(key)
} else {
store.selected.add(key)
}
this.cursorMove(1, null)
},
up(ev: KeyboardEvent) { this.cursorMove(-columns.value, ev) },
down(ev: KeyboardEvent) { this.cursorMove(columns.value, ev) },
left(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
right(ev: KeyboardEvent) { this.cursorMove(1, ev) },
cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey
// Move cursor up or down (keyboard navigation)
const docs = props.documents
if (docs.length === 0) {
store.cursor = ''
return
}
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 =
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
let moveto
if (index === N) moveto = d > 0 ? 0 : N - 1
else {
moveto = increment(index, d)
// Wrapping either end, just land outside the list
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
}
console.log("Gallery cursorMove", d, index, moveto, moveto - index)
store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
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 = docs[p].key
if (store.selected.has(key)) store.selected.delete(key)
else store.selected.add(key)
}
}
// @ts-ignore
scrolltr = tr
if (!scrolltimer) {
scrolltimer = setTimeout(() => {
if (scrolltr)
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltimer = null
}, 300)
}
if (moveto === N) focusBreadcrumb()
}
})
const focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus()
}
let scrolltimer: any = null
let scrolltr: any = null
watchEffect(() => {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value.key
if (store.cursor) {
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
if (a) { a.focus(); a.scrollIntoView({ block: 'center', behavior: 'smooth' }) }
}
})
watchEffect(() => {
if (!props.documents.length && store.cursor) {
store.cursor = ''
focusBreadcrumb()
}
})
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(
JSON.stringify({
op: 'mkdir',
path: `${doc.loc}/${name}`
})
)
},
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Mkdir failed', msg.error.message, msg.error)
editing.value = null
} else {
console.log('mkdir', msg)
router.push(doc.urlrouter)
}
}
})
// We should get an update from watch but this is quicker
doc.name = name
doc.key = crypto.randomUUID()
}
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: Doc) => store.selected.has(doc.key)) &&
!allSelected.value
)
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
set: (value: boolean) => {}
})
const allSelected = computed({
get: () => {
return (
props.documents.length > 0 &&
props.documents.every((doc: Doc) => store.selected.has(doc.key))
)
},
set: (value: boolean) => {
console.log('Setting allSelected', value)
for (const doc of props.documents) {
if (value) {
store.selected.add(doc.key)
} else {
store.selected.delete(doc.key)
}
}
}
})
const loc = computed(() => props.path.join('/'))
const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key
ContextMenu.showContextMenu({
x: ev.x, y: ev.y, items: [
{ label: 'Rename', onClick: () => { editing.value = doc } },
],
})
}
</script>
<style scoped>
.gallery {
padding: 1rem;
width: 100%;
display: grid;
gap: .5rem;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
grid-auto-rows: 15rem;
}
.breadcrumb {
position: absolute;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<a :id="`file-${doc.key}`" :href=doc.url tabindex=-1
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }"
@contextmenu.stop
@focus.stop="store.cursor = doc.key"
@click="ev => {
console.log('Gallery click', doc.key, store.cursor, !!media)
if (m!.play()) ev.preventDefault()
store.cursor = doc.key
}"
>
<figure>
<slot></slot>
<MediaPreview ref=m :doc="doc" :tabindex=-1 />
<caption>
<label>
<SelectBox :doc=doc />
<span :title="doc.name + '\n' + doc.modified + '\n' + doc.sizedisp">{{ doc.name }}</span>
</label>
</caption>
</figure>
</a>
</template>
<script setup lang=ts>
import { ref } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import MediaPreview from '@/components/MediaPreview.vue'
const store = useMainStore()
const props = defineProps<{
doc: Doc
index: number
}>()
const m = ref<typeof MediaPreview | null>(null)
</script>
<style scoped>
.gallery figure {
height: 15rem;
position: relative;
border-radius: .5rem;
overflow: hidden;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
overflow: hidden;
}
figure caption {
font-weight: 600;
color: var(--text-color);
text-shadow: 0 0 .2rem #000, 0 0 1rem #000;
}
.cursor caption {
background: var(--accent-color);
}
caption {
position: absolute;
overflow: hidden;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
caption label {
width: 100%;
display: flex;
align-items: center;
}
label span {
flex: 1 1;
margin-right: 2rem;
text-align: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
label input[type='checkbox'] {
width: 2rem;
height: 2rem;
opacity: 0;
flex-shrink: 0;
}
label input[type='checkbox']:checked {
opacity: 1;
}
a {
text-decoration: none;
}
</style>

View File

@@ -1,46 +1,47 @@
<template>
<nav class="headermain">
<div class="buttons">
<template v-if="documentStore.error">
<div class="error-message" @click="documentStore.error = ''">{{ documentStore.error }}</div>
<div class="smallgap"></div>
</template>
<UploadButton :path="props.path" />
<SvgButton
name="create-folder"
data-tooltip="New folder"
@click="() => documentStore.fileExplorer!.newFolder()"
<nav class="headermain buttons">
<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="() => store.fileExplorer!.newFolder()"
/>
<slot></slot>
<div class="spacer smallgap"></div>
<template v-if="showSearchInput">
<input
ref="search"
type="search"
:value="query"
@input="updateSearch"
placeholder="Find files"
class="margin-input"
/>
<slot></slot>
<div class="spacer smallgap"></div>
<template v-if="showSearchInput">
<input
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" />
<SvgButton name="cog" @click="settingsMenu" />
</div>
</template>
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" />
<SvgButton name="cog" @click="settingsMenu" />
</nav>
</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 +55,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 +73,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 +84,6 @@ const settingsMenu = (e: Event) => {
items,
})
}
const props = defineProps<{
path: Array<string>
query: string
}>()
defineExpose({
toggleSearchInput,
closeSearch,
@@ -96,23 +92,18 @@ defineExpose({
<style scoped>
.buttons {
flex: 1000 0 auto;
padding: 0;
display: flex;
align-items: center;
height: 3.5em;
z-index: 10;
}
.buttons > * {
flex-shrink: 1;
}
input[type='search'] {
background: var(--input-background);
color: var(--input-color);
border: 0;
border-radius: 0.1em;
padding: 0.5em;
outline: none;
font-size: 1.5em;
max-width: 30vw;
max-width: 15ch;
}
</style>

View File

@@ -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>
@@ -150,5 +150,6 @@ const download = async () => {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
}
</style>

View File

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

View File

@@ -0,0 +1,100 @@
<template>
<img v-if=doc.img :src="preview() ? doc.previewurl : doc.url" alt="">
<span v-else-if=doc.dir class="folder icon"></span>
<video ref=vid v-else-if=video() :src=doc.url controls preload=none @click.prevent>📄</video>
<audio ref=aud v-else-if=audio() :src=doc.url controls preload=metadata @click.stop>📄</audio>
<span v-else-if=archive() class="archive icon"></span>
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
</template>
<script setup lang=ts>
import { compile, computed, ref } from 'vue'
import type { Doc } from '@/repositories/Document'
const aud = ref<HTMLAudioElement | null>(null)
const vid = ref<HTMLVideoElement | null>(null)
const media = computed(() => aud.value || vid.value)
const props = defineProps<{
doc: Doc
}>()
defineExpose({
play() {
if (media.value) {
if (media.value.paused) media.value.play()
else media.value.pause()
return true
}
return false
},
})
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext)
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
const preview = () => (
props.doc.size > 500000 &&
['png', 'bmp', 'ico', 'webp', 'avif', 'jpg', 'jpeg'].includes(props.doc.ext)
)
</script>
<style scoped>
img, embed, .icon {
font-size: 10em;
border-radius: .5rem;
overflow: hidden;
text-align: center;
object-fit: cover;
object-position: center;
min-width: 50%;
height: 100%;
}
audio, video {
height: 100%;
min-width: 50%;
max-width: 100%;
padding-bottom: 2rem;
margin: auto;
}
.folder::before {
content: '📁';
}
.folder:hover::before, .cursor .folder::before {
content: '📂';
}
.archive::before {
content: '📦';
}
.file::before {
content: '📄';
}
.ext-img::before {
content: '💿';
}
.ext-exe::before, .ext-msi::before, .ext-dmg::before, .ext-pkg::before {
content: '⚙️';
}
.ext-torrent::before {
content: '🏴‍☠️';
}
.icon {
filter: brightness(0.9);
}
figure.cursor .icon {
filter: brightness(1);
}
img::before, video::before {
/* broken image */
background: #888;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
text-align: center;
text-shadow: 0 0 .5rem #000;
filter: grayscale(1);
content: '❌';
}
</style>

View File

@@ -0,0 +1,22 @@
<template>
<input type=checkbox tabindex=-1 :checked="store.selected.has(doc.key)"
@change="ev => {
if ((ev.target as HTMLInputElement).checked) {
store.selected.add(doc.key)
} else {
store.selected.delete(doc.key)
}
}"
>
</template>
<script setup lang=ts>
import { useMainStore } from '@/stores/main'
import type { Doc } from '@/repositories/Document'
const props = defineProps<{
doc: Doc
}>()
const store = useMainStore()
</script>

View File

@@ -6,7 +6,7 @@
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineProps } from 'vue'
import { defineAsyncComponent } from 'vue'
const props = defineProps<{
name: string

View File

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

View File

@@ -1,17 +1,53 @@
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(/^\/#/, '')
}
get img(): boolean {
const ext = this.name.split('.').pop()?.toLowerCase()
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg'].includes(ext || '')
}
get previewurl(): string {
return this.url.replace(/^\/files/, '/preview')
}
get ext(): string {
const ext = this.name.split('.').pop()
return ext ? ext.toLowerCase() : ''
}
}
export type errorEvent = {
error: {
code: number
@@ -36,7 +72,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>
}

View File

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

View File

@@ -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,22 @@ 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,
cursor: '' as string,
server: {} as Record<string, any>,
prefs: {
gallery: false,
sortListing: '' as SortOrder,
sortFiltered: '' as SortOrder,
},
user: {
username: '',
privileged: false,
@@ -32,29 +37,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 +72,25 @@ 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
},
sort(name: SortOrder | '') {
if (this.query) this.prefs.sortFiltered = name
else this.prefs.sortListing = name
},
focusBreadcrumb() {
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
}
},
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 +111,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])

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

View File

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

View File

@@ -1,20 +1,42 @@
<template>
<FileExplorer
<div v-if="!props.path || documents.length === 0" class="empty-container">
<component :is="cog" class="cog"/>
<p v-if="!store.connected">No Connection</p>
<p v-else-if="store.document.length === 0">Waiting for File List</p>
<p v-else-if="store.query">No matches!</p>
<p v-else-if="!store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === props.path.join('/'))">Folder not found.</p>
<p v-else>Empty folder</p>
</div>
<Gallery
v-else-if="store.prefs.gallery"
ref="fileExplorer"
:key="Router.currentRoute.value.path"
:key="`gallery-${Router.currentRoute.value.path}`"
:path="props.path"
:documents="documents"
v-if="props.path"
/>
<FileExplorer
v-else
ref="fileExplorer"
:key="`explorer-${Router.currentRoute.value.path}`"
:path="props.path"
:documents="documents"
/>
<div v-if="!store.prefs.gallery && documents.some(doc => doc.img)" class="suggest-gallery">
<p>Media files found. Would you like a gallery view?</p>
<SvgButton name="eye" taborder=0 @click="() => { store.prefs.gallery = true }">Gallery</SvgButton>
</div>
</template>
<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 { needleFormat, localeIncludes, collator } from '@/utils'
import { sorted } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue'
import cog from '@/assets/svg/cog.svg'
const documentStore = useDocumentStore()
const store = useMainStore()
const fileExplorer = ref()
const props = defineProps<{
path: Array<string>
@@ -24,19 +46,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 +81,43 @@ const documents = computed(() => {
})
watchEffect(() => {
documentStore.fileExplorer = fileExplorer.value
store.fileExplorer = fileExplorer.value
store.query = props.query
})
</script>
<style scoped>
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
font-size: 2rem;
text-shadow: 0 0 1rem #000, 0 0 2rem #000;
color: var(--accent-color);
}
@keyframes rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(359deg); }
}
.suggest-gallery p {
font-size: 2rem;
color: var(--accent-color);
}
.suggest-gallery {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
svg.cog {
width: 10rem;
height: 10rem;
margin: 0 auto;
animation: rotate 10s linear infinite;
filter: drop-shadow(0 0 1rem black);
fill: var(--primary-color);
}
</style>

View File

@@ -45,6 +45,7 @@ export default defineConfig({
"/login": dev_backend,
"/logout": dev_backend,
"/zip": dev_backend,
"/preview": dev_backend,
}
},
build: {

View File

@@ -22,6 +22,7 @@ dependencies = [
"msgspec",
"natsort",
"pathvalidate",
"pillow",
"pyjwt",
"sanic",
"stream-zip",
@@ -29,7 +30,7 @@ dependencies = [
]
[project.urls]
Homepage = ""
Homepage = "https://git.zi.fi/Vasanko/cista-storage"
[project.scripts]
cista = "cista.__main__:main"
@@ -40,20 +41,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
View 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