Compare commits

..
15 Commits
Author SHA1 Message Date
LeoVasanko 07089aa9a7 frontend: add About dialog and unify global dialog backdrop 2026-05-03 23:40:54 +00:00
LeoVasanko c3146744b7 PageUp/PgDn/Home/End navigation; fix rename API path; immediate local rename update 2026-05-03 22:58:51 +00:00
LeoVasanko 48d7435d0b Allow portrait documents to render somewhat taller because gaps were being left between them depending on window width. 2026-05-03 18:36:14 +00:00
LeoVasanko 3e80325053 Smoother scrolling in gallery when using keyboard navigation. 2026-05-03 18:33:19 +00:00
LeoVasanko 31fc02ddbf Keep the file extension always visible but discreetly as part of the filename. Fix Escape in rename field. 2026-05-03 18:22:03 +00:00
LeoVasanko 2406ea87b0 Gallery mode file extension and filename display improvements. 2026-05-03 17:26:25 +00:00
LeoVasanko 3a1dd2b7da Show document icon when no preview is available or has failed. While loading, pulse the icon. 2026-05-02 19:54:38 +00:00
LeoVasanko 3df6b079c9 Better logging of ffmpeg errors 2026-05-02 19:49:06 +00:00
LeoVasanko af804e2c9f Less noisy preview timeout logging 2026-05-02 19:30:16 +00:00
LeoVasanko 1dc0c4441a Fixes on server startup and shutdown with Sanic internals. Parallel shutdown tasks as non-blocking async tasks to avoid blocking the event loop and causing a warning of that. 2026-05-02 19:22:23 +00:00
LeoVasanko c7ba0d5a04 Dynamically adjusting layout to maximize screen space used for document previews. Row width changes with aspect ratio of items on that row. Server side tracking of size as part of the main listing. 2026-05-02 18:45:44 +00:00
LeoVasanko 0071058b29 Avoid gallery layout collapse while previews are still loading. Maintain the boxes fixed size regardless. 2026-05-02 16:19:42 +00:00
LeoVasanko 338c74de69 Avoid noisy output on normal WebSocket closing. 2026-05-02 16:12:42 +00:00
LeoVasanko b13f08eab2 Avoid InvalidStateError on conversion tasks trying to set their result after preview cancelled. 2026-05-02 16:04:21 +00:00
LeoVasanko 5d566d6deb Restore CISTA_HOME internal environment passing of config dir removed in commit 31e0197, that was not being passed across processes. 2026-05-02 05:47:24 +00:00
26 changed files with 1201 additions and 187 deletions
+3 -1
View File
@@ -185,7 +185,9 @@ def _resolve_confdir(args):
def _confdir(args): def _confdir(args):
confdir = _resolve_confdir(args) confdir = _resolve_confdir(args)
config.init_confdir(confdir) if confdir is not None:
os.environ["CISTA_HOME"] = confdir.as_posix()
config.init_confdir()
def _user(args): def _user(args):
+10 -6
View File
@@ -148,12 +148,16 @@ async def main_after_start(app):
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers) # Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
@app.before_server_stop @app.before_server_stop
async def main_stop(app): async def main_stop(app):
watching.stop(app) async with asyncio.TaskGroup() as tg:
await onlyoffice.close_oo_client() tg.create_task(asyncio.to_thread(watching.stop, app))
await shutdown_preview_workers() tg.create_task(onlyoffice.close_oo_client())
app.ctx.threadexec.shutdown() tg.create_task(shutdown_preview_workers())
app.ctx.zipexec.shutdown(cancel_futures=True) tg.create_task(sso.close_client())
await sso.close_client()
async with asyncio.TaskGroup() as tg:
tg.create_task(asyncio.to_thread(app.ctx.threadexec.shutdown))
tg.create_task(asyncio.to_thread(app.ctx.zipexec.shutdown, cancel_futures=True))
logger.debug("Cista worker threads all finished") logger.debug("Cista worker threads all finished")
+3 -3
View File
@@ -63,10 +63,10 @@ config: Config
conffile: Path conffile: Path
def init_confdir(confdir: Path | str | None = None) -> None: def init_confdir() -> None:
global conffile global conffile
if confdir is not None: if p := os.environ.get("CISTA_HOME"):
home = Path(confdir).expanduser() home = Path(p)
else: else:
xdg = os.environ.get("XDG_CONFIG_HOME") xdg = os.environ.get("XDG_CONFIG_HOME")
home = ( home = (
+3 -1
View File
@@ -129,7 +129,9 @@ def log_reachable_info() -> None:
def setup_docker(confdir: Path | None = None) -> int: def setup_docker(confdir: Path | None = None) -> int:
"""Build and run the patched OnlyOffice Docker image.""" """Build and run the patched OnlyOffice Docker image."""
config.init_confdir(confdir) if confdir is not None:
os.environ["CISTA_HOME"] = confdir.as_posix()
config.init_confdir()
if config.conffile.exists(): if config.conffile.exists():
config.load_config() config.load_config()
else: else:
+48 -11
View File
@@ -20,7 +20,8 @@ from sanic import Blueprint, empty, raw, redirect
from sanic.exceptions import NotFound from sanic.exceptions import NotFound
from sanic.log import logger from sanic.log import logger
from cista import auth, config, onlyoffice, sharefs from cista import auth, config, onlyoffice, sharefs, watching
from cista.fileio import fuid
from cista.preview_worker import ( from cista.preview_worker import (
DOC_PREVIEW_SUFFIXES, DOC_PREVIEW_SUFFIXES,
OFFICE_PREVIEW_SUFFIXES, OFFICE_PREVIEW_SUFFIXES,
@@ -244,7 +245,12 @@ class _PreviewWorkerPool:
args[0].name, args[0].name,
) )
if not future.done(): if not future.done():
future.set_exception(PreviewTimeoutError(args[0].name)) future.set_exception(
PreviewTimeoutError(
args[0].name,
backend=_expected_preview_backend(args[0]),
)
)
continue continue
filepath = args[0] filepath = args[0]
@@ -258,11 +264,13 @@ class _PreviewWorkerPool:
future.set_result((out, resp)) future.set_result((out, resp))
except TimeoutError: except TimeoutError:
replace = True replace = True
logger.warning(
"Preview timeout (%ds) for %s", int(PREVIEW_TIMEOUT), filepath.name
)
if not future.done(): if not future.done():
future.set_exception(PreviewTimeoutError(filepath.name)) future.set_exception(
PreviewTimeoutError(
filepath.name,
backend=_expected_preview_backend(filepath),
)
)
except WorkerChecksumError: except WorkerChecksumError:
replace = True replace = True
logger.error("Preview checksum mismatch for %s", filepath.name) logger.error("Preview checksum mismatch for %s", filepath.name)
@@ -410,6 +418,10 @@ async def verify_preview(request):
class PreviewTimeoutError(Exception): class PreviewTimeoutError(Exception):
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT.""" """Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
def __init__(self, message: str, *, backend: str | None = None):
super().__init__(message)
self.backend = backend
class PreviewError(Exception): class PreviewError(Exception):
"""Raised when the preview subprocess exits with a non-zero status.""" """Raised when the preview subprocess exits with a non-zero status."""
@@ -466,11 +478,13 @@ class OOConversionManager:
filepath, request_timeout=5.0 filepath, request_timeout=5.0
) )
except Exception as e: except Exception as e:
future.set_exception(e) if not future.done():
future.set_exception(e)
async with self._lock: async with self._lock:
self._in_flight.pop(key, None) self._in_flight.pop(key, None)
else: else:
future.set_result(png_bytes) if not future.done():
future.set_result(png_bytes)
async with self._lock: async with self._lock:
self._in_flight.pop(key, None) self._in_flight.pop(key, None)
@@ -548,6 +562,21 @@ def _preview_job_priority(path) -> int:
return 4 return 4
def _expected_preview_backend(path: Path) -> str:
"""Best-effort backend label used for timeout/access logging."""
suffix = path.suffix.lower()
if suffix in OFFICE_PREVIEW_SUFFIXES:
return "onlyoffice"
if suffix in DOC_PREVIEW_SUFFIXES:
return "pdf"
mime_type, _ = mimetypes.guess_type(path.name)
if mime_type and mime_type.startswith("video/"):
return "video"
if mime_type and mime_type.startswith("image/"):
return "pyvips"
return "preview"
def is_previewable_path(path) -> bool: def is_previewable_path(path) -> bool:
suffix = path.suffix.lower() suffix = path.suffix.lower()
if suffix in DOC_PREVIEW_SUFFIXES or suffix in OFFICE_PREVIEW_SUFFIXES: if suffix in DOC_PREVIEW_SUFFIXES or suffix in OFFICE_PREVIEW_SUFFIXES:
@@ -609,10 +638,12 @@ async def preview(req, path):
timeout=PREVIEW_TIMEOUT, timeout=PREVIEW_TIMEOUT,
) )
except TimeoutError: except TimeoutError:
logger.warning("Preview timeout for %s", filepath) req.ctx.log_extra = f"{_expected_preview_backend(filepath)} timeout"
return empty(503) return empty(503)
except PreviewTimeoutError: except PreviewTimeoutError as e:
logger.warning("Preview worker timeout for %s", filepath) req.ctx.log_extra = (
f"{(e.backend or _expected_preview_backend(filepath))} timeout"
)
return empty(503) return empty(503)
except httpx.HTTPStatusError: except httpx.HTTPStatusError:
req.ctx.log_extra = "onlyoffice N/A" req.ctx.log_extra = "onlyoffice N/A"
@@ -654,6 +685,12 @@ async def preview(req, path):
# Preview generation failed, redirect to the file itself # Preview generation failed, redirect to the file itself
return redirect(f"/files/{path}", status=303) return redirect(f"/files/{path}", status=303)
# Store aspect ratio if the worker returned dimensions
if preview_resp and preview_resp.width and preview_resp.height:
ar = round(preview_resp.height / preview_resp.width, 2)
fuid_str = fuid(stat)
watching.notify_ar(fuid_str, ar)
# Build headers and cache the full response # Build headers and cache the full response
preview_mime = ( preview_mime = (
preview_resp.mime preview_resp.mime
+37 -1
View File
@@ -17,6 +17,7 @@ import gc
import io import io
import logging import logging
import mimetypes import mimetypes
import shlex
import struct import struct
import subprocess import subprocess
import sys import sys
@@ -95,6 +96,8 @@ class PreviewResponse(msgspec.Struct, omit_defaults=True):
timings: list[float] | None = None timings: list[float] | None = None
error: str | None = None error: str | None = None
stderr: str | None = None stderr: str | None = None
width: int | None = None
height: int | None = None
_enc = msgspec.json.Encoder() _enc = msgspec.json.Encoder()
@@ -173,6 +176,7 @@ def _get_image_dimensions(path: Path) -> tuple[int, int] | None:
""" """
try: try:
img = pyvips.Image.new_from_file(str(path)) img = pyvips.Image.new_from_file(str(path))
img = img.autorot()
except pyvips.error.Error: except pyvips.error.Error:
return None return None
else: else:
@@ -214,7 +218,18 @@ def _image_via_ffmpeg(path: Path, maxsize: int, quality: int) -> bytes:
cmd.insert(4, "-s") cmd.insert(4, "-s")
cmd.insert(5, f"{new_w}x{new_h}") cmd.insert(5, f"{new_w}x{new_h}")
try: try:
subprocess.run(cmd, capture_output=True, check=True, shell=False) # noqa: S603 try:
subprocess.run(cmd, capture_output=True, check=True, shell=False) # noqa: S603
except subprocess.CalledProcessError as e:
shell_cmd = shlex.join(cmd)
stderr = (e.stderr or b"").decode(errors="replace").strip()
if stderr:
raise RuntimeError(
f"ffmpeg failed (exit {e.returncode}): {shell_cmd}\n{stderr}"
) from e
raise RuntimeError(
f"ffmpeg failed (exit {e.returncode}): {shell_cmd}"
) from e
with Path(tmp_path).open("rb") as f: with Path(tmp_path).open("rb") as f:
return f.read() return f.read()
finally: finally:
@@ -228,6 +243,8 @@ def process_image_pyvips(path, *, maxsize, quality):
# HEIC/HEIF: ffmpeg handles tile assembly and HDR correctly; # HEIC/HEIF: ffmpeg handles tile assembly and HDR correctly;
# skip pyvips entirely. # skip pyvips entirely.
if suffix in (".heic", ".heif"): if suffix in (".heic", ".heif"):
heic_dims = _get_image_dimensions(path)
width, height = heic_dims or (None, None)
ret = _image_via_ffmpeg(path, maxsize, quality) ret = _image_via_ffmpeg(path, maxsize, quality)
t_end = perf_counter() t_end = perf_counter()
return ret, PreviewResponse( return ret, PreviewResponse(
@@ -235,13 +252,17 @@ def process_image_pyvips(path, *, maxsize, quality):
mime="image/avif", mime="image/avif",
backend="ffmpeg", backend="ffmpeg",
timings=[round((t_end - t_start) * 1000, 1)], timings=[round((t_end - t_start) * 1000, 1)],
width=width,
height=height,
) )
# Other image formats: pyvips first, ffmpeg fallback. # Other image formats: pyvips first, ffmpeg fallback.
load_opts = {"access": "sequential"} load_opts = {"access": "sequential"}
orig_w = orig_h = None
try: try:
img = pyvips.Image.new_from_file(str(path), **load_opts) img = pyvips.Image.new_from_file(str(path), **load_opts)
img = img.autorot() img = img.autorot()
orig_w, orig_h = img.width, img.height
scale = min(maxsize / img.width, maxsize / img.height, 1.0) scale = min(maxsize / img.width, maxsize / img.height, 1.0)
if scale < 1.0: if scale < 1.0:
img = img.resize(scale) img = img.resize(scale)
@@ -253,6 +274,7 @@ def process_image_pyvips(path, *, maxsize, quality):
) )
backend = "pyvips" backend = "pyvips"
except pyvips.error.Error: except pyvips.error.Error:
orig_w, orig_h = None, None
ret = _image_via_ffmpeg(path, maxsize, quality) ret = _image_via_ffmpeg(path, maxsize, quality)
backend = "ffmpeg" backend = "ffmpeg"
t_end = perf_counter() t_end = perf_counter()
@@ -262,6 +284,8 @@ def process_image_pyvips(path, *, maxsize, quality):
mime="image/avif", mime="image/avif",
backend=backend, backend=backend,
timings=[round((t_end - t_start) * 1000, 1)], timings=[round((t_end - t_start) * 1000, 1)],
width=orig_w,
height=orig_h,
) )
@@ -270,6 +294,7 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
t_start = perf_counter() t_start = perf_counter()
img = pyvips.Image.new_from_buffer(data, "") img = pyvips.Image.new_from_buffer(data, "")
img = img.autorot() img = img.autorot()
orig_w, orig_h = img.width, img.height
scale = min(maxsize / img.width, maxsize / img.height, 1.0) scale = min(maxsize / img.width, maxsize / img.height, 1.0)
if scale < 1.0: if scale < 1.0:
img = img.resize(scale) img = img.resize(scale)
@@ -286,6 +311,8 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
mime="image/avif", mime="image/avif",
backend="pyvips", backend="pyvips",
timings=[round((t_end - t_start) * 1000, 1)], timings=[round((t_end - t_start) * 1000, 1)],
width=orig_w,
height=orig_h,
) )
@@ -315,6 +342,8 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
round((t_load_end - t_load_start) * 1000, 1), round((t_load_end - t_load_start) * 1000, 1),
round((t_save_end - t_save_start) * 1000, 1), round((t_save_end - t_save_start) * 1000, 1),
], ],
width=round(w),
height=round(h),
) )
@@ -347,6 +376,11 @@ def process_video(path, *, maxsize, quality):
raise RuntimeError("No frames found in video") raise RuntimeError("No frames found in video")
# Resize frame to thumbnail size # Resize frame to thumbnail size
# Capture display dimensions before resize (accounting for rotation)
disp_w = frame.width
disp_h = frame.height
if frame.rotation in (90, 270):
disp_w, disp_h = disp_h, disp_w
if frame.width > maxsize or frame.height > maxsize: if frame.width > maxsize or frame.height > maxsize:
scale_factor = min(maxsize / frame.width, maxsize / frame.height) scale_factor = min(maxsize / frame.width, maxsize / frame.height)
new_width = int(frame.width * scale_factor) new_width = int(frame.width * scale_factor)
@@ -442,6 +476,8 @@ def process_video(path, *, maxsize, quality):
round((t_load_end - t_load_start) * 1000, 1), round((t_load_end - t_load_start) * 1000, 1),
round((t_save_end - t_save_start) * 1000, 1), round((t_save_end - t_save_start) * 1000, 1),
], ],
width=disp_w,
height=disp_h,
) )
del imgdata, istream, ostream, icc, occ, frame del imgdata, istream, ostream, icc, occ, frame
gc.collect() gc.collect()
+2 -1
View File
@@ -12,7 +12,7 @@ class ErrorMsg(msgspec.Struct):
## Directory listings ## Directory listings
class FileEntry(msgspec.Struct, array_like=True, frozen=True): class FileEntry(msgspec.Struct, array_like=True, frozen=True, omit_defaults=True):
level: int level: int
name: str name: str
key: str key: str
@@ -20,6 +20,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
size: int size: int
allocated: int allocated: int
isfile: int isfile: int
ar: float | None = None
def __str__(self): def __str__(self):
return self.key or "FileEntry()" return self.key or "FileEntry()"
+40 -1
View File
@@ -1,6 +1,7 @@
"""Custom access logging middleware for Sanic.""" """Custom access logging middleware for Sanic."""
import logging import logging
import os
import sys import sys
import unicodedata import unicodedata
from ipaddress import IPv6Address from ipaddress import IPv6Address
@@ -9,6 +10,40 @@ from sanic.log import LOGGING_CONFIG_DEFAULTS
logger = logging.getLogger("cista.access") logger = logging.getLogger("cista.access")
class ReentrantSafeStreamHandler(logging.StreamHandler):
"""Stream handler that degrades gracefully on signal-time reentrant writes.
Python's buffered text streams are not reentrant. If a signal handler logs
while another log write is in progress, StreamHandler.emit can raise:
RuntimeError("reentrant call inside <_io.BufferedWriter ...>")
Instead of letting logging emit a long "--- Logging error ---" traceback,
we fall back to a best-effort os.write to the same file descriptor.
"""
def emit(self, record: logging.LogRecord) -> None:
msg = ""
try:
msg = self.format(record)
stream = self.stream
stream.write(msg + self.terminator)
self.flush()
except RuntimeError as exc:
if "reentrant call inside" not in str(exc):
self.handleError(record)
return
stream = self.stream
fd = stream.fileno()
encoding = getattr(stream, "encoding", None) or "utf-8"
data = (msg + self.terminator).encode(encoding, errors="replace")
os.write(fd, data)
except RecursionError:
raise
except Exception:
self.handleError(record)
_RESET = "\033[0m" _RESET = "\033[0m"
_STATUS_INFO = "\033[32m" # 1xx (green) _STATUS_INFO = "\033[32m" # 1xx (green)
_STATUS_OK = "\033[1;92m" # 2xx (bright green) _STATUS_OK = "\033[1;92m" # 2xx (bright green)
@@ -236,7 +271,7 @@ def log_ws_close(
def configure_access_logging() -> None: def configure_access_logging() -> None:
"""Configure the cista.access logger to output to stderr.""" """Configure the cista.access logger to output to stderr."""
handler = logging.StreamHandler(sys.stderr) handler = ReentrantSafeStreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(message)s")) handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler) logger.addHandler(handler)
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
@@ -271,6 +306,10 @@ def configure_main_logging() -> None:
Patches LOGGING_CONFIG_DEFAULTS so the formatter survives every dictConfig Patches LOGGING_CONFIG_DEFAULTS so the formatter survives every dictConfig
call Sanic makes during serve_single() / serve(). call Sanic makes during serve_single() / serve().
""" """
for handler_name in ("console", "error_console", "access_console"):
LOGGING_CONFIG_DEFAULTS["handlers"][handler_name]["class"] = (
"cista.sanic_logging.ReentrantSafeStreamHandler"
)
LOGGING_CONFIG_DEFAULTS["formatters"]["generic"] = { LOGGING_CONFIG_DEFAULTS["formatters"]["generic"] = {
"class": "cista.sanic_logging._EmojiFormatter", "class": "cista.sanic_logging._EmojiFormatter",
} }
+7 -1
View File
@@ -4,11 +4,17 @@ from pathlib import Path
from fastapi_vue.hostutil import parse_endpoint from fastapi_vue.hostutil import parse_endpoint
from sanic import Sanic from sanic import Sanic
from sanic.worker.loader import AppLoader
from cista import config, server80 from cista import config, server80
from cista.app import app from cista.app import app
def load_app() -> Sanic:
"""Return the app instance for spawned Sanic worker/reloader processes."""
return app
def run(*, dev=False): def run(*, dev=False):
"""Run Sanic main process that spawns worker processes to serve HTTP requests.""" """Run Sanic main process that spawns worker processes to serve HTTP requests."""
_url, opts = parse_listen(config.config.listen) _url, opts = parse_listen(config.config.listen)
@@ -29,7 +35,7 @@ def run(*, dev=False):
access_log=False, access_log=False,
) # type: ignore[call-arg] ) # type: ignore[call-arg]
if dev: if dev:
Sanic.serve() Sanic.serve(app_loader=AppLoader(factory=load_app))
else: else:
Sanic.serve_single() Sanic.serve_single()
+7
View File
@@ -2,6 +2,7 @@ import time
from functools import wraps from functools import wraps
import msgspec import msgspec
import websockets.exceptions
from sanic import errorpages from sanic import errorpages
from sanic.exceptions import SanicException from sanic.exceptions import SanicException
from sanic.log import logger from sanic.log import logger
@@ -67,6 +68,12 @@ def websocket_wrapper(handler):
try: try:
await auth.verify(request) await auth.verify(request)
await handler(request, ws, *args, **kwargs) await handler(request, ws, *args, **kwargs)
except (
websockets.exceptions.ConnectionClosedOK,
websockets.exceptions.ConnectionClosedError,
):
# Normal websocket closure - already logged in access log
pass
except Exception as e: except Exception as e:
context, code, message = {}, 500, str(e) or "Internal Server Error" context, code, message = {}, 500, str(e) or "Internal Server Error"
if isinstance(e, SanicException): if isinstance(e, SanicException):
+71 -4
View File
@@ -55,6 +55,10 @@ class FormatUpdateLoopError(RuntimeError):
pass pass
class _WatcherStoppingError(Exception):
"""Internal control-flow exception for quick watcher shutdown."""
class State: class State:
def __init__(self): def __init__(self):
self.lock = threading.RLock() self.lock = threading.RLock()
@@ -154,6 +158,17 @@ stop_event = threading.Event()
# Thread-safe queue for signaling path updates from websockets # Thread-safe queue for signaling path updates from websockets
_update_queue: queue.Queue[PurePosixPath] = queue.Queue() _update_queue: queue.Queue[PurePosixPath] = queue.Queue()
# Thread-safe queue for AR updates from the preview worker
_ar_queue: queue.Queue[tuple[str, float]] = queue.Queue()
# AR map: fuid -> aspect ratio (height/width). Written only by the watcher thread.
_ar_map: dict[str, float] = {}
def notify_ar(fuid_key: str, ar: float) -> None:
"""Called from preview handler to update the AR for a file."""
_ar_queue.put_nowait((fuid_key, ar))
def notify_change(*paths: PurePosixPath | str): def notify_change(*paths: PurePosixPath | str):
"""Signal that paths have changed. Called from control/upload websockets.""" """Signal that paths have changed. Called from control/upload websockets."""
@@ -186,14 +201,16 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
except Exception: except Exception:
logger.exception(f"get_allocated_size failed for {path}") logger.exception(f"get_allocated_size failed for {path}")
allocated = st.st_size if isfile else 0 allocated = st.st_size if isfile else 0
key = fuid(st)
entry = FileEntry( entry = FileEntry(
level=len(rel.parts), level=len(rel.parts),
name=rel.name, name=rel.name,
key=fuid(st), key=key,
mtime=int(st.st_mtime), mtime=int(st.st_mtime),
size=st.st_size if isfile else 0, size=st.st_size if isfile else 0,
allocated=allocated, allocated=allocated,
isfile=isfile, isfile=isfile,
ar=_ar_map.get(key) if isfile else None,
) )
if isfile: if isfile:
return [entry] return [entry]
@@ -202,7 +219,7 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
li = [] li = []
for f in path.iterdir(): for f in path.iterdir():
if stop_event.is_set(): if stop_event.is_set():
raise SystemExit("quit") raise _WatcherStoppingError
if f.name.startswith("."): if f.name.startswith("."):
continue # No dotfiles continue # No dotfiles
with suppress(FileNotFoundError): with suppress(FileNotFoundError):
@@ -214,7 +231,11 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
li.append((int(isfile), f.name, s)) li.append((int(isfile), f.name, s))
# Build the tree as a list of FileEntries # Build the tree as a list of FileEntries
for [_, name, s] in humansorted(li): for [_, name, s] in humansorted(li):
if stop_event.is_set():
raise _WatcherStoppingError
sub = walk(rel / name, stat=s) sub = walk(rel / name, stat=s)
if not sub:
continue
child = sub[0] child = sub[0]
entry = FileEntry( entry = FileEntry(
level=entry.level, level=entry.level,
@@ -666,7 +687,10 @@ def watcher(loop):
inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix()) inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix())
# Initialize the tree from filesystem # Initialize the tree from filesystem
update_root(loop) try:
update_root(loop)
except _WatcherStoppingError:
return
path_index = PathIndex(state.root[:]) path_index = PathIndex(state.root[:])
trefresh = time.monotonic() + 300.0 trefresh = time.monotonic() + 300.0
@@ -750,7 +774,10 @@ def watcher(loop):
# Process each collapsed path # Process each collapsed path
new_root = path_index.root new_root = path_index.root
for path in collapsed: for path in collapsed:
new_entries = walk(path) try:
new_entries = walk(path)
except _WatcherStoppingError:
return
new_root = path_index.apply_update(path, new_entries) new_root = path_index.apply_update(path, new_entries)
# Broadcast if changed # Broadcast if changed
@@ -769,12 +796,51 @@ def watcher(loop):
with state.lock: with state.lock:
broadcast(update_msg, loop) broadcast(update_msg, loop)
state.root = fresh state.root = fresh
except _WatcherStoppingError:
return
except Exception: except Exception:
logger.exception("Fallback failed; sending full root") logger.exception("Fallback failed; sending full root")
with state.lock: with state.lock:
broadcast(format_root(fresh), loop) broadcast(format_root(fresh), loop)
state.root = fresh state.root = fresh
# Drain AR updates from preview worker (immediate, no debounce)
ar_new_root: list[FileEntry] | None = None
try:
while True:
fuid_key, ar = _ar_queue.get_nowait()
_ar_map[fuid_key] = ar
# Patch the matching entry in the current root
root_to_patch = (
ar_new_root if ar_new_root is not None else path_index.root
)
for i, entry in enumerate(root_to_patch):
if entry.key == fuid_key and entry.isfile and entry.ar != ar:
if ar_new_root is None:
ar_new_root = root_to_patch[:]
ar_new_root[i] = FileEntry(
level=entry.level,
name=entry.name,
key=entry.key,
mtime=entry.mtime,
size=entry.size,
allocated=entry.allocated,
isfile=entry.isfile,
ar=ar,
)
break
except queue.Empty:
pass
if ar_new_root is not None:
try:
update_msg = format_update(state.root, ar_new_root)
with state.lock:
broadcast(update_msg, loop)
state.root = ar_new_root
path_index = PathIndex(ar_new_root)
except Exception:
logger.exception("AR update broadcast failed")
# Collect events from websocket signals (non-blocking) # Collect events from websocket signals (non-blocking)
try: try:
while True: while True:
@@ -820,6 +886,7 @@ def start(app):
global rootpath global rootpath
config.load_config() config.load_config()
rootpath = config.config.path rootpath = config.config.path
stop_event.clear()
app.ctx.watcher = threading.Thread( app.ctx.watcher = threading.Thread(
target=watcher, target=watcher,
args=[app.loop], args=[app.loop],
+46 -7
View File
@@ -8,6 +8,7 @@
<SettingsModal /> <SettingsModal />
<UserManagementModal /> <UserManagementModal />
<UserTokensModal /> <UserTokensModal />
<AboutModal />
<AccessDeniedModal /> <AccessDeniedModal />
<header> <header>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" /> <HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
@@ -28,11 +29,12 @@ import type HeaderMain from '@/components/HeaderMain.vue'
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS' import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import type { ComputedRef } from 'vue' import type { ComputedRef } from 'vue'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue' import { onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import Router from '@/router/index' import Router from '@/router/index'
import { computed } from 'vue' import { computed } from 'vue'
import AboutModal from './components/AboutModal.vue'
import AccessDeniedModal from './components/AccessDeniedModal.vue' import AccessDeniedModal from './components/AccessDeniedModal.vue'
import SelectionToolbar from './components/SelectionToolbar.vue' import SelectionToolbar from './components/SelectionToolbar.vue'
import type SettingsModalVue from './components/SettingsModal.vue' import type SettingsModalVue from './components/SettingsModal.vue'
@@ -56,12 +58,16 @@ const path: ComputedRef<Path> = computed(() => {
query query
} }
}) })
watchEffect(() => { watch(
document.title = () => path.value.path,
path.value.path.replace(/\/$/, '').split('/').pop() || () => {
store.server.name || document.title =
'Cista Storage' path.value.path.replace(/\/$/, '').split('/').pop() ||
}) store.server.name ||
'Cista Storage'
},
{ immediate: true }
)
onMounted(loadSession) onMounted(loadSession)
onMounted(watchConnect) onMounted(watchConnect)
onUnmounted(watchDisconnect) onUnmounted(watchDisconnect)
@@ -95,6 +101,8 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
event.key === 'ArrowDown' || event.key === 'ArrowDown' ||
event.key === 'ArrowLeft' || event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' || event.key === 'ArrowRight' ||
event.key === 'PageUp' ||
event.key === 'PageDown' ||
(c && event.code === 'Space') (c && event.code === 'Space')
) { ) {
if (!input) event.preventDefault() if (!input) event.preventDefault()
@@ -104,6 +112,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
//console.log("key pressed", event) //console.log("key pressed", event)
/// Long if-else machina for all keys we handle here /// Long if-else machina for all keys we handle here
let arrow = '' let arrow = ''
let paging = ''
const inHeader = !!(event.target as HTMLElement).closest('.headermain') const inHeader = !!(event.target as HTMLElement).closest('.headermain')
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb') const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
// Handle arrows: in search input with text, only up/down; otherwise all arrows // Handle arrows: in search input with text, only up/down; otherwise all arrows
@@ -115,10 +124,22 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
if (searchHasText && (dir === 'left' || dir === 'right')) { if (searchHasText && (dir === 'left' || dir === 'right')) {
return // Let browser handle cursor movement return // Let browser handle cursor movement
} }
// Don't intercept arrows for non-search inputs (e.g. rename input)
if (input && !searchInput) return
arrow = dir arrow = dir
} else if (
event.key === 'PageUp' ||
event.key === 'PageDown' ||
event.key === 'Home' ||
event.key === 'End'
) {
if (input) return
paging = event.key
} }
if (arrow) { if (arrow) {
// Arrow key handling - fall through to bottom // Arrow key handling - fall through to bottom
} else if (paging) {
// Paging/navigation key handling - fall through to bottom
} }
// Find: process on keydown so that we can bypass the built-in search hotkey // Find: process on keydown so that we can bypass the built-in search hotkey
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) { else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
@@ -136,6 +157,8 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
else if (keyup && event.key === 'Escape') { else if (keyup && event.key === 'Escape') {
store.error = '' store.error = ''
store.clearToast() store.clearToast()
// Keep rename and other non-search inputs isolated from search behavior.
if (input && !searchInput) return
headerMain.value!.clearSearch(event) headerMain.value!.clearSearch(event)
store.focusBreadcrumb() store.focusBreadcrumb()
} else if (!input && keyup && event.key === 'Backspace') { } else if (!input && keyup && event.key === 'Backspace') {
@@ -235,12 +258,28 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
break break
} }
} }
} else if (paging && !keyup && !inHeader && !inBreadcrumb) {
switch (paging) {
case 'PageUp':
f = () => fileExplorer.pageUp?.(event)
break
case 'PageDown':
f = () => fileExplorer.pageDown?.(event)
break
case 'Home':
f = () => fileExplorer.home?.(event)
break
case 'End':
f = () => fileExplorer.end?.(event)
break
}
} }
if (f) { if (f) {
// Initial move, then t0 delay until repeats at tr intervals // Initial move, then t0 delay until repeats at tr intervals
const t0 = 200, const t0 = 200,
tr = event.altKey ? 20 : 100 tr = event.altKey ? 20 : 100
f() f()
if (paging === 'Home' || paging === 'End') return
timer = setTimeout(() => { timer = setTimeout(() => {
timer = setInterval(f, tr) timer = setInterval(f, tr)
}, t0 - tr) }, t0 - tr)
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" fill="#f80"/><path fill="#fff" d="M381 298h-84V167h-66L339 35l108 132h-66zm-168-84h-84v131H63l108 132 108-132h-66z"/></svg>

After

Width:  |  Height:  |  Size: 242 B

+110
View File
@@ -0,0 +1,110 @@
<template>
<ModalDialog name="about" title="">
<div class="about-content">
<div class="about-logo-pane">
<img :src="logoUrl" alt="Cista Storage logo" class="about-logo" />
</div>
<div class="about-details">
<h3 class="about-name">Cista {{ softwareVersion }}</h3>
<p class="about-link">
<a :href="projectUrl" target="_blank" rel="noopener noreferrer">{{ displayProjectUrl }}</a>
</p>
<div class="dialog-buttons about-actions">
<div class="spacer"></div>
<input id="close" type="reset" value="Close" class="button" @click="close" />
</div>
</div>
</div>
</ModalDialog>
</template>
<script setup lang="ts">
import logoUrl from '@/assets/logo-square.svg?url'
import ModalDialog from '@/components/ModalDialog.vue'
import { useMainStore } from '@/stores/main'
import { computed } from 'vue'
const store = useMainStore()
const softwareVersion = computed(() => store.server.version || 'unknown')
const projectUrl = 'https://git.zi.fi/Vasanko/cista-storage'
const displayProjectUrl = projectUrl.replace(/^https?:\/\//, '')
const close = () => {
store.dialog = ''
}
</script>
<style scoped>
:deep(#about.modal-dialog) {
overflow: hidden;
}
.about-content {
display: grid;
grid-template-columns: 11rem minmax(0, 1fr);
align-items: stretch;
width: min(35rem, 92vw);
min-width: 0;
min-height: 0;
margin: -1rem;
overflow: hidden;
}
.about-logo-pane {
display: block;
padding: 0;
overflow: hidden;
}
.about-logo {
width: 100%;
height: auto;
aspect-ratio: 1 / 1;
margin: 0;
display: block;
}
.about-details {
display: flex;
flex-direction: column;
justify-content: center;
padding: 1.25rem;
}
.about-name {
margin: 0;
}
.about-link {
margin: 0.65rem 0 1rem;
word-break: break-word;
}
.about-actions {
margin-top: auto;
}
@media (max-width: 40rem) {
.about-content {
grid-template-columns: 1fr;
width: min(24rem, 90vw);
}
.about-logo-pane {
width: 100%;
aspect-ratio: 1 / 1;
}
.about-logo {
width: 100%;
height: 100%;
aspect-ratio: 1 / 1;
object-fit: contain;
}
.about-details {
padding: 0.85rem;
}
}
</style>
+7 -20
View File
@@ -1,32 +1,19 @@
<template> <template>
<div v-if="store.dialog === 'accessdenied'" class="modal-overlay"> <ModalDialog name="accessdenied" title="">
<div class="modal-dialog" id="accessdenied"> <div class="access-denied">
<div class="modal-content access-denied"> <p class="icon"></p>
<p class="icon"></p> <p class="message">Access Denied</p>
<p class="message">Access Denied</p> <button @click="reload" class="button">Reload</button>
<button @click="reload" class="button">Reload</button>
</div>
</div> </div>
</div> </ModalDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMainStore } from '@/stores/main' import ModalDialog from '@/components/ModalDialog.vue'
import { holdGlobalBackdrop } from 'paskia'
import { watchEffect } from 'vue'
const store = useMainStore()
const reload = () => { const reload = () => {
location.reload() location.reload()
} }
// Keep backdrop active when this dialog shows
watchEffect(() => {
if (store.dialog === 'accessdenied') {
holdGlobalBackdrop()
}
})
</script> </script>
<style scoped> <style scoped>
+106 -39
View File
@@ -76,6 +76,7 @@ import { apiFetch } from '@/repositories/Client'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { formatSize } from '@/utils' import { formatSize } from '@/utils'
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
import ContextMenu from '@imengyu/vue3-context-menu' import ContextMenu from '@imengyu/vue3-context-menu'
import { import {
computed, computed,
@@ -112,21 +113,95 @@ const parseErrorMessage = async (res: Response) => {
} }
} }
const getCursorIndex = () =>
store.cursor
? props.documents.findIndex(doc => doc.key === store.cursor)
: props.documents.length
const getDocElement = (key: string) =>
document.getElementById(`file-${key}`) as HTMLElement | null
const moveCursorTo = (moveto: number, ev: KeyboardEvent | null) => {
const select = !!ev?.shiftKey
const docs = props.documents
if (docs.length === 0) {
store.cursor = ''
return
}
const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = getCursorIndex()
store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? getDocElement(store.cursor) : null
if (select) {
let [begin, end] = moveto >= index ? [index, moveto] : [moveto, index]
for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue
const key = docs[p]!.key
if (store.selected.has(key)) store.selected.delete(key)
else store.selected.add(key)
}
}
keepCursorVisibleSmooth(tr)
if (moveto === N) {
if (index > moveto) focusBreadcrumb()
else focusHeader()
}
}
const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
const docs = props.documents
if (docs.length === 0) return
const scroller =
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
const currentIndex = getCursorIndex()
const currentEl = store.cursor ? getDocElement(store.cursor) : null
const currentCenter = currentEl
? currentEl.getBoundingClientRect().top +
currentEl.getBoundingClientRect().height / 2
: scroller.getBoundingClientRect().top + scroller.clientHeight / 2
const targetCenter =
currentCenter + direction * Math.max(120, scroller.clientHeight - 140)
let bestIndex = direction > 0 ? docs.length - 1 : 0
let bestDistance = Number.POSITIVE_INFINITY
for (let i = 0; i < docs.length; i++) {
if (
currentIndex !== docs.length &&
((direction > 0 && i <= currentIndex) || (direction < 0 && i >= currentIndex))
)
continue
const el = getDocElement(docs[i]!.key)
if (!el) continue
const center =
el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2
const distance = Math.abs(center - targetCenter)
if (distance < bestDistance) {
bestDistance = distance
bestIndex = i
}
}
markKeyboardFollow()
moveCursorTo(bestIndex, ev)
}
// File rename // File rename
const editing = shallowRef<Doc | null>(null) const editing = shallowRef<Doc | null>(null)
const rename = async (doc: Doc, newName: string) => { const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name const oldName = doc.name
doc.name = newName // We should get an update from watch but this is quicker doc.name = newName // We should get an update from watch but this is quicker
store.documentsChanged()
try { try {
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/' const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
const res = await apiFetch( const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`, const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
{ method: 'POST' }
)
if (!res.ok) throw new Error(await parseErrorMessage(res)) if (!res.ok) throw new Error(await parseErrorMessage(res))
} catch (err) { } catch (err) {
console.error('Rename failed', err) console.error('Rename failed', err)
doc.name = oldName doc.name = oldName
store.documentsChanged()
store.showToast(err instanceof Error ? err.message : 'Rename failed') store.showToast(err instanceof Error ? err.message : 'Rename failed')
} }
} }
@@ -176,14 +251,33 @@ defineExpose({
} else { } else {
store.selected.add(key) store.selected.add(key)
} }
markKeyboardFollow()
this.cursorMove(1, null) this.cursorMove(1, null)
}, },
up(ev: KeyboardEvent) { up(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(-1, ev) this.cursorMove(-1, ev)
}, },
down(ev: KeyboardEvent) { down(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(1, ev) this.cursorMove(1, ev)
}, },
pageUp(ev: KeyboardEvent) {
pageMove(-1, ev)
},
pageDown(ev: KeyboardEvent) {
pageMove(1, ev)
},
home(ev: KeyboardEvent) {
if (!props.documents.length) return
markKeyboardFollow()
moveCursorTo(0, ev)
},
end(ev: KeyboardEvent) {
if (!props.documents.length) return
markKeyboardFollow()
moveCursorTo(props.documents.length - 1, ev)
},
left(ev: KeyboardEvent) { left(ev: KeyboardEvent) {
// Only go back if we're in a subfolder (not at root) // Only go back if we're in a subfolder (not at root)
if (props.path.length > 0) { if (props.path.length > 0) {
@@ -197,8 +291,6 @@ defineExpose({
if (a) a.click() if (a) a.click()
}, },
cursorMove(d: number, ev: KeyboardEvent | null) { cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey
// Move cursor up or down (keyboard navigation)
const docs = props.documents const docs = props.documents
if (docs.length === 0) { if (docs.length === 0) {
store.cursor = '' store.cursor = ''
@@ -207,35 +299,9 @@ defineExpose({
const N = docs.length const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1) const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = store.cursor const index = getCursorIndex()
? docs.findIndex(doc => doc.key === store.cursor)
: docs.length
const moveto = increment(index, d) const moveto = increment(index, d)
store.cursor = docs[moveto]?.key ?? '' moveCursorTo(moveto, ev)
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
if (select) {
// Go forwards, possibly wrapping over the end; the last entry is not toggled
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue
const key = docs[p]!.key
if (store.selected.has(key)) store.selected.delete(key)
else store.selected.add(key)
}
}
// @ts-ignore
scrolltr = tr
if (!scrolltimer) {
scrolltimer = setTimeout(() => {
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltimer = null
}, 300)
}
// When leaving the file list: up goes to breadcrumbs, down goes to header
if (moveto === N) {
if (d < 0) focusBreadcrumb()
else focusHeader()
}
} }
}) })
const focusHeader = () => { const focusHeader = () => {
@@ -248,8 +314,9 @@ const focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus() if (el) el.focus()
} }
let scrolltimer: any = null const keyboardFollowScroll = createKeyboardFollowScroll()
let scrolltr: any = null const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
watchEffect(() => { watchEffect(() => {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value?.key if (editing.value) store.cursor = editing.value?.key
@@ -257,7 +324,7 @@ watchEffect(() => {
const a = document.querySelector( const a = document.querySelector(
`#file-${store.cursor} .name a` `#file-${store.cursor} .name a`
) as HTMLAnchorElement | null ) as HTMLAnchorElement | null
if (a) a.focus() if (a) a.focus({ preventScroll: true })
} }
}) })
watchEffect(() => { watchEffect(() => {
@@ -276,11 +343,11 @@ onMounted(() => {
modifiedTimer = setInterval(updateModified, 1000) modifiedTimer = setInterval(updateModified, 1000)
const active = document.querySelector('.cursor') as HTMLElement | null const active = document.querySelector('.cursor') as HTMLElement | null
if (active) { if (active) {
active.scrollIntoView({ block: 'center', behavior: 'instant' }) active.focus({ preventScroll: true })
active.focus()
} }
}) })
onUnmounted(() => { onUnmounted(() => {
keyboardFollowScroll.cancel()
clearInterval(modifiedTimer) clearInterval(modifiedTimer)
}) })
const mkdir = async (doc: Doc, name: string) => { const mkdir = async (doc: Doc, name: string) => {
+225 -43
View File
@@ -3,7 +3,14 @@
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" /> <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> <template v-for="(doc, index) in documents" :key=doc.key>
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/> <BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)" :class="{ 'folder-start': showFolderBreadcrumb(index) }" /> <GalleryFigure
:doc=doc
:editing="editing === doc ? {rename, exit} : null"
:style="{ '--gallery-figure-height': rowHeightsByKey[doc.key] ?? '15em' }"
@menu="contextMenu($event, doc)"
@rename="editing = doc; store.cursor = doc.key"
:class="{ 'folder-start': showFolderBreadcrumb(index) }"
/>
</template> </template>
</div> </div>
</template> </template>
@@ -13,6 +20,7 @@ import { apiFetch } from '@/repositories/Client'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import type { SortOrder } from '@/utils/docsort' import type { SortOrder } from '@/utils/docsort'
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
import ContextMenu from '@imengyu/vue3-context-menu' import ContextMenu from '@imengyu/vue3-context-menu'
import { import {
computed, computed,
@@ -21,6 +29,7 @@ import {
onUnmounted, onUnmounted,
ref, ref,
shallowRef, shallowRef,
watch,
watchEffect watchEffect
} from 'vue' } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -56,28 +65,200 @@ const exit = () => {
const rename = async (doc: Doc, newName: string) => { const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name const oldName = doc.name
doc.name = newName // We should get an update from watch but this is quicker doc.name = newName // We should get an update from watch but this is quicker
store.documentsChanged()
try { try {
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/' const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
const res = await apiFetch( const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`, const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
{ method: 'POST' }
)
if (!res.ok) throw new Error(await parseErrorMessage(res)) if (!res.ok) throw new Error(await parseErrorMessage(res))
} catch (err) { } catch (err) {
console.error('Rename failed', err) console.error('Rename failed', err)
doc.name = oldName doc.name = oldName
store.documentsChanged()
store.showToast(err instanceof Error ? err.message : 'Rename failed') store.showToast(err instanceof Error ? err.message : 'Rename failed')
} }
} }
const gallery = ref<HTMLElement>() const gallery = ref<HTMLElement>()
const columnCount = ref(1) const columnCount = ref(1)
const columnWidthPx = ref(240)
const emPx = ref(16)
const aspectByKey = ref<Record<string, number>>({})
const optimalRowHeightPx = (ratios: number[]) => {
const w = Math.max(1, columnWidthPx.value)
const minH = Math.max(1, Math.round(7 * emPx.value))
const maxH = Math.max(minH, Math.round(30 * emPx.value))
const usable = ratios.filter(ar => Number.isFinite(ar) && ar > 0)
if (usable.length === 0) return Math.round(15 * emPx.value)
let bestH = Math.round(15 * emPx.value)
let bestScore = -1
for (let h = minH; h <= maxH; h++) {
let score = 0
for (const ar of usable) {
let shownW = w
let shownH = w * ar
if (shownH > h) {
shownH = h
shownW = h / ar
}
// Fill efficiency in the row cell (0..1)
score += (shownW * shownH) / (w * h)
}
if (score > bestScore) {
bestScore = score
bestH = h
}
}
return bestH
}
const setAspect = (key: string, ar: number) => {
if (!Number.isFinite(ar) || ar <= 0) return
if (aspectByKey.value[key] === ar) return
aspectByKey.value = {
...aspectByKey.value,
[key]: ar
}
}
const rowHeightsByKey = computed<Record<string, string>>(() => {
const docs = props.documents
const cols = Math.max(1, columnCount.value)
const byKey = aspectByKey.value
const out: Record<string, string> = {}
const assignRows = (group: Doc[]) => {
for (let start = 0; start < group.length; start += cols) {
const row = group.slice(start, start + cols)
const ratios = row
.filter(doc => doc.previewable)
.map(doc => byKey[doc.key])
.filter((ar): ar is number => ar != null)
const height = `${optimalRowHeightPx(ratios)}px`
for (const doc of row) out[doc.key] = height
}
}
let group: Doc[] = []
for (let i = 0; i < docs.length; i++) {
if (i > 0 && docs[i]!.loc !== docs[i - 1]!.loc) {
assignRows(group)
group = []
}
group.push(docs[i]!)
}
assignRows(group)
return out
})
// Seed collected ratios from server-provided ar values on docs
const seedFromDocs = () => {
for (const doc of props.documents)
if (doc.previewable && doc.ar != null) setAspect(doc.key, doc.ar)
}
const onImgLoad = (e: Event) => {
const img = e.target as HTMLImageElement
if (img.tagName !== 'IMG' || img.naturalWidth === 0) return
const anchor = img.closest('a[id^="file-"]') as HTMLAnchorElement | null
if (!anchor) return
const key = anchor.id.slice('file-'.length)
if (!key) return
setAspect(key, img.naturalHeight / img.naturalWidth)
}
const updateColumns = () => { const updateColumns = () => {
if (!gallery.value) return if (!gallery.value) return
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split( const style = getComputedStyle(gallery.value)
' ' const templates = style.gridTemplateColumns
).length .split(' ')
.filter(part => !!part && part !== 'none')
columnCount.value = Math.max(1, templates.length)
const first = templates[0]
if (first && first.endsWith('px')) {
const parsed = Number.parseFloat(first)
if (Number.isFinite(parsed) && parsed > 0) columnWidthPx.value = parsed
}
const parsedEm = Number.parseFloat(style.fontSize)
if (Number.isFinite(parsedEm) && parsedEm > 0) emPx.value = parsedEm
} }
const columns = computed(() => columnCount.value) const columns = computed(() => columnCount.value)
const getCursorIndex = () =>
store.cursor
? props.documents.findIndex(doc => doc.key === store.cursor)
: props.documents.length
const getDocElement = (key: string) =>
document.getElementById(`file-${key}`) as HTMLElement | null
const moveCursorTo = (moveto: number, ev: KeyboardEvent | null) => {
const select = !!ev?.shiftKey
const docs = props.documents
if (docs.length === 0) {
store.cursor = ''
return
}
const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = getCursorIndex()
store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? getDocElement(store.cursor) : null
if (select) {
let [begin, end] = moveto >= index ? [index, moveto] : [moveto, index]
for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue
const key = docs[p]!.key
if (store.selected.has(key)) store.selected.delete(key)
else store.selected.add(key)
}
}
keepCursorVisibleSmooth(tr)
if (moveto === N) {
if (index > moveto) focusBreadcrumb()
else focusHeader()
}
}
const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
const docs = props.documents
if (docs.length === 0) return
const scroller =
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
const currentIndex = getCursorIndex()
const currentEl = store.cursor ? getDocElement(store.cursor) : null
const currentCenter = currentEl
? currentEl.getBoundingClientRect().top +
currentEl.getBoundingClientRect().height / 2
: scroller.getBoundingClientRect().top + scroller.clientHeight / 2
const targetCenter =
currentCenter + direction * Math.max(120, scroller.clientHeight - 140)
let bestIndex = direction > 0 ? docs.length - 1 : 0
let bestDistance = Number.POSITIVE_INFINITY
for (let i = 0; i < docs.length; i++) {
if (
currentIndex !== docs.length &&
((direction > 0 && i <= currentIndex) || (direction < 0 && i >= currentIndex))
)
continue
const el = getDocElement(docs[i]!.key)
if (!el) continue
const center =
el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2
const distance = Math.abs(center - targetCenter)
if (distance < bestDistance) {
bestDistance = distance
bestIndex = i
}
}
markKeyboardFollow()
moveCursorTo(bestIndex, ev)
}
defineExpose({ defineExpose({
newFolder() { newFolder() {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
@@ -127,23 +308,42 @@ defineExpose({
} else { } else {
store.selected.add(key) store.selected.add(key)
} }
markKeyboardFollow()
this.cursorMove(1, null) this.cursorMove(1, null)
}, },
up(ev: KeyboardEvent) { up(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(-columns.value, ev) this.cursorMove(-columns.value, ev)
}, },
down(ev: KeyboardEvent) { down(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(columns.value, ev) this.cursorMove(columns.value, ev)
}, },
left(ev: KeyboardEvent) { left(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(-1, ev) this.cursorMove(-1, ev)
}, },
right(ev: KeyboardEvent) { right(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(1, ev) this.cursorMove(1, ev)
}, },
pageUp(ev: KeyboardEvent) {
pageMove(-1, ev)
},
pageDown(ev: KeyboardEvent) {
pageMove(1, ev)
},
home(ev: KeyboardEvent) {
if (!props.documents.length) return
markKeyboardFollow()
moveCursorTo(0, ev)
},
end(ev: KeyboardEvent) {
if (!props.documents.length) return
markKeyboardFollow()
moveCursorTo(props.documents.length - 1, ev)
},
cursorMove(d: number, ev: KeyboardEvent | null) { cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey
// Move cursor up or down (keyboard navigation)
const docs = props.documents const docs = props.documents
if (docs.length === 0) { if (docs.length === 0) {
store.cursor = '' store.cursor = ''
@@ -152,7 +352,7 @@ defineExpose({
const N = docs.length const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1) const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N const index = getCursorIndex()
// Stop navigation sideways away from the grid (only with up/down) // Stop navigation sideways away from the grid (only with up/down)
if (ev && index === 0 && ev.key === 'ArrowLeft') return if (ev && index === 0 && ev.key === 'ArrowLeft') return
if (ev && index === N - 1 && ev.key === 'ArrowRight') return if (ev && index === N - 1 && ev.key === 'ArrowRight') return
@@ -164,31 +364,7 @@ defineExpose({
// Wrapping either end, just land outside the list // Wrapping either end, just land outside the list
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
} }
store.cursor = docs[moveto]?.key ?? '' moveCursorTo(moveto, ev)
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
if (select) {
// Go forwards, possibly wrapping over the end; the last entry is not toggled
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue
const key = docs[p]!.key
if (store.selected.has(key)) store.selected.delete(key)
else store.selected.add(key)
}
}
// @ts-ignore
scrolltr = tr
if (!scrolltimer) {
scrolltimer = setTimeout(() => {
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltimer = null
}, 300)
}
// When leaving the file list: up goes to breadcrumbs, down goes to header
if (moveto === N) {
if (d < 0) focusBreadcrumb()
else focusHeader()
}
} }
}) })
const focusHeader = () => { const focusHeader = () => {
@@ -201,18 +377,18 @@ const focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus() if (el) el.focus()
} }
let scrolltimer: any = null const keyboardFollowScroll = createKeyboardFollowScroll()
let scrolltr: any = null const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
watchEffect(() => { watchEffect(() => {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value.key if (editing.value) store.cursor = editing.value.key
if (store.cursor) { if (store.cursor && !editing.value) {
const a = document.querySelector( const a = document.querySelector(
`#file-${store.cursor}` `#file-${store.cursor}`
) as HTMLAnchorElement | null ) as HTMLAnchorElement | null
if (a) { if (a) {
a.focus() a.focus({ preventScroll: true })
a.scrollIntoView({ block: 'center', behavior: 'smooth' })
} }
} }
}) })
@@ -226,18 +402,24 @@ let resizeObserver: ResizeObserver | null = null
onMounted(() => { onMounted(() => {
const active = document.querySelector('.cursor') as HTMLElement | null const active = document.querySelector('.cursor') as HTMLElement | null
if (active) { if (active) {
active.scrollIntoView({ block: 'center', behavior: 'instant' }) active.focus({ preventScroll: true })
active.focus()
} }
updateColumns() updateColumns()
seedFromDocs()
if (gallery.value) { if (gallery.value) {
resizeObserver = new ResizeObserver(updateColumns) resizeObserver = new ResizeObserver(updateColumns)
resizeObserver.observe(gallery.value) resizeObserver.observe(gallery.value)
gallery.value.addEventListener('load', onImgLoad, { capture: true })
} }
}) })
onUnmounted(() => { onUnmounted(() => {
keyboardFollowScroll.cancel()
resizeObserver?.disconnect() resizeObserver?.disconnect()
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
}) })
// Re-seed aspect ratios whenever docs update (e.g., ar patch from server)
watch(() => props.documents, seedFromDocs)
const mkdir = async (doc: Doc, name: string) => { const mkdir = async (doc: Doc, name: string) => {
doc.name = name doc.name = name
doc.key = crypto.randomUUID() doc.key = crypto.randomUUID()
+120 -17
View File
@@ -10,21 +10,33 @@
> >
<figure> <figure>
<slot></slot> <slot></slot>
<MediaPreview ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" /> <MediaPreview :key="snap.ext" ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
<div class="titlespacer"></div> <div class="titlespacer"></div>
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)"> <figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
<template v-if="editing"> <template v-if="editing">
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit /> <SelectBox :doc=doc @click="store.cursor = doc.key"/>
<div class="filename-row rename-row">
<div class="rename-wrap">
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
</div>
</div>
<div class=namespacer></div>
</template> </template>
<template v-else> <template v-else>
<SelectBox :doc=doc @click="store.cursor = doc.key"/> <SelectBox :doc=doc @click="store.cursor = doc.key"/>
<span>{{ doc.name }}<SparseIndicator :doc="doc" class="after-name" /></span> <div class="filename-row">
<span class="filename-group">
<span class="filename">{{ snap.displayName }}<SparseIndicator :doc="doc" class="after-name" /></span>
<span v-if="snap.ext" class="file-ext">.{{ snap.ext }}</span>
</span>
<button class="rename-btn" @click="$emit('rename')" title="Rename"></button>
</div>
<div class=namespacer></div> <div class=namespacer></div>
</template> </template>
</figcaption> </figcaption>
</figure> </figure>
<CursorTooltip ref="tooltip" :text="tooltipText"> <CursorTooltip ref="tooltip" :text="tooltipText">
<div class="tooltip-name">{{ doc.name }}</div> <div class="tooltip-name">{{ snap.name }}</div>
<div class="tooltip-details">{{ doc.modified }} {{ doc.sizedisp }}</div> <div class="tooltip-details">{{ doc.modified }} {{ doc.sizedisp }}</div>
<div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div> <div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
</CursorTooltip> </CursorTooltip>
@@ -42,7 +54,7 @@ import SparseIndicator from './SparseIndicator.vue'
const store = useMainStore() const store = useMainStore()
type EditingProp = { type EditingProp = {
rename: (name: string) => void rename: (doc: Doc, newName: string) => void
exit: () => void exit: () => void
} }
@@ -60,6 +72,20 @@ const sparseText = computed(() => {
return `${formatSize(allocated)} allocated of ${formatSize(size)}` return `${formatSize(allocated)} allocated of ${formatSize(size)}`
}) })
// Single subscription to docVersion; all doc-derived values come from here.
// This is needed because Doc instances are non-reactive plain objects, so
// mutating doc.name alone won't invalidate computed caches.
const snap = computed(() => {
void store.docVersion
const { name, ext } = props.doc
const base = ext ? name.slice(0, name.length - ext.length - 1) : name
return {
name,
ext,
displayName: base.replace(/[_.]+/g, ' ')
}
})
const onclick = (ev: Event) => { const onclick = (ev: Event) => {
if (m.value!.play()) ev.preventDefault() if (m.value!.play()) ev.preventDefault()
store.cursor = props.doc.key store.cursor = props.doc.key
@@ -81,8 +107,78 @@ const onclick = (ev: Event) => {
.after-name { .after-name {
margin-left: 0.3em; margin-left: 0.3em;
} }
.filename-row {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0;
flex: 0 1 auto;
min-width: 0;
position: relative;
overflow: visible;
max-width: calc(100% - 4.5em);
}
.filename-row::after {
content: '';
position: absolute;
left: 100%;
top: 0;
width: 1.4em;
height: 100%;
}
.filename-group {
display: inline-flex;
align-items: baseline;
min-width: 0;
max-width: 100%;
}
.filename {
cursor: default;
padding: .5em 0;
color: #fff;
font-size: 0.8em;
font-weight: 600;
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
text-wrap: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 0 1 auto;
min-width: 0;
}
.file-ext {
color: rgba(255, 255, 255, 0.8);
font-size: 0.8em;
font-weight: 600;
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
padding: 0 .15em 0 0;
white-space: nowrap;
flex: 0 0 auto;
}
.rename-btn {
position: absolute;
left: 100%;
top: 50%;
transform: translate(0.2em, -50%);
z-index: 2;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: 0.8em;
line-height: 1;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.12s ease;
}
.filename-row:hover .rename-btn {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
figure { figure {
max-height: 15em; height: var(--gallery-figure-height, 15em);
max-height: var(--gallery-figure-height, 15em);
position: relative; position: relative;
border-radius: .5em; border-radius: .5em;
overflow: hidden; overflow: hidden;
@@ -92,12 +188,13 @@ figure {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
transition: height 0.4s ease, max-height 0.4s ease;
} }
figure > article { figure > article {
flex: 0 0 auto; flex: 0 0 auto;
} }
figure :deep(.video-container) { figure :deep(.video-container) {
height: 15em; height: var(--gallery-figure-height, 15em);
} }
.titlespacer { .titlespacer {
flex-shrink: 100000; flex-shrink: 100000;
@@ -124,17 +221,10 @@ figcaption input[type='checkbox'] {
figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] { figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] {
opacity: 1; opacity: 1;
} }
figcaption span { .cursor .filename {
cursor: default; color: var(--accent-color);
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 { .cursor .file-ext {
color: var(--accent-color); color: var(--accent-color);
} }
figcaption .namespacer { figcaption .namespacer {
@@ -142,4 +232,17 @@ figcaption .namespacer {
height: 2em; height: 2em;
width: 2em; width: 2em;
} }
.rename-wrap {
font-size: 0.8em;
width: auto;
min-width: 0;
max-width: 100%;
}
.rename-row {
max-width: calc(100% - 4.5em);
}
.rename-wrap :deep(#FileRenameInput) {
min-width: 0;
max-width: 100%;
}
</style> </style>
+8
View File
@@ -164,6 +164,14 @@ const settingsMenu = (e: Event) => {
} }
}) })
} }
items.push({
label: '️ About Cista...',
onClick: () => {
store.dialog = 'about'
}
})
ContextMenu.showContextMenu({ ContextMenu.showContextMenu({
// @ts-ignore // @ts-ignore
x: e.target.getBoundingClientRect().right, x: e.target.getBoundingClientRect().right,
+119 -22
View File
@@ -1,9 +1,35 @@
<template> <template>
<div v-if=showProgress() class="preview-progress" aria-label="Preview pending"> <div v-if="showPreviewImage || showNativeImage" class="preview-image-shell">
<SpinnerIcon /> <span
v-show="activeImageLoading"
class="file icon"
:class="[`ext-${doc.ext}`, 'loading-pulse']"
:style="loadingPulseStyle"
></span>
<img
v-if="showPreviewImage"
:src="previewSrc"
alt=""
:class="{ ready: !previewImageLoading }"
@load="onPreviewImageLoad"
@error="onPreviewImageError"
>
<img
v-else
:src="doc.url"
alt=""
:class="{ ready: !nativeImageLoading }"
@load="onNativeImageLoad"
@error="onNativeImageError"
>
</div>
<div v-else-if=showProgress() class="preview-progress" aria-label="Preview pending">
<span
class="file icon"
:class="[`ext-${doc.ext}`, { 'loading-pulse': !previewLoadFailed }]"
:style="loadingPulseStyle"
></span>
</div> </div>
<img v-else-if="previewSrc && !video() && !audio()" :src="previewSrc" alt="">
<img v-else-if=doc.img :src=doc.url alt="">
<span v-else-if=doc.dir class="folder icon"></span> <span v-else-if=doc.dir class="folder icon"></span>
<div v-else-if=video() class="video-container" :class="{ pending: !doc.complete }"> <div v-else-if=video() class="video-container" :class="{ pending: !doc.complete }">
<video v-if=doc.complete ref=vid :src=doc.url :poster=previewSrc preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video> <video v-if=doc.complete ref=vid :src=doc.url :poster=previewSrc preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
@@ -18,10 +44,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Play as PlayIcon, Spinner as SpinnerIcon } from '@/assets/svg' import { Play as PlayIcon } from '@/assets/svg'
import type { Doc } from '@/repositories/Document' import type { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
const aud = ref<HTMLAudioElement | null>(null) const aud = ref<HTMLAudioElement | null>(null)
const vid = ref<HTMLVideoElement | null>(null) const vid = ref<HTMLVideoElement | null>(null)
@@ -30,11 +56,58 @@ const props = defineProps<{
doc: Doc doc: Doc
quality: string quality: string
}>() }>()
const previewImageFailed = ref(false)
const nativeImageFailed = ref(false)
const previewImageLoading = ref(true)
const nativeImageLoading = ref(true)
const previewSrc = computed(() => const previewSrc = computed(() =>
props.doc.previewurl props.doc.previewurl
? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}` ? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`
: '' : ''
) )
const showPreviewImage = computed(
() => !!previewSrc.value && !video() && !audio() && !previewImageFailed.value
)
const showNativeImage = computed(() => props.doc.img && !nativeImageFailed.value)
const activeImageLoading = computed(() =>
showPreviewImage.value ? previewImageLoading.value : nativeImageLoading.value
)
const previewLoadFailed = computed(
() => previewImageFailed.value || nativeImageFailed.value
)
const loadingPulseDelayMs = computed(() => {
let hash = 0
for (const ch of props.doc.key) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
return hash % 1800
})
const loadingPulseStyle = computed(() => ({
animationDelay: `${-loadingPulseDelayMs.value}ms`
}))
const onPreviewImageLoad = () => {
previewImageLoading.value = false
}
const onPreviewImageError = () => {
previewImageLoading.value = false
previewImageFailed.value = true
}
const onNativeImageLoad = () => {
nativeImageLoading.value = false
}
const onNativeImageError = () => {
nativeImageLoading.value = false
nativeImageFailed.value = true
}
watch(
() => props.doc.key,
() => {
previewImageFailed.value = false
nativeImageFailed.value = false
previewImageLoading.value = true
nativeImageLoading.value = true
}
)
const onplay = () => { const onplay = () => {
if (!media.value) return if (!media.value) return
@@ -154,6 +227,7 @@ img, embed, .icon, audio, video {
border-radius: calc(.5em / 8); border-radius: calc(.5em / 8);
} }
.preview-progress { .preview-progress {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -162,18 +236,47 @@ img, embed, .icon, audio, video {
max-height: 100%; max-height: 100%;
aspect-ratio: 1; aspect-ratio: 1;
} }
.preview-progress :deep(svg) { .preview-progress .icon {
width: 4.5em; opacity: 0.9;
height: 4.5em;
opacity: 0.8;
animation: media-preview-spin 0.9s linear infinite;
} }
@keyframes media-preview-spin { .preview-image-shell {
from { position: relative;
transform: rotate(0deg); display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
}
.preview-image-shell img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-width: 0;
object-fit: contain;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.preview-image-shell img.ready {
opacity: 1;
}
.loading-pulse {
animation: media-preview-pulse 1.8s ease-in-out infinite;
}
@keyframes media-preview-pulse {
0% {
transform: scale(1);
opacity: 0.86;
} }
to { 50% {
transform: rotate(360deg); transform: scale(1.04);
opacity: 0.98;
}
100% {
transform: scale(1);
opacity: 0.86;
} }
} }
.folder::before { .folder::before {
@@ -219,12 +322,6 @@ img, embed, .icon, audio, video {
figure.cursor .icon { figure.cursor .icon {
filter: brightness(1); filter: brightness(1);
} }
img::before {
/* broken image */
text-shadow: 0 0 .5rem #000;
filter: grayscale(1);
content: '❌';
}
.video-container { .video-container {
position: relative; position: relative;
display: flex; display: flex;
+65 -3
View File
@@ -15,15 +15,55 @@
<script setup lang="ts"> <script setup lang="ts">
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia' import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
import { nextTick, ref, watchEffect } from 'vue' import { nextTick, onBeforeUnmount, ref, watch, watchEffect } from 'vue'
const overlay = ref<HTMLDivElement | null>(null) const overlay = ref<HTMLDivElement | null>(null)
const dialog = ref<HTMLDivElement | null>(null) const dialog = ref<HTMLDivElement | null>(null)
const store = useMainStore() const store = useMainStore()
let backdropHeld = false
const ensureGlobalBackdropStyles = () => {
if (typeof document === 'undefined') return
if (document.getElementById('paskia-dialog')) return
const style = document.createElement('style')
style.id = 'paskia-dialog'
style.textContent = `body::before {
content: '';
position: fixed;
inset: 0;
z-index: 1099;
background: transparent;
backdrop-filter: blur(0) brightness(1);
-webkit-backdrop-filter: blur(0) brightness(1);
pointer-events: none;
visibility: hidden;
transition: all 0.2s ease-out;
}
body.paskia-backdrop::before {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
visibility: visible;
}
body.paskia-backdrop {
overflow: auto;
}
#paskia-iframe {
border: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
color-scheme: auto;
background: transparent;
}
`
document.head.insertBefore(style, document.head.firstChild)
}
const close = () => { const close = () => {
store.dialog = '' store.dialog = ''
releaseGlobalBackdrop()
} }
const props = defineProps<{ const props = defineProps<{
@@ -33,7 +73,6 @@ const props = defineProps<{
const show = () => { const show = () => {
store.dialog = props.name store.dialog = props.name
holdGlobalBackdrop()
nextTick(() => { nextTick(() => {
overlay.value?.focus() overlay.value?.focus()
const input = dialog.value?.querySelector('input') const input = dialog.value?.querySelector('input')
@@ -41,6 +80,29 @@ const show = () => {
}) })
} }
defineExpose({ show, close }) defineExpose({ show, close })
watch(
() => store.dialog === props.name,
isOpen => {
if (isOpen && !backdropHeld) {
ensureGlobalBackdropStyles()
holdGlobalBackdrop()
backdropHeld = true
} else if (!isOpen && backdropHeld) {
releaseGlobalBackdrop()
backdropHeld = false
}
},
{ immediate: true }
)
onBeforeUnmount(() => {
if (backdropHeld) {
releaseGlobalBackdrop()
backdropHeld = false
}
})
watchEffect(() => { watchEffect(() => {
if (overlay.value) { if (overlay.value) {
overlay.value.focus() overlay.value.focus()
+4 -1
View File
@@ -13,6 +13,7 @@ export type DocProps = {
dir: boolean dir: boolean
ghost?: boolean ghost?: boolean
expires?: number // Unix timestamp for ghost expiry expires?: number // Unix timestamp for ghost expiry
ar?: number // Aspect ratio (height/width) from server, if known
} }
export class Doc { export class Doc {
@@ -26,6 +27,7 @@ export class Doc {
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry) public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
/** @internal Use the name getter/setter instead */ /** @internal Use the name getter/setter instead */
public _name: string = '' public _name: string = ''
public ar?: number // Aspect ratio (height/width), provided by server after first preview render
constructor(props: Partial<DocProps> = {}) { constructor(props: Partial<DocProps> = {}) {
const { name, ...rest } = props const { name, ...rest } = props
@@ -130,7 +132,8 @@ export type FileEntry = [
number, // mtime number, // mtime
number, // size number, // size
number, // allocated (actual disk usage) number, // allocated (actual disk usage)
number // isfile number, // isfile
number? // ar: aspect ratio (height/width), present if known
] ]
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>] export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
+5
View File
@@ -164,6 +164,11 @@ const handleWatchMessage = (event: MessageEvent) => {
case !!msg.update: case !!msg.update:
handleUpdateMessage(msg) handleUpdateMessage(msg)
break break
case !!msg.ar: {
const store = useMainStore()
store.updateAr(msg.ar as Record<string, number>)
break
}
case !!msg.space: case !!msg.space:
const store = useMainStore() const store = useMainStore()
store.space = msg.space store.space = msg.space
+27 -4
View File
@@ -5,7 +5,7 @@ import { collator } from '@/utils'
import { type SortOrder, sorted } from '@/utils/docsort' import { type SortOrder, sorted } from '@/utils/docsort'
import SearchWorker from '@/workers/searchWorker?worker' import SearchWorker from '@/workers/searchWorker?worker'
import { type StateTree, defineStore } from 'pinia' import { type StateTree, defineStore } from 'pinia'
import { documentRef, getDocuments, setDocuments } from './documentStore' import { documentRef, getDocuments, setDocuments, triggerUpdate } from './documentStore'
// Singleton search worker instance // Singleton search worker instance
let searchWorker: Worker | null = null let searchWorker: Worker | null = null
@@ -84,7 +84,7 @@ export const useMainStore = defineStore('main', {
paskia?: boolean paskia?: boolean
office_previews?: boolean office_previews?: boolean
}, },
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens', dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens' | 'about',
uprogress: {} as any, uprogress: {} as any,
dprogress: {} as any, dprogress: {} as any,
prefs: { prefs: {
@@ -124,7 +124,7 @@ export const useMainStore = defineStore('main', {
updateRoot(root: FileEntry[]) { updateRoot(root: FileEntry[]) {
const docs = [] const docs = []
let loc = [] as string[] let loc = [] as string[]
for (const [level, name, key, mtime, size, allocated, isfile] of root) { for (const [level, name, key, mtime, size, allocated, isfile, ar] of root) {
loc = loc.slice(0, level - 1) loc = loc.slice(0, level - 1)
docs.push( docs.push(
new Doc({ new Doc({
@@ -134,7 +134,8 @@ export const useMainStore = defineStore('main', {
size, size,
allocated, allocated,
mtime, mtime,
dir: !isfile dir: !isfile,
ar
}) })
) )
loc.push(name) loc.push(name)
@@ -157,6 +158,22 @@ export const useMainStore = defineStore('main', {
// Sync documents to search worker // Sync documents to search worker
this.syncSearchWorker() this.syncSearchWorker()
}, },
/** Patch aspect ratios on existing docs from a server ar update message */
updateAr(arMap: Record<string, number>) {
const docs = getDocuments()
let changed = false
for (const doc of docs) {
const ar = arMap[doc.key]
if (ar != null && doc.ar !== ar) {
doc.ar = ar
changed = true
}
}
if (changed) {
triggerUpdate()
this.docVersion++
}
},
/** Add a ghost file/folder for optimistic UI updates */ /** Add a ghost file/folder for optimistic UI updates */
addGhost(doc: Doc) { addGhost(doc: Doc) {
doc.ghost = true doc.ghost = true
@@ -236,6 +253,12 @@ export const useMainStore = defineStore('main', {
})) }))
worker.postMessage({ type: 'update', documents: docData }) worker.postMessage({ type: 'update', documents: docData })
}, },
/** Notify UI/search that existing document objects were mutated in-place */
documentsChanged() {
triggerUpdate()
this.docVersion++
this.syncSearchWorker()
},
search(query: string, loc: string) { search(query: string, loc: string) {
const worker = getSearchWorker() const worker = getSearchWorker()
const id = ++searchId const id = ++searchId
+124
View File
@@ -0,0 +1,124 @@
type ScrollOptions = {
topPad?: number
bottomPad?: number
keyboardWindowMs?: number
getScrollContainer?: () => HTMLElement | null
}
export function createKeyboardFollowScroll(options: ScrollOptions = {}) {
const {
topPad = 84,
bottomPad = 84,
keyboardWindowMs = 260,
getScrollContainer = () =>
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
} = options
let scrollAnimationFrame: number | null = null
let scrollTargetY: number | null = null
let scrollVelocity = 0
let keyboardFollowUntil = 0
const markKeyboardFollow = () => {
keyboardFollowUntil = performance.now() + keyboardWindowMs
}
const keyboardFollowActive = () => performance.now() < keyboardFollowUntil
const clampScrollY = (y: number, scroller: HTMLElement) => {
const maxY = Math.max(0, scroller.scrollHeight - scroller.clientHeight)
return Math.min(maxY, Math.max(0, y))
}
const cursorScrollTarget = (el: HTMLElement): number | null => {
const scroller = getScrollContainer() ?? document.documentElement
const rect = el.getBoundingClientRect()
const scrollerRect = scroller.getBoundingClientRect()
const visibleTop = scrollerRect.top + topPad
const visibleBottom = scrollerRect.bottom - bottomPad
if (rect.top >= visibleTop && rect.bottom <= visibleBottom) return null
if (rect.top < visibleTop) {
return clampScrollY(scroller.scrollTop + (rect.top - visibleTop), scroller)
}
return clampScrollY(scroller.scrollTop + (rect.bottom - visibleBottom), scroller)
}
const runSmoothCursorScroll = () => {
if (scrollAnimationFrame != null) return
const step = () => {
const scroller = getScrollContainer() ?? document.documentElement
if (scrollTargetY == null) {
scrollVelocity *= 0.68
if (Math.abs(scrollVelocity) > 0.05) {
const next = clampScrollY(scroller.scrollTop + scrollVelocity, scroller)
scroller.scrollTop = next
scrollAnimationFrame = requestAnimationFrame(step)
return
}
scrollVelocity = 0
scrollAnimationFrame = null
return
}
const current = scroller.scrollTop
const delta = scrollTargetY - current
const absDelta = Math.abs(delta)
if (absDelta < 0.6 && Math.abs(scrollVelocity) < 0.08) {
scroller.scrollTop = scrollTargetY
scrollVelocity = 0
scrollTargetY = null
scrollAnimationFrame = null
return
}
const stiffness = Math.min(0.022, 0.01 + absDelta / 10000)
const damping = 0.76
scrollVelocity += delta * stiffness
scrollVelocity *= damping
const next = clampScrollY(current + scrollVelocity, scroller)
if (next === current) scrollVelocity = 0
scroller.scrollTop = next
scrollAnimationFrame = requestAnimationFrame(step)
}
scrollAnimationFrame = requestAnimationFrame(step)
}
const keepVisible = (el: HTMLElement | null) => {
if (!keyboardFollowActive()) {
scrollTargetY = null
scrollVelocity = 0
return
}
if (!el) {
scrollTargetY = null
return
}
const target = cursorScrollTarget(el)
if (target == null) {
scrollTargetY = null
return
}
scrollTargetY = target
runSmoothCursorScroll()
}
const cancel = () => {
if (scrollAnimationFrame != null) cancelAnimationFrame(scrollAnimationFrame)
scrollAnimationFrame = null
scrollTargetY = null
scrollVelocity = 0
keyboardFollowUntil = 0
}
return { markKeyboardFollow, keepVisible, cancel }
}
+3 -1
View File
@@ -1,3 +1,4 @@
import os
from pathlib import Path, PurePath from pathlib import Path, PurePath
from uuid import uuid4 from uuid import uuid4
@@ -23,7 +24,8 @@ def _persist_config():
@pytest.fixture @pytest.fixture
def setup_storage(tmp_path: Path): def setup_storage(tmp_path: Path):
config.init_confdir(tmp_path) os.environ["CISTA_HOME"] = str(tmp_path)
config.init_confdir()
user = config.User() user = config.User()
auth.set_password(user, "secret") auth.set_password(user, "secret")
admin = config.User(privileged=True) admin = config.User(privileged=True)