Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07089aa9a7 | ||
|
|
c3146744b7 | ||
|
|
48d7435d0b | ||
|
|
3e80325053 | ||
|
|
31fc02ddbf | ||
|
|
2406ea87b0 | ||
|
|
3a1dd2b7da | ||
|
|
3df6b079c9 | ||
|
|
af804e2c9f | ||
|
|
1dc0c4441a | ||
|
|
c7ba0d5a04 | ||
|
|
0071058b29 | ||
|
|
338c74de69 | ||
|
|
b13f08eab2 |
+10
-6
@@ -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)
|
||||
@app.before_server_stop
|
||||
async def main_stop(app):
|
||||
watching.stop(app)
|
||||
await onlyoffice.close_oo_client()
|
||||
await shutdown_preview_workers()
|
||||
app.ctx.threadexec.shutdown()
|
||||
app.ctx.zipexec.shutdown(cancel_futures=True)
|
||||
await sso.close_client()
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(asyncio.to_thread(watching.stop, app))
|
||||
tg.create_task(onlyoffice.close_oo_client())
|
||||
tg.create_task(shutdown_preview_workers())
|
||||
tg.create_task(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")
|
||||
|
||||
|
||||
|
||||
+48
-11
@@ -20,7 +20,8 @@ from sanic import Blueprint, empty, raw, redirect
|
||||
from sanic.exceptions import NotFound
|
||||
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 (
|
||||
DOC_PREVIEW_SUFFIXES,
|
||||
OFFICE_PREVIEW_SUFFIXES,
|
||||
@@ -244,7 +245,12 @@ class _PreviewWorkerPool:
|
||||
args[0].name,
|
||||
)
|
||||
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
|
||||
|
||||
filepath = args[0]
|
||||
@@ -258,11 +264,13 @@ class _PreviewWorkerPool:
|
||||
future.set_result((out, resp))
|
||||
except TimeoutError:
|
||||
replace = True
|
||||
logger.warning(
|
||||
"Preview timeout (%ds) for %s", int(PREVIEW_TIMEOUT), filepath.name
|
||||
)
|
||||
if not future.done():
|
||||
future.set_exception(PreviewTimeoutError(filepath.name))
|
||||
future.set_exception(
|
||||
PreviewTimeoutError(
|
||||
filepath.name,
|
||||
backend=_expected_preview_backend(filepath),
|
||||
)
|
||||
)
|
||||
except WorkerChecksumError:
|
||||
replace = True
|
||||
logger.error("Preview checksum mismatch for %s", filepath.name)
|
||||
@@ -410,6 +418,10 @@ async def verify_preview(request):
|
||||
class PreviewTimeoutError(Exception):
|
||||
"""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):
|
||||
"""Raised when the preview subprocess exits with a non-zero status."""
|
||||
@@ -466,11 +478,13 @@ class OOConversionManager:
|
||||
filepath, request_timeout=5.0
|
||||
)
|
||||
except Exception as e:
|
||||
future.set_exception(e)
|
||||
if not future.done():
|
||||
future.set_exception(e)
|
||||
async with self._lock:
|
||||
self._in_flight.pop(key, None)
|
||||
else:
|
||||
future.set_result(png_bytes)
|
||||
if not future.done():
|
||||
future.set_result(png_bytes)
|
||||
async with self._lock:
|
||||
self._in_flight.pop(key, None)
|
||||
|
||||
@@ -548,6 +562,21 @@ def _preview_job_priority(path) -> int:
|
||||
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:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in DOC_PREVIEW_SUFFIXES or suffix in OFFICE_PREVIEW_SUFFIXES:
|
||||
@@ -609,10 +638,12 @@ async def preview(req, path):
|
||||
timeout=PREVIEW_TIMEOUT,
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning("Preview timeout for %s", filepath)
|
||||
req.ctx.log_extra = f"{_expected_preview_backend(filepath)} timeout"
|
||||
return empty(503)
|
||||
except PreviewTimeoutError:
|
||||
logger.warning("Preview worker timeout for %s", filepath)
|
||||
except PreviewTimeoutError as e:
|
||||
req.ctx.log_extra = (
|
||||
f"{(e.backend or _expected_preview_backend(filepath))} timeout"
|
||||
)
|
||||
return empty(503)
|
||||
except httpx.HTTPStatusError:
|
||||
req.ctx.log_extra = "onlyoffice N/A"
|
||||
@@ -654,6 +685,12 @@ async def preview(req, path):
|
||||
# Preview generation failed, redirect to the file itself
|
||||
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
|
||||
preview_mime = (
|
||||
preview_resp.mime
|
||||
|
||||
+37
-1
@@ -17,6 +17,7 @@ import gc
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
import shlex
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -95,6 +96,8 @@ class PreviewResponse(msgspec.Struct, omit_defaults=True):
|
||||
timings: list[float] | None = None
|
||||
error: str | None = None
|
||||
stderr: str | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
|
||||
|
||||
_enc = msgspec.json.Encoder()
|
||||
@@ -173,6 +176,7 @@ def _get_image_dimensions(path: Path) -> tuple[int, int] | None:
|
||||
"""
|
||||
try:
|
||||
img = pyvips.Image.new_from_file(str(path))
|
||||
img = img.autorot()
|
||||
except pyvips.error.Error:
|
||||
return None
|
||||
else:
|
||||
@@ -214,7 +218,18 @@ def _image_via_ffmpeg(path: Path, maxsize: int, quality: int) -> bytes:
|
||||
cmd.insert(4, "-s")
|
||||
cmd.insert(5, f"{new_w}x{new_h}")
|
||||
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:
|
||||
return f.read()
|
||||
finally:
|
||||
@@ -228,6 +243,8 @@ def process_image_pyvips(path, *, maxsize, quality):
|
||||
# HEIC/HEIF: ffmpeg handles tile assembly and HDR correctly;
|
||||
# skip pyvips entirely.
|
||||
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)
|
||||
t_end = perf_counter()
|
||||
return ret, PreviewResponse(
|
||||
@@ -235,13 +252,17 @@ def process_image_pyvips(path, *, maxsize, quality):
|
||||
mime="image/avif",
|
||||
backend="ffmpeg",
|
||||
timings=[round((t_end - t_start) * 1000, 1)],
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
|
||||
# Other image formats: pyvips first, ffmpeg fallback.
|
||||
load_opts = {"access": "sequential"}
|
||||
orig_w = orig_h = None
|
||||
try:
|
||||
img = pyvips.Image.new_from_file(str(path), **load_opts)
|
||||
img = img.autorot()
|
||||
orig_w, orig_h = img.width, img.height
|
||||
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
||||
if scale < 1.0:
|
||||
img = img.resize(scale)
|
||||
@@ -253,6 +274,7 @@ def process_image_pyvips(path, *, maxsize, quality):
|
||||
)
|
||||
backend = "pyvips"
|
||||
except pyvips.error.Error:
|
||||
orig_w, orig_h = None, None
|
||||
ret = _image_via_ffmpeg(path, maxsize, quality)
|
||||
backend = "ffmpeg"
|
||||
t_end = perf_counter()
|
||||
@@ -262,6 +284,8 @@ def process_image_pyvips(path, *, maxsize, quality):
|
||||
mime="image/avif",
|
||||
backend=backend,
|
||||
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()
|
||||
img = pyvips.Image.new_from_buffer(data, "")
|
||||
img = img.autorot()
|
||||
orig_w, orig_h = img.width, img.height
|
||||
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
||||
if scale < 1.0:
|
||||
img = img.resize(scale)
|
||||
@@ -286,6 +311,8 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
|
||||
mime="image/avif",
|
||||
backend="pyvips",
|
||||
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_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")
|
||||
|
||||
# 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:
|
||||
scale_factor = min(maxsize / frame.width, maxsize / frame.height)
|
||||
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_save_end - t_save_start) * 1000, 1),
|
||||
],
|
||||
width=disp_w,
|
||||
height=disp_h,
|
||||
)
|
||||
del imgdata, istream, ostream, icc, occ, frame
|
||||
gc.collect()
|
||||
|
||||
+2
-1
@@ -12,7 +12,7 @@ class ErrorMsg(msgspec.Struct):
|
||||
## 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
|
||||
name: str
|
||||
key: str
|
||||
@@ -20,6 +20,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
||||
size: int
|
||||
allocated: int
|
||||
isfile: int
|
||||
ar: float | None = None
|
||||
|
||||
def __str__(self):
|
||||
return self.key or "FileEntry()"
|
||||
|
||||
+40
-1
@@ -1,6 +1,7 @@
|
||||
"""Custom access logging middleware for Sanic."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import unicodedata
|
||||
from ipaddress import IPv6Address
|
||||
@@ -9,6 +10,40 @@ from sanic.log import LOGGING_CONFIG_DEFAULTS
|
||||
|
||||
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"
|
||||
_STATUS_INFO = "\033[32m" # 1xx (green)
|
||||
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
|
||||
@@ -236,7 +271,7 @@ def log_ws_close(
|
||||
|
||||
def configure_access_logging() -> None:
|
||||
"""Configure the cista.access logger to output to stderr."""
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler = ReentrantSafeStreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
@@ -271,6 +306,10 @@ def configure_main_logging() -> None:
|
||||
Patches LOGGING_CONFIG_DEFAULTS so the formatter survives every dictConfig
|
||||
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"] = {
|
||||
"class": "cista.sanic_logging._EmojiFormatter",
|
||||
}
|
||||
|
||||
+7
-1
@@ -4,11 +4,17 @@ from pathlib import Path
|
||||
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
from sanic import Sanic
|
||||
from sanic.worker.loader import AppLoader
|
||||
|
||||
from cista import config, server80
|
||||
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):
|
||||
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
|
||||
_url, opts = parse_listen(config.config.listen)
|
||||
@@ -29,7 +35,7 @@ def run(*, dev=False):
|
||||
access_log=False,
|
||||
) # type: ignore[call-arg]
|
||||
if dev:
|
||||
Sanic.serve()
|
||||
Sanic.serve(app_loader=AppLoader(factory=load_app))
|
||||
else:
|
||||
Sanic.serve_single()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import time
|
||||
from functools import wraps
|
||||
|
||||
import msgspec
|
||||
import websockets.exceptions
|
||||
from sanic import errorpages
|
||||
from sanic.exceptions import SanicException
|
||||
from sanic.log import logger
|
||||
@@ -67,6 +68,12 @@ def websocket_wrapper(handler):
|
||||
try:
|
||||
await auth.verify(request)
|
||||
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:
|
||||
context, code, message = {}, 500, str(e) or "Internal Server Error"
|
||||
if isinstance(e, SanicException):
|
||||
|
||||
+71
-4
@@ -55,6 +55,10 @@ class FormatUpdateLoopError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class _WatcherStoppingError(Exception):
|
||||
"""Internal control-flow exception for quick watcher shutdown."""
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self):
|
||||
self.lock = threading.RLock()
|
||||
@@ -154,6 +158,17 @@ stop_event = threading.Event()
|
||||
# Thread-safe queue for signaling path updates from websockets
|
||||
_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):
|
||||
"""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:
|
||||
logger.exception(f"get_allocated_size failed for {path}")
|
||||
allocated = st.st_size if isfile else 0
|
||||
key = fuid(st)
|
||||
entry = FileEntry(
|
||||
level=len(rel.parts),
|
||||
name=rel.name,
|
||||
key=fuid(st),
|
||||
key=key,
|
||||
mtime=int(st.st_mtime),
|
||||
size=st.st_size if isfile else 0,
|
||||
allocated=allocated,
|
||||
isfile=isfile,
|
||||
ar=_ar_map.get(key) if isfile else None,
|
||||
)
|
||||
if isfile:
|
||||
return [entry]
|
||||
@@ -202,7 +219,7 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
||||
li = []
|
||||
for f in path.iterdir():
|
||||
if stop_event.is_set():
|
||||
raise SystemExit("quit")
|
||||
raise _WatcherStoppingError
|
||||
if f.name.startswith("."):
|
||||
continue # No dotfiles
|
||||
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))
|
||||
# Build the tree as a list of FileEntries
|
||||
for [_, name, s] in humansorted(li):
|
||||
if stop_event.is_set():
|
||||
raise _WatcherStoppingError
|
||||
sub = walk(rel / name, stat=s)
|
||||
if not sub:
|
||||
continue
|
||||
child = sub[0]
|
||||
entry = FileEntry(
|
||||
level=entry.level,
|
||||
@@ -666,7 +687,10 @@ def watcher(loop):
|
||||
inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix())
|
||||
|
||||
# Initialize the tree from filesystem
|
||||
update_root(loop)
|
||||
try:
|
||||
update_root(loop)
|
||||
except _WatcherStoppingError:
|
||||
return
|
||||
path_index = PathIndex(state.root[:])
|
||||
|
||||
trefresh = time.monotonic() + 300.0
|
||||
@@ -750,7 +774,10 @@ def watcher(loop):
|
||||
# Process each collapsed path
|
||||
new_root = path_index.root
|
||||
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)
|
||||
|
||||
# Broadcast if changed
|
||||
@@ -769,12 +796,51 @@ def watcher(loop):
|
||||
with state.lock:
|
||||
broadcast(update_msg, loop)
|
||||
state.root = fresh
|
||||
except _WatcherStoppingError:
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("Fallback failed; sending full root")
|
||||
with state.lock:
|
||||
broadcast(format_root(fresh), loop)
|
||||
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)
|
||||
try:
|
||||
while True:
|
||||
@@ -820,6 +886,7 @@ def start(app):
|
||||
global rootpath
|
||||
config.load_config()
|
||||
rootpath = config.config.path
|
||||
stop_event.clear()
|
||||
app.ctx.watcher = threading.Thread(
|
||||
target=watcher,
|
||||
args=[app.loop],
|
||||
|
||||
+46
-7
@@ -8,6 +8,7 @@
|
||||
<SettingsModal />
|
||||
<UserManagementModal />
|
||||
<UserTokensModal />
|
||||
<AboutModal />
|
||||
<AccessDeniedModal />
|
||||
<header>
|
||||
<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 { useMainStore } from '@/stores/main'
|
||||
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 Router from '@/router/index'
|
||||
import { computed } from 'vue'
|
||||
import AboutModal from './components/AboutModal.vue'
|
||||
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||
import SelectionToolbar from './components/SelectionToolbar.vue'
|
||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||
@@ -56,12 +58,16 @@ const path: ComputedRef<Path> = computed(() => {
|
||||
query
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
document.title =
|
||||
path.value.path.replace(/\/$/, '').split('/').pop() ||
|
||||
store.server.name ||
|
||||
'Cista Storage'
|
||||
})
|
||||
watch(
|
||||
() => path.value.path,
|
||||
() => {
|
||||
document.title =
|
||||
path.value.path.replace(/\/$/, '').split('/').pop() ||
|
||||
store.server.name ||
|
||||
'Cista Storage'
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
onMounted(loadSession)
|
||||
onMounted(watchConnect)
|
||||
onUnmounted(watchDisconnect)
|
||||
@@ -95,6 +101,8 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'PageUp' ||
|
||||
event.key === 'PageDown' ||
|
||||
(c && event.code === 'Space')
|
||||
) {
|
||||
if (!input) event.preventDefault()
|
||||
@@ -104,6 +112,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
//console.log("key pressed", event)
|
||||
/// Long if-else machina for all keys we handle here
|
||||
let arrow = ''
|
||||
let paging = ''
|
||||
const inHeader = !!(event.target as HTMLElement).closest('.headermain')
|
||||
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
|
||||
// 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')) {
|
||||
return // Let browser handle cursor movement
|
||||
}
|
||||
// Don't intercept arrows for non-search inputs (e.g. rename input)
|
||||
if (input && !searchInput) return
|
||||
arrow = dir
|
||||
} else if (
|
||||
event.key === 'PageUp' ||
|
||||
event.key === 'PageDown' ||
|
||||
event.key === 'Home' ||
|
||||
event.key === 'End'
|
||||
) {
|
||||
if (input) return
|
||||
paging = event.key
|
||||
}
|
||||
if (arrow) {
|
||||
// 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
|
||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
||||
@@ -136,6 +157,8 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
else if (keyup && event.key === 'Escape') {
|
||||
store.error = ''
|
||||
store.clearToast()
|
||||
// Keep rename and other non-search inputs isolated from search behavior.
|
||||
if (input && !searchInput) return
|
||||
headerMain.value!.clearSearch(event)
|
||||
store.focusBreadcrumb()
|
||||
} else if (!input && keyup && event.key === 'Backspace') {
|
||||
@@ -235,12 +258,28 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
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) {
|
||||
// Initial move, then t0 delay until repeats at tr intervals
|
||||
const t0 = 200,
|
||||
tr = event.altKey ? 20 : 100
|
||||
f()
|
||||
if (paging === 'Home' || paging === 'End') return
|
||||
timer = setTimeout(() => {
|
||||
timer = setInterval(f, tr)
|
||||
}, t0 - tr)
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -1,32 +1,19 @@
|
||||
<template>
|
||||
<div v-if="store.dialog === 'accessdenied'" class="modal-overlay">
|
||||
<div class="modal-dialog" id="accessdenied">
|
||||
<div class="modal-content access-denied">
|
||||
<p class="icon">⛔</p>
|
||||
<p class="message">Access Denied</p>
|
||||
<button @click="reload" class="button">Reload</button>
|
||||
</div>
|
||||
<ModalDialog name="accessdenied" title="">
|
||||
<div class="access-denied">
|
||||
<p class="icon">⛔</p>
|
||||
<p class="message">Access Denied</p>
|
||||
<button @click="reload" class="button">Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { holdGlobalBackdrop } from 'paskia'
|
||||
import { watchEffect } from 'vue'
|
||||
|
||||
const store = useMainStore()
|
||||
import ModalDialog from '@/components/ModalDialog.vue'
|
||||
|
||||
const reload = () => {
|
||||
location.reload()
|
||||
}
|
||||
|
||||
// Keep backdrop active when this dialog shows
|
||||
watchEffect(() => {
|
||||
if (store.dialog === 'accessdenied') {
|
||||
holdGlobalBackdrop()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -76,6 +76,7 @@ import { apiFetch } from '@/repositories/Client'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { formatSize } from '@/utils'
|
||||
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import {
|
||||
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
|
||||
const editing = shallowRef<Doc | null>(null)
|
||||
const rename = async (doc: Doc, newName: string) => {
|
||||
const oldName = doc.name
|
||||
doc.name = newName // We should get an update from watch but this is quicker
|
||||
store.documentsChanged()
|
||||
try {
|
||||
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
||||
const res = await apiFetch(
|
||||
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
|
||||
const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
|
||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||
} catch (err) {
|
||||
console.error('Rename failed', err)
|
||||
doc.name = oldName
|
||||
store.documentsChanged()
|
||||
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
||||
}
|
||||
}
|
||||
@@ -176,14 +251,33 @@ defineExpose({
|
||||
} else {
|
||||
store.selected.add(key)
|
||||
}
|
||||
markKeyboardFollow()
|
||||
this.cursorMove(1, null)
|
||||
},
|
||||
up(ev: KeyboardEvent) {
|
||||
markKeyboardFollow()
|
||||
this.cursorMove(-1, ev)
|
||||
},
|
||||
down(ev: KeyboardEvent) {
|
||||
markKeyboardFollow()
|
||||
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) {
|
||||
// Only go back if we're in a subfolder (not at root)
|
||||
if (props.path.length > 0) {
|
||||
@@ -197,8 +291,6 @@ defineExpose({
|
||||
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) {
|
||||
store.cursor = ''
|
||||
@@ -207,35 +299,9 @@ defineExpose({
|
||||
const N = docs.length
|
||||
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||
const index = store.cursor
|
||||
? docs.findIndex(doc => doc.key === store.cursor)
|
||||
: docs.length
|
||||
const index = getCursorIndex()
|
||||
const moveto = increment(index, d)
|
||||
store.cursor = docs[moveto]?.key ?? ''
|
||||
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
||||
if (select) {
|
||||
// Go forwards, possibly wrapping over the end; the last entry is not toggled
|
||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||
if (p === N) continue
|
||||
const key = docs[p]!.key
|
||||
if (store.selected.has(key)) store.selected.delete(key)
|
||||
else store.selected.add(key)
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
scrolltr = tr
|
||||
if (!scrolltimer) {
|
||||
scrolltimer = setTimeout(() => {
|
||||
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
scrolltimer = null
|
||||
}, 300)
|
||||
}
|
||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
||||
if (moveto === N) {
|
||||
if (d < 0) focusBreadcrumb()
|
||||
else focusHeader()
|
||||
}
|
||||
moveCursorTo(moveto, ev)
|
||||
}
|
||||
})
|
||||
const focusHeader = () => {
|
||||
@@ -248,8 +314,9 @@ const focusBreadcrumb = () => {
|
||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
let scrolltimer: any = null
|
||||
let scrolltr: any = null
|
||||
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||
watchEffect(() => {
|
||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
||||
if (editing.value) store.cursor = editing.value?.key
|
||||
@@ -257,7 +324,7 @@ watchEffect(() => {
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor} .name a`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) a.focus()
|
||||
if (a) a.focus({ preventScroll: true })
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
@@ -276,11 +343,11 @@ onMounted(() => {
|
||||
modifiedTimer = setInterval(updateModified, 1000)
|
||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||
active.focus()
|
||||
active.focus({ preventScroll: true })
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
keyboardFollowScroll.cancel()
|
||||
clearInterval(modifiedTimer)
|
||||
})
|
||||
const mkdir = async (doc: Doc, name: string) => {
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -13,6 +20,7 @@ import { apiFetch } from '@/repositories/Client'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import type { SortOrder } from '@/utils/docsort'
|
||||
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import {
|
||||
computed,
|
||||
@@ -21,6 +29,7 @@ import {
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -56,28 +65,200 @@ const exit = () => {
|
||||
const rename = async (doc: Doc, newName: string) => {
|
||||
const oldName = doc.name
|
||||
doc.name = newName // We should get an update from watch but this is quicker
|
||||
store.documentsChanged()
|
||||
try {
|
||||
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
||||
const res = await apiFetch(
|
||||
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
|
||||
const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
|
||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||
} catch (err) {
|
||||
console.error('Rename failed', err)
|
||||
doc.name = oldName
|
||||
store.documentsChanged()
|
||||
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
||||
}
|
||||
}
|
||||
const gallery = ref<HTMLElement>()
|
||||
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 = () => {
|
||||
if (!gallery.value) return
|
||||
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(
|
||||
' '
|
||||
).length
|
||||
const style = getComputedStyle(gallery.value)
|
||||
const templates = style.gridTemplateColumns
|
||||
.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 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({
|
||||
newFolder() {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
@@ -127,23 +308,42 @@ defineExpose({
|
||||
} else {
|
||||
store.selected.add(key)
|
||||
}
|
||||
markKeyboardFollow()
|
||||
this.cursorMove(1, null)
|
||||
},
|
||||
up(ev: KeyboardEvent) {
|
||||
markKeyboardFollow()
|
||||
this.cursorMove(-columns.value, ev)
|
||||
},
|
||||
down(ev: KeyboardEvent) {
|
||||
markKeyboardFollow()
|
||||
this.cursorMove(columns.value, ev)
|
||||
},
|
||||
left(ev: KeyboardEvent) {
|
||||
markKeyboardFollow()
|
||||
this.cursorMove(-1, ev)
|
||||
},
|
||||
right(ev: KeyboardEvent) {
|
||||
markKeyboardFollow()
|
||||
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) {
|
||||
const select = !!ev?.shiftKey
|
||||
// Move cursor up or down (keyboard navigation)
|
||||
const docs = props.documents
|
||||
if (docs.length === 0) {
|
||||
store.cursor = ''
|
||||
@@ -152,7 +352,7 @@ defineExpose({
|
||||
const N = docs.length
|
||||
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||
const index = store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
|
||||
const index = getCursorIndex()
|
||||
// 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
|
||||
@@ -164,31 +364,7 @@ defineExpose({
|
||||
// Wrapping either end, just land outside the list
|
||||
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
|
||||
}
|
||||
store.cursor = docs[moveto]?.key ?? ''
|
||||
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
||||
if (select) {
|
||||
// Go forwards, possibly wrapping over the end; the last entry is not toggled
|
||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||
if (p === N) continue
|
||||
const key = docs[p]!.key
|
||||
if (store.selected.has(key)) store.selected.delete(key)
|
||||
else store.selected.add(key)
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
scrolltr = tr
|
||||
if (!scrolltimer) {
|
||||
scrolltimer = setTimeout(() => {
|
||||
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
scrolltimer = null
|
||||
}, 300)
|
||||
}
|
||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
||||
if (moveto === N) {
|
||||
if (d < 0) focusBreadcrumb()
|
||||
else focusHeader()
|
||||
}
|
||||
moveCursorTo(moveto, ev)
|
||||
}
|
||||
})
|
||||
const focusHeader = () => {
|
||||
@@ -201,18 +377,18 @@ const focusBreadcrumb = () => {
|
||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
let scrolltimer: any = null
|
||||
let scrolltr: any = null
|
||||
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||
watchEffect(() => {
|
||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
||||
if (editing.value) store.cursor = editing.value.key
|
||||
if (store.cursor) {
|
||||
if (store.cursor && !editing.value) {
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor}`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) {
|
||||
a.focus()
|
||||
a.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
a.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -226,18 +402,24 @@ let resizeObserver: ResizeObserver | null = null
|
||||
onMounted(() => {
|
||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||
active.focus()
|
||||
active.focus({ preventScroll: true })
|
||||
}
|
||||
updateColumns()
|
||||
seedFromDocs()
|
||||
if (gallery.value) {
|
||||
resizeObserver = new ResizeObserver(updateColumns)
|
||||
resizeObserver.observe(gallery.value)
|
||||
gallery.value.addEventListener('load', onImgLoad, { capture: true })
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
keyboardFollowScroll.cancel()
|
||||
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) => {
|
||||
doc.name = name
|
||||
doc.key = crypto.randomUUID()
|
||||
|
||||
@@ -10,21 +10,33 @@
|
||||
>
|
||||
<figure>
|
||||
<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>
|
||||
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
|
||||
<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 v-else>
|
||||
<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>
|
||||
</template>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<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 v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
|
||||
</CursorTooltip>
|
||||
@@ -42,7 +54,7 @@ import SparseIndicator from './SparseIndicator.vue'
|
||||
|
||||
const store = useMainStore()
|
||||
type EditingProp = {
|
||||
rename: (name: string) => void
|
||||
rename: (doc: Doc, newName: string) => void
|
||||
exit: () => void
|
||||
}
|
||||
|
||||
@@ -60,6 +72,20 @@ const sparseText = computed(() => {
|
||||
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) => {
|
||||
if (m.value!.play()) ev.preventDefault()
|
||||
store.cursor = props.doc.key
|
||||
@@ -81,8 +107,78 @@ const onclick = (ev: Event) => {
|
||||
.after-name {
|
||||
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 {
|
||||
max-height: 15em;
|
||||
height: var(--gallery-figure-height, 15em);
|
||||
max-height: var(--gallery-figure-height, 15em);
|
||||
position: relative;
|
||||
border-radius: .5em;
|
||||
overflow: hidden;
|
||||
@@ -92,12 +188,13 @@ figure {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: height 0.4s ease, max-height 0.4s ease;
|
||||
}
|
||||
figure > article {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
figure :deep(.video-container) {
|
||||
height: 15em;
|
||||
height: var(--gallery-figure-height, 15em);
|
||||
}
|
||||
.titlespacer {
|
||||
flex-shrink: 100000;
|
||||
@@ -124,17 +221,10 @@ figcaption input[type='checkbox'] {
|
||||
figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] {
|
||||
opacity: 1;
|
||||
}
|
||||
figcaption span {
|
||||
cursor: default;
|
||||
padding: .5em;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
.cursor .filename {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.cursor figcaption span {
|
||||
.cursor .file-ext {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
figcaption .namespacer {
|
||||
@@ -142,4 +232,17 @@ figcaption .namespacer {
|
||||
height: 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>
|
||||
|
||||
@@ -164,6 +164,14 @@ const settingsMenu = (e: Event) => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: 'ℹ️ About Cista...',
|
||||
onClick: () => {
|
||||
store.dialog = 'about'
|
||||
}
|
||||
})
|
||||
|
||||
ContextMenu.showContextMenu({
|
||||
// @ts-ignore
|
||||
x: e.target.getBoundingClientRect().right,
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
<template>
|
||||
<div v-if=showProgress() class="preview-progress" aria-label="Preview pending">
|
||||
<SpinnerIcon />
|
||||
<div v-if="showPreviewImage || showNativeImage" class="preview-image-shell">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -18,10 +44,10 @@
|
||||
</template>
|
||||
|
||||
<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 { useMainStore } from '@/stores/main'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const aud = ref<HTMLAudioElement | null>(null)
|
||||
const vid = ref<HTMLVideoElement | null>(null)
|
||||
@@ -30,11 +56,58 @@ const props = defineProps<{
|
||||
doc: Doc
|
||||
quality: string
|
||||
}>()
|
||||
const previewImageFailed = ref(false)
|
||||
const nativeImageFailed = ref(false)
|
||||
const previewImageLoading = ref(true)
|
||||
const nativeImageLoading = ref(true)
|
||||
const previewSrc = computed(() =>
|
||||
props.doc.previewurl
|
||||
? `${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 = () => {
|
||||
if (!media.value) return
|
||||
@@ -154,6 +227,7 @@ img, embed, .icon, audio, video {
|
||||
border-radius: calc(.5em / 8);
|
||||
}
|
||||
.preview-progress {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -162,18 +236,47 @@ img, embed, .icon, audio, video {
|
||||
max-height: 100%;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
.preview-progress :deep(svg) {
|
||||
width: 4.5em;
|
||||
height: 4.5em;
|
||||
opacity: 0.8;
|
||||
animation: media-preview-spin 0.9s linear infinite;
|
||||
.preview-progress .icon {
|
||||
opacity: 0.9;
|
||||
}
|
||||
@keyframes media-preview-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
.preview-image-shell {
|
||||
position: relative;
|
||||
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 {
|
||||
transform: rotate(360deg);
|
||||
50% {
|
||||
transform: scale(1.04);
|
||||
opacity: 0.98;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.86;
|
||||
}
|
||||
}
|
||||
.folder::before {
|
||||
@@ -219,12 +322,6 @@ img, embed, .icon, audio, video {
|
||||
figure.cursor .icon {
|
||||
filter: brightness(1);
|
||||
}
|
||||
img::before {
|
||||
/* broken image */
|
||||
text-shadow: 0 0 .5rem #000;
|
||||
filter: grayscale(1);
|
||||
content: '❌';
|
||||
}
|
||||
.video-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -15,15 +15,55 @@
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
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 dialog = ref<HTMLDivElement | null>(null)
|
||||
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 = () => {
|
||||
store.dialog = ''
|
||||
releaseGlobalBackdrop()
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -33,7 +73,6 @@ const props = defineProps<{
|
||||
|
||||
const show = () => {
|
||||
store.dialog = props.name
|
||||
holdGlobalBackdrop()
|
||||
nextTick(() => {
|
||||
overlay.value?.focus()
|
||||
const input = dialog.value?.querySelector('input')
|
||||
@@ -41,6 +80,29 @@ const show = () => {
|
||||
})
|
||||
}
|
||||
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(() => {
|
||||
if (overlay.value) {
|
||||
overlay.value.focus()
|
||||
|
||||
@@ -13,6 +13,7 @@ export type DocProps = {
|
||||
dir: boolean
|
||||
ghost?: boolean
|
||||
expires?: number // Unix timestamp for ghost expiry
|
||||
ar?: number // Aspect ratio (height/width) from server, if known
|
||||
}
|
||||
|
||||
export class Doc {
|
||||
@@ -26,6 +27,7 @@ export class Doc {
|
||||
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
|
||||
/** @internal Use the name getter/setter instead */
|
||||
public _name: string = ''
|
||||
public ar?: number // Aspect ratio (height/width), provided by server after first preview render
|
||||
|
||||
constructor(props: Partial<DocProps> = {}) {
|
||||
const { name, ...rest } = props
|
||||
@@ -130,7 +132,8 @@ export type FileEntry = [
|
||||
number, // mtime
|
||||
number, // size
|
||||
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>]
|
||||
|
||||
@@ -164,6 +164,11 @@ const handleWatchMessage = (event: MessageEvent) => {
|
||||
case !!msg.update:
|
||||
handleUpdateMessage(msg)
|
||||
break
|
||||
case !!msg.ar: {
|
||||
const store = useMainStore()
|
||||
store.updateAr(msg.ar as Record<string, number>)
|
||||
break
|
||||
}
|
||||
case !!msg.space:
|
||||
const store = useMainStore()
|
||||
store.space = msg.space
|
||||
|
||||
@@ -5,7 +5,7 @@ import { collator } from '@/utils'
|
||||
import { type SortOrder, sorted } from '@/utils/docsort'
|
||||
import SearchWorker from '@/workers/searchWorker?worker'
|
||||
import { type StateTree, defineStore } from 'pinia'
|
||||
import { documentRef, getDocuments, setDocuments } from './documentStore'
|
||||
import { documentRef, getDocuments, setDocuments, triggerUpdate } from './documentStore'
|
||||
|
||||
// Singleton search worker instance
|
||||
let searchWorker: Worker | null = null
|
||||
@@ -84,7 +84,7 @@ export const useMainStore = defineStore('main', {
|
||||
paskia?: boolean
|
||||
office_previews?: boolean
|
||||
},
|
||||
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens',
|
||||
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens' | 'about',
|
||||
uprogress: {} as any,
|
||||
dprogress: {} as any,
|
||||
prefs: {
|
||||
@@ -124,7 +124,7 @@ export const useMainStore = defineStore('main', {
|
||||
updateRoot(root: FileEntry[]) {
|
||||
const docs = []
|
||||
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)
|
||||
docs.push(
|
||||
new Doc({
|
||||
@@ -134,7 +134,8 @@ export const useMainStore = defineStore('main', {
|
||||
size,
|
||||
allocated,
|
||||
mtime,
|
||||
dir: !isfile
|
||||
dir: !isfile,
|
||||
ar
|
||||
})
|
||||
)
|
||||
loc.push(name)
|
||||
@@ -157,6 +158,22 @@ export const useMainStore = defineStore('main', {
|
||||
// Sync documents to search worker
|
||||
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 */
|
||||
addGhost(doc: Doc) {
|
||||
doc.ghost = true
|
||||
@@ -236,6 +253,12 @@ export const useMainStore = defineStore('main', {
|
||||
}))
|
||||
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) {
|
||||
const worker = getSearchWorker()
|
||||
const id = ++searchId
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user