Compare commits
53 Commits
Author | SHA1 | Date | |
---|---|---|---|
f38bb4bab9 | |||
26f9bef087 | |||
634dabe52d | |||
a383358369 | |||
369dc3ecaf | |||
0cf9c254e5 | |||
58b9dd3dd4 | |||
0965a56204 | |||
52beedcef0 | |||
8592d462f2 | |||
b2a24fca57 | |||
7cc7e32c33 | |||
fa98cb9177 | |||
b3ab09a614 | |||
a49dd2f111 | |||
dbb06e111c | |||
667e31aa08 | |||
007f021d6f | |||
b2188eee68 | |||
7311ffdff1 | |||
102a970174 | |||
a9d713dbd0 | |||
434e55f303 | |||
be9772c90e | |||
bb2448dc24 | |||
115f3e20d0 | |||
a366a0bcc6 | |||
2ff0f78759 | |||
9a2d6e8065 | |||
62388eb555 | |||
53778543bf | |||
8dda230510 | |||
696e3ab568 | |||
85ac12ad33 | |||
e56cc47105 | |||
ebbd96bc94 | |||
a9b6d04361 | |||
5808fe17ad | |||
671359e327 | |||
ba9495eb65 | |||
de482afd60 | |||
a547052e29 | |||
07c2ff4c15 | |||
e20b04189f | |||
8da141744e | |||
11887edde3 | |||
034c6fdea9 | |||
c5083f0f2b | |||
f8a9197474 | |||
5285cb2fb5 | |||
b6b387d09b | |||
669762dfe7 | |||
51fd07d4fa |
20
README.md
20
README.md
@ -1,8 +1,8 @@
|
||||
# Cista Web Storage
|
||||
|
||||
<img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.jpg" align=right width=250>
|
||||
<img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.webp" align=left width=250>
|
||||
|
||||
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.
|
||||
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**.
|
||||
|
||||
@ -46,7 +46,9 @@ To use your own TLS certificates, place them in the config folder and run:
|
||||
cista -l cista.example.com
|
||||
```
|
||||
|
||||
Most admins instead find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address. Caddy configuration **/etc/caddy/Caddyfile** is dead simple:
|
||||
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 {
|
||||
@ -72,7 +74,7 @@ pip install -e '.[dev]'
|
||||
cista --dev -l :8000 /path/to/files
|
||||
```
|
||||
|
||||
We use `hatch shell` for installing on a virtual environment, to not disturb the rest of the system with our hacking.
|
||||
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.
|
||||
|
||||
@ -80,9 +82,9 @@ Vue is used to build files in `cista/wwwroot`, included prebuilt in the Python p
|
||||
|
||||
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.
|
||||
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**:
|
||||
Create `/etc/systemd/system/cista@.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
@ -90,7 +92,7 @@ Description=Cista storage %i
|
||||
|
||||
[Service]
|
||||
User=storage
|
||||
ExecStart=cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/@%i/
|
||||
ExecStart=cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
@ -105,9 +107,9 @@ systemctl enable --now cista@foo.example.com
|
||||
systemctl enable --now cista@bar.example.com
|
||||
```
|
||||
|
||||
Public exposure is easiest using the Caddy web server, but Nginx or others also work. Run the server with -l domain.example.com if you have TLS certificates in the config folder.
|
||||
Public exposure is easiest using the Caddy web server.
|
||||
|
||||
**/etc/caddy/Caddyfile**:
|
||||
`/etc/caddy/Caddyfile`:
|
||||
|
||||
```Caddyfile
|
||||
foo.example.com, bar.example.com {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@ -61,6 +62,7 @@ def _main():
|
||||
path = None
|
||||
_confdir(args)
|
||||
exists = config.conffile.exists()
|
||||
print(config.conffile, exists)
|
||||
import_droppy = args["--import-droppy"]
|
||||
necessary_opts = exists or import_droppy or path
|
||||
if not necessary_opts:
|
||||
@ -117,7 +119,8 @@ def _confdir(args):
|
||||
raise ValueError("Config path is not a directory")
|
||||
# Accidentally pointed to the db.toml, use parent
|
||||
confdir = confdir.parent
|
||||
config.conffile = confdir / config.conffile.name
|
||||
os.environ["CISTA_HOME"] = confdir.as_posix()
|
||||
config.init_confdir() # Uses environ if available
|
||||
|
||||
|
||||
def _user(args):
|
||||
|
21
cista/api.py
21
cista/api.py
@ -111,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),
|
||||
)
|
||||
|
46
cista/app.py
46
cista/app.py
@ -1,7 +1,9 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import mimetypes
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from multiprocessing import cpu_count
|
||||
from pathlib import Path, PurePath, PurePosixPath
|
||||
from stat import S_IFDIR, S_IFREG
|
||||
from urllib.parse import unquote
|
||||
@ -10,12 +12,13 @@ from wsgiref.handlers import format_date_time
|
||||
import brotli
|
||||
import sanic.helpers
|
||||
from blake3 import blake3
|
||||
from sanic import Blueprint, Sanic, empty, raw
|
||||
from sanic import Blueprint, Sanic, empty, raw, redirect
|
||||
from sanic.exceptions import Forbidden, NotFound
|
||||
from sanic.log import logging
|
||||
from sanic.log import logger
|
||||
from setproctitle import setproctitle
|
||||
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,21 +27,28 @@ 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)
|
||||
|
||||
|
||||
setproctitle("cista-main")
|
||||
|
||||
|
||||
@app.before_server_start
|
||||
async def main_start(app, loop):
|
||||
config.load_config()
|
||||
await watching.start(app, loop)
|
||||
setproctitle(f"cista {config.config.path.name}")
|
||||
workers = max(2, min(8, cpu_count()))
|
||||
app.ctx.threadexec = ThreadPoolExecutor(
|
||||
max_workers=8, thread_name_prefix="cista-ioworker"
|
||||
max_workers=workers, 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()
|
||||
|
||||
@ -122,7 +132,7 @@ def _load_wwwroot(www):
|
||||
if not wwwnew:
|
||||
msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
|
||||
if not www:
|
||||
logging.warning(msg)
|
||||
logger.warning(msg)
|
||||
if not app.debug:
|
||||
msg = "Web frontend missing. Cista installation is broken.\n"
|
||||
wwwnew[""] = (
|
||||
@ -141,7 +151,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):
|
||||
@ -151,9 +161,12 @@ async def load_wwwroot(app):
|
||||
)
|
||||
|
||||
|
||||
quit = threading.Event()
|
||||
|
||||
|
||||
async def refresh_wwwroot():
|
||||
while True:
|
||||
await asyncio.sleep(0.5)
|
||||
try:
|
||||
while not quit.is_set():
|
||||
try:
|
||||
wwwold = www
|
||||
await load_wwwroot(app)
|
||||
@ -169,9 +182,10 @@ async def refresh_wwwroot():
|
||||
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
|
||||
print(f"Error loading wwwroot: {e!r}")
|
||||
await asyncio.sleep(0.5)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
@app.route("/<path:path>", methods=["GET", "HEAD"])
|
||||
@ -191,6 +205,12 @@ async def wwwroot(req, path=""):
|
||||
return raw(data, headers=headers)
|
||||
|
||||
|
||||
@app.route("/favicon.ico", methods=["GET", "HEAD"])
|
||||
async def favicon(req):
|
||||
# Browsers keep asking for it when viewing files (not HTML with icon link)
|
||||
return redirect("/assets/logo-97d1d7eb.svg", status=308)
|
||||
|
||||
|
||||
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
||||
loc = PurePosixPath()
|
||||
idx = 0
|
||||
@ -251,7 +271,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)
|
||||
|
@ -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")
|
||||
@ -159,3 +159,35 @@ async def logout_post(request):
|
||||
res = json({"message": msg})
|
||||
session.delete(res)
|
||||
return res
|
||||
|
||||
|
||||
@bp.post("/password-change")
|
||||
async def change_password(request):
|
||||
try:
|
||||
if request.headers.content_type == "application/json":
|
||||
username = request.json["username"]
|
||||
pwchange = request.json["passwordChange"]
|
||||
password = request.json["password"]
|
||||
else:
|
||||
username = request.form["username"][0]
|
||||
pwchange = request.form["passwordChange"][0]
|
||||
password = request.form["password"][0]
|
||||
if not username or not password:
|
||||
raise KeyError
|
||||
except KeyError:
|
||||
raise BadRequest(
|
||||
"Missing username, passwordChange or password",
|
||||
) from None
|
||||
try:
|
||||
user = login(username, password)
|
||||
set_password(user, pwchange)
|
||||
except ValueError as e:
|
||||
raise Forbidden(str(e), context={"redirect": "/login"}) from e
|
||||
|
||||
if "text/html" in request.headers.accept:
|
||||
res = redirect("/")
|
||||
session.flash(res, "Password updated")
|
||||
else:
|
||||
res = json({"message": "Password updated"})
|
||||
session.create(res, username)
|
||||
return res
|
||||
|
@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from functools import wraps
|
||||
from hashlib import sha256
|
||||
from pathlib import Path, PurePath
|
||||
@ -33,7 +35,23 @@ class Link(msgspec.Struct, omit_defaults=True):
|
||||
|
||||
|
||||
config = None
|
||||
conffile = Path.home() / ".local/share/cista/db.toml"
|
||||
conffile = None
|
||||
|
||||
|
||||
def init_confdir():
|
||||
if p := os.environ.get("CISTA_HOME"):
|
||||
home = Path(p)
|
||||
else:
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
home = (
|
||||
Path(xdg).expanduser() / "cista" if xdg else Path.home() / ".config/cista"
|
||||
)
|
||||
if not home.is_dir():
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
home.chmod(0o700)
|
||||
|
||||
global conffile
|
||||
conffile = home / "db.toml"
|
||||
|
||||
|
||||
def derived_secret(*params, len=8) -> bytes:
|
||||
@ -61,8 +79,8 @@ def dec_hook(typ, obj):
|
||||
|
||||
def config_update(modify):
|
||||
global config
|
||||
if not conffile.exists():
|
||||
conffile.parent.mkdir(parents=True, exist_ok=True)
|
||||
if conffile is None:
|
||||
init_confdir()
|
||||
tmpname = conffile.with_suffix(".tmp")
|
||||
try:
|
||||
f = tmpname.open("xb")
|
||||
@ -76,10 +94,6 @@ def config_update(modify):
|
||||
old = conffile.read_bytes()
|
||||
c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook)
|
||||
except FileNotFoundError:
|
||||
# No existing config file, make sure we have a folder...
|
||||
confdir = conffile.parent
|
||||
confdir.mkdir(parents=True, exist_ok=True)
|
||||
confdir.chmod(0o700)
|
||||
old = b""
|
||||
c = None
|
||||
c = modify(c)
|
||||
@ -92,7 +106,9 @@ def config_update(modify):
|
||||
f.write(new)
|
||||
f.close()
|
||||
if sys.platform == "win32":
|
||||
conffile.unlink() # Windows doesn't support atomic replace
|
||||
# Windows doesn't support atomic replace
|
||||
with suppress(FileNotFoundError):
|
||||
conffile.unlink()
|
||||
tmpname.rename(conffile) # Atomic replace
|
||||
except:
|
||||
f.close()
|
||||
@ -120,6 +136,8 @@ def modifies_config(modify):
|
||||
|
||||
def load_config():
|
||||
global config
|
||||
if conffile is None:
|
||||
init_confdir()
|
||||
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
|
||||
|
||||
|
||||
|
117
cista/preview.py
Normal file
117
cista/preview.py
Normal file
@ -0,0 +1,117 @@
|
||||
import asyncio
|
||||
import gc
|
||||
import io
|
||||
import mimetypes
|
||||
import urllib.parse
|
||||
from pathlib import PurePosixPath
|
||||
from urllib.parse import unquote
|
||||
from wsgiref.handlers import format_date_time
|
||||
|
||||
import av
|
||||
import av.datasets
|
||||
import fitz # PyMuPDF
|
||||
from PIL import Image
|
||||
from sanic import Blueprint, empty, raw
|
||||
from sanic.exceptions import NotFound
|
||||
from sanic.log import logger
|
||||
|
||||
from cista import config
|
||||
from cista.util.filename import sanitize
|
||||
|
||||
DISPLAYMATRIX = av.stream.SideData.DISPLAYMATRIX
|
||||
|
||||
bp = Blueprint("preview", url_prefix="/preview")
|
||||
|
||||
|
||||
@bp.get("/<path:path>")
|
||||
async def preview(req, path):
|
||||
"""Preview a file"""
|
||||
maxsize = int(req.args.get("px", 1024))
|
||||
maxzoom = float(req.args.get("zoom", 2.0))
|
||||
quality = int(req.args.get("q", 40))
|
||||
rel = PurePosixPath(sanitize(unquote(path)))
|
||||
path = config.config.path / rel
|
||||
stat = path.lstat()
|
||||
etag = config.derived_secret(
|
||||
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
||||
).hex()
|
||||
savename = PurePosixPath(path.name).with_suffix(".webp")
|
||||
headers = {
|
||||
"etag": etag,
|
||||
"last-modified": format_date_time(stat.st_mtime),
|
||||
"cache-control": "max-age=604800, immutable"
|
||||
+ ("" if config.config.public else ", private"),
|
||||
"content-type": "image/webp",
|
||||
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
|
||||
}
|
||||
if req.headers.if_none_match == etag:
|
||||
# The client has it cached, respond 304 Not Modified
|
||||
return empty(304, headers=headers)
|
||||
|
||||
if not path.is_file():
|
||||
raise NotFound("File not found")
|
||||
|
||||
img = await asyncio.get_event_loop().run_in_executor(
|
||||
req.app.ctx.threadexec, dispatch, path, quality, maxsize, maxzoom
|
||||
)
|
||||
return raw(img, headers=headers)
|
||||
|
||||
|
||||
def dispatch(path, quality, maxsize, maxzoom):
|
||||
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
|
||||
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
||||
if mimetypes.guess_type(path.name)[0].startswith("video/"):
|
||||
return process_video(path, quality=quality, maxsize=maxsize)
|
||||
return process_image(path, quality=quality, maxsize=maxsize)
|
||||
|
||||
|
||||
def process_image(path, *, maxsize, quality):
|
||||
img = Image.open(path)
|
||||
w, h = img.size
|
||||
img.thumbnail((min(w, maxsize), min(h, maxsize)))
|
||||
# Fix rotation based on EXIF data
|
||||
try:
|
||||
rotate_values = {3: 180, 6: 270, 8: 90}
|
||||
orientation = img._getexif().get(274)
|
||||
if orientation in rotate_values:
|
||||
logger.debug(f"Rotating preview {path} by {rotate_values[orientation]}")
|
||||
img = img.rotate(rotate_values[orientation], expand=True)
|
||||
except AttributeError:
|
||||
...
|
||||
except Exception as e:
|
||||
logger.error(f"Error rotating preview image: {e}")
|
||||
# Save as webp
|
||||
imgdata = io.BytesIO()
|
||||
img.save(imgdata, format="webp", quality=quality, method=4)
|
||||
return imgdata.getvalue()
|
||||
|
||||
|
||||
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
||||
pdf = fitz.open(path)
|
||||
page = pdf.load_page(page_number)
|
||||
w, h = page.rect[2:4]
|
||||
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
||||
mat = fitz.Matrix(zoom, zoom)
|
||||
pix = page.get_pixmap(matrix=mat)
|
||||
return pix.pil_tobytes(format="webp", quality=quality, method=4)
|
||||
|
||||
|
||||
def process_video(path, *, maxsize, quality):
|
||||
with av.open(str(path)) as container:
|
||||
stream = container.streams.video[0]
|
||||
stream.codec_context.skip_frame = "NONKEY"
|
||||
rot = stream.side_data and stream.side_data.get(DISPLAYMATRIX) or 0
|
||||
container.seek(container.duration // 8)
|
||||
img = next(container.decode(stream)).to_image()
|
||||
del stream
|
||||
|
||||
img.thumbnail((maxsize, maxsize))
|
||||
imgdata = io.BytesIO()
|
||||
if rot:
|
||||
img = img.rotate(rot, expand=True)
|
||||
img.save(imgdata, format="webp", quality=quality, method=4)
|
||||
del img
|
||||
ret = imgdata.getvalue()
|
||||
del imgdata
|
||||
gc.collect()
|
||||
return ret
|
@ -112,7 +112,7 @@ class ErrorMsg(msgspec.Struct):
|
||||
## Directory listings
|
||||
|
||||
|
||||
class FileEntry(msgspec.Struct, array_like=True):
|
||||
class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
||||
level: int
|
||||
name: str
|
||||
key: str
|
||||
@ -120,9 +120,12 @@ class FileEntry(msgspec.Struct, array_like=True):
|
||||
size: int
|
||||
isfile: int
|
||||
|
||||
def __repr__(self):
|
||||
def __str__(self):
|
||||
return self.key or "FileEntry()"
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.name} ({self.size}, {self.mtime})"
|
||||
|
||||
|
||||
class Update(msgspec.Struct, array_like=True):
|
||||
...
|
||||
|
@ -26,7 +26,6 @@ def run(*, dev=False):
|
||||
motd=False,
|
||||
dev=dev,
|
||||
auto_reload=dev,
|
||||
reload_dir={confdir},
|
||||
access_log=True,
|
||||
) # type: ignore
|
||||
if dev:
|
||||
@ -51,7 +50,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:
|
||||
|
@ -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) or code == 500:
|
||||
logger.exception(f"{code} {e!r}")
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
@ -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
|
||||
@ -23,7 +24,7 @@ class State:
|
||||
def __init__(self):
|
||||
self.lock = threading.RLock()
|
||||
self._space = Space(0, 0, 0, 0)
|
||||
self._listing: list[FileEntry] = []
|
||||
self.root: list[FileEntry] = []
|
||||
|
||||
@property
|
||||
def space(self):
|
||||
@ -35,174 +36,85 @@ class State:
|
||||
with self.lock:
|
||||
self._space = space
|
||||
|
||||
@property
|
||||
def root(self) -> list[FileEntry]:
|
||||
with self.lock:
|
||||
return self._listing[:]
|
||||
|
||||
@root.setter
|
||||
def root(self, listing: list[FileEntry]):
|
||||
with self.lock:
|
||||
self._listing = listing
|
||||
def treeiter(rootmod):
|
||||
relpath = PurePosixPath()
|
||||
for i, entry in enumerate(rootmod):
|
||||
if entry.level > 0:
|
||||
relpath = PurePosixPath(*relpath.parts[: entry.level - 1]) / entry.name
|
||||
yield i, relpath, entry
|
||||
|
||||
def _slice(self, idx: PurePosixPath | tuple[PurePosixPath, int]):
|
||||
relpath, relfile = idx if isinstance(idx, tuple) else (idx, 0)
|
||||
begin, end = 0, len(self._listing)
|
||||
level = 0
|
||||
|
||||
def treeget(rootmod: list[FileEntry], path: PurePosixPath):
|
||||
begin = None
|
||||
ret = []
|
||||
for i, relpath, entry in treeiter(rootmod):
|
||||
if begin is None:
|
||||
if relpath == path:
|
||||
begin = i
|
||||
ret.append(entry)
|
||||
continue
|
||||
if entry.level <= len(path.parts):
|
||||
break
|
||||
ret.append(entry)
|
||||
return begin, ret
|
||||
|
||||
|
||||
def treeinspos(rootmod: list[FileEntry], relpath: PurePosixPath, relfile: int):
|
||||
# Find the first entry greater than the new one
|
||||
# precondition: the new entry doesn't exist
|
||||
isfile = 0
|
||||
|
||||
# Special case for root
|
||||
if not relpath.parts:
|
||||
return slice(begin, end)
|
||||
|
||||
begin += 1
|
||||
for part in relpath.parts:
|
||||
level += 1
|
||||
found = False
|
||||
|
||||
while begin < end:
|
||||
entry = self._listing[begin]
|
||||
|
||||
level = 0
|
||||
i = 0
|
||||
for i, rel, entry in treeiter(rootmod):
|
||||
if entry.level > level:
|
||||
# We haven't found item at level, skip subdirectories
|
||||
continue
|
||||
if entry.level < level:
|
||||
break
|
||||
|
||||
if entry.level == level:
|
||||
if entry.name == part:
|
||||
found = True
|
||||
if level == len(relpath.parts):
|
||||
isfile = relfile
|
||||
else:
|
||||
begin += 1
|
||||
break
|
||||
cmp = entry.isfile - isfile or sortkey(entry.name) > sortkey(part)
|
||||
if cmp > 0:
|
||||
break
|
||||
|
||||
begin += 1
|
||||
|
||||
if not found:
|
||||
return slice(begin, begin)
|
||||
|
||||
# Found the starting point, now find the end of the slice
|
||||
for end in range(begin + 1, len(self._listing) + 1):
|
||||
if end == len(self._listing) or self._listing[end].level <= level:
|
||||
break
|
||||
return slice(begin, end)
|
||||
|
||||
def __getitem__(self, index: PurePosixPath | tuple[PurePosixPath, int]):
|
||||
with self.lock:
|
||||
return self._listing[self._slice(index)]
|
||||
|
||||
def __setitem__(
|
||||
self, index: tuple[PurePosixPath, int], value: list[FileEntry]
|
||||
) -> None:
|
||||
rel, isfile = index
|
||||
with self.lock:
|
||||
if rel.parts:
|
||||
parent = self._slice(rel.parent)
|
||||
if parent.start == parent.stop:
|
||||
raise ValueError(
|
||||
f"Parent folder {rel.as_posix()} is missing for {rel.name}"
|
||||
# We have passed the level, so the new item is the first
|
||||
return i
|
||||
if level == 0:
|
||||
# root
|
||||
level += 1
|
||||
continue
|
||||
ename = rel.parts[level - 1]
|
||||
name = relpath.parts[level - 1]
|
||||
esort = sortkey(ename)
|
||||
nsort = sortkey(name)
|
||||
# Non-leaf are always folders, only use relfile at leaf
|
||||
isfile = relfile if len(relpath.parts) == level else 0
|
||||
# First compare by isfile, then by sorting order and if that too matches then case sensitive
|
||||
cmp = (
|
||||
entry.isfile - isfile
|
||||
or (esort > nsort) - (esort < nsort)
|
||||
or (ename > name) - (ename < name)
|
||||
)
|
||||
self._listing[self._slice(index)] = value
|
||||
|
||||
def __delitem__(self, relpath: PurePosixPath):
|
||||
with self.lock:
|
||||
del self._listing[self._slice(relpath)]
|
||||
if cmp > 0:
|
||||
return i
|
||||
if cmp < 0:
|
||||
continue
|
||||
level += 1
|
||||
if level > len(relpath.parts):
|
||||
print("ERROR: insertpos", relpath, i, entry.name, entry.level, level)
|
||||
break
|
||||
else:
|
||||
i += 1
|
||||
return i
|
||||
|
||||
|
||||
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 not quit:
|
||||
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 30 seconds
|
||||
refreshdl = time.monotonic() + 30.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
|
||||
def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]:
|
||||
path = rootpath / rel
|
||||
ret = []
|
||||
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]:
|
||||
st = stat or path.stat()
|
||||
isfile = int(not S_ISDIR(st.st_mode))
|
||||
entry = FileEntry(
|
||||
level=len(rel.parts),
|
||||
name=rel.name,
|
||||
@ -213,86 +125,147 @@ def _walk(rel: PurePosixPath, isfile: int, st: stat_result) -> list[FileEntry]:
|
||||
)
|
||||
if isfile:
|
||||
return [entry]
|
||||
ret = [entry]
|
||||
path = rootpath / rel
|
||||
try:
|
||||
# Walk all entries of the directory
|
||||
ret: list[FileEntry] = [...] # type: ignore
|
||||
li = []
|
||||
for f in path.iterdir():
|
||||
if quit:
|
||||
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):
|
||||
if quit:
|
||||
raise SystemExit("quit")
|
||||
subtree = _walk(rel / name, isfile, s)
|
||||
child = subtree[0]
|
||||
entry.mtime = max(entry.mtime, child.mtime)
|
||||
entry.size += child.size
|
||||
ret.extend(subtree)
|
||||
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)
|
||||
child = sub[0]
|
||||
entry = FileEntry(
|
||||
level=entry.level,
|
||||
name=entry.name,
|
||||
key=entry.key,
|
||||
size=entry.size + child.size,
|
||||
mtime=max(entry.mtime, child.mtime),
|
||||
isfile=entry.isfile,
|
||||
)
|
||||
ret.extend(sub)
|
||||
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}")
|
||||
if ret:
|
||||
ret[0] = entry
|
||||
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)
|
||||
new = walk(relpath)
|
||||
with state.lock:
|
||||
old = state[relpath]
|
||||
if old == new:
|
||||
return
|
||||
def update_root(loop):
|
||||
"""Full filesystem scan"""
|
||||
old = state.root
|
||||
new = walk(PurePosixPath())
|
||||
if old != new:
|
||||
update = format_update(old, new)
|
||||
with state.lock:
|
||||
broadcast(update, loop)
|
||||
state.root = new
|
||||
|
||||
|
||||
def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
|
||||
"""Called on FS updates, check the filesystem and broadcast any changes."""
|
||||
new = walk(relpath)
|
||||
obegin, old = treeget(rootmod, relpath)
|
||||
if old == new:
|
||||
logger.debug(
|
||||
f"Watch: Event without changes needed {relpath}"
|
||||
if old
|
||||
else f"Watch: Event with old and new missing: {relpath}"
|
||||
)
|
||||
return
|
||||
if obegin is not None:
|
||||
del rootmod[obegin : obegin + len(old)]
|
||||
if new:
|
||||
state[relpath, new[0].isfile] = new
|
||||
logger.debug(f"Watch: Update {relpath}" if old else f"Watch: Created {relpath}")
|
||||
i = treeinspos(rootmod, relpath, new[0].isfile)
|
||||
rootmod[i:i] = new
|
||||
else:
|
||||
del state[relpath]
|
||||
broadcast(format_update(old, state.root), loop)
|
||||
logger.debug(f"Watch: Removed {relpath}")
|
||||
|
||||
|
||||
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
|
||||
oremain, nremain = set(old), set(new)
|
||||
update = []
|
||||
keep_count = 0
|
||||
while oidx < len(old) and nidx < len(new):
|
||||
modified = False
|
||||
# Matching entries are kept
|
||||
if old[oidx] == new[nidx]:
|
||||
entry = old[oidx]
|
||||
oremain.remove(entry)
|
||||
nremain.remove(entry)
|
||||
keep_count += 1
|
||||
oidx += 1
|
||||
nidx += 1
|
||||
continue
|
||||
if keep_count > 0:
|
||||
modified = True
|
||||
update.append(UpdKeep(keep_count))
|
||||
keep_count = 0
|
||||
|
||||
# Items only in old are deleted
|
||||
del_count = 0
|
||||
rest = new[nidx:]
|
||||
while oidx < len(old) and old[oidx] not in rest:
|
||||
while oidx < len(old) and old[oidx] not in nremain:
|
||||
oremain.remove(old[oidx])
|
||||
del_count += 1
|
||||
oidx += 1
|
||||
if del_count:
|
||||
update.append(UpdDel(del_count))
|
||||
continue
|
||||
|
||||
# Items only in new are inserted
|
||||
insert_items = []
|
||||
rest = old[oidx:]
|
||||
while nidx < len(new) and new[nidx] not in rest:
|
||||
insert_items.append(new[nidx])
|
||||
while nidx < len(new) and new[nidx] not in oremain:
|
||||
entry = new[nidx]
|
||||
nremain.remove(entry)
|
||||
insert_items.append(entry)
|
||||
nidx += 1
|
||||
if insert_items:
|
||||
modified = True
|
||||
update.append(UpdIns(insert_items))
|
||||
|
||||
if not modified:
|
||||
raise Exception(
|
||||
f"Infinite loop in diff {nidx=} {oidx=} {len(old)=} {len(new)=}"
|
||||
)
|
||||
|
||||
# Diff any remaining
|
||||
if keep_count > 0:
|
||||
update.append(UpdKeep(keep_count))
|
||||
if oidx < len(old):
|
||||
update.append(UpdDel(len(old) - oidx))
|
||||
elif nidx < len(new):
|
||||
if oremain:
|
||||
update.append(UpdDel(len(oremain)))
|
||||
elif nremain:
|
||||
update.append(UpdIns(new[nidx:]))
|
||||
|
||||
return msgspec.json.encode({"update": update}).decode()
|
||||
@ -316,20 +289,108 @@ 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
|
||||
t0 = time.perf_counter()
|
||||
update_root(loop)
|
||||
t1 = time.perf_counter()
|
||||
logger.debug(f"Root update took {t1 - t0:.1f}s")
|
||||
trefresh = time.monotonic() + 300.0
|
||||
tspace = time.monotonic() + 5.0
|
||||
# Watch for changes (frequent wakeups needed for quiting)
|
||||
while not quit.is_set():
|
||||
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 events, update the tree
|
||||
dirty = False
|
||||
rootmod = state.root[:]
|
||||
for event in i.event_gen(yield_nones=False, timeout_s=0.1):
|
||||
assert event
|
||||
if quit.is_set():
|
||||
return
|
||||
interesting = any(f in modified_flags for f in event[1])
|
||||
if event[2] == rootpath.as_posix() and event[3] == "zzz":
|
||||
logger.debug(f"Watch: {interesting=} {event=}")
|
||||
if interesting:
|
||||
# Update modified path
|
||||
t0 = time.perf_counter()
|
||||
path = PurePosixPath(event[2]) / event[3]
|
||||
update_path(rootmod, path.relative_to(rootpath), loop)
|
||||
t1 = time.perf_counter()
|
||||
logger.debug(f"Watch: Update {event[3]} took {t1 - t0:.1f}s")
|
||||
if not dirty:
|
||||
t = time.monotonic()
|
||||
dirty = True
|
||||
# Wait a maximum of 0.5s to push the updates
|
||||
if dirty and time.monotonic() >= t + 0.5:
|
||||
break
|
||||
if dirty and state.root != rootmod:
|
||||
t0 = time.perf_counter()
|
||||
update = format_update(state.root, rootmod)
|
||||
t1 = time.perf_counter()
|
||||
with state.lock:
|
||||
broadcast(update, loop)
|
||||
state.root = rootmod
|
||||
t2 = time.perf_counter()
|
||||
logger.debug(
|
||||
f"Format update took {t1 - t0:.1f}s, broadcast {t2 - t1:.1f}s"
|
||||
)
|
||||
|
||||
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()
|
||||
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.jpg
BIN
docs/cista.jpg
Binary file not shown.
Before Width: | Height: | Size: 39 KiB |
BIN
docs/cista.webp
Normal file
BIN
docs/cista.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 40 KiB |
2
frontend/.npmrc
Normal file
2
frontend/.npmrc
Normal file
@ -0,0 +1,2 @@
|
||||
audit=false
|
||||
fund=false
|
@ -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
|
||||
```
|
||||
|
||||
|
@ -8,5 +8,4 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@400;700&display=swap" rel="stylesheet">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
<div id="app"></div>
|
||||
<body id="app">
|
||||
|
@ -12,6 +12,9 @@
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@imengyu/vue3-context-menu": "^1.3.3",
|
||||
"@vueuse/core": "^10.4.1",
|
||||
@ -21,7 +24,6 @@
|
||||
"pinia": "^2.1.6",
|
||||
"pinia-plugin-persistedstate": "^3.2.0",
|
||||
"unplugin-vue-components": "^0.25.2",
|
||||
"vite-plugin-rewrite-all": "^1.0.1",
|
||||
"vite-svg-loader": "^4.0.0",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.4"
|
||||
|
@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<LoginModal />
|
||||
<SettingsModal />
|
||||
<header>
|
||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
||||
<HeaderSelected :path="path.pathList" />
|
||||
@ -9,6 +10,10 @@
|
||||
<main>
|
||||
<RouterView :path="path.pathList" :query="path.query" />
|
||||
</main>
|
||||
<footer>
|
||||
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
|
||||
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@ -21,6 +26,8 @@ import { useMainStore } from '@/stores/main'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import Router from '@/router/index'
|
||||
import type { SortOrder } from './utils/docsort'
|
||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||
|
||||
interface Path {
|
||||
path: string
|
||||
@ -48,6 +55,13 @@ const headerMain = ref<typeof HeaderMain | null>(null)
|
||||
let vert = 0
|
||||
let timer: any = null
|
||||
const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
if (store.dialog) {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
return
|
||||
}
|
||||
const fileExplorer = store.fileExplorer as any
|
||||
if (!fileExplorer) return
|
||||
const c = fileExplorer.isCursor()
|
||||
@ -57,70 +71,79 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
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 (!input && 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()
|
||||
}
|
||||
// Search also on / (UNIX style)
|
||||
else if (keyup && !input && event.key === '/') {
|
||||
else if (!input && keyup && event.key === '/') {
|
||||
headerMain.value!.toggleSearchInput()
|
||||
}
|
||||
// Globally close search on Escape
|
||||
// Globally close search, clear errors on Escape
|
||||
else if (keyup && event.key === 'Escape') {
|
||||
store.error = ''
|
||||
headerMain.value!.closeSearch(event)
|
||||
store.focusBreadcrumb()
|
||||
}
|
||||
// Select all (toggle); keydown to prevent builtin
|
||||
else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
|
||||
else if (!input && keyup && event.key === 'Backspace') {
|
||||
Router.back()
|
||||
}
|
||||
// Select all (toggle); keydown to precede and prevent builtin
|
||||
else if (!input && !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 (
|
||||
!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')) {
|
||||
else if (!input && c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
|
||||
fileExplorer.cursorRename()
|
||||
}
|
||||
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
|
||||
else if (c && event.code === 'Space') {
|
||||
else if (!input && c && event.code === 'Space') {
|
||||
if (keyup && !event.altKey && !event.ctrlKey)
|
||||
fileExplorer.cursorSelect()
|
||||
} else return
|
||||
}
|
||||
else return
|
||||
/// We are handling this!
|
||||
event.preventDefault()
|
||||
if (!vert) {
|
||||
if (timer) {
|
||||
clearTimeout(timer) // Good for either timeout or interval
|
||||
timer = null
|
||||
}
|
||||
return
|
||||
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 (!timer) {
|
||||
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(() => {
|
||||
@ -133,4 +156,3 @@ onUnmounted(() => {
|
||||
})
|
||||
export type { Path }
|
||||
</script>
|
||||
@/stores/main
|
||||
|
@ -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 {
|
||||
@ -98,10 +89,11 @@
|
||||
--header-background: none;
|
||||
--header-color: black;
|
||||
}
|
||||
nav,
|
||||
.headermain,
|
||||
.menu,
|
||||
.rename-button {
|
||||
display: none;
|
||||
.rename-button,
|
||||
.suggest-gallery {
|
||||
display: none !important;
|
||||
}
|
||||
.breadcrumb > a {
|
||||
color: black !important;
|
||||
@ -116,16 +108,31 @@
|
||||
}
|
||||
.breadcrumb svg {
|
||||
fill: black !important;
|
||||
margin: 0 .5rem 0 1rem !important;
|
||||
}
|
||||
body#app {
|
||||
height: auto !important;
|
||||
}
|
||||
main {
|
||||
height: auto !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
thead tr {
|
||||
font-size: 1rem !important;
|
||||
position: static !important;
|
||||
background: none !important;
|
||||
border-bottom: 1pt solid black !important;
|
||||
}
|
||||
audio::-webkit-media-controls-timeline,
|
||||
video::-webkit-media-controls-timeline {
|
||||
display: none;
|
||||
}
|
||||
audio::-webkit-media-controls,
|
||||
video::-webkit-media-controls {
|
||||
display: none;
|
||||
}
|
||||
tr, figure {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.selection {
|
||||
min-width: 0 !important;
|
||||
padding: 0 !important;
|
||||
@ -142,14 +149,13 @@
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
font-size: var(--root-font-size);
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Hide scrollbar for all browsers */
|
||||
main::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
main {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
@ -166,6 +172,7 @@ tbody .modified {
|
||||
font-family: 'Roboto Mono';
|
||||
}
|
||||
header {
|
||||
flex: 0 0 auto;
|
||||
background-color: var(--header-background);
|
||||
color: var(--header-color);
|
||||
font-size: var(--header-font-size);
|
||||
@ -207,21 +214,23 @@ table {
|
||||
border: 0;
|
||||
gap: 0;
|
||||
}
|
||||
#app {
|
||||
height: 100%;
|
||||
body#app {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
main {
|
||||
flex: 1 1 auto;
|
||||
padding-bottom: 3em; /* convenience space on the bottom */
|
||||
overflow-y: scroll;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
header nav.headermain {
|
||||
/* Position so that tooltips can appear on top of other positioned elements */
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
main {
|
||||
height: calc(100svh - var(--header-height));
|
||||
padding-bottom: 3em; /* convenience space on the bottom */
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.spacer { flex-grow: 1 }
|
||||
.smallgap { flex-shrink: 1; width: 2em }
|
||||
|
||||
|
@ -2,16 +2,19 @@
|
||||
<nav
|
||||
class="breadcrumb"
|
||||
aria-label="Breadcrumb"
|
||||
@keyup.left.stop="move(-1)"
|
||||
@keyup.right.stop="move(1)"
|
||||
@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="home"
|
||||
:class="{ current: !!isCurrent(0) }"
|
||||
:aria-current="isCurrent(0)"
|
||||
@click.prevent="navigate(0)"
|
||||
title="/"
|
||||
>
|
||||
<component :is="home" />
|
||||
</a>
|
||||
@ -21,6 +24,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>
|
||||
@ -30,6 +34,7 @@
|
||||
import home from '@/assets/svg/home.svg'
|
||||
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { exists } from '@/utils/fileutil'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@ -82,6 +87,15 @@ watchEffect(() => {
|
||||
else if (props.path.length > longcut.length) {
|
||||
longest.value = longcut.concat(props.path.slice(longcut.length))
|
||||
}
|
||||
else {
|
||||
// Prune deleted folders from longest
|
||||
for (let i = props.path.length; i < longest.value.length; ++i) {
|
||||
if (!exists(longest.value.slice(0, i + 1))) {
|
||||
longest.value = longest.value.slice(0, i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// If needed, focus primary navigation to new location
|
||||
if (props.primary) nextTick(() => {
|
||||
const act = document.activeElement as HTMLElement
|
||||
@ -101,31 +115,36 @@ 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;
|
||||
overflow: hidden;
|
||||
}
|
||||
.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,
|
||||
@ -136,7 +155,7 @@ watchEffect(() => {
|
||||
0 0
|
||||
);
|
||||
}
|
||||
.breadcrumb a:only-child {
|
||||
.breadcrumb > a:only-child {
|
||||
clip-path: polygon(
|
||||
0 0,
|
||||
calc(100% - 1em) 0,
|
||||
@ -148,9 +167,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);
|
||||
}
|
||||
@ -170,6 +189,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>
|
||||
|
171
frontend/src/components/DownloadButton.vue
Normal file
171
frontend/src/components/DownloadButton.vue
Normal file
@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<SvgButton name="download" data-tooltip="Download" @click="download" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import type { SelectedItems } from '@/repositories/Document'
|
||||
import { reactive } from 'vue';
|
||||
|
||||
const store = useMainStore()
|
||||
|
||||
const status_init = {
|
||||
total: 0,
|
||||
xfer: 0,
|
||||
t0: 0,
|
||||
tlast: 0,
|
||||
statbytes: 0,
|
||||
statdur: 0,
|
||||
files: [] as string[],
|
||||
filestart: 0,
|
||||
fileidx: 0,
|
||||
filecount: 0,
|
||||
filename: '',
|
||||
filesize: 0,
|
||||
filepos: 0,
|
||||
status: 'idle',
|
||||
}
|
||||
store.dprogress = {...status_init}
|
||||
setInterval(() => {
|
||||
if (Date.now() - store.dprogress.tlast > 3000) {
|
||||
// Reset
|
||||
store.dprogress.statbytes = 0
|
||||
store.dprogress.statdur = 1
|
||||
} else {
|
||||
// Running average by decay
|
||||
store.dprogress.statbytes *= .9
|
||||
store.dprogress.statdur *= .9
|
||||
}
|
||||
}, 100)
|
||||
const statReset = () => {
|
||||
Object.assign(store.dprogress, status_init)
|
||||
store.dprogress.t0 = Date.now()
|
||||
store.dprogress.tlast = store.dprogress.t0 + 1
|
||||
}
|
||||
const cancelDownloads = () => {
|
||||
location.reload() // FIXME
|
||||
}
|
||||
|
||||
|
||||
const linkdl = (href: string) => {
|
||||
const a = document.createElement('a')
|
||||
a.href = href
|
||||
a.download = ''
|
||||
a.click()
|
||||
}
|
||||
|
||||
const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => {
|
||||
let hdir = ''
|
||||
let h = handle
|
||||
console.log('Downloading to filesystem', sel.recursive)
|
||||
for (const [rel, full, doc] of sel.recursive) {
|
||||
if (doc.dir) continue
|
||||
store.dprogress.files.push(rel)
|
||||
++store.dprogress.filecount
|
||||
store.dprogress.total += doc.size
|
||||
}
|
||||
for (const [rel, full, doc] of sel.recursive) {
|
||||
// Create any missing directories
|
||||
if (hdir && !rel.startsWith(hdir + '/')) {
|
||||
hdir = ''
|
||||
h = handle
|
||||
}
|
||||
const r = rel.slice(hdir.length)
|
||||
for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) {
|
||||
if (!dir) continue
|
||||
hdir += `${dir}/`
|
||||
try {
|
||||
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to create directory', hdir, error)
|
||||
return
|
||||
}
|
||||
console.log('Created', hdir)
|
||||
}
|
||||
if (doc.dir) continue // Target was a folder and was created
|
||||
const name = rel.split('/').pop()!.normalize('NFC')
|
||||
// Download file
|
||||
let fileHandle
|
||||
try {
|
||||
fileHandle = await h.getFileHandle(name, { create: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to create file', rel, full, hdir + name, error)
|
||||
return
|
||||
}
|
||||
const writable = await fileHandle.createWritable()
|
||||
const url = `/files/${rel}`
|
||||
console.log('Fetching', url)
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
|
||||
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
if (res.body) {
|
||||
++store.dprogress.fileidx
|
||||
const reader = res.body.getReader()
|
||||
await writable.truncate(0)
|
||||
store.error = "Direct download."
|
||||
store.dprogress.tlast = Date.now()
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
await writable.write(value)
|
||||
const now = Date.now()
|
||||
const size = value.byteLength
|
||||
store.dprogress.xfer += size
|
||||
store.dprogress.filepos += size
|
||||
store.dprogress.statbytes += size
|
||||
store.dprogress.statdur += now - store.dprogress.tlast
|
||||
store.dprogress.tlast = now
|
||||
}
|
||||
}
|
||||
await writable.close()
|
||||
console.log('Saved', hdir + name)
|
||||
}
|
||||
statReset()
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
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)
|
||||
store.error = 'No existing files selected'
|
||||
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) {
|
||||
store.selected.clear()
|
||||
store.error = "Single file via browser downloads"
|
||||
return linkdl(`/files/${files[0][1]}`)
|
||||
}
|
||||
// Use FileSystem API if multiple files and the browser supports it
|
||||
if ('showDirectoryPicker' in window) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const handle = await window.showDirectoryPicker({
|
||||
startIn: 'downloads',
|
||||
mode: 'readwrite'
|
||||
})
|
||||
await filesystemdl(sel, handle)
|
||||
store.selected.clear()
|
||||
return
|
||||
} catch (e) {
|
||||
console.error('Download to folder aborted', e)
|
||||
}
|
||||
}
|
||||
// Otherwise, zip and download
|
||||
console.log("Falling back to zip download")
|
||||
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
|
||||
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
||||
store.error = "Downloading as ZIP via browser downloads"
|
||||
store.selected.clear()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
38
frontend/src/components/EmptyFolder.vue
Normal file
38
frontend/src/components/EmptyFolder.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<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="!exists(props.path)">Folder not found</p>
|
||||
<p v-else>Empty folder</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import cog from '@/assets/svg/cog.svg'
|
||||
import { exists } from '@/utils/fileutil'
|
||||
|
||||
const store = useMainStore()
|
||||
const props = defineProps<{
|
||||
path: string[],
|
||||
documents: Document[],
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes rotate {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
svg.cog {
|
||||
width: 10rem;
|
||||
height: 10rem;
|
||||
margin: 0 auto;
|
||||
animation: rotate 10s linear infinite;
|
||||
filter: drop-shadow(0 0 1rem black);
|
||||
fill: #888;
|
||||
}
|
||||
</style>
|
@ -28,11 +28,11 @@
|
||||
|
||||
<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"
|
||||
@ -49,16 +49,10 @@
|
||||
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<a
|
||||
:href="doc.url"
|
||||
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 tabindex=-1 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 :key=nowkey />
|
||||
@ -75,7 +69,6 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-container">Nothing to see here</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@ -87,7 +80,6 @@ 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>
|
||||
@ -95,7 +87,6 @@ const props = defineProps<{
|
||||
}>()
|
||||
const store = useMainStore()
|
||||
const router = useRouter()
|
||||
const cursor = shallowRef<Doc | null>(null)
|
||||
// File rename
|
||||
const editing = shallowRef<Doc | null>(null)
|
||||
const rename = (doc: Doc, newName: string) => {
|
||||
@ -124,6 +115,7 @@ const rename = (doc: Doc, newName: string) => {
|
||||
}
|
||||
defineExpose({
|
||||
newFolder() {
|
||||
console.log("New folder")
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
editing.value = new Doc({
|
||||
loc: loc.value,
|
||||
@ -133,46 +125,51 @@ defineExpose({
|
||||
mtime: now,
|
||||
size: 0,
|
||||
})
|
||||
store.cursor = editing.value.key
|
||||
},
|
||||
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 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 (store.selected.has(doc.key)) {
|
||||
store.selected.delete(doc.key)
|
||||
const key = store.cursor
|
||||
if (!key) return
|
||||
if (store.selected.has(key)) {
|
||||
store.selected.delete(key)
|
||||
} else {
|
||||
store.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 docs = props.documents
|
||||
if (docs.length === 0) {
|
||||
cursor.value = null
|
||||
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 =
|
||||
cursor.value !== null ? docs.indexOf(cursor.value) : docs.length
|
||||
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : docs.length
|
||||
const moveto = increment(index, d)
|
||||
cursor.value = docs[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]
|
||||
@ -202,18 +199,18 @@ 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()
|
||||
}
|
||||
})
|
||||
@ -222,7 +219,14 @@ let modifiedTimer: any = null
|
||||
const updateModified = () => {
|
||||
nowkey.value = Math.floor(Date.now() / 1000)
|
||||
}
|
||||
onMounted(() => { updateModified(); modifiedTimer = setInterval(updateModified, 1000) })
|
||||
onMounted(() => {
|
||||
updateModified(); modifiedTimer = setInterval(updateModified, 1000)
|
||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||
active.focus()
|
||||
}
|
||||
})
|
||||
onUnmounted(() => { clearInterval(modifiedTimer) })
|
||||
const mkdir = (doc: Doc, name: string) => {
|
||||
const control = connect(controlUrl, {
|
||||
@ -287,7 +291,7 @@ const allSelected = computed({
|
||||
const loc = computed(() => props.path.join('/'))
|
||||
|
||||
const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||
cursor.value = doc
|
||||
store.cursor = doc.key
|
||||
ContextMenu.showContextMenu({
|
||||
x: ev.x, y: ev.y, items: [
|
||||
{ label: 'Rename', onClick: () => { editing.value = doc } },
|
||||
@ -310,29 +314,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;
|
||||
@ -367,7 +378,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;
|
||||
@ -388,9 +399,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;
|
||||
}
|
||||
|
@ -56,4 +56,10 @@ input#FileRenameInput {
|
||||
outline: none;
|
||||
font: inherit;
|
||||
}
|
||||
.gallery input#FileRenameInput {
|
||||
padding: .75em;
|
||||
font-weight: 600;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
266
frontend/src/components/Gallery.vue
Normal file
266
frontend/src/components/Gallery.vue
Normal file
@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
|
||||
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
|
||||
<template v-for="(doc, index) in documents" :key=doc.key>
|
||||
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)">
|
||||
<template v-if=showFolderBreadcrumb(index)>
|
||||
<BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" class="folder-change"/>
|
||||
<div class="spacer"></div>
|
||||
</template>
|
||||
</GalleryFigure>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { connect, controlUrl } from '@/repositories/WS'
|
||||
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 exit = () => { editing.value = 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,
|
||||
})
|
||||
store.cursor = editing.value.key
|
||||
},
|
||||
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
|
||||
// Stop navigation sideways away from the grid (only with up/down)
|
||||
if (ev && index === 0 && ev.key === "ArrowLeft") return
|
||||
if (ev && index === N - 1 && ev.key === "ArrowRight") return
|
||||
// Calculate new position
|
||||
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
|
||||
}
|
||||
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()
|
||||
}
|
||||
})
|
||||
onMounted(() => {
|
||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||
active.focus()
|
||||
}
|
||||
})
|
||||
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: 1em;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: .5em;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
||||
grid-template-rows: repeat(minmax(auto, 15em));
|
||||
align-items: end;
|
||||
}
|
||||
.breadcrumb {
|
||||
border-radius: .5em 0 0 .5em;
|
||||
}
|
||||
.spacer {
|
||||
flex: 0 1000000000 4rem;
|
||||
}
|
||||
</style>
|
109
frontend/src/components/GalleryFigure.vue
Normal file
109
frontend/src/components/GalleryFigure.vue
Normal file
@ -0,0 +1,109 @@
|
||||
<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=onclick
|
||||
>
|
||||
<figure>
|
||||
<slot></slot>
|
||||
<MediaPreview ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
|
||||
<div class="titlespacer"></div>
|
||||
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
|
||||
<template v-if="editing">
|
||||
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
|
||||
</template>
|
||||
<template v-else>
|
||||
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||
<span :title="doc.name + '\n' + doc.modified + '\n' + doc.sizedisp">{{ doc.name }}</span>
|
||||
<div class=namespacer></div>
|
||||
</template>
|
||||
</figcaption>
|
||||
</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()
|
||||
type EditingProp = {
|
||||
rename: (name: string) => void;
|
||||
exit: () => void;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc,
|
||||
editing?: EditingProp,
|
||||
}>()
|
||||
const m = ref<typeof MediaPreview | null>(null)
|
||||
|
||||
const onclick = (ev: Event) => {
|
||||
if (m.value!.play()) ev.preventDefault()
|
||||
store.cursor = props.doc.key
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
figure {
|
||||
max-height: 15em;
|
||||
position: relative;
|
||||
border-radius: .5em;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
overflow: hidden;
|
||||
}
|
||||
figure > article {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.titlespacer {
|
||||
flex-shrink: 100000;
|
||||
width: 100%;
|
||||
height: 2em;
|
||||
}
|
||||
figcaption {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
figcaption input[type='checkbox'] {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
margin: .25em 0 .25em .25em;
|
||||
opacity: 0;
|
||||
flex-shrink: 0;
|
||||
transition: opacity var(--transition-time) ease-in-out;
|
||||
}
|
||||
figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] {
|
||||
opacity: 1;
|
||||
}
|
||||
figcaption span {
|
||||
cursor: default;
|
||||
padding: .5em;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cursor figcaption span {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
figcaption .namespacer {
|
||||
flex-shrink: 100000;
|
||||
height: 2em;
|
||||
width: 2em;
|
||||
}
|
||||
</style>
|
@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<nav class="headermain">
|
||||
<div class="buttons">
|
||||
<nav class="headermain buttons">
|
||||
<template v-if="store.error">
|
||||
<div class="error-message" @click="store.error = ''">{{ store.error }}</div>
|
||||
<div class="smallgap"></div>
|
||||
@ -9,7 +8,7 @@
|
||||
<SvgButton
|
||||
name="create-folder"
|
||||
data-tooltip="New folder"
|
||||
@click="() => store.fileExplorer!.newFolder()"
|
||||
@click="() => { console.log('New', store.fileExplorer); store.fileExplorer!.newFolder(); console.log('Done')}"
|
||||
/>
|
||||
<slot></slot>
|
||||
<div class="spacer smallgap"></div>
|
||||
@ -19,13 +18,13 @@
|
||||
type="search"
|
||||
:value="query"
|
||||
@input="updateSearch"
|
||||
placeholder="Search words"
|
||||
placeholder="Find files"
|
||||
class="margin-input"
|
||||
/>
|
||||
</template>
|
||||
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
|
||||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" />
|
||||
<SvgButton name="cog" @click="settingsMenu" />
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@ -74,6 +73,7 @@ watchEffect(() => {
|
||||
const settingsMenu = (e: Event) => {
|
||||
// show the context menu
|
||||
const items = []
|
||||
items.push({ label: 'Settings', onClick: () => { store.dialog = 'settings' }})
|
||||
if (store.user.isLoggedIn) {
|
||||
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
|
||||
} else {
|
||||
@ -93,24 +93,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>
|
||||
@/stores/main
|
||||
|
@ -2,7 +2,7 @@
|
||||
<template v-if="store.selected.size">
|
||||
<div class="smallgap"></div>
|
||||
<p class="select-text">{{ store.selected.size }} selected ➤</p>
|
||||
<SvgButton name="download" data-tooltip="Download" @click="download" />
|
||||
<DownloadButton />
|
||||
<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')" />
|
||||
@ -14,7 +14,6 @@
|
||||
import {connect, controlUrl} from '@/repositories/WS'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { computed } from 'vue'
|
||||
import type { SelectedItems } from '@/repositories/Document'
|
||||
|
||||
const store = useMainStore()
|
||||
const props = defineProps({
|
||||
@ -53,95 +52,6 @@ const op = (op: string, dst?: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const linkdl = (href: string) => {
|
||||
const a = document.createElement('a')
|
||||
a.href = href
|
||||
a.download = ''
|
||||
a.click()
|
||||
}
|
||||
|
||||
const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => {
|
||||
let hdir = ''
|
||||
let h = handle
|
||||
console.log('Downloading to filesystem', sel.recursive)
|
||||
for (const [rel, full, doc] of sel.recursive) {
|
||||
// Create any missing directories
|
||||
if (hdir && !rel.startsWith(hdir + '/')) {
|
||||
hdir = ''
|
||||
h = handle
|
||||
}
|
||||
const r = rel.slice(hdir.length)
|
||||
for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) {
|
||||
hdir += `${dir}/`
|
||||
try {
|
||||
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to create directory', hdir, error)
|
||||
return
|
||||
}
|
||||
console.log('Created', hdir)
|
||||
}
|
||||
if (doc.dir) continue // Target was a folder and was created
|
||||
const name = rel.split('/').pop()!.normalize('NFC')
|
||||
// Download file
|
||||
let fileHandle
|
||||
try {
|
||||
fileHandle = await h.getFileHandle(name, { create: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to create file', rel, full, hdir + name, error)
|
||||
return
|
||||
}
|
||||
const writable = await fileHandle.createWritable()
|
||||
const url = `/files/${rel}`
|
||||
console.log('Fetching', url)
|
||||
const res = await fetch(url)
|
||||
if (!res.ok)
|
||||
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
|
||||
if (res.body) await res.body.pipeTo(writable)
|
||||
else {
|
||||
// Zero-sized files don't have a body, so we need to create an empty file
|
||||
await writable.truncate(0)
|
||||
await writable.close()
|
||||
}
|
||||
console.log('Saved', hdir + name)
|
||||
}
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
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)
|
||||
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) {
|
||||
store.selected.clear()
|
||||
return linkdl(`/files/${files[0][1]}`)
|
||||
}
|
||||
// Use FileSystem API if multiple files and the browser supports it
|
||||
if ('showDirectoryPicker' in window) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const handle = await window.showDirectoryPicker({
|
||||
startIn: 'downloads',
|
||||
mode: 'readwrite'
|
||||
})
|
||||
filesystemdl(sel, handle).then(() => {
|
||||
store.selected.clear()
|
||||
})
|
||||
return
|
||||
} catch (e) {
|
||||
console.error('Download to folder aborted', e)
|
||||
}
|
||||
}
|
||||
// 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`)
|
||||
store.selected.clear()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@ -150,6 +60,6 @@ const download = async () => {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@/stores/main
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ModalDialog v-if="store.user.isOpenLoginModal" title="Authentication required" @blur="store.user.isOpenLoginModal = false">
|
||||
<ModalDialog name="login" title="Authentication required">
|
||||
<form @submit.prevent="login">
|
||||
<div class="login-container">
|
||||
<label for="username">Username:</label>
|
||||
@ -99,4 +99,3 @@ const login = async () => {
|
||||
height: 1em;
|
||||
}
|
||||
</style>
|
||||
@/stores/main
|
||||
|
168
frontend/src/components/MediaPreview.vue
Normal file
168
frontend/src/components/MediaPreview.vue
Normal file
@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt="">
|
||||
<img v-else-if=doc.img :src=doc.url alt="">
|
||||
<span v-else-if=doc.dir class="folder icon"></span>
|
||||
<video ref=vid v-else-if=video() :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
||||
<div v-else-if=audio() class="audio icon">
|
||||
<audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio>
|
||||
</div>
|
||||
<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 { 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 poster = computed(() => `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`)
|
||||
const props = defineProps<{
|
||||
doc: Doc
|
||||
quality: string
|
||||
}>()
|
||||
|
||||
const onplay = () => {
|
||||
if (!media.value) return
|
||||
media.value.controls = true
|
||||
media.value.setAttribute('data-playing', '')
|
||||
}
|
||||
const onpaused = () => {
|
||||
if (!media.value) return
|
||||
media.value.controls = false
|
||||
media.value.removeAttribute('data-playing')
|
||||
}
|
||||
let fscurrent: HTMLVideoElement | null = null
|
||||
const next = () => {
|
||||
if (!media.value) return
|
||||
media.value.load() // Restore poster
|
||||
const medias = Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]
|
||||
if (medias.length === 0) return
|
||||
let el: HTMLAudioElement | HTMLVideoElement | null = null
|
||||
for (const i in medias) {
|
||||
if (medias[i] === (fscurrent || media.value)) {
|
||||
el = medias[+i + 1] || medias[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!el) return
|
||||
if (el.tagName === "VIDEO" && document.fullscreenElement === media.value) {
|
||||
// Fullscreen needs to use the current video element for the next video
|
||||
// because we are not allowed to fullscreen the next one.
|
||||
// FIXME: Write our own player to avoid this problem...
|
||||
const elem = media.value as HTMLVideoElement
|
||||
const playing = el as HTMLVideoElement
|
||||
if (elem === playing) {
|
||||
playing.play() // Only one video, just replay
|
||||
return
|
||||
}
|
||||
if (!fscurrent) {
|
||||
elem.addEventListener('fullscreenchange', ev => {
|
||||
if (!fscurrent) return
|
||||
// Restore the original video element and continue with the one that was playing
|
||||
fscurrent.currentTime = elem.currentTime
|
||||
fscurrent.click()
|
||||
if (!elem.paused) fscurrent.play()
|
||||
fscurrent = null
|
||||
elem.src = props.doc.url
|
||||
elem.poster = poster.value
|
||||
onpaused()
|
||||
}, {once: true})
|
||||
}
|
||||
fscurrent = playing
|
||||
elem.src = playing.src
|
||||
elem.poster = ''
|
||||
elem.play()
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
el.click()
|
||||
}
|
||||
}
|
||||
defineExpose({
|
||||
play() {
|
||||
if (!media.value) return false
|
||||
if (media.value.paused) {
|
||||
media.value.play()
|
||||
for (const el of Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]) {
|
||||
if (el === media.value) continue
|
||||
el.pause()
|
||||
}
|
||||
} else {
|
||||
media.value.pause()
|
||||
}
|
||||
return true
|
||||
},
|
||||
media,
|
||||
})
|
||||
|
||||
|
||||
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 = () => (
|
||||
['bmp', 'ico', 'tif', 'tiff', 'pdf'].includes(props.doc.ext) ||
|
||||
props.doc.size > 500000 &&
|
||||
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext)
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
img, embed, .icon, audio, video {
|
||||
font-size: 8em;
|
||||
overflow: hidden;
|
||||
min-width: 50%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: calc(.5em / 8);
|
||||
}
|
||||
.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: '🏴☠️';
|
||||
}
|
||||
.audio audio {
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-time) ease-in-out;
|
||||
}
|
||||
.audio:hover audio {
|
||||
opacity: 1;
|
||||
}
|
||||
.audio.icon::before {
|
||||
width: 100%;
|
||||
content: '🔈';
|
||||
}
|
||||
.audio.icon:has(audio[data-playing])::before {
|
||||
position: absolute;
|
||||
content: '🔊';
|
||||
bottom: 0;
|
||||
}
|
||||
.icon {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
figure.cursor .icon {
|
||||
filter: brightness(1);
|
||||
}
|
||||
img::before {
|
||||
/* broken image */
|
||||
text-shadow: 0 0 .5rem #000;
|
||||
filter: grayscale(1);
|
||||
content: '❌';
|
||||
}
|
||||
</style>
|
@ -1,34 +1,45 @@
|
||||
<template>
|
||||
<dialog ref="dialog">
|
||||
<dialog v-if="store.dialog === name" ref="dialog" :id=props.name @keydown.escape=close>
|
||||
<h1 v-if="props.title">{{ props.title }}</h1>
|
||||
<div>
|
||||
<slot>
|
||||
Dialog with no content
|
||||
<button onclick="dialog.close()">OK</button>
|
||||
<button @click=close>OK</button>
|
||||
</slot>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, watchEffect, nextTick } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
|
||||
const dialog = ref<HTMLDialogElement | null>(null)
|
||||
const store = useMainStore()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
}>(),
|
||||
{
|
||||
title: ''
|
||||
const close = () => {
|
||||
dialog.value!.close()
|
||||
store.dialog = ''
|
||||
}
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
title: string,
|
||||
name: typeof store.dialog,
|
||||
}>()
|
||||
|
||||
const show = () => {
|
||||
store.dialog = props.name
|
||||
setTimeout(() => {
|
||||
dialog.value!.showModal()
|
||||
nextTick(() => {
|
||||
const input = dialog.value!.querySelector('input')
|
||||
if (input) input.focus()
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
defineExpose({ show })
|
||||
onMounted(() => {
|
||||
show()
|
||||
defineExpose({ show, close })
|
||||
watchEffect(() => {
|
||||
if (dialog.value) show()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
22
frontend/src/components/SelectBox.vue
Normal file
22
frontend/src/components/SelectBox.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<input type=checkbox tabindex=-1 :checked="store.selected.has(doc.key)" @click.stop
|
||||
@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>
|
127
frontend/src/components/SettingsModal.vue
Normal file
127
frontend/src/components/SettingsModal.vue
Normal file
@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<ModalDialog name=settings title="Settings">
|
||||
<form>
|
||||
<template v-if="store.user.isLoggedIn">
|
||||
<h3>Update your authentication</h3>
|
||||
<div class="login-container">
|
||||
<label for="username">New password:</label>
|
||||
<input
|
||||
ref="passwordChange"
|
||||
id="passwordChange"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
v-model="form.passwordChange"
|
||||
/>
|
||||
<label for="password">Current password:</label>
|
||||
<input
|
||||
ref="password"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
v-model="form.password"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="error-text">
|
||||
{{ form.error || '\u00A0' }}
|
||||
</h3>
|
||||
<div class="dialog-buttons">
|
||||
<input id="close" type="reset" value="Close" class="button" @click=close />
|
||||
<div class="spacer"></div>
|
||||
<input id="submit" type="submit" value="Submit" class="button" @click.prevent="submit" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>No settings are available because you have not logged in.</p>
|
||||
<div class="dialog-buttons">
|
||||
<div class="spacer"></div>
|
||||
<input id="close" type="reset" value="Close" class="button" @click=close />
|
||||
</div>
|
||||
</template>
|
||||
</form>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { changePassword } from '@/repositories/User'
|
||||
import type { ISimpleError } from '@/repositories/Client'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
|
||||
const confirmLoading = ref<boolean>(false)
|
||||
const store = useMainStore()
|
||||
const passwordChange = ref()
|
||||
const password = ref()
|
||||
|
||||
const form = reactive({
|
||||
passwordChange: '',
|
||||
password: '',
|
||||
error: ''
|
||||
})
|
||||
|
||||
const close = () => {
|
||||
form.passwordChange = ''
|
||||
form.password = ''
|
||||
form.error = ''
|
||||
store.dialog = ''
|
||||
}
|
||||
const submit = async (ev: Event) => {
|
||||
ev.preventDefault()
|
||||
try {
|
||||
form.error = ''
|
||||
if (form.passwordChange) {
|
||||
if (!form.password) {
|
||||
form.error = '⚠️ Current password is required'
|
||||
password.value!.focus()
|
||||
return
|
||||
}
|
||||
await changePassword(store.user.username, form.passwordChange, form.password)
|
||||
}
|
||||
close()
|
||||
} catch (error) {
|
||||
const httpError = error as ISimpleError
|
||||
form.error = httpError.message || '🛑 Unknown error'
|
||||
} finally {
|
||||
confirmLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.button-login {
|
||||
color: #fff;
|
||||
background: var(--soft-color);
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
border: 0;
|
||||
border-radius: .5rem;
|
||||
padding: .5rem 2rem;
|
||||
margin-left: auto;
|
||||
transition: all var(--transition-time) linear;
|
||||
}
|
||||
.button-login:hover, .button-login:focus {
|
||||
background: var(--accent-color);
|
||||
box-shadow: 0 0 .3rem #000;
|
||||
}
|
||||
.error-text {
|
||||
color: var(--red-color);
|
||||
height: 1em;
|
||||
}
|
||||
</style>
|
@ -6,7 +6,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, defineProps } from 'vue'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
|
94
frontend/src/components/TransferBar.vue
Normal file
94
frontend/src/components/TransferBar.vue
Normal file
@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="transferprogress" v-if="status.total" :style="`background: linear-gradient(to right, var(--bar) 0, var(--bar) ${percent}%, var(--nobar) ${percent}%, var(--nobar) 100%);`">
|
||||
<div class="statustext">
|
||||
<span v-if="status.filecount > 1" class="index">
|
||||
[{{ status.fileidx }}/{{ status.filecount }}]
|
||||
</span>
|
||||
<span class="filename">{{ status.filename.split('/').pop() }}
|
||||
<span v-if="status.filesize > 1e7" class="percent">
|
||||
{{ (status.filepos / status.filesize * 100).toFixed(0) + '\u202F%' }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="position" v-if="status.total > 1e7">
|
||||
{{ (status.xfer / 1e6).toFixed(0) + '\u202F/\u202F' + (status.total / 1e6).toFixed(0) + '\u202FMB' }}
|
||||
</span>
|
||||
<span class="speed">{{ speeddisp }}</span>
|
||||
<button class="close" @click="$emit('cancel')">❌</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineEmits(['cancel'])
|
||||
|
||||
const props = defineProps<{
|
||||
status: {
|
||||
total: number
|
||||
xfer: number
|
||||
filecount: number
|
||||
fileidx: number
|
||||
filesize: number
|
||||
filepos: number
|
||||
filename: string
|
||||
statbytes: number
|
||||
statdur: number
|
||||
tlast: number
|
||||
}
|
||||
}>()
|
||||
|
||||
const percent = computed(() => props.status.xfer / props.status.total * 100)
|
||||
const speed = computed(() => {
|
||||
let s = props.status.statbytes / props.status.statdur / 1e3
|
||||
const tsince = (Date.now() - props.status.tlast) / 1e3
|
||||
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
|
||||
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 < 10 ? 1 : 0) + '\u202FMB/s': 'stalled')
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.transferprogress {
|
||||
--bar: var(--accent-color);
|
||||
--nobar: var(--header-background);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--primary-color);
|
||||
width: 100%;
|
||||
}
|
||||
.statustext {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 .5em;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
span {
|
||||
color: #ccc;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
.filename {
|
||||
color: #fff;
|
||||
flex: 1 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
}
|
||||
.index { min-width: 3.5em }
|
||||
.position { min-width: 4em }
|
||||
.speed { min-width: 4em }
|
||||
|
||||
.upload .statustext::before {
|
||||
font-size: 1.5em;
|
||||
content: '🔺'
|
||||
}
|
||||
.download .statustext::before {
|
||||
font-size: 1.5em;
|
||||
content: '🔻'
|
||||
}
|
||||
</style>
|
@ -1,8 +1,17 @@
|
||||
<template>
|
||||
<template>
|
||||
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
|
||||
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
|
||||
</template>
|
||||
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" />
|
||||
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderInput.click()" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { connect, uploadUrl } from '@/repositories/WS';
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { collator } from '@/utils';
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
|
||||
const fileInput = ref()
|
||||
const folderInput = ref()
|
||||
@ -94,7 +103,7 @@ const cancelUploads = () => {
|
||||
|
||||
const uprogress_init = {
|
||||
total: 0,
|
||||
uploaded: 0,
|
||||
xfer: 0,
|
||||
t0: 0,
|
||||
tlast: 0,
|
||||
statbytes: 0,
|
||||
@ -108,59 +117,50 @@ const uprogress_init = {
|
||||
filepos: 0,
|
||||
status: 'idle',
|
||||
}
|
||||
const uprogress = reactive({...uprogress_init})
|
||||
const percent = computed(() => uprogress.uploaded / uprogress.total * 100)
|
||||
const speed = computed(() => {
|
||||
let s = uprogress.statbytes / uprogress.statdur / 1e3
|
||||
const tsince = (Date.now() - uprogress.tlast) / 1e3
|
||||
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
|
||||
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 < 10 ? 1 : 0) + '\u202FMB/s': 'stalled')
|
||||
store.uprogress = {...uprogress_init}
|
||||
setInterval(() => {
|
||||
if (Date.now() - uprogress.tlast > 3000) {
|
||||
if (Date.now() - store.uprogress.tlast > 3000) {
|
||||
// Reset
|
||||
uprogress.statbytes = 0
|
||||
uprogress.statdur = 1
|
||||
store.uprogress.statbytes = 0
|
||||
store.uprogress.statdur = 1
|
||||
} else {
|
||||
// Running average by decay
|
||||
uprogress.statbytes *= .9
|
||||
uprogress.statdur *= .9
|
||||
store.uprogress.statbytes *= .9
|
||||
store.uprogress.statdur *= .9
|
||||
}
|
||||
}, 100)
|
||||
const statUpdate = ({name, size, start, end}: {name: string, size: number, start: number, end: number}) => {
|
||||
if (name !== uprogress.filename) return // If stats have been reset
|
||||
if (name !== store.uprogress.filename) return // If stats have been reset
|
||||
const now = Date.now()
|
||||
uprogress.uploaded = uprogress.filestart + end
|
||||
uprogress.filepos = end
|
||||
uprogress.statbytes += end - start
|
||||
uprogress.statdur += now - uprogress.tlast
|
||||
uprogress.tlast = now
|
||||
store.uprogress.xfer = store.uprogress.filestart + end
|
||||
store.uprogress.filepos = end
|
||||
store.uprogress.statbytes += end - start
|
||||
store.uprogress.statdur += now - store.uprogress.tlast
|
||||
store.uprogress.tlast = now
|
||||
// File finished?
|
||||
if (end === size) {
|
||||
uprogress.filestart += size
|
||||
store.uprogress.filestart += size
|
||||
statNextFile()
|
||||
if (++uprogress.fileidx >= uprogress.filecount) statReset()
|
||||
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
|
||||
}
|
||||
}
|
||||
const statNextFile = () => {
|
||||
const f = uprogress.files.shift()
|
||||
const f = store.uprogress.files.shift()
|
||||
if (!f) return statReset()
|
||||
uprogress.filepos = 0
|
||||
uprogress.filesize = f.file.size
|
||||
uprogress.filename = f.cloudName
|
||||
store.uprogress.filepos = 0
|
||||
store.uprogress.filesize = f.file.size
|
||||
store.uprogress.filename = f.cloudName
|
||||
}
|
||||
const statReset = () => {
|
||||
Object.assign(uprogress, uprogress_init)
|
||||
uprogress.t0 = Date.now()
|
||||
uprogress.tlast = uprogress.t0 + 1
|
||||
Object.assign(store.uprogress, uprogress_init)
|
||||
store.uprogress.t0 = Date.now()
|
||||
store.uprogress.tlast = store.uprogress.t0 + 1
|
||||
}
|
||||
const statsAdd = (f: CloudFile[]) => {
|
||||
if (uprogress.files.length === 0) statReset()
|
||||
uprogress.total += f.reduce((a, b) => a + b.file.size, 0)
|
||||
uprogress.filecount += f.length
|
||||
uprogress.files = [...uprogress.files, ...f]
|
||||
if (store.uprogress.files.length === 0) statReset()
|
||||
store.uprogress.total += f.reduce((a, b) => a + b.file.size, 0)
|
||||
store.uprogress.filecount += f.length
|
||||
store.uprogress.files = [...store.uprogress.files, ...f]
|
||||
statNextFile()
|
||||
}
|
||||
let upqueue = [] as CloudFile[]
|
||||
@ -190,7 +190,7 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
|
||||
// @ts-ignore
|
||||
ws.sendData = async (data: any) => {
|
||||
// Wait until the WS is ready to send another message
|
||||
uprogress.status = "uploading"
|
||||
store.uprogress.status = "uploading"
|
||||
await new Promise(resolve => {
|
||||
const t = setInterval(() => {
|
||||
if (ws.bufferedAmount > 1<<20) return
|
||||
@ -198,7 +198,7 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
|
||||
clearInterval(t)
|
||||
}, 1)
|
||||
})
|
||||
uprogress.status = "processing"
|
||||
store.uprogress.status = "processing"
|
||||
ws.send(data)
|
||||
}
|
||||
})
|
||||
@ -219,7 +219,7 @@ const worker = async () => {
|
||||
if (f.cloudPos === f.file.size) upqueue.shift()
|
||||
}
|
||||
if (upqueue.length) startWorker()
|
||||
uprogress.status = "idle"
|
||||
store.uprogress.status = "idle"
|
||||
workerRunning = false
|
||||
}
|
||||
let workerRunning: any = false
|
||||
@ -242,64 +242,3 @@ onUnmounted(() => {
|
||||
removeEventListener('drop', uploadHandler)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<template>
|
||||
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
|
||||
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
|
||||
</template>
|
||||
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" />
|
||||
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderInput.click()" />
|
||||
<div class="uploadprogress" v-if="uprogress.total" :style="`background: linear-gradient(to right, var(--bar) 0, var(--bar) ${percent}%, var(--nobar) ${percent}%, var(--nobar) 100%);`">
|
||||
<div class="statustext">
|
||||
<span v-if="uprogress.filecount > 1" class="index">
|
||||
[{{ uprogress.fileidx }}/{{ uprogress.filecount }}]
|
||||
</span>
|
||||
<span class="filename">{{ uprogress.filename.split('/').pop() }}
|
||||
<span v-if="uprogress.filesize > 1e7" class="percent">
|
||||
{{ (uprogress.filepos / uprogress.filesize * 100).toFixed(0) + '\u202F%' }}
|
||||
</span>
|
||||
</span>
|
||||
<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>
|
||||
<button class="close" @click="cancelUploads">❌</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.uploadprogress {
|
||||
--bar: var(--accent-color);
|
||||
--nobar: var(--header-background);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--primary-color);
|
||||
position: fixed;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100vw;
|
||||
}
|
||||
.statustext {
|
||||
display: flex;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
span {
|
||||
color: #ccc;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
.filename {
|
||||
color: #fff;
|
||||
flex: 1 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
}
|
||||
.index { min-width: 3.5em }
|
||||
.position { min-width: 4em }
|
||||
.speed { min-width: 4em }
|
||||
</style>
|
||||
@/stores/main
|
||||
|
@ -36,6 +36,23 @@ export class Doc {
|
||||
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 previewable(): boolean {
|
||||
if (this.img) return true
|
||||
const ext = this.name.split('.').pop()?.toLowerCase()
|
||||
// Not a comprehensive list, but good enough for now
|
||||
return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].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: {
|
||||
|
@ -1,6 +1,8 @@
|
||||
import Client from '@/repositories/Client'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
export const url_login = '/login'
|
||||
export const url_logout = '/logout'
|
||||
export const url_password = '/password-change'
|
||||
|
||||
export async function loginUser(username: string, password: string) {
|
||||
const user = await Client.post(url_login, {
|
||||
@ -13,3 +15,12 @@ export async function logoutUser() {
|
||||
const data = await Client.post(url_logout)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function changePassword(username: string, passwordChange: string, password: string) {
|
||||
const data = await Client.post(url_password, {
|
||||
username,
|
||||
passwordChange,
|
||||
password
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
@ -53,7 +53,7 @@ export const watchConnect = () => {
|
||||
if ('error' in msg) {
|
||||
if (msg.error.code === 401) {
|
||||
store.user.isLoggedIn = false
|
||||
store.user.isOpenLoginModal = true
|
||||
store.dialog = 'login'
|
||||
} else {
|
||||
store.error = msg.error.message
|
||||
}
|
||||
@ -67,7 +67,7 @@ export const watchConnect = () => {
|
||||
store.error = ''
|
||||
if (msg.user) store.login(msg.user.username, msg.user.privileged)
|
||||
else if (store.isUserLogged) store.logout()
|
||||
if (!msg.server.public && !msg.user) store.user.isOpenLoginModal = true
|
||||
if (!msg.server.public && !msg.user) store.dialog = 'login'
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -87,9 +87,14 @@ const watchReconnect = (event: MessageEvent) => {
|
||||
store.connected = false
|
||||
store.error = 'Reconnecting...'
|
||||
}
|
||||
if (watchTimeout !== null) clearTimeout(watchTimeout)
|
||||
// Don't hammer the server while on login dialog
|
||||
if (store.dialog === 'login') {
|
||||
watchTimeout = setTimeout(watchReconnect, 100)
|
||||
return
|
||||
}
|
||||
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, reconnDelay)
|
||||
}
|
||||
|
||||
@ -148,8 +153,8 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
||||
function handleError(msg: errorEvent) {
|
||||
const store = useMainStore()
|
||||
if (msg.error.code === 401) {
|
||||
store.user.isOpenLoginModal = true
|
||||
store.user.isLoggedIn = false
|
||||
store.dialog = 'login'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -1,42 +1,50 @@
|
||||
import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { defineStore } from 'pinia'
|
||||
import { defineStore, type StateTree } 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 User = {
|
||||
username: string
|
||||
privileged: boolean
|
||||
isOpenLoginModal: boolean
|
||||
isLoggedIn: boolean
|
||||
}
|
||||
|
||||
export const useMainStore = defineStore({
|
||||
id: 'main',
|
||||
state: () => ({
|
||||
document: shallowRef<Doc[]>([]),
|
||||
selected: new Set<FUID>(),
|
||||
selected: new Set<FUID>([]),
|
||||
query: '' as string,
|
||||
fileExplorer: null as any,
|
||||
error: '' as string,
|
||||
connected: false,
|
||||
cursor: '' as string,
|
||||
server: {} as Record<string, any>,
|
||||
dialog: '' as '' | 'login' | 'settings',
|
||||
uprogress: {} as any,
|
||||
dprogress: {} as any,
|
||||
prefs: {
|
||||
gallery: false,
|
||||
sortListing: '' as SortOrder,
|
||||
sortFiltered: '' as SortOrder,
|
||||
},
|
||||
user: {
|
||||
username: '',
|
||||
privileged: false,
|
||||
isLoggedIn: false,
|
||||
isOpenLoginModal: false
|
||||
} as User
|
||||
username: '' as string,
|
||||
privileged: false as boolean,
|
||||
isLoggedIn: false as boolean,
|
||||
}
|
||||
}),
|
||||
persist: {
|
||||
paths: ['prefs'],
|
||||
paths: ['prefs', 'cursor', 'selected'],
|
||||
serializer: {
|
||||
deserialize: (data: string): StateTree => {
|
||||
const ret = JSON.parse(data)
|
||||
ret.selected = new Set(ret.selected)
|
||||
return ret
|
||||
},
|
||||
serialize: (tree: StateTree): string => {
|
||||
tree.selected = Array.from(tree.selected)
|
||||
return JSON.stringify(tree)
|
||||
}
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
updateRoot(root: FileEntry[]) {
|
||||
@ -60,11 +68,11 @@ export const useMainStore = defineStore({
|
||||
this.user.username = username
|
||||
this.user.privileged = privileged
|
||||
this.user.isLoggedIn = true
|
||||
this.user.isOpenLoginModal = false
|
||||
this.dialog = ''
|
||||
if (!this.connected) watchConnect()
|
||||
},
|
||||
loginDialog() {
|
||||
this.user.isOpenLoginModal = true
|
||||
this.dialog = 'login'
|
||||
},
|
||||
async logout() {
|
||||
console.log("Logout")
|
||||
@ -77,6 +85,19 @@ export const useMainStore = defineStore({
|
||||
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()
|
||||
},
|
||||
cancelDownloads() {
|
||||
location.reload() // FIXME
|
||||
},
|
||||
cancelUploads() {
|
||||
location.reload() // FIXME
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
|
||||
|
8
frontend/src/utils/fileutil.ts
Normal file
8
frontend/src/utils/fileutil.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { useMainStore } from '@/stores/main'
|
||||
|
||||
|
||||
export const exists = (path: string[]) => {
|
||||
const store = useMainStore()
|
||||
const p = path.join('/')
|
||||
return store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
|
||||
}
|
@ -1,19 +1,32 @@
|
||||
<template>
|
||||
<FileExplorer
|
||||
<Gallery
|
||||
v-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.previewable)" class="suggest-gallery">
|
||||
<SvgButton name="eye" taborder=0 @click="() => { store.prefs.gallery = true }"></SvgButton>
|
||||
Gallery View
|
||||
</div>
|
||||
<EmptyFolder :documents=documents :path=props.path />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watchEffect, ref, computed } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import Router from '@/router/index'
|
||||
import { needleFormat, localeIncludes, collator } from '@/utils';
|
||||
import { sorted } from '@/utils/docsort';
|
||||
import { needleFormat, localeIncludes, collator } from '@/utils'
|
||||
import { sorted } from '@/utils/docsort'
|
||||
import FileExplorer from '@/components/FileExplorer.vue'
|
||||
|
||||
const store = useMainStore()
|
||||
const fileExplorer = ref()
|
||||
@ -64,3 +77,27 @@ watchEffect(() => {
|
||||
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 .3rem #000, 0 0 2rem #0008;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.suggest-gallery p {
|
||||
font-size: 2rem;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.suggest-gallery {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
@ -4,7 +4,6 @@ import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// @ts-ignore
|
||||
import pluginRewriteAll from 'vite-plugin-rewrite-all'
|
||||
import svgLoader from 'vite-svg-loader'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
|
||||
@ -21,7 +20,6 @@ const dev_backend = {
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
pluginRewriteAll(),
|
||||
svgLoader(), // import svg files
|
||||
Components(), // auto import components
|
||||
],
|
||||
@ -44,7 +42,9 @@ export default defineConfig({
|
||||
"/files": dev_backend,
|
||||
"/login": dev_backend,
|
||||
"/logout": dev_backend,
|
||||
"/password-change": dev_backend,
|
||||
"/zip": dev_backend,
|
||||
"/preview": dev_backend,
|
||||
}
|
||||
},
|
||||
build: {
|
||||
|
@ -22,8 +22,12 @@ dependencies = [
|
||||
"msgspec",
|
||||
"natsort",
|
||||
"pathvalidate",
|
||||
"pillow",
|
||||
"pyav",
|
||||
"pyjwt",
|
||||
"pymupdf",
|
||||
"sanic",
|
||||
"setproctitle",
|
||||
"stream-zip",
|
||||
"tomli_w",
|
||||
]
|
||||
@ -45,7 +49,7 @@ source = "vcs"
|
||||
|
||||
[tool.hatch.build]
|
||||
artifacts = ["cista/wwwroot"]
|
||||
hooks.custom.path = "scripts/build-frontend.py"
|
||||
targets.sdist.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.
|
||||
|
@ -1,5 +1,8 @@
|
||||
# noqa: INP001
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from sys import stderr
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||
|
||||
@ -7,6 +10,18 @@ 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
|
||||
stderr.write(">>> Building Cista frontend\n")
|
||||
npm = shutil.which("npm")
|
||||
if npm is None:
|
||||
raise RuntimeError(
|
||||
"NodeJS `npm` is required for building Cista but it was not found"
|
||||
)
|
||||
# npm --prefix doesn't work on Windows, so we chdir instead
|
||||
os.chdir("frontend")
|
||||
try:
|
||||
stderr.write("### npm install\n")
|
||||
subprocess.run([npm, "install"], check=True) # noqa: S603
|
||||
stderr.write("\n### npm run build\n")
|
||||
subprocess.run([npm, "run", "build"], check=True) # noqa: S603
|
||||
finally:
|
||||
os.chdir("..")
|
||||
|
@ -1,10 +1,7 @@
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
|
||||
from cista.protocol import FileEntry, UpdateMessage, UpdDel, UpdIns, UpdKeep
|
||||
from cista.watching import State, format_update
|
||||
from cista.watching import format_update
|
||||
|
||||
|
||||
def decode(data: str):
|
||||
@ -37,6 +34,14 @@ def test_insertions():
|
||||
assert decode(format_update(old_list, new_list)) == expected
|
||||
|
||||
|
||||
def test_insertion_at_end():
|
||||
old_list = [*f(3), FileEntry(1, "xxx", "xxx", 0, 0, 1)]
|
||||
newfile = FileEntry(1, "yyy", "yyy", 0, 0, 1)
|
||||
new_list = [*old_list, newfile]
|
||||
expected = [UpdKeep(4), UpdIns([newfile])]
|
||||
assert decode(format_update(old_list, new_list)) == expected
|
||||
|
||||
|
||||
def test_deletions():
|
||||
old_list = f(3)
|
||||
new_list = [old_list[0], old_list[2]]
|
||||
@ -83,54 +88,3 @@ def test_longer_lists():
|
||||
def sortkey(name):
|
||||
# Define the sorting key for names here
|
||||
return name.lower()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def state():
|
||||
entries = [
|
||||
FileEntry(0, "", "root", 0, 0, 0),
|
||||
FileEntry(1, "bar", "bar", 0, 0, 0),
|
||||
FileEntry(2, "baz", "bar/baz", 0, 0, 0),
|
||||
FileEntry(1, "foo", "foo", 0, 0, 0),
|
||||
FileEntry(1, "xxx", "xxx", 0, 0, 0),
|
||||
FileEntry(2, "yyy", "xxx/yyy", 0, 0, 1),
|
||||
]
|
||||
s = State()
|
||||
s._listing = entries
|
||||
return s
|
||||
|
||||
|
||||
def test_existing_directory(state):
|
||||
path = PurePosixPath("bar")
|
||||
expected_slice = slice(1, 3) # Includes 'bar' and 'baz'
|
||||
assert state._slice(path) == expected_slice
|
||||
|
||||
|
||||
def test_existing_file(state):
|
||||
path = PurePosixPath("xxx/yyy")
|
||||
expected_slice = slice(5, 6) # Only includes 'yyy'
|
||||
assert state._slice(path) == expected_slice
|
||||
|
||||
|
||||
def test_nonexistent_directory(state):
|
||||
path = PurePosixPath("zzz")
|
||||
expected_slice = slice(6, 6) # 'zzz' would be inserted at end
|
||||
assert state._slice(path) == expected_slice
|
||||
|
||||
|
||||
def test_nonexistent_file(state):
|
||||
path = (PurePosixPath("bar/mmm"), 1)
|
||||
expected_slice = slice(3, 3) # A file would be inserted after 'baz' under 'bar'
|
||||
assert state._slice(path) == expected_slice
|
||||
|
||||
|
||||
def test_root_directory(state):
|
||||
path = PurePosixPath()
|
||||
expected_slice = slice(0, 6) # Entire tree
|
||||
assert state._slice(path) == expected_slice
|
||||
|
||||
|
||||
def test_directory_with_subdirs_and_files(state):
|
||||
path = PurePosixPath("xxx")
|
||||
expected_slice = slice(4, 6) # Includes 'xxx' and 'yyy'
|
||||
assert state._slice(path) == expected_slice
|
||||
|
Loading…
x
Reference in New Issue
Block a user