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