Compare commits
29 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 |
@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -61,6 +62,7 @@ def _main():
|
|||||||
path = None
|
path = None
|
||||||
_confdir(args)
|
_confdir(args)
|
||||||
exists = config.conffile.exists()
|
exists = config.conffile.exists()
|
||||||
|
print(config.conffile, exists)
|
||||||
import_droppy = args["--import-droppy"]
|
import_droppy = args["--import-droppy"]
|
||||||
necessary_opts = exists or import_droppy or path
|
necessary_opts = exists or import_droppy or path
|
||||||
if not necessary_opts:
|
if not necessary_opts:
|
||||||
@ -117,7 +119,8 @@ def _confdir(args):
|
|||||||
raise ValueError("Config path is not a directory")
|
raise ValueError("Config path is not a directory")
|
||||||
# Accidentally pointed to the db.toml, use parent
|
# Accidentally pointed to the db.toml, use parent
|
||||||
confdir = confdir.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):
|
def _user(args):
|
||||||
|
17
cista/app.py
17
cista/app.py
@ -3,6 +3,7 @@ import datetime
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import threading
|
import threading
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from multiprocessing import cpu_count
|
||||||
from pathlib import Path, PurePath, PurePosixPath
|
from pathlib import Path, PurePath, PurePosixPath
|
||||||
from stat import S_IFDIR, S_IFREG
|
from stat import S_IFDIR, S_IFREG
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
@ -11,9 +12,10 @@ from wsgiref.handlers import format_date_time
|
|||||||
import brotli
|
import brotli
|
||||||
import sanic.helpers
|
import sanic.helpers
|
||||||
from blake3 import blake3
|
from blake3 import blake3
|
||||||
from sanic import Blueprint, Sanic, empty, raw
|
from sanic import Blueprint, Sanic, empty, raw, redirect
|
||||||
from sanic.exceptions import Forbidden, NotFound
|
from sanic.exceptions import Forbidden, NotFound
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
|
from setproctitle import setproctitle
|
||||||
from stream_zip import ZIP_AUTO, stream_zip
|
from stream_zip import ZIP_AUTO, stream_zip
|
||||||
|
|
||||||
from cista import auth, config, preview, session, watching
|
from cista import auth, config, preview, session, watching
|
||||||
@ -30,11 +32,16 @@ app.blueprint(bp)
|
|||||||
app.exception(Exception)(handle_sanic_exception)
|
app.exception(Exception)(handle_sanic_exception)
|
||||||
|
|
||||||
|
|
||||||
|
setproctitle("cista-main")
|
||||||
|
|
||||||
|
|
||||||
@app.before_server_start
|
@app.before_server_start
|
||||||
async def main_start(app, loop):
|
async def main_start(app, loop):
|
||||||
config.load_config()
|
config.load_config()
|
||||||
|
setproctitle(f"cista {config.config.path.name}")
|
||||||
|
workers = max(2, min(8, cpu_count()))
|
||||||
app.ctx.threadexec = ThreadPoolExecutor(
|
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)
|
await watching.start(app, loop)
|
||||||
|
|
||||||
@ -198,6 +205,12 @@ async def wwwroot(req, path=""):
|
|||||||
return raw(data, headers=headers)
|
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]]:
|
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
||||||
loc = PurePosixPath()
|
loc = PurePosixPath()
|
||||||
idx = 0
|
idx = 0
|
||||||
|
@ -159,3 +159,35 @@ async def logout_post(request):
|
|||||||
res = json({"message": msg})
|
res = json({"message": msg})
|
||||||
session.delete(res)
|
session.delete(res)
|
||||||
return 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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import suppress
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from pathlib import Path, PurePath
|
from pathlib import Path, PurePath
|
||||||
@ -33,7 +35,23 @@ class Link(msgspec.Struct, omit_defaults=True):
|
|||||||
|
|
||||||
|
|
||||||
config = None
|
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:
|
def derived_secret(*params, len=8) -> bytes:
|
||||||
@ -61,8 +79,8 @@ def dec_hook(typ, obj):
|
|||||||
|
|
||||||
def config_update(modify):
|
def config_update(modify):
|
||||||
global config
|
global config
|
||||||
if not conffile.exists():
|
if conffile is None:
|
||||||
conffile.parent.mkdir(parents=True, exist_ok=True)
|
init_confdir()
|
||||||
tmpname = conffile.with_suffix(".tmp")
|
tmpname = conffile.with_suffix(".tmp")
|
||||||
try:
|
try:
|
||||||
f = tmpname.open("xb")
|
f = tmpname.open("xb")
|
||||||
@ -76,10 +94,6 @@ def config_update(modify):
|
|||||||
old = conffile.read_bytes()
|
old = conffile.read_bytes()
|
||||||
c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook)
|
c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook)
|
||||||
except FileNotFoundError:
|
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""
|
old = b""
|
||||||
c = None
|
c = None
|
||||||
c = modify(c)
|
c = modify(c)
|
||||||
@ -92,7 +106,9 @@ def config_update(modify):
|
|||||||
f.write(new)
|
f.write(new)
|
||||||
f.close()
|
f.close()
|
||||||
if sys.platform == "win32":
|
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
|
tmpname.rename(conffile) # Atomic replace
|
||||||
except:
|
except:
|
||||||
f.close()
|
f.close()
|
||||||
@ -120,6 +136,8 @@ def modifies_config(modify):
|
|||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
global config
|
global config
|
||||||
|
if conffile is None:
|
||||||
|
init_confdir()
|
||||||
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
|
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,52 +1,117 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import gc
|
||||||
import io
|
import io
|
||||||
|
import mimetypes
|
||||||
|
import urllib.parse
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from urllib.parse import unquote
|
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 PIL import Image
|
||||||
from sanic import Blueprint, raw
|
from sanic import Blueprint, empty, raw
|
||||||
from sanic.exceptions import Forbidden, NotFound
|
from sanic.exceptions import NotFound
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
|
|
||||||
from cista import config
|
from cista import config
|
||||||
from cista.util.filename import sanitize
|
from cista.util.filename import sanitize
|
||||||
|
|
||||||
|
DISPLAYMATRIX = av.stream.SideData.DISPLAYMATRIX
|
||||||
|
|
||||||
bp = Blueprint("preview", url_prefix="/preview")
|
bp = Blueprint("preview", url_prefix="/preview")
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<path:path>")
|
@bp.get("/<path:path>")
|
||||||
async def preview(req, path):
|
async def preview(req, path):
|
||||||
"""Preview a file"""
|
"""Preview a file"""
|
||||||
width = int(req.query_string) if req.query_string else 1024
|
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)))
|
rel = PurePosixPath(sanitize(unquote(path)))
|
||||||
path = config.config.path / rel
|
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():
|
if not path.is_file():
|
||||||
raise NotFound("File not found")
|
raise NotFound("File not found")
|
||||||
size = path.lstat().st_size
|
|
||||||
if size > 20 * 10**6:
|
|
||||||
raise Forbidden("File too large")
|
|
||||||
img = await asyncio.get_event_loop().run_in_executor(
|
img = await asyncio.get_event_loop().run_in_executor(
|
||||||
req.app.ctx.threadexec, process_image, path, width
|
req.app.ctx.threadexec, dispatch, path, quality, maxsize, maxzoom
|
||||||
)
|
)
|
||||||
return raw(img, content_type="image/webp")
|
return raw(img, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
def process_image(path, maxsize):
|
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)
|
img = Image.open(path)
|
||||||
w, h = img.size
|
w, h = img.size
|
||||||
img.thumbnail((min(w, maxsize), min(h, maxsize)))
|
img.thumbnail((min(w, maxsize), min(h, maxsize)))
|
||||||
# Fix rotation based on EXIF data
|
# Fix rotation based on EXIF data
|
||||||
try:
|
try:
|
||||||
rotate_values = {3: 180, 6: 270, 8: 90}
|
rotate_values = {3: 180, 6: 270, 8: 90}
|
||||||
exif = img._getexif()
|
orientation = img._getexif().get(274)
|
||||||
if exif:
|
|
||||||
orientation = exif.get(274)
|
|
||||||
if orientation in rotate_values:
|
if orientation in rotate_values:
|
||||||
logger.debug(f"Rotating preview {path} by {rotate_values[orientation]}")
|
logger.debug(f"Rotating preview {path} by {rotate_values[orientation]}")
|
||||||
img = img.rotate(rotate_values[orientation], expand=True)
|
img = img.rotate(rotate_values[orientation], expand=True)
|
||||||
|
except AttributeError:
|
||||||
|
...
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error rotating preview image: {e}")
|
logger.error(f"Error rotating preview image: {e}")
|
||||||
# Save as webp
|
# Save as webp
|
||||||
imgdata = io.BytesIO()
|
imgdata = io.BytesIO()
|
||||||
img.save(imgdata, format="webp", quality=70, method=6)
|
img.save(imgdata, format="webp", quality=quality, method=4)
|
||||||
return imgdata.getvalue()
|
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
|
## Directory listings
|
||||||
|
|
||||||
|
|
||||||
class FileEntry(msgspec.Struct, array_like=True):
|
class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
||||||
level: int
|
level: int
|
||||||
name: str
|
name: str
|
||||||
key: str
|
key: str
|
||||||
@ -120,9 +120,12 @@ class FileEntry(msgspec.Struct, array_like=True):
|
|||||||
size: int
|
size: int
|
||||||
isfile: int
|
isfile: int
|
||||||
|
|
||||||
def __repr__(self):
|
def __str__(self):
|
||||||
return self.key or "FileEntry()"
|
return self.key or "FileEntry()"
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"{self.name} ({self.size}, {self.mtime})"
|
||||||
|
|
||||||
|
|
||||||
class Update(msgspec.Struct, array_like=True):
|
class Update(msgspec.Struct, array_like=True):
|
||||||
...
|
...
|
||||||
|
@ -26,7 +26,6 @@ def run(*, dev=False):
|
|||||||
motd=False,
|
motd=False,
|
||||||
dev=dev,
|
dev=dev,
|
||||||
auto_reload=dev,
|
auto_reload=dev,
|
||||||
reload_dir={confdir},
|
|
||||||
access_log=True,
|
access_log=True,
|
||||||
) # type: ignore
|
) # type: ignore
|
||||||
if dev:
|
if dev:
|
||||||
|
@ -59,7 +59,7 @@ def websocket_wrapper(handler):
|
|||||||
code = e.status_code
|
code = e.status_code
|
||||||
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
||||||
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
|
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
|
||||||
if not getattr(e, "quiet", False):
|
if not getattr(e, "quiet", False) or code == 500:
|
||||||
logger.exception(f"{code} {e!r}")
|
logger.exception(f"{code} {e!r}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ class State:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self._space = Space(0, 0, 0, 0)
|
self._space = Space(0, 0, 0, 0)
|
||||||
self._listing: list[FileEntry] = []
|
self.root: list[FileEntry] = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def space(self):
|
def space(self):
|
||||||
@ -36,80 +36,70 @@ class State:
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
self._space = space
|
self._space = space
|
||||||
|
|
||||||
@property
|
|
||||||
def root(self) -> list[FileEntry]:
|
|
||||||
with self.lock:
|
|
||||||
return self._listing[:]
|
|
||||||
|
|
||||||
@root.setter
|
def treeiter(rootmod):
|
||||||
def root(self, listing: list[FileEntry]):
|
relpath = PurePosixPath()
|
||||||
with self.lock:
|
for i, entry in enumerate(rootmod):
|
||||||
self._listing = listing
|
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)
|
def treeget(rootmod: list[FileEntry], path: PurePosixPath):
|
||||||
begin, end = 0, len(self._listing)
|
begin = None
|
||||||
level = 0
|
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
|
isfile = 0
|
||||||
|
level = 0
|
||||||
# Special case for root
|
i = 0
|
||||||
if not relpath.parts:
|
for i, rel, entry in treeiter(rootmod):
|
||||||
return slice(begin, end)
|
if entry.level > level:
|
||||||
|
# We haven't found item at level, skip subdirectories
|
||||||
begin += 1
|
continue
|
||||||
for part in relpath.parts:
|
|
||||||
level += 1
|
|
||||||
found = False
|
|
||||||
|
|
||||||
while begin < end:
|
|
||||||
entry = self._listing[begin]
|
|
||||||
|
|
||||||
if entry.level < level:
|
if entry.level < level:
|
||||||
break
|
# We have passed the level, so the new item is the first
|
||||||
|
return i
|
||||||
if entry.level == level:
|
if level == 0:
|
||||||
if entry.name == part:
|
# root
|
||||||
found = True
|
level += 1
|
||||||
if level == len(relpath.parts):
|
continue
|
||||||
isfile = relfile
|
ename = rel.parts[level - 1]
|
||||||
else:
|
name = relpath.parts[level - 1]
|
||||||
begin += 1
|
esort = sortkey(ename)
|
||||||
break
|
nsort = sortkey(name)
|
||||||
cmp = entry.isfile - isfile or sortkey(entry.name) > sortkey(part)
|
# Non-leaf are always folders, only use relfile at leaf
|
||||||
if cmp > 0:
|
isfile = relfile if len(relpath.parts) == level else 0
|
||||||
break
|
# First compare by isfile, then by sorting order and if that too matches then case sensitive
|
||||||
|
cmp = (
|
||||||
begin += 1
|
entry.isfile - isfile
|
||||||
|
or (esort > nsort) - (esort < nsort)
|
||||||
if not found:
|
or (ename > name) - (ename < name)
|
||||||
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}"
|
|
||||||
)
|
)
|
||||||
self._listing[self._slice(index)] = value
|
if cmp > 0:
|
||||||
|
return i
|
||||||
def __delitem__(self, relpath: PurePosixPath):
|
if cmp < 0:
|
||||||
with self.lock:
|
continue
|
||||||
del self._listing[self._slice(relpath)]
|
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()
|
state = State()
|
||||||
@ -124,7 +114,7 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
ret = []
|
ret = []
|
||||||
try:
|
try:
|
||||||
st = stat or path.stat()
|
st = stat or path.stat()
|
||||||
isfile = not S_ISDIR(st.st_mode)
|
isfile = int(not S_ISDIR(st.st_mode))
|
||||||
entry = FileEntry(
|
entry = FileEntry(
|
||||||
level=len(rel.parts),
|
level=len(rel.parts),
|
||||||
name=rel.name,
|
name=rel.name,
|
||||||
@ -136,7 +126,7 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
if isfile:
|
if isfile:
|
||||||
return [entry]
|
return [entry]
|
||||||
# Walk all entries of the directory
|
# Walk all entries of the directory
|
||||||
ret = [entry]
|
ret: list[FileEntry] = [...] # type: ignore
|
||||||
li = []
|
li = []
|
||||||
for f in path.iterdir():
|
for f in path.iterdir():
|
||||||
if quit.is_set():
|
if quit.is_set():
|
||||||
@ -153,42 +143,57 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
# Build the tree as a list of FileEntries
|
# Build the tree as a list of FileEntries
|
||||||
for [_, name, s] in humansorted(li):
|
for [_, name, s] in humansorted(li):
|
||||||
sub = walk(rel / name, stat=s)
|
sub = walk(rel / name, stat=s)
|
||||||
ret.extend(sub)
|
|
||||||
child = sub[0]
|
child = sub[0]
|
||||||
entry.mtime = max(entry.mtime, child.mtime)
|
entry = FileEntry(
|
||||||
entry.size += child.size
|
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:
|
except FileNotFoundError:
|
||||||
pass # Things may be rapidly in motion
|
pass # Things may be rapidly in motion
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
if e.errno == 13: # Permission denied
|
if e.errno == 13: # Permission denied
|
||||||
pass
|
pass
|
||||||
logger.error(f"Watching {path=}: {e!r}")
|
logger.error(f"Watching {path=}: {e!r}")
|
||||||
|
if ret:
|
||||||
|
ret[0] = entry
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
def update_root(loop):
|
def update_root(loop):
|
||||||
"""Full filesystem scan"""
|
"""Full filesystem scan"""
|
||||||
new = walk(PurePosixPath())
|
|
||||||
with state.lock:
|
|
||||||
old = state.root
|
old = state.root
|
||||||
|
new = walk(PurePosixPath())
|
||||||
if old != new:
|
if old != new:
|
||||||
|
update = format_update(old, new)
|
||||||
|
with state.lock:
|
||||||
|
broadcast(update, loop)
|
||||||
state.root = new
|
state.root = new
|
||||||
broadcast(format_update(old, new), loop)
|
|
||||||
|
|
||||||
|
|
||||||
def update_path(relpath: PurePosixPath, loop):
|
def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
|
||||||
"""Called on FS updates, check the filesystem and broadcast any changes."""
|
"""Called on FS updates, check the filesystem and broadcast any changes."""
|
||||||
new = walk(relpath)
|
new = walk(relpath)
|
||||||
with state.lock:
|
obegin, old = treeget(rootmod, relpath)
|
||||||
old = state[relpath]
|
|
||||||
if old == new:
|
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
|
return
|
||||||
old = state.root
|
if obegin is not None:
|
||||||
|
del rootmod[obegin : obegin + len(old)]
|
||||||
if new:
|
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:
|
else:
|
||||||
del state[relpath]
|
logger.debug(f"Watch: Removed {relpath}")
|
||||||
broadcast(format_update(old, state.root), loop)
|
|
||||||
|
|
||||||
|
|
||||||
def update_space(loop):
|
def update_space(loop):
|
||||||
@ -210,40 +215,57 @@ def update_space(loop):
|
|||||||
def format_update(old, new):
|
def format_update(old, new):
|
||||||
# Make keep/del/insert diff until one of the lists ends
|
# Make keep/del/insert diff until one of the lists ends
|
||||||
oidx, nidx = 0, 0
|
oidx, nidx = 0, 0
|
||||||
|
oremain, nremain = set(old), set(new)
|
||||||
update = []
|
update = []
|
||||||
keep_count = 0
|
keep_count = 0
|
||||||
while oidx < len(old) and nidx < len(new):
|
while oidx < len(old) and nidx < len(new):
|
||||||
|
modified = False
|
||||||
|
# Matching entries are kept
|
||||||
if old[oidx] == new[nidx]:
|
if old[oidx] == new[nidx]:
|
||||||
|
entry = old[oidx]
|
||||||
|
oremain.remove(entry)
|
||||||
|
nremain.remove(entry)
|
||||||
keep_count += 1
|
keep_count += 1
|
||||||
oidx += 1
|
oidx += 1
|
||||||
nidx += 1
|
nidx += 1
|
||||||
continue
|
continue
|
||||||
if keep_count > 0:
|
if keep_count > 0:
|
||||||
|
modified = True
|
||||||
update.append(UpdKeep(keep_count))
|
update.append(UpdKeep(keep_count))
|
||||||
keep_count = 0
|
keep_count = 0
|
||||||
|
|
||||||
|
# Items only in old are deleted
|
||||||
del_count = 0
|
del_count = 0
|
||||||
rest = new[nidx:]
|
while oidx < len(old) and old[oidx] not in nremain:
|
||||||
while oidx < len(old) and old[oidx] not in rest:
|
oremain.remove(old[oidx])
|
||||||
del_count += 1
|
del_count += 1
|
||||||
oidx += 1
|
oidx += 1
|
||||||
if del_count:
|
if del_count:
|
||||||
update.append(UpdDel(del_count))
|
update.append(UpdDel(del_count))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Items only in new are inserted
|
||||||
insert_items = []
|
insert_items = []
|
||||||
rest = old[oidx:]
|
while nidx < len(new) and new[nidx] not in oremain:
|
||||||
while nidx < len(new) and new[nidx] not in rest:
|
entry = new[nidx]
|
||||||
insert_items.append(new[nidx])
|
nremain.remove(entry)
|
||||||
|
insert_items.append(entry)
|
||||||
nidx += 1
|
nidx += 1
|
||||||
|
if insert_items:
|
||||||
|
modified = True
|
||||||
update.append(UpdIns(insert_items))
|
update.append(UpdIns(insert_items))
|
||||||
|
|
||||||
|
if not modified:
|
||||||
|
raise Exception(
|
||||||
|
f"Infinite loop in diff {nidx=} {oidx=} {len(old)=} {len(new)=}"
|
||||||
|
)
|
||||||
|
|
||||||
# Diff any remaining
|
# Diff any remaining
|
||||||
if keep_count > 0:
|
if keep_count > 0:
|
||||||
update.append(UpdKeep(keep_count))
|
update.append(UpdKeep(keep_count))
|
||||||
if oidx < len(old):
|
if oremain:
|
||||||
update.append(UpdDel(len(old) - oidx))
|
update.append(UpdDel(len(oremain)))
|
||||||
elif nidx < len(new):
|
elif nremain:
|
||||||
update.append(UpdIns(new[nidx:]))
|
update.append(UpdIns(new[nidx:]))
|
||||||
|
|
||||||
return msgspec.json.encode({"update": update}).decode()
|
return msgspec.json.encode({"update": update}).decode()
|
||||||
@ -289,13 +311,14 @@ def watcher_inotify(loop):
|
|||||||
while not quit.is_set():
|
while not quit.is_set():
|
||||||
i = inotify.adapters.InotifyTree(rootpath.as_posix())
|
i = inotify.adapters.InotifyTree(rootpath.as_posix())
|
||||||
# Initialize the tree from filesystem
|
# Initialize the tree from filesystem
|
||||||
|
t0 = time.perf_counter()
|
||||||
update_root(loop)
|
update_root(loop)
|
||||||
trefresh = time.monotonic() + 30.0
|
t1 = time.perf_counter()
|
||||||
|
logger.debug(f"Root update took {t1 - t0:.1f}s")
|
||||||
|
trefresh = time.monotonic() + 300.0
|
||||||
tspace = time.monotonic() + 5.0
|
tspace = time.monotonic() + 5.0
|
||||||
# Watch for changes (frequent wakeups needed for quiting)
|
# Watch for changes (frequent wakeups needed for quiting)
|
||||||
for event in i.event_gen(timeout_s=0.1):
|
while not quit.is_set():
|
||||||
if quit.is_set():
|
|
||||||
break
|
|
||||||
t = time.monotonic()
|
t = time.monotonic()
|
||||||
# The watching is not entirely reliable, so do a full refresh every 30 seconds
|
# The watching is not entirely reliable, so do a full refresh every 30 seconds
|
||||||
if t >= trefresh:
|
if t >= trefresh:
|
||||||
@ -304,10 +327,40 @@ def watcher_inotify(loop):
|
|||||||
if t >= tspace:
|
if t >= tspace:
|
||||||
tspace = time.monotonic() + 5.0
|
tspace = time.monotonic() + 5.0
|
||||||
update_space(loop)
|
update_space(loop)
|
||||||
# Inotify event, update the tree
|
# Inotify events, update the tree
|
||||||
if event and any(f in modified_flags for f in event[1]):
|
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
|
# Update modified path
|
||||||
update_path(PurePosixPath(event[2]) / event[3], loop)
|
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
|
del i # Free the inotify object
|
||||||
|
|
||||||
|
BIN
docs/cista.webp
BIN
docs/cista.webp
Binary file not shown.
Before Width: | Height: | Size: 363 KiB After Width: | Height: | Size: 40 KiB |
2
frontend/.npmrc
Normal file
2
frontend/.npmrc
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
audit=false
|
||||||
|
fund=false
|
@ -8,5 +8,4 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@400;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@400;700&display=swap" rel="stylesheet">
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
<body id="app">
|
||||||
<div id="app"></div>
|
|
||||||
|
@ -12,6 +12,9 @@
|
|||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@imengyu/vue3-context-menu": "^1.3.3",
|
"@imengyu/vue3-context-menu": "^1.3.3",
|
||||||
"@vueuse/core": "^10.4.1",
|
"@vueuse/core": "^10.4.1",
|
||||||
@ -21,7 +24,6 @@
|
|||||||
"pinia": "^2.1.6",
|
"pinia": "^2.1.6",
|
||||||
"pinia-plugin-persistedstate": "^3.2.0",
|
"pinia-plugin-persistedstate": "^3.2.0",
|
||||||
"unplugin-vue-components": "^0.25.2",
|
"unplugin-vue-components": "^0.25.2",
|
||||||
"vite-plugin-rewrite-all": "^1.0.1",
|
|
||||||
"vite-svg-loader": "^4.0.0",
|
"vite-svg-loader": "^4.0.0",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
"vue-router": "^4.2.4"
|
"vue-router": "^4.2.4"
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<LoginModal />
|
<LoginModal />
|
||||||
|
<SettingsModal />
|
||||||
<header>
|
<header>
|
||||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
||||||
<HeaderSelected :path="path.pathList" />
|
<HeaderSelected :path="path.pathList" />
|
||||||
@ -9,6 +10,10 @@
|
|||||||
<main>
|
<main>
|
||||||
<RouterView :path="path.pathList" :query="path.query" />
|
<RouterView :path="path.pathList" :query="path.query" />
|
||||||
</main>
|
</main>
|
||||||
|
<footer>
|
||||||
|
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
|
||||||
|
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
|
||||||
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@ -22,6 +27,7 @@ import { useMainStore } from '@/stores/main'
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import Router from '@/router/index'
|
import Router from '@/router/index'
|
||||||
import type { SortOrder } from './utils/docsort'
|
import type { SortOrder } from './utils/docsort'
|
||||||
|
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||||
|
|
||||||
interface Path {
|
interface Path {
|
||||||
path: string
|
path: string
|
||||||
@ -49,6 +55,13 @@ const headerMain = ref<typeof HeaderMain | null>(null)
|
|||||||
let vert = 0
|
let vert = 0
|
||||||
let timer: any = null
|
let timer: any = null
|
||||||
const globalShortcutHandler = (event: KeyboardEvent) => {
|
const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||||
|
if (store.dialog) {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
const fileExplorer = store.fileExplorer as any
|
const fileExplorer = store.fileExplorer as any
|
||||||
if (!fileExplorer) return
|
if (!fileExplorer) return
|
||||||
const c = fileExplorer.isCursor()
|
const c = fileExplorer.isCursor()
|
||||||
@ -69,13 +82,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
//console.log("key pressed", event)
|
//console.log("key pressed", event)
|
||||||
/// Long if-else machina for all keys we handle here
|
/// Long if-else machina for all keys we handle here
|
||||||
let arrow = ''
|
let arrow = ''
|
||||||
if (event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase()
|
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
|
// Find: process on keydown so that we can bypass the built-in search hotkey
|
||||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
||||||
headerMain.value!.toggleSearchInput()
|
headerMain.value!.toggleSearchInput()
|
||||||
}
|
}
|
||||||
// Search also on / (UNIX style)
|
// Search also on / (UNIX style)
|
||||||
else if (keyup && !input && event.key === '/') {
|
else if (!input && keyup && event.key === '/') {
|
||||||
headerMain.value!.toggleSearchInput()
|
headerMain.value!.toggleSearchInput()
|
||||||
}
|
}
|
||||||
// Globally close search, clear errors on Escape
|
// Globally close search, clear errors on Escape
|
||||||
@ -88,7 +101,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
Router.back()
|
Router.back()
|
||||||
}
|
}
|
||||||
// Select all (toggle); keydown to precede and prevent builtin
|
// Select all (toggle); keydown to precede and prevent builtin
|
||||||
else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
|
else if (!input && !keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
|
||||||
fileExplorer.toggleSelectAll()
|
fileExplorer.toggleSelectAll()
|
||||||
}
|
}
|
||||||
// G toggles Gallery
|
// G toggles Gallery
|
||||||
@ -104,11 +117,11 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
|
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
|
||||||
}
|
}
|
||||||
// Rename
|
// 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()
|
fileExplorer.cursorRename()
|
||||||
}
|
}
|
||||||
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
|
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
|
||||||
else if (c && event.code === 'Space') {
|
else if (!input && c && event.code === 'Space') {
|
||||||
if (keyup && !event.altKey && !event.ctrlKey)
|
if (keyup && !event.altKey && !event.ctrlKey)
|
||||||
fileExplorer.cursorSelect()
|
fileExplorer.cursorSelect()
|
||||||
}
|
}
|
||||||
|
@ -89,9 +89,10 @@
|
|||||||
--header-background: none;
|
--header-background: none;
|
||||||
--header-color: black;
|
--header-color: black;
|
||||||
}
|
}
|
||||||
nav,
|
.headermain,
|
||||||
.menu,
|
.menu,
|
||||||
.rename-button {
|
.rename-button,
|
||||||
|
.suggest-gallery {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
.breadcrumb > a {
|
.breadcrumb > a {
|
||||||
@ -107,9 +108,12 @@
|
|||||||
}
|
}
|
||||||
.breadcrumb svg {
|
.breadcrumb svg {
|
||||||
fill: black !important;
|
fill: black !important;
|
||||||
|
margin: 0 .5rem 0 1rem !important;
|
||||||
|
}
|
||||||
|
body#app {
|
||||||
|
height: auto !important;
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
height: auto !important;
|
|
||||||
padding-bottom: 0 !important;
|
padding-bottom: 0 !important;
|
||||||
}
|
}
|
||||||
thead tr {
|
thead tr {
|
||||||
@ -118,6 +122,17 @@
|
|||||||
background: none !important;
|
background: none !important;
|
||||||
border-bottom: 1pt solid black !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 {
|
.selection {
|
||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
@ -141,10 +156,6 @@ html {
|
|||||||
font-size: var(--root-font-size);
|
font-size: var(--root-font-size);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
/* Hide scrollbar for all browsers */
|
|
||||||
main::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
main {
|
main {
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
@ -161,6 +172,7 @@ tbody .modified {
|
|||||||
font-family: 'Roboto Mono';
|
font-family: 'Roboto Mono';
|
||||||
}
|
}
|
||||||
header {
|
header {
|
||||||
|
flex: 0 0 auto;
|
||||||
background-color: var(--header-background);
|
background-color: var(--header-background);
|
||||||
color: var(--header-color);
|
color: var(--header-color);
|
||||||
font-size: var(--header-font-size);
|
font-size: var(--header-font-size);
|
||||||
@ -202,21 +214,23 @@ table {
|
|||||||
border: 0;
|
border: 0;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
#app {
|
body#app {
|
||||||
height: 100%;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
header nav.headermain {
|
||||||
/* Position so that tooltips can appear on top of other positioned elements */
|
/* Position so that tooltips can appear on top of other positioned elements */
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
main {
|
|
||||||
height: calc(100svh - var(--header-height));
|
|
||||||
padding-bottom: 3em; /* convenience space on the bottom */
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
.spacer { flex-grow: 1 }
|
.spacer { flex-grow: 1 }
|
||||||
.smallgap { flex-shrink: 1; width: 2em }
|
.smallgap { flex-shrink: 1; width: 2em }
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
>
|
>
|
||||||
<a href="#/"
|
<a href="#/"
|
||||||
:ref="el => setLinkRef(0, el)"
|
:ref="el => setLinkRef(0, el)"
|
||||||
|
class="home"
|
||||||
:class="{ current: !!isCurrent(0) }"
|
:class="{ current: !!isCurrent(0) }"
|
||||||
:aria-current="isCurrent(0)"
|
:aria-current="isCurrent(0)"
|
||||||
@click.prevent="navigate(0)"
|
@click.prevent="navigate(0)"
|
||||||
@ -33,6 +34,7 @@
|
|||||||
import home from '@/assets/svg/home.svg'
|
import home from '@/assets/svg/home.svg'
|
||||||
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { exists } from '@/utils/fileutil'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@ -85,6 +87,15 @@ watchEffect(() => {
|
|||||||
else if (props.path.length > longcut.length) {
|
else if (props.path.length > longcut.length) {
|
||||||
longest.value = longcut.concat(props.path.slice(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 needed, focus primary navigation to new location
|
||||||
if (props.primary) nextTick(() => {
|
if (props.primary) nextTick(() => {
|
||||||
const act = document.activeElement as HTMLElement
|
const act = document.activeElement as HTMLElement
|
||||||
@ -111,6 +122,7 @@ watchEffect(() => {
|
|||||||
min-height: 2em;
|
min-height: 2em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 1em 0 0;
|
padding: 0 1em 0 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.breadcrumb > a {
|
.breadcrumb > a {
|
||||||
flex: 0 4 auto;
|
flex: 0 4 auto;
|
||||||
|
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>
|
@ -80,8 +80,6 @@ import { connect, controlUrl } from '@/repositories/WS'
|
|||||||
import { formatSize } from '@/utils'
|
import { formatSize } from '@/utils'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
import type { SortOrder } from '@/utils/docsort'
|
|
||||||
import type SvgButtonVue from './SvgButton.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
@ -117,6 +115,7 @@ const rename = (doc: Doc, newName: string) => {
|
|||||||
}
|
}
|
||||||
defineExpose({
|
defineExpose({
|
||||||
newFolder() {
|
newFolder() {
|
||||||
|
console.log("New folder")
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
editing.value = new Doc({
|
editing.value = new Doc({
|
||||||
loc: loc.value,
|
loc: loc.value,
|
||||||
@ -126,6 +125,7 @@ defineExpose({
|
|||||||
mtime: now,
|
mtime: now,
|
||||||
size: 0,
|
size: 0,
|
||||||
})
|
})
|
||||||
|
store.cursor = editing.value.key
|
||||||
},
|
},
|
||||||
toggleSelectAll() {
|
toggleSelectAll() {
|
||||||
console.log('Select')
|
console.log('Select')
|
||||||
@ -219,7 +219,14 @@ let modifiedTimer: any = null
|
|||||||
const updateModified = () => {
|
const updateModified = () => {
|
||||||
nowkey.value = Math.floor(Date.now() / 1000)
|
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) })
|
onUnmounted(() => { clearInterval(modifiedTimer) })
|
||||||
const mkdir = (doc: Doc, name: string) => {
|
const mkdir = (doc: Doc, name: string) => {
|
||||||
const control = connect(controlUrl, {
|
const control = connect(controlUrl, {
|
||||||
|
@ -56,4 +56,10 @@ input#FileRenameInput {
|
|||||||
outline: none;
|
outline: none;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
.gallery input#FileRenameInput {
|
||||||
|
padding: .75em;
|
||||||
|
font-weight: 600;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,18 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
|
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
|
||||||
<GalleryFigure v-for="(doc, index) in documents" :key="doc.key" :doc="doc" :index="index">
|
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
|
||||||
<BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" v-if="showFolderBreadcrumb(index)" class="folder-change"/>
|
<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>
|
</GalleryFigure>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import FileRenameInput from './FileRenameInput.vue'
|
|
||||||
import { connect, controlUrl } from '@/repositories/WS'
|
import { connect, controlUrl } from '@/repositories/WS'
|
||||||
import { formatSize } from '@/utils'
|
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
import type { SortOrder } from '@/utils/docsort'
|
import type { SortOrder } from '@/utils/docsort'
|
||||||
@ -25,6 +29,7 @@ const store = useMainStore()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
// File rename
|
// File rename
|
||||||
const editing = shallowRef<Doc | null>(null)
|
const editing = shallowRef<Doc | null>(null)
|
||||||
|
const exit = () => { editing.value = null }
|
||||||
const rename = (doc: Doc, newName: string) => {
|
const rename = (doc: Doc, newName: string) => {
|
||||||
const oldName = doc.name
|
const oldName = doc.name
|
||||||
const control = connect(controlUrl, {
|
const control = connect(controlUrl, {
|
||||||
@ -65,6 +70,7 @@ defineExpose({
|
|||||||
mtime: now,
|
mtime: now,
|
||||||
size: 0,
|
size: 0,
|
||||||
})
|
})
|
||||||
|
store.cursor = editing.value.key
|
||||||
},
|
},
|
||||||
toggleSelectAll() {
|
toggleSelectAll() {
|
||||||
console.log('Select')
|
console.log('Select')
|
||||||
@ -107,6 +113,10 @@ defineExpose({
|
|||||||
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
const index =
|
const index =
|
||||||
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
|
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
|
let moveto
|
||||||
if (index === N) moveto = d > 0 ? 0 : N - 1
|
if (index === N) moveto = d > 0 ? 0 : N - 1
|
||||||
else {
|
else {
|
||||||
@ -114,7 +124,6 @@ defineExpose({
|
|||||||
// Wrapping either end, just land outside the list
|
// Wrapping either end, just land outside the list
|
||||||
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
|
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
|
||||||
}
|
}
|
||||||
console.log("Gallery cursorMove", d, index, moveto, moveto - index)
|
|
||||||
store.cursor = docs[moveto]?.key ?? ''
|
store.cursor = docs[moveto]?.key ?? ''
|
||||||
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
||||||
if (select) {
|
if (select) {
|
||||||
@ -159,13 +168,13 @@ watchEffect(() => {
|
|||||||
focusBreadcrumb()
|
focusBreadcrumb()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
let nowkey = ref(0)
|
onMounted(() => {
|
||||||
let modifiedTimer: any = null
|
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||||
const updateModified = () => {
|
if (active) {
|
||||||
nowkey.value = Math.floor(Date.now() / 1000)
|
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||||
}
|
active.focus()
|
||||||
onMounted(() => { updateModified(); modifiedTimer = setInterval(updateModified, 1000) })
|
}
|
||||||
onUnmounted(() => { clearInterval(modifiedTimer) })
|
})
|
||||||
const mkdir = (doc: Doc, name: string) => {
|
const mkdir = (doc: Doc, name: string) => {
|
||||||
const control = connect(controlUrl, {
|
const control = connect(controlUrl, {
|
||||||
open() {
|
open() {
|
||||||
@ -240,15 +249,18 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.gallery {
|
.gallery {
|
||||||
padding: 1rem;
|
padding: 1em;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: .5rem;
|
gap: .5em;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
||||||
grid-auto-rows: 15rem;
|
grid-template-rows: repeat(minmax(auto, 15em));
|
||||||
|
align-items: end;
|
||||||
}
|
}
|
||||||
.breadcrumb {
|
.breadcrumb {
|
||||||
position: absolute;
|
border-radius: .5em 0 0 .5em;
|
||||||
z-index: 1;
|
}
|
||||||
|
.spacer {
|
||||||
|
flex: 0 1000000000 4rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -3,20 +3,22 @@
|
|||||||
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }"
|
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }"
|
||||||
@contextmenu.stop
|
@contextmenu.stop
|
||||||
@focus.stop="store.cursor = doc.key"
|
@focus.stop="store.cursor = doc.key"
|
||||||
@click="ev => {
|
@click=onclick
|
||||||
if (m!.play()) ev.preventDefault()
|
|
||||||
store.cursor = doc.key
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<figure>
|
<figure>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
<MediaPreview ref=m :doc="doc" :tabindex=-1 />
|
<MediaPreview ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
|
||||||
<caption>
|
<div class="titlespacer"></div>
|
||||||
<label>
|
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
|
||||||
<SelectBox :doc=doc />
|
<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>
|
<span :title="doc.name + '\n' + doc.modified + '\n' + doc.sizedisp">{{ doc.name }}</span>
|
||||||
</label>
|
<div class=namespacer></div>
|
||||||
</caption>
|
</template>
|
||||||
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
@ -28,67 +30,80 @@ import { Doc } from '@/repositories/Document'
|
|||||||
import MediaPreview from '@/components/MediaPreview.vue'
|
import MediaPreview from '@/components/MediaPreview.vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
type EditingProp = {
|
||||||
|
rename: (name: string) => void;
|
||||||
|
exit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
doc: Doc
|
doc: Doc,
|
||||||
index: number
|
editing?: EditingProp,
|
||||||
}>()
|
}>()
|
||||||
const m = ref<typeof MediaPreview | null>(null)
|
const m = ref<typeof MediaPreview | null>(null)
|
||||||
|
|
||||||
|
const onclick = (ev: Event) => {
|
||||||
|
if (m.value!.play()) ev.preventDefault()
|
||||||
|
store.cursor = props.doc.key
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.gallery figure {
|
figure {
|
||||||
height: 15rem;
|
max-height: 15em;
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: .5rem;
|
border-radius: .5em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: end;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
figure caption {
|
figure > article {
|
||||||
font-weight: 600;
|
flex: 0 0 auto;
|
||||||
color: var(--text-color);
|
|
||||||
text-shadow: 0 0 .2rem #000, 0 0 1rem #000;
|
|
||||||
}
|
}
|
||||||
.cursor caption {
|
.titlespacer {
|
||||||
background: var(--accent-color);
|
flex-shrink: 100000;
|
||||||
|
width: 100%;
|
||||||
|
height: 2em;
|
||||||
}
|
}
|
||||||
caption {
|
figcaption {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
caption label {
|
figcaption input[type='checkbox'] {
|
||||||
width: 100%;
|
width: 1.5em;
|
||||||
display: flex;
|
height: 1.5em;
|
||||||
align-items: center;
|
margin: .25em 0 .25em .25em;
|
||||||
}
|
|
||||||
label span {
|
|
||||||
flex: 1 1;
|
|
||||||
margin-right: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
label input[type='checkbox'] {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
transition: opacity var(--transition-time) ease-in-out;
|
||||||
}
|
}
|
||||||
label input[type='checkbox']:checked {
|
figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
a {
|
figcaption span {
|
||||||
text-decoration: none;
|
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>
|
</style>
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
<SvgButton
|
<SvgButton
|
||||||
name="create-folder"
|
name="create-folder"
|
||||||
data-tooltip="New folder"
|
data-tooltip="New folder"
|
||||||
@click="() => store.fileExplorer!.newFolder()"
|
@click="() => { console.log('New', store.fileExplorer); store.fileExplorer!.newFolder(); console.log('Done')}"
|
||||||
/>
|
/>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
<div class="spacer smallgap"></div>
|
<div class="spacer smallgap"></div>
|
||||||
@ -73,6 +73,7 @@ watchEffect(() => {
|
|||||||
const settingsMenu = (e: Event) => {
|
const settingsMenu = (e: Event) => {
|
||||||
// show the context menu
|
// show the context menu
|
||||||
const items = []
|
const items = []
|
||||||
|
items.push({ label: 'Settings', onClick: () => { store.dialog = 'settings' }})
|
||||||
if (store.user.isLoggedIn) {
|
if (store.user.isLoggedIn) {
|
||||||
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
|
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
|
||||||
} else {
|
} else {
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<template v-if="store.selected.size">
|
<template v-if="store.selected.size">
|
||||||
<div class="smallgap"></div>
|
<div class="smallgap"></div>
|
||||||
<p class="select-text">{{ store.selected.size }} selected ➤</p>
|
<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="copy" data-tooltip="Copy here" @click="op('cp', dst)" />
|
||||||
<SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" />
|
<SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" />
|
||||||
<SvgButton name="trash" data-tooltip="Delete ⚠️" @click="op('rm')" />
|
<SvgButton name="trash" data-tooltip="Delete ⚠️" @click="op('rm')" />
|
||||||
@ -14,7 +14,6 @@
|
|||||||
import {connect, controlUrl} from '@/repositories/WS'
|
import {connect, controlUrl} from '@/repositories/WS'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { SelectedItems } from '@/repositories/Document'
|
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const props = defineProps({
|
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<form @submit.prevent="login">
|
||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
<label for="username">Username:</label>
|
<label for="username">Username:</label>
|
||||||
@ -99,4 +99,3 @@ const login = async () => {
|
|||||||
height: 1em;
|
height: 1em;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@/stores/main
|
|
||||||
|
@ -1,32 +1,99 @@
|
|||||||
<template>
|
<template>
|
||||||
<img v-if=doc.img :src="preview() ? doc.previewurl : doc.url" alt="">
|
<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>
|
<span v-else-if=doc.dir class="folder icon"></span>
|
||||||
<video ref=vid v-else-if=video() :src=doc.url controls preload=none @click.prevent>📄</video>
|
<video ref=vid v-else-if=video() :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
||||||
<audio ref=aud v-else-if=audio() :src=doc.url controls preload=metadata @click.stop>📄</audio>
|
<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-if=archive() class="archive icon"></span>
|
||||||
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
|
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang=ts>
|
<script setup lang=ts>
|
||||||
import { compile, computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { Doc } from '@/repositories/Document'
|
import type { Doc } from '@/repositories/Document'
|
||||||
|
|
||||||
const aud = ref<HTMLAudioElement | null>(null)
|
const aud = ref<HTMLAudioElement | null>(null)
|
||||||
const vid = ref<HTMLVideoElement | null>(null)
|
const vid = ref<HTMLVideoElement | null>(null)
|
||||||
const media = computed(() => aud.value || vid.value)
|
const media = computed(() => aud.value || vid.value)
|
||||||
|
const poster = computed(() => `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`)
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
doc: Doc
|
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({
|
defineExpose({
|
||||||
play() {
|
play() {
|
||||||
if (media.value) {
|
if (!media.value) return false
|
||||||
if (media.value.paused) media.value.play()
|
if (media.value.paused) {
|
||||||
else media.value.pause()
|
media.value.play()
|
||||||
return true
|
for (const el of Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]) {
|
||||||
|
if (el === media.value) continue
|
||||||
|
el.pause()
|
||||||
}
|
}
|
||||||
return false
|
} else {
|
||||||
|
media.value.pause()
|
||||||
|
}
|
||||||
|
return true
|
||||||
},
|
},
|
||||||
|
media,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@ -34,28 +101,20 @@ const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext)
|
|||||||
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].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 archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
|
||||||
const preview = () => (
|
const preview = () => (
|
||||||
|
['bmp', 'ico', 'tif', 'tiff', 'pdf'].includes(props.doc.ext) ||
|
||||||
props.doc.size > 500000 &&
|
props.doc.size > 500000 &&
|
||||||
['png', 'bmp', 'ico', 'webp', 'avif', 'jpg', 'jpeg'].includes(props.doc.ext)
|
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext)
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
img, embed, .icon {
|
img, embed, .icon, audio, video {
|
||||||
font-size: 10em;
|
font-size: 8em;
|
||||||
border-radius: .5rem;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-align: center;
|
|
||||||
object-fit: cover;
|
|
||||||
object-position: center;
|
|
||||||
min-width: 50%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
audio, video {
|
|
||||||
height: 100%;
|
|
||||||
min-width: 50%;
|
min-width: 50%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding-bottom: 2rem;
|
max-height: 100%;
|
||||||
margin: auto;
|
border-radius: calc(.5em / 8);
|
||||||
}
|
}
|
||||||
.folder::before {
|
.folder::before {
|
||||||
content: '📁';
|
content: '📁';
|
||||||
@ -78,21 +137,30 @@ audio, video {
|
|||||||
.ext-torrent::before {
|
.ext-torrent::before {
|
||||||
content: '🏴☠️';
|
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 {
|
.icon {
|
||||||
filter: brightness(0.9);
|
filter: brightness(0.9);
|
||||||
}
|
}
|
||||||
figure.cursor .icon {
|
figure.cursor .icon {
|
||||||
filter: brightness(1);
|
filter: brightness(1);
|
||||||
}
|
}
|
||||||
img::before, video::before {
|
img::before {
|
||||||
/* broken image */
|
/* broken image */
|
||||||
background: #888;
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
text-align: center;
|
|
||||||
text-shadow: 0 0 .5rem #000;
|
text-shadow: 0 0 .5rem #000;
|
||||||
filter: grayscale(1);
|
filter: grayscale(1);
|
||||||
content: '❌';
|
content: '❌';
|
||||||
|
@ -1,34 +1,45 @@
|
|||||||
<template>
|
<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>
|
<h1 v-if="props.title">{{ props.title }}</h1>
|
||||||
<div>
|
<div>
|
||||||
<slot>
|
<slot>
|
||||||
Dialog with no content
|
Dialog with no content
|
||||||
<button onclick="dialog.close()">OK</button>
|
<button @click=close>OK</button>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 dialog = ref<HTMLDialogElement | null>(null)
|
||||||
|
const store = useMainStore()
|
||||||
|
|
||||||
const props = withDefaults(
|
const close = () => {
|
||||||
defineProps<{
|
dialog.value!.close()
|
||||||
title: string
|
store.dialog = ''
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
title: ''
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const show = () => {
|
|
||||||
dialog.value!.showModal()
|
|
||||||
}
|
}
|
||||||
defineExpose({ show })
|
|
||||||
onMounted(() => {
|
const props = defineProps<{
|
||||||
show()
|
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, close })
|
||||||
|
watchEffect(() => {
|
||||||
|
if (dialog.value) show()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<input type=checkbox tabindex=-1 :checked="store.selected.has(doc.key)"
|
<input type=checkbox tabindex=-1 :checked="store.selected.has(doc.key)" @click.stop
|
||||||
@change="ev => {
|
@change="ev => {
|
||||||
if ((ev.target as HTMLInputElement).checked) {
|
if ((ev.target as HTMLInputElement).checked) {
|
||||||
store.selected.add(doc.key)
|
store.selected.add(doc.key)
|
||||||
|
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>
|
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">
|
<script setup lang="ts">
|
||||||
import { connect, uploadUrl } from '@/repositories/WS';
|
import { connect, uploadUrl } from '@/repositories/WS';
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { collator } from '@/utils';
|
import { collator } from '@/utils';
|
||||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
|
|
||||||
const fileInput = ref()
|
const fileInput = ref()
|
||||||
const folderInput = ref()
|
const folderInput = ref()
|
||||||
@ -94,7 +103,7 @@ const cancelUploads = () => {
|
|||||||
|
|
||||||
const uprogress_init = {
|
const uprogress_init = {
|
||||||
total: 0,
|
total: 0,
|
||||||
uploaded: 0,
|
xfer: 0,
|
||||||
t0: 0,
|
t0: 0,
|
||||||
tlast: 0,
|
tlast: 0,
|
||||||
statbytes: 0,
|
statbytes: 0,
|
||||||
@ -108,59 +117,50 @@ const uprogress_init = {
|
|||||||
filepos: 0,
|
filepos: 0,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
}
|
}
|
||||||
const uprogress = reactive({...uprogress_init})
|
store.uprogress = {...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')
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (Date.now() - uprogress.tlast > 3000) {
|
if (Date.now() - store.uprogress.tlast > 3000) {
|
||||||
// Reset
|
// Reset
|
||||||
uprogress.statbytes = 0
|
store.uprogress.statbytes = 0
|
||||||
uprogress.statdur = 1
|
store.uprogress.statdur = 1
|
||||||
} else {
|
} else {
|
||||||
// Running average by decay
|
// Running average by decay
|
||||||
uprogress.statbytes *= .9
|
store.uprogress.statbytes *= .9
|
||||||
uprogress.statdur *= .9
|
store.uprogress.statdur *= .9
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 100)
|
||||||
const statUpdate = ({name, size, start, end}: {name: string, size: number, start: number, end: number}) => {
|
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()
|
const now = Date.now()
|
||||||
uprogress.uploaded = uprogress.filestart + end
|
store.uprogress.xfer = store.uprogress.filestart + end
|
||||||
uprogress.filepos = end
|
store.uprogress.filepos = end
|
||||||
uprogress.statbytes += end - start
|
store.uprogress.statbytes += end - start
|
||||||
uprogress.statdur += now - uprogress.tlast
|
store.uprogress.statdur += now - store.uprogress.tlast
|
||||||
uprogress.tlast = now
|
store.uprogress.tlast = now
|
||||||
// File finished?
|
// File finished?
|
||||||
if (end === size) {
|
if (end === size) {
|
||||||
uprogress.filestart += size
|
store.uprogress.filestart += size
|
||||||
statNextFile()
|
statNextFile()
|
||||||
if (++uprogress.fileidx >= uprogress.filecount) statReset()
|
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const statNextFile = () => {
|
const statNextFile = () => {
|
||||||
const f = uprogress.files.shift()
|
const f = store.uprogress.files.shift()
|
||||||
if (!f) return statReset()
|
if (!f) return statReset()
|
||||||
uprogress.filepos = 0
|
store.uprogress.filepos = 0
|
||||||
uprogress.filesize = f.file.size
|
store.uprogress.filesize = f.file.size
|
||||||
uprogress.filename = f.cloudName
|
store.uprogress.filename = f.cloudName
|
||||||
}
|
}
|
||||||
const statReset = () => {
|
const statReset = () => {
|
||||||
Object.assign(uprogress, uprogress_init)
|
Object.assign(store.uprogress, uprogress_init)
|
||||||
uprogress.t0 = Date.now()
|
store.uprogress.t0 = Date.now()
|
||||||
uprogress.tlast = uprogress.t0 + 1
|
store.uprogress.tlast = store.uprogress.t0 + 1
|
||||||
}
|
}
|
||||||
const statsAdd = (f: CloudFile[]) => {
|
const statsAdd = (f: CloudFile[]) => {
|
||||||
if (uprogress.files.length === 0) statReset()
|
if (store.uprogress.files.length === 0) statReset()
|
||||||
uprogress.total += f.reduce((a, b) => a + b.file.size, 0)
|
store.uprogress.total += f.reduce((a, b) => a + b.file.size, 0)
|
||||||
uprogress.filecount += f.length
|
store.uprogress.filecount += f.length
|
||||||
uprogress.files = [...uprogress.files, ...f]
|
store.uprogress.files = [...store.uprogress.files, ...f]
|
||||||
statNextFile()
|
statNextFile()
|
||||||
}
|
}
|
||||||
let upqueue = [] as CloudFile[]
|
let upqueue = [] as CloudFile[]
|
||||||
@ -190,7 +190,7 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
ws.sendData = async (data: any) => {
|
ws.sendData = async (data: any) => {
|
||||||
// Wait until the WS is ready to send another message
|
// Wait until the WS is ready to send another message
|
||||||
uprogress.status = "uploading"
|
store.uprogress.status = "uploading"
|
||||||
await new Promise(resolve => {
|
await new Promise(resolve => {
|
||||||
const t = setInterval(() => {
|
const t = setInterval(() => {
|
||||||
if (ws.bufferedAmount > 1<<20) return
|
if (ws.bufferedAmount > 1<<20) return
|
||||||
@ -198,7 +198,7 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
|
|||||||
clearInterval(t)
|
clearInterval(t)
|
||||||
}, 1)
|
}, 1)
|
||||||
})
|
})
|
||||||
uprogress.status = "processing"
|
store.uprogress.status = "processing"
|
||||||
ws.send(data)
|
ws.send(data)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -219,7 +219,7 @@ const worker = async () => {
|
|||||||
if (f.cloudPos === f.file.size) upqueue.shift()
|
if (f.cloudPos === f.file.size) upqueue.shift()
|
||||||
}
|
}
|
||||||
if (upqueue.length) startWorker()
|
if (upqueue.length) startWorker()
|
||||||
uprogress.status = "idle"
|
store.uprogress.status = "idle"
|
||||||
workerRunning = false
|
workerRunning = false
|
||||||
}
|
}
|
||||||
let workerRunning: any = false
|
let workerRunning: any = false
|
||||||
@ -242,64 +242,3 @@ onUnmounted(() => {
|
|||||||
removeEventListener('drop', uploadHandler)
|
removeEventListener('drop', uploadHandler)
|
||||||
})
|
})
|
||||||
</script>
|
</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
|
|
||||||
|
@ -40,6 +40,12 @@ export class Doc {
|
|||||||
const ext = this.name.split('.').pop()?.toLowerCase()
|
const ext = this.name.split('.').pop()?.toLowerCase()
|
||||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg'].includes(ext || '')
|
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 {
|
get previewurl(): string {
|
||||||
return this.url.replace(/^\/files/, '/preview')
|
return this.url.replace(/^\/files/, '/preview')
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import Client from '@/repositories/Client'
|
import Client from '@/repositories/Client'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
export const url_login = '/login'
|
export const url_login = '/login'
|
||||||
export const url_logout = '/logout '
|
export const url_logout = '/logout'
|
||||||
|
export const url_password = '/password-change'
|
||||||
|
|
||||||
export async function loginUser(username: string, password: string) {
|
export async function loginUser(username: string, password: string) {
|
||||||
const user = await Client.post(url_login, {
|
const user = await Client.post(url_login, {
|
||||||
@ -13,3 +15,12 @@ export async function logoutUser() {
|
|||||||
const data = await Client.post(url_logout)
|
const data = await Client.post(url_logout)
|
||||||
return data
|
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 ('error' in msg) {
|
||||||
if (msg.error.code === 401) {
|
if (msg.error.code === 401) {
|
||||||
store.user.isLoggedIn = false
|
store.user.isLoggedIn = false
|
||||||
store.user.isOpenLoginModal = true
|
store.dialog = 'login'
|
||||||
} else {
|
} else {
|
||||||
store.error = msg.error.message
|
store.error = msg.error.message
|
||||||
}
|
}
|
||||||
@ -67,7 +67,7 @@ export const watchConnect = () => {
|
|||||||
store.error = ''
|
store.error = ''
|
||||||
if (msg.user) store.login(msg.user.username, msg.user.privileged)
|
if (msg.user) store.login(msg.user.username, msg.user.privileged)
|
||||||
else if (store.isUserLogged) store.logout()
|
else if (store.isUserLogged) store.logout()
|
||||||
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.connected = false
|
||||||
store.error = 'Reconnecting...'
|
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)
|
reconnDelay = Math.min(5000, reconnDelay + 500)
|
||||||
// The server closes the websocket after errors, so we need to reopen it
|
// The server closes the websocket after errors, so we need to reopen it
|
||||||
if (watchTimeout !== null) clearTimeout(watchTimeout)
|
|
||||||
watchTimeout = setTimeout(watchConnect, reconnDelay)
|
watchTimeout = setTimeout(watchConnect, reconnDelay)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,8 +153,8 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
|||||||
function handleError(msg: errorEvent) {
|
function handleError(msg: errorEvent) {
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
if (msg.error.code === 401) {
|
if (msg.error.code === 401) {
|
||||||
store.user.isOpenLoginModal = true
|
|
||||||
store.user.isLoggedIn = false
|
store.user.isLoggedIn = false
|
||||||
|
store.dialog = 'login'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,44 +1,50 @@
|
|||||||
import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
|
import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore, type StateTree } from 'pinia'
|
||||||
import { collator } from '@/utils'
|
import { collator } from '@/utils'
|
||||||
import { logoutUser } from '@/repositories/User'
|
import { logoutUser } from '@/repositories/User'
|
||||||
import { watchConnect } from '@/repositories/WS'
|
import { watchConnect } from '@/repositories/WS'
|
||||||
import { shallowRef } from 'vue'
|
import { shallowRef } from 'vue'
|
||||||
import { sorted, type SortOrder } from '@/utils/docsort'
|
import { sorted, type SortOrder } from '@/utils/docsort'
|
||||||
|
|
||||||
type User = {
|
|
||||||
username: string
|
|
||||||
privileged: boolean
|
|
||||||
isOpenLoginModal: boolean
|
|
||||||
isLoggedIn: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useMainStore = defineStore({
|
export const useMainStore = defineStore({
|
||||||
id: 'main',
|
id: 'main',
|
||||||
state: () => ({
|
state: () => ({
|
||||||
document: shallowRef<Doc[]>([]),
|
document: shallowRef<Doc[]>([]),
|
||||||
selected: new Set<FUID>(),
|
selected: new Set<FUID>([]),
|
||||||
query: '' as string,
|
query: '' as string,
|
||||||
fileExplorer: null as any,
|
fileExplorer: null as any,
|
||||||
error: '' as string,
|
error: '' as string,
|
||||||
connected: false,
|
connected: false,
|
||||||
cursor: '' as string,
|
cursor: '' as string,
|
||||||
server: {} as Record<string, any>,
|
server: {} as Record<string, any>,
|
||||||
|
dialog: '' as '' | 'login' | 'settings',
|
||||||
|
uprogress: {} as any,
|
||||||
|
dprogress: {} as any,
|
||||||
prefs: {
|
prefs: {
|
||||||
gallery: false,
|
gallery: false,
|
||||||
sortListing: '' as SortOrder,
|
sortListing: '' as SortOrder,
|
||||||
sortFiltered: '' as SortOrder,
|
sortFiltered: '' as SortOrder,
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
username: '',
|
username: '' as string,
|
||||||
privileged: false,
|
privileged: false as boolean,
|
||||||
isLoggedIn: false,
|
isLoggedIn: false as boolean,
|
||||||
isOpenLoginModal: false
|
}
|
||||||
} as User
|
|
||||||
}),
|
}),
|
||||||
persist: {
|
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: {
|
actions: {
|
||||||
updateRoot(root: FileEntry[]) {
|
updateRoot(root: FileEntry[]) {
|
||||||
@ -62,11 +68,11 @@ export const useMainStore = defineStore({
|
|||||||
this.user.username = username
|
this.user.username = username
|
||||||
this.user.privileged = privileged
|
this.user.privileged = privileged
|
||||||
this.user.isLoggedIn = true
|
this.user.isLoggedIn = true
|
||||||
this.user.isOpenLoginModal = false
|
this.dialog = ''
|
||||||
if (!this.connected) watchConnect()
|
if (!this.connected) watchConnect()
|
||||||
},
|
},
|
||||||
loginDialog() {
|
loginDialog() {
|
||||||
this.user.isOpenLoginModal = true
|
this.dialog = 'login'
|
||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
console.log("Logout")
|
console.log("Logout")
|
||||||
@ -85,7 +91,13 @@ export const useMainStore = defineStore({
|
|||||||
},
|
},
|
||||||
focusBreadcrumb() {
|
focusBreadcrumb() {
|
||||||
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
|
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
|
||||||
}
|
},
|
||||||
|
cancelDownloads() {
|
||||||
|
location.reload() // FIXME
|
||||||
|
},
|
||||||
|
cancelUploads() {
|
||||||
|
location.reload() // FIXME
|
||||||
|
},
|
||||||
},
|
},
|
||||||
getters: {
|
getters: {
|
||||||
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
|
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,14 +1,6 @@
|
|||||||
<template>
|
<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="!store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === props.path.join('/'))">Folder not found.</p>
|
|
||||||
<p v-else>Empty folder</p>
|
|
||||||
</div>
|
|
||||||
<Gallery
|
<Gallery
|
||||||
v-else-if="store.prefs.gallery"
|
v-if="store.prefs.gallery"
|
||||||
ref="fileExplorer"
|
ref="fileExplorer"
|
||||||
:key="`gallery-${Router.currentRoute.value.path}`"
|
:key="`gallery-${Router.currentRoute.value.path}`"
|
||||||
:path="props.path"
|
:path="props.path"
|
||||||
@ -21,10 +13,11 @@
|
|||||||
:path="props.path"
|
:path="props.path"
|
||||||
:documents="documents"
|
:documents="documents"
|
||||||
/>
|
/>
|
||||||
<div v-if="!store.prefs.gallery && documents.some(doc => doc.img)" class="suggest-gallery">
|
<div v-if="!store.prefs.gallery && documents.some(doc => doc.previewable)" class="suggest-gallery">
|
||||||
<p>Media files found. Would you like a gallery view?</p>
|
<SvgButton name="eye" taborder=0 @click="() => { store.prefs.gallery = true }"></SvgButton>
|
||||||
<SvgButton name="eye" taborder=0 @click="() => { store.prefs.gallery = true }">Gallery</SvgButton>
|
Gallery View
|
||||||
</div>
|
</div>
|
||||||
|
<EmptyFolder :documents=documents :path=props.path />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@ -34,7 +27,6 @@ import Router from '@/router/index'
|
|||||||
import { needleFormat, localeIncludes, collator } from '@/utils'
|
import { needleFormat, localeIncludes, collator } from '@/utils'
|
||||||
import { sorted } from '@/utils/docsort'
|
import { sorted } from '@/utils/docsort'
|
||||||
import FileExplorer from '@/components/FileExplorer.vue'
|
import FileExplorer from '@/components/FileExplorer.vue'
|
||||||
import cog from '@/assets/svg/cog.svg'
|
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const fileExplorer = ref()
|
const fileExplorer = ref()
|
||||||
@ -94,13 +86,9 @@ watchEffect(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
text-shadow: 0 0 1rem #000, 0 0 2rem #000;
|
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
@keyframes rotate {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(359deg); }
|
|
||||||
}
|
|
||||||
.suggest-gallery p {
|
.suggest-gallery p {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
@ -112,12 +100,4 @@ watchEffect(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg.cog {
|
|
||||||
width: 10rem;
|
|
||||||
height: 10rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
animation: rotate 10s linear infinite;
|
|
||||||
filter: drop-shadow(0 0 1rem black);
|
|
||||||
fill: var(--primary-color);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -4,7 +4,6 @@ import { defineConfig } from 'vite'
|
|||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import pluginRewriteAll from 'vite-plugin-rewrite-all'
|
|
||||||
import svgLoader from 'vite-svg-loader'
|
import svgLoader from 'vite-svg-loader'
|
||||||
import Components from 'unplugin-vue-components/vite'
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
|
||||||
@ -21,7 +20,6 @@ const dev_backend = {
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
pluginRewriteAll(),
|
|
||||||
svgLoader(), // import svg files
|
svgLoader(), // import svg files
|
||||||
Components(), // auto import components
|
Components(), // auto import components
|
||||||
],
|
],
|
||||||
@ -44,6 +42,7 @@ export default defineConfig({
|
|||||||
"/files": dev_backend,
|
"/files": dev_backend,
|
||||||
"/login": dev_backend,
|
"/login": dev_backend,
|
||||||
"/logout": dev_backend,
|
"/logout": dev_backend,
|
||||||
|
"/password-change": dev_backend,
|
||||||
"/zip": dev_backend,
|
"/zip": dev_backend,
|
||||||
"/preview": dev_backend,
|
"/preview": dev_backend,
|
||||||
}
|
}
|
||||||
|
@ -23,8 +23,11 @@ dependencies = [
|
|||||||
"natsort",
|
"natsort",
|
||||||
"pathvalidate",
|
"pathvalidate",
|
||||||
"pillow",
|
"pillow",
|
||||||
|
"pyav",
|
||||||
"pyjwt",
|
"pyjwt",
|
||||||
|
"pymupdf",
|
||||||
"sanic",
|
"sanic",
|
||||||
|
"setproctitle",
|
||||||
"stream-zip",
|
"stream-zip",
|
||||||
"tomli_w",
|
"tomli_w",
|
||||||
]
|
]
|
||||||
@ -46,7 +49,7 @@ source = "vcs"
|
|||||||
|
|
||||||
[tool.hatch.build]
|
[tool.hatch.build]
|
||||||
artifacts = ["cista/wwwroot"]
|
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.version-file = "cista/_version.py"
|
||||||
hooks.vcs.template = """
|
hooks.vcs.template = """
|
||||||
# This file is automatically generated by hatch build.
|
# This file is automatically generated by hatch build.
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
# noqa: INP001
|
# noqa: INP001
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from sys import stderr
|
||||||
|
|
||||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
|
|
||||||
@ -7,6 +10,18 @@ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|||||||
class CustomBuildHook(BuildHookInterface):
|
class CustomBuildHook(BuildHookInterface):
|
||||||
def initialize(self, version, build_data):
|
def initialize(self, version, build_data):
|
||||||
super().initialize(version, build_data)
|
super().initialize(version, build_data)
|
||||||
print("Building Cista frontend...")
|
stderr.write(">>> Building Cista frontend\n")
|
||||||
subprocess.run("npm install --prefix frontend".split(" "), check=True) # noqa: S603
|
npm = shutil.which("npm")
|
||||||
subprocess.run("npm run build --prefix frontend".split(" "), check=True) # noqa: S603
|
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 msgspec
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cista.protocol import FileEntry, UpdateMessage, UpdDel, UpdIns, UpdKeep
|
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):
|
def decode(data: str):
|
||||||
@ -37,6 +34,14 @@ def test_insertions():
|
|||||||
assert decode(format_update(old_list, new_list)) == expected
|
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():
|
def test_deletions():
|
||||||
old_list = f(3)
|
old_list = f(3)
|
||||||
new_list = [old_list[0], old_list[2]]
|
new_list = [old_list[0], old_list[2]]
|
||||||
@ -83,54 +88,3 @@ def test_longer_lists():
|
|||||||
def sortkey(name):
|
def sortkey(name):
|
||||||
# Define the sorting key for names here
|
# Define the sorting key for names here
|
||||||
return name.lower()
|
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