Compare commits

...
18 Commits
Author SHA1 Message Date
LeoVasanko 146497d731 Fix display of vertical images and videos such they fit in the gallery item. 2026-01-22 02:35:24 +00:00
LeoVasanko 442816a0ae Fix video preview images that were displayed sideways for portrait video. The least bad approach loses HDR but shows in correct orientation. For 0 and 180 degrees we preserve HDR too. 2026-01-22 02:20:39 +00:00
LeoVasanko d32afa6016 Display play button on video previews to indicate it is a video that can be played. 2026-01-22 01:43:50 +00:00
LeoVasanko fa60c962c4 Attempt a better mobile layout, to fix sizing issues with Brave Android. 2026-01-22 00:57:30 +00:00
LeoVasanko e55e11b399 Fix flickering tooltip when hovering modified in FileExplorer. Use the new, improved tooltip also in gallery to show full name, modified and size of an item. 2026-01-22 00:49:50 +00:00
LeoVasanko b6c21152e7 Fix modified tooltip (exact timestamp) flickering on/off because of timestamp updates every second. Made the tooltip follow mouse cursor. 2026-01-22 00:39:26 +00:00
LeoVasanko f354fc5c71 Less aggressive automatic gallery mode switching, only when folder changes. Fixes issues with focus being lost from search. 2026-01-22 00:32:18 +00:00
LeoVasanko 5bda809921 Cleaner folder headers in gallery mode search results. Multi folder results are always grouped by folder (FileExplorer and Gallery), but still otherwise respecting the chosen sort order. Overall this produces a much cleaner layout. 2026-01-22 00:23:23 +00:00
LeoVasanko 2cc92cd786 Deprecation, remove unused import. 2026-01-22 00:16:59 +00:00
LeoVasanko ba6380e71e Add a script to run devserver. Migrate to build and JS utils provided by fastapi-vue. Frontend directory renamed to frontend-build. Update Sanic, deprecations. 2026-01-21 23:32:04 +00:00
LeoVasanko 0d853032bf Cleaner handling when preview generation fails. Using original file as fallback. 2026-01-21 23:29:51 +00:00
LeoVasanko 1cb512e65d Remove overly eager Gallery automode. No longer switches to gallery when merely changing sort column. 2025-10-01 07:23:24 +00:00
LeoVasanko 972aaee9fe Improved reliability of direct to folder downloads. 2025-10-01 06:49:50 +00:00
LeoVasanko 055eaa8a21 Fix admin UI password reset and user deletion functions. 2025-10-01 06:31:20 +00:00
LeoVasanko 05fb81c36d Implement web-based user management / admin setup. (#8)
Implement Admin Settings dialog for user management and toggling the public server flag, not needing CLI for maintenance anymore.
2025-09-30 23:10:33 +00:00
LeoVasanko 6639174e8f Fix Vue TypeScript module declarations for build 2025-10-01 05:32:14 +00:00
LeoVasanko 5e2e71eafb Clean up the remaining uses of print() 2025-08-18 22:49:40 +00:00
LeoVasanko 7e4c5bc911 Use zstd rather than brotli for static file compression. 2025-08-18 04:51:46 +00:00
32 changed files with 1192 additions and 182 deletions
+1 -1
View File
@@ -4,5 +4,5 @@
__pycache__/ __pycache__/
*.egg-info/ *.egg-info/
/cista/_version.py /cista/_version.py
/cista/wwwroot/* /cista/frontend-build/
/dist /dist
+2 -2
View File
@@ -15,12 +15,12 @@ fileserver = FileServer()
@bp.before_server_start @bp.before_server_start
async def start_fileserver(app, _): async def start_fileserver(app):
await fileserver.start() await fileserver.start()
@bp.after_server_stop @bp.after_server_stop
async def stop_fileserver(app, _): async def stop_fileserver(app):
await fileserver.stop() await fileserver.stop()
+19 -18
View File
@@ -9,7 +9,6 @@ from stat import S_IFDIR, S_IFREG
from urllib.parse import unquote from urllib.parse import unquote
from wsgiref.handlers import format_date_time from wsgiref.handlers import format_date_time
import brotli
import sanic.helpers import sanic.helpers
from blake3 import blake3 from blake3 import blake3
from sanic import Blueprint, Sanic, empty, raw, redirect from sanic import Blueprint, Sanic, empty, raw, redirect
@@ -17,6 +16,7 @@ from sanic.exceptions import Forbidden, NotFound
from sanic.log import logger from sanic.log import logger
from setproctitle import setproctitle from setproctitle import setproctitle
from stream_zip import ZIP_AUTO, stream_zip from stream_zip import ZIP_AUTO, stream_zip
from zstandard import ZstdCompressor
from cista import auth, config, preview, session, watching from cista import auth, config, preview, session, watching
from cista.api import bp from cista.api import bp
@@ -36,19 +36,19 @@ setproctitle("cista-main")
@app.before_server_start @app.before_server_start
async def main_start(app, loop): async def main_start(app):
config.load_config() config.load_config()
setproctitle(f"cista {config.config.path.name}") setproctitle(f"cista {config.config.path.name}")
workers = max(2, min(8, cpu_count())) workers = max(2, min(8, cpu_count()))
app.ctx.threadexec = ThreadPoolExecutor( app.ctx.threadexec = ThreadPoolExecutor(
max_workers=workers, thread_name_prefix="cista-ioworker" max_workers=workers, thread_name_prefix="cista-ioworker"
) )
watching.start(app, loop) watching.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, loop): async def main_stop(app):
quit.set() quit.set()
watching.stop(app) watching.stop(app)
app.ctx.threadexec.shutdown() app.ctx.threadexec.shutdown()
@@ -75,7 +75,7 @@ async def use_session(req):
@app.before_server_start @app.before_server_start
def http_fileserver(app, _): def http_fileserver(app):
bp = Blueprint("fileserver") bp = Blueprint("fileserver")
bp.on_request(auth.verify) bp.on_request(auth.verify)
bp.static( bp.static(
@@ -93,8 +93,9 @@ www = {}
def _load_wwwroot(www): def _load_wwwroot(www):
wwwnew = {} wwwnew = {}
base = Path(__file__).with_name("wwwroot") base = Path(__file__).with_name("frontend-build")
paths = [PurePath()] paths = [PurePath()]
zstd = ZstdCompressor(level=18)
while paths: while paths:
path = paths.pop(0) path = paths.pop(0)
current = base / path current = base / path
@@ -126,11 +127,11 @@ def _load_wwwroot(www):
else "no-cache", else "no-cache",
"content-type": mime, "content-type": mime,
} }
# Precompress with Brotli # Precompress with ZSTD
br = brotli.compress(data) zs = zstd.compress(data)
if len(br) >= len(data): if len(zs) >= len(data):
br = False zs = False
wwwnew[name] = data, br, headers wwwnew[name] = data, zs, headers
if not wwwnew: if not wwwnew:
msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n" msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
if not www: if not www:
@@ -182,9 +183,9 @@ async def refresh_wwwroot():
for name in sorted(set(wwwold) - set(www)): for name in sorted(set(wwwold) - set(www)):
changes += f"Deleted /{name}\n" changes += f"Deleted /{name}\n"
if changes: if changes:
print(f"Updated wwwroot:\n{changes}", end="", flush=True) logger.info(f"Updated wwwroot:\n{changes}", end="", flush=True)
except Exception as e: except Exception as e:
print(f"Error loading wwwroot: {e!r}") logger.error(f"Error loading wwwroot: {e!r}")
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
@@ -196,14 +197,14 @@ async def wwwroot(req, path=""):
name = unquote(path) name = unquote(path)
if name not in www: if name not in www:
raise NotFound(f"File not found: /{path}", extra={"name": name}) raise NotFound(f"File not found: /{path}", extra={"name": name})
data, br, headers = www[name] data, zs, headers = www[name]
if req.headers.if_none_match == headers["etag"]: if req.headers.if_none_match == headers["etag"]:
# The client has it cached, respond 304 Not Modified # The client has it cached, respond 304 Not Modified
return empty(304, headers=headers) return empty(304, headers=headers)
# Brotli compressed? # Zstandard compressed?
if br and "br" in req.headers.accept_encoding.split(", "): if zs and "zstd" in req.headers.accept_encoding.split(", "):
headers = {**headers, "content-encoding": "br"} headers = {**headers, "content-encoding": "zstd"}
data = br data = zs
return raw(data, headers=headers) return raw(data, headers=headers)
+101
View File
@@ -10,6 +10,7 @@ from sanic import Blueprint, html, json, redirect
from sanic.exceptions import BadRequest, Forbidden, Unauthorized from sanic.exceptions import BadRequest, Forbidden, Unauthorized
from cista import config, session from cista import config, session
from cista.util import pwgen
_argon = argon2.PasswordHasher() _argon = argon2.PasswordHasher()
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$") _droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
@@ -191,3 +192,103 @@ async def change_password(request):
res = json({"message": "Password updated"}) res = json({"message": "Password updated"})
session.create(res, username) session.create(res, username)
return res return res
@bp.get("/users")
async def list_users(request):
verify(request, privileged=True)
users = []
for name, user in config.config.users.items():
users.append(
{
"username": name,
"privileged": user.privileged,
"lastSeen": user.lastSeen,
}
)
return json({"users": users})
@bp.post("/users")
async def create_user(request):
verify(request, privileged=True)
try:
if request.headers.content_type == "application/json":
username = request.json["username"]
password = request.json.get("password")
privileged = request.json.get("privileged", False)
else:
username = request.form["username"][0]
password = request.form.get("password", [None])[0]
privileged = request.form.get("privileged", ["false"])[0].lower() == "true"
if not username or not username.isidentifier():
raise ValueError("Invalid username")
except (KeyError, ValueError) as e:
raise BadRequest(str(e)) from e
if username in config.config.users:
raise BadRequest("User already exists")
if not password:
password = pwgen.generate()
changes = {"privileged": privileged}
changes["hash"] = _argon.hash(_pwnorm(password))
try:
config.update_user(username, changes)
except Exception as e:
raise BadRequest(str(e)) from e
return json({"message": f"User {username} created", "password": password})
@bp.put("/users/<username>")
async def update_user(request, username):
verify(request, privileged=True)
try:
if request.headers.content_type == "application/json":
changes = request.json
else:
changes = {}
if "password" in request.form:
changes["password"] = request.form["password"][0]
if "privileged" in request.form:
changes["privileged"] = request.form["privileged"][0].lower() == "true"
except KeyError as e:
raise BadRequest("Missing fields") from e
password_response = None
if "password" in changes:
if changes["password"] == "":
changes["password"] = pwgen.generate()
password_response = changes["password"]
changes["hash"] = _argon.hash(_pwnorm(changes["password"]))
del changes["password"]
if not changes:
return json({"message": "No changes"})
try:
config.update_user(username, changes)
except Exception as e:
raise BadRequest(str(e)) from e
response = {"message": f"User {username} updated"}
if password_response:
response["password"] = password_response
return json(response)
@bp.delete("/users/<username>")
async def delete_user(request, username):
verify(request, privileged=True)
if username not in config.config.users:
raise BadRequest("User does not exist")
try:
config.del_user(username)
except Exception as e:
raise BadRequest(str(e)) from e
return json({"message": f"User {username} deleted"})
@bp.put("/config/public")
async def update_public(request):
verify(request, privileged=True)
try:
public = request.json["public"]
except KeyError:
raise BadRequest("Missing public field") from None
config.update_config({"public": public})
return json({"message": "Public setting updated"})
+63 -40
View File
@@ -13,7 +13,7 @@ import fitz # PyMuPDF
import numpy as np import numpy as np
import pillow_heif import pillow_heif
from PIL import Image from PIL import Image
from sanic import Blueprint, empty, raw 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
@@ -43,12 +43,12 @@ async def preview(req, path):
maxzoom = float(req.args.get("zoom", 2.0)) maxzoom = float(req.args.get("zoom", 2.0))
quality = int(req.args.get("q", 60)) quality = int(req.args.get("q", 60))
rel = PurePosixPath(sanitize(unquote(path))) rel = PurePosixPath(sanitize(unquote(path)))
path = config.config.path / rel filepath = config.config.path / rel
stat = path.lstat() stat = filepath.lstat()
etag = config.derived_secret( etag = config.derived_secret(
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom "preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
).hex() ).hex()
savename = PurePosixPath(path.name).with_suffix(".avif") savename = PurePosixPath(filepath.name).with_suffix(".avif")
headers = { headers = {
"etag": etag, "etag": etag,
"last-modified": format_date_time(stat.st_mtime), "last-modified": format_date_time(stat.st_mtime),
@@ -61,22 +61,30 @@ async def preview(req, path):
# The client has it cached, respond 304 Not Modified # The client has it cached, respond 304 Not Modified
return empty(304, headers=headers) return empty(304, headers=headers)
if not path.is_file(): if not filepath.is_file():
raise NotFound("File not found") raise NotFound("File not found")
img = await asyncio.get_event_loop().run_in_executor( img = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, dispatch, path, quality, maxsize, maxzoom req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
) )
if not img:
# Preview generation failed, redirect to the file itself
return redirect(f"/files/{path}", status=303)
return raw(img, headers=headers) return raw(img, headers=headers)
def dispatch(path, quality, maxsize, maxzoom): def dispatch(path, quality, maxsize, maxzoom):
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"): try:
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom) if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
type, _ = mimetypes.guess_type(path.name) return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
if type and type.startswith("video/"): type, _ = mimetypes.guess_type(path.name)
return process_video(path, quality=quality, maxsize=maxsize) if type and type.startswith("video/"):
return process_image(path, quality=quality, maxsize=maxsize) return process_video(path, quality=quality, maxsize=maxsize)
return process_image(path, quality=quality, maxsize=maxsize)
except ValueError as e:
logger.warning(f"Cannot generate preview for {path.name}: {e}")
except Exception as e:
logger.exception(f"Error generating preview for {path.name}: {e}")
def process_image(path, *, maxsize, quality): def process_image(path, *, maxsize, quality):
@@ -121,7 +129,7 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
w, h = page.rect[2:4] w, h = page.rect[2:4]
zoom = min(maxsize / w, maxsize / h, maxzoom) zoom = min(maxsize / w, maxsize / h, maxzoom)
mat = fitz.Matrix(zoom, zoom) mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat) # type: ignore[attr-defined] pix = page.get_pixmap(matrix=mat)
t_load_end = perf_counter() t_load_end = perf_counter()
t_save_start = perf_counter() t_save_start = perf_counter()
@@ -166,35 +174,49 @@ def process_video(path, *, maxsize, quality):
new_height = int(frame.height * scale_factor) new_height = int(frame.height * scale_factor)
frame = frame.reformat(width=new_width, height=new_height) frame = frame.reformat(width=new_width, height=new_height)
# Simple rotation detection and logging # Apply EXIF rotation if present
if frame.rotation: if frame.rotation:
try: # frame.rotation indicates clockwise rotation needed to display correctly
fplanes = frame.to_ndarray() # np.rot90 rotates counter-clockwise, so we negate k
# Split into Y, U, V planes of proper dimensions k = (frame.rotation // 90) % 4 # Convert to counter-clockwise rotations
planes = [ if k == 2:
fplanes[: frame.height], # 180° rotation can be done in YUV420p, preserving HDR
fplanes[frame.height : frame.height + frame.height // 4].reshape( try:
frame.height // 2, frame.width // 2 fplanes = frame.to_ndarray()
), # Split into Y, U, V planes of proper dimensions
fplanes[frame.height + frame.height // 4 :].reshape( planes = [
frame.height // 2, frame.width // 2 fplanes[: frame.height],
), fplanes[
] frame.height : frame.height + frame.height // 4
# Rotate ].reshape(frame.height // 2, frame.width // 2),
planes = [np.rot90(p, frame.rotation // 90) for p in planes] fplanes[frame.height + frame.height // 4 :].reshape(
# Restore PyAV format frame.height // 2, frame.width // 2
planes = np.hstack([p.flat for p in planes]).reshape( ),
-1, planes[0].shape[1] ]
) # Rotate each plane by 180°
frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name) planes = [np.rot90(p, 2) for p in planes]
del planes, fplanes # Restore PyAV format
except Exception as e: planes = np.hstack([p.flat for p in planes]).reshape(
if "not yet supported" in str(e): -1, planes[0].shape[1]
logger.warning( )
f"Not rotating {path.name} preview image by {frame.rotation}°:\n PyAV: {e}" frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
del planes, fplanes
except Exception as e:
logger.exception(f"Error rotating video frame by 180°: {e}")
elif k in (1, 3):
# 90° or 270° rotation requires RGB conversion (loses HDR)
try:
rgb = frame.to_ndarray(format="rgb24")
rgb = np.rot90(rgb, k)
frame = av.VideoFrame.from_ndarray(rgb, format="rgb24")
frame = frame.reformat(
format="yuv420p"
) # Convert back for encoding
del rgb
except Exception as e:
logger.exception(
f"Error rotating video frame by {frame.rotation}°: {e}"
) )
else:
logger.exception(f"Error rotating video frame: {e}")
t_load_end = perf_counter() t_load_end = perf_counter()
t_save_start = perf_counter() t_save_start = perf_counter()
@@ -211,6 +233,7 @@ def process_video(path, *, maxsize, quality):
assert isinstance(ostream, av.VideoStream) assert isinstance(ostream, av.VideoStream)
ostream.width = frame.width ostream.width = frame.width
ostream.height = frame.height ostream.height = frame.height
ostream.pix_fmt = frame.format.name
icc = istream.codec_context icc = istream.codec_context
occ = ostream.codec_context occ = ostream.codec_context
+2 -2
View File
@@ -1,4 +1,5 @@
from time import monotonic from time import monotonic
from typing import Callable
class LRUCache: class LRUCache:
@@ -12,7 +13,7 @@ class LRUCache:
cache (list): Internal list storing the cache items. cache (list): Internal list storing the cache items.
""" """
def __init__(self, open: callable, *, capacity: int, maxage: float): def __init__(self, open: Callable, *, capacity: int, maxage: float):
""" """
Initialize LRUCache. Initialize LRUCache.
@@ -50,7 +51,6 @@ class LRUCache:
# Add/restore to end of cache # Add/restore to end of cache
self.cache.insert(0, (key, f, monotonic())) self.cache.insert(0, (key, f, monotonic()))
self.expire_items() self.expire_items()
print(self.cache)
return f return f
def expire_items(self): def expire_items(self):
+2 -2
View File
@@ -440,14 +440,14 @@ def watcher_poll(loop):
quit.wait(0.1 + 8 * dur) quit.wait(0.1 + 8 * dur)
def start(app, loop): def start(app):
global rootpath global rootpath
config.load_config() config.load_config()
rootpath = config.config.path rootpath = config.config.path
use_inotify = sys.platform == "linux" use_inotify = sys.platform == "linux"
app.ctx.watcher = threading.Thread( app.ctx.watcher = threading.Thread(
target=watcher_inotify if use_inotify else watcher_poll, target=watcher_inotify if use_inotify else watcher_poll,
args=[loop], args=[app.loop],
# Descriptive name for system monitoring # Descriptive name for system monitoring
name=f"cista-watcher {rootpath}", name=f"cista-watcher {rootpath}",
) )
+6
View File
@@ -1 +1,7 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang=en> <html lang=en>
<meta charset=UTF-8> <meta charset=UTF-8>
<title>Cista Storage</title> <title>Cista Storage</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
<link rel="icon" href="/src/assets/logo.svg"> <link rel="icon" href="/src/assets/logo.svg">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+2
View File
@@ -1,6 +1,7 @@
<template> <template>
<LoginModal /> <LoginModal />
<SettingsModal /> <SettingsModal />
<UserManagementModal />
<header> <header>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query"> <HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
<HeaderSelected :path="path.pathList" /> <HeaderSelected :path="path.pathList" />
@@ -28,6 +29,7 @@ import { computed } from 'vue'
import Router from '@/router/index' import Router from '@/router/index'
import type { SortOrder } from './utils/docsort' import type { SortOrder } from './utils/docsort'
import type SettingsModalVue from './components/SettingsModal.vue' import type SettingsModalVue from './components/SettingsModal.vue'
import UserManagementModal from './components/UserManagementModal.vue'
interface Path { interface Path {
path: string path: string
+10 -4
View File
@@ -110,6 +110,7 @@
margin: 0 .5rem 0 1rem !important; margin: 0 .5rem 0 1rem !important;
} }
body#app { body#app {
position: static !important;
height: auto !important; height: auto !important;
} }
main { main {
@@ -165,6 +166,11 @@ body {
font-family: 'Roboto'; font-family: 'Roboto';
color: var(--primary-color); color: var(--primary-color);
margin: 0; margin: 0;
/* Prevent any scrolling on body */
overflow: hidden;
/* Fallback for older browsers */
height: 100vh;
height: 100dvh;
} }
tbody .size, tbody .size,
tbody .modified { tbody .modified {
@@ -214,12 +220,14 @@ table {
gap: 0; gap: 0;
} }
body#app { body#app {
height: 100vh; position: fixed;
inset: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
main { main {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; /* Allow flex child to shrink below content size */
padding-bottom: 3em; /* convenience space on the bottom */ padding-bottom: 3em; /* convenience space on the bottom */
overflow-y: scroll; overflow-y: scroll;
text-align: center; text-align: center;
@@ -237,6 +245,7 @@ header nav.headermain {
z-index: 101; z-index: 101;
content: attr(data-tooltip); content: attr(data-tooltip);
position: absolute; position: absolute;
pointer-events: none;
font-size: 1rem; font-size: 1rem;
text-align: center; text-align: center;
padding: .5rem 1rem; padding: .5rem 1rem;
@@ -248,9 +257,6 @@ header nav.headermain {
white-space: pre; white-space: pre;
animation: appearbriefly calc(10 * var(--transition-time)) linear forwards; animation: appearbriefly calc(10 * var(--transition-time)) linear forwards;
} }
.modified [data-tooltip]:hover:after {
transform: translate(calc(1rem + 1ex + -100%), calc(-1.5rem + 100%));
}
@keyframes appearbriefly { @keyframes appearbriefly {
from { from {
opacity: 0; opacity: 0;
+86
View File
@@ -0,0 +1,86 @@
<template>
<Teleport to="body">
<div v-if="visible" class="cursor-tooltip" :style="tooltipStyle">
<slot></slot>
</div>
</Teleport>
</template>
<script lang="ts">
// Global activation state - shared across all instances
let globalActive = false
let globalDeactivateTimer: ReturnType<typeof setTimeout> | null = null
</script>
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = defineProps<{
text: string
delay?: number
}>()
const visible = ref(false)
const mouseX = ref(0)
const mouseY = ref(0)
let hoverTimer: ReturnType<typeof setTimeout> | null = null
const tooltipStyle = computed(() => ({
left: `${mouseX.value + 12}px`,
top: `${mouseY.value + 12}px`,
}))
const startHover = (e: MouseEvent) => {
mouseX.value = e.clientX
mouseY.value = e.clientY
// Clear any pending deactivation
if (globalDeactivateTimer) {
clearTimeout(globalDeactivateTimer)
globalDeactivateTimer = null
}
const delay = globalActive ? 0 : (props.delay ?? 800)
hoverTimer = setTimeout(() => {
visible.value = true
globalActive = true
}, delay)
}
const updatePosition = (e: MouseEvent) => {
mouseX.value = e.clientX
mouseY.value = e.clientY
}
const endHover = () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
hoverTimer = null
}
visible.value = false
// Deactivate global state after a short delay if no new tooltip started
if (globalDeactivateTimer) clearTimeout(globalDeactivateTimer)
globalDeactivateTimer = setTimeout(() => {
globalActive = false
}, 500)
}
defineExpose({
startHover,
updatePosition,
endHover,
})
</script>
<style scoped>
.cursor-tooltip {
position: fixed;
z-index: 10000;
padding: .5rem 1rem;
border-radius: 3rem 0 3rem 0;
box-shadow: 0 0 1rem var(--accent-color);
background-color: var(--accent-color);
color: var(--primary-color);
white-space: nowrap;
pointer-events: none;
font-size: 1rem;
}
</style>
+34 -29
View File
@@ -78,7 +78,7 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true }) h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
} catch (error) { } catch (error) {
console.error('Failed to create directory', hdir, error) console.error('Failed to create directory', hdir, error)
return throw new Error(`Failed to create directory ${hdir}: ${error}`)
} }
console.log('Created', hdir) console.log('Created', hdir)
} }
@@ -90,37 +90,42 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
fileHandle = await h.getFileHandle(name, { create: true }) fileHandle = await h.getFileHandle(name, { create: true })
} catch (error) { } catch (error) {
console.error('Failed to create file', rel, full, hdir + name, error) console.error('Failed to create file', rel, full, hdir + name, error)
return throw new Error(`Failed to create file ${hdir + name}: ${error}`)
} }
const writable = await fileHandle.createWritable() try {
const url = `/files/${rel}` const writable = await fileHandle.createWritable()
console.log('Fetching', url) const url = `/files/${rel}`
const res = await fetch(url) console.log('Fetching', url)
if (!res.ok) { const res = await fetch(url)
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}` if (!res.ok) {
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`) store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
} throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
if (res.body) {
++store.dprogress.fileidx
const reader = res.body.getReader()
await writable.truncate(0)
store.error = "Direct download."
store.dprogress.tlast = Date.now()
while (true) {
const { value, done } = await reader.read()
if (done) break
await writable.write(value)
const now = Date.now()
const size = value.byteLength
store.dprogress.xfer += size
store.dprogress.filepos += size
store.dprogress.statbytes += size
store.dprogress.statdur += now - store.dprogress.tlast
store.dprogress.tlast = now
} }
if (res.body) {
++store.dprogress.fileidx
const reader = res.body.getReader()
await writable.truncate(0)
store.error = "Direct download."
store.dprogress.tlast = Date.now()
while (true) {
const { value, done } = await reader.read()
if (done) break
await writable.write(value)
const now = Date.now()
const size = value.byteLength
store.dprogress.xfer += size
store.dprogress.filepos += size
store.dprogress.statbytes += size
store.dprogress.statdur += now - store.dprogress.tlast
store.dprogress.tlast = now
}
}
await writable.close()
console.log('Saved', hdir + name)
} catch (error) {
console.error('Failed to write file', hdir + name, error)
throw new Error(`Failed to write file ${hdir + name}: ${error}`)
} }
await writable.close()
console.log('Saved', hdir + name)
} }
statReset() statReset()
} }
-1
View File
@@ -10,7 +10,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineProps } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import cog from '@/assets/svg/cog.svg' import cog from '@/assets/svg/cog.svg'
import { exists } from '@/utils/fileutil' import { exists } from '@/utils/fileutil'
+2 -2
View File
@@ -17,7 +17,7 @@
<td class="name"> <td class="name">
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" /> <FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
</td> </td>
<FileModified :doc=editing :key=nowkey /> <FileModified :doc=editing :now=nowkey />
<FileSize :doc=editing /> <FileSize :doc=editing />
<td class="menu"></td> <td class="menu"></td>
</tr> </tr>
@@ -55,7 +55,7 @@
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊</button> <button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊</button>
</template> </template>
</td> </td>
<FileModified :doc=doc :key=nowkey /> <FileModified :doc=doc :now=nowkey />
<FileSize :doc=doc /> <FileSize :doc=doc />
<td class="menu"> <td class="menu">
<button tabindex=-1 @click.stop="contextMenu($event, doc)"></button> <button tabindex=-1 @click.stop="contextMenu($event, doc)"></button>
+25 -8
View File
@@ -1,22 +1,39 @@
<template> <template>
<td class="modified right"> <td class="modified right">
<time :data-tooltip=tooltip :datetime=datetime>{{ doc.modified }}</time> <time
:datetime=datetime
@mouseenter="tooltip?.startHover"
@mousemove="tooltip?.updatePosition"
@mouseleave="tooltip?.endHover"
>{{ modified }}</time>
<CursorTooltip ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
</td> </td>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { computed } from 'vue' import { formatUnixDate } from '@/utils'
import { computed, ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
const props = defineProps<{
doc: Doc
now: number
}>()
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
// Reference props.now to trigger reactivity when time updates
const modified = computed(() => {
props.now // trigger reactivity
return formatUnixDate(props.doc.mtime)
})
const datetime = computed(() => const datetime = computed(() =>
new Date(1000 * props.doc.mtime).toISOString().replace('.000Z', 'Z') new Date(1000 * props.doc.mtime).toISOString().replace('.000Z', 'Z')
) )
const tooltip = computed(() => const tooltipText = computed(() =>
datetime.value.replace('T', '\n').replace('Z', ' UTC') datetime.value.replace('T', ' ').replace('Z', ' UTC')
) )
const props = defineProps<{
doc: Doc
}>()
</script> </script>
+23 -15
View File
@@ -2,12 +2,8 @@
<div v-if="props.documents.length || editing" class="gallery" ref="gallery"> <div v-if="props.documents.length || editing" class="gallery" ref="gallery">
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" /> <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>
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)"> <BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
<template v-if=showFolderBreadcrumb(index)> <GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)" :class="{ 'folder-start': showFolderBreadcrumb(index) }" />
<BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" class="folder-change"/>
<div class="spacer"></div>
</template>
</GalleryFigure>
</template> </template>
</div> </div>
</template> </template>
@@ -55,10 +51,12 @@ const rename = (doc: Doc, newName: string) => {
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
} }
const gallery = ref<HTMLElement>() const gallery = ref<HTMLElement>()
const columns = computed(() => { const columnCount = ref(1)
if (!gallery.value) return 1 const updateColumns = () => {
return getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length if (!gallery.value) return
}) columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
}
const columns = computed(() => columnCount.value)
defineExpose({ defineExpose({
newFolder() { newFolder() {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
@@ -168,12 +166,21 @@ watchEffect(() => {
focusBreadcrumb() focusBreadcrumb()
} }
}) })
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.scrollIntoView({ block: 'center', behavior: 'instant' })
active.focus() active.focus()
} }
updateColumns()
if (gallery.value) {
resizeObserver = new ResizeObserver(updateColumns)
resizeObserver.observe(gallery.value)
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
}) })
const mkdir = (doc: Doc, name: string) => { const mkdir = (doc: Doc, name: string) => {
const control = connect(controlUrl, { const control = connect(controlUrl, {
@@ -205,6 +212,8 @@ const showFolderBreadcrumb = (i: number) => {
const docloc = docs[i].loc const docloc = docs[i].loc
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
} }
const selectionIndeterminate = computed({ const selectionIndeterminate = computed({
get: () => { get: () => {
return ( return (
@@ -254,13 +263,12 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
display: grid; display: grid;
gap: .5em; gap: .5em;
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr)); grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
grid-template-rows: repeat(minmax(auto, 15em));
align-items: end; align-items: end;
} }
.breadcrumb { .folder-indicator {
border-radius: .5em 0 0 .5em; grid-column: 1 / -1;
} }
.spacer { .folder-start {
flex: 0 1000000000 4rem; grid-column-start: 1;
} }
</style> </style>
+24 -3
View File
@@ -4,6 +4,9 @@
@contextmenu.stop @contextmenu.stop
@focus.stop="store.cursor = doc.key" @focus.stop="store.cursor = doc.key"
@click=onclick @click=onclick
@mouseenter="tooltip?.startHover"
@mousemove="tooltip?.updatePosition"
@mouseleave="tooltip?.endHover"
> >
<figure> <figure>
<slot></slot> <slot></slot>
@@ -15,19 +18,24 @@
</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 :title="doc.name + '\n' + doc.modified + '\n' + doc.sizedisp">{{ doc.name }}</span> <span>{{ doc.name }}</span>
<div class=namespacer></div> <div class=namespacer></div>
</template> </template>
</figcaption> </figcaption>
</figure> </figure>
<CursorTooltip ref="tooltip" :text="tooltipText">
<div class="tooltip-name">{{ doc.name }}</div>
<div class="tooltip-details">{{ doc.modified }} {{ doc.sizedisp }}</div>
</CursorTooltip>
</a> </a>
</template> </template>
<script setup lang=ts> <script setup lang=ts>
import { ref } from 'vue' import { ref, computed } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import MediaPreview from '@/components/MediaPreview.vue' import MediaPreview from '@/components/MediaPreview.vue'
import CursorTooltip from './CursorTooltip.vue'
const store = useMainStore() const store = useMainStore()
type EditingProp = { type EditingProp = {
@@ -40,6 +48,9 @@ const props = defineProps<{
editing?: EditingProp, editing?: EditingProp,
}>() }>()
const m = ref<typeof MediaPreview | null>(null) const m = ref<typeof MediaPreview | null>(null)
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const tooltipText = computed(() => props.doc.key)
const onclick = (ev: Event) => { const onclick = (ev: Event) => {
if (m.value!.play()) ev.preventDefault() if (m.value!.play()) ev.preventDefault()
@@ -48,6 +59,13 @@ const onclick = (ev: Event) => {
</script> </script>
<style scoped> <style scoped>
.tooltip-name {
font-weight: 600;
text-align: center;
}
.tooltip-details {
text-align: center;
}
figure { figure {
max-height: 15em; max-height: 15em;
position: relative; position: relative;
@@ -57,12 +75,15 @@ figure {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: end; justify-content: center;
overflow: hidden; overflow: hidden;
} }
figure > article { figure > article {
flex: 0 0 auto; flex: 0 0 auto;
} }
figure :deep(.video-container) {
height: 15em;
}
.titlespacer { .titlespacer {
flex-shrink: 100000; flex-shrink: 100000;
width: 100%; width: 100%;
+4 -1
View File
@@ -73,7 +73,10 @@ watchEffect(() => {
const settingsMenu = (e: Event) => { const settingsMenu = (e: Event) => {
// show the context menu // show the context menu
const items = [] const items = []
items.push({ label: 'Settings', onClick: () => { store.dialog = 'settings' }}) items.push({ label: 'Change Password', onClick: () => { store.dialog = 'settings' }})
if (store.user.privileged) {
items.push({ label: 'Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
}
if (store.user.isLoggedIn) { if (store.user.isLoggedIn) {
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() }) items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
} else { } else {
+44 -1
View File
@@ -2,7 +2,10 @@
<img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt=""> <img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt="">
<img v-else-if=doc.img :src=doc.url alt=""> <img v-else-if=doc.img :src=doc.url alt="">
<span v-else-if=doc.dir class="folder icon"></span> <span v-else-if=doc.dir class="folder icon"></span>
<video ref=vid v-else-if=video() :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video> <div v-else-if=video() class="video-container">
<video ref=vid :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
<div class="play-overlay"><PlayIcon /></div>
</div>
<div v-else-if=audio() class="audio icon"> <div v-else-if=audio() class="audio icon">
<audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio> <audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio>
</div> </div>
@@ -13,6 +16,7 @@
<script setup lang=ts> <script setup lang=ts>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { Doc } from '@/repositories/Document' import type { Doc } from '@/repositories/Document'
import PlayIcon from '@/assets/svg/play.svg'
const aud = ref<HTMLAudioElement | null>(null) const aud = ref<HTMLAudioElement | null>(null)
const vid = ref<HTMLVideoElement | null>(null) const vid = ref<HTMLVideoElement | null>(null)
@@ -165,4 +169,43 @@ img::before {
filter: grayscale(1); filter: grayscale(1);
content: '❌'; content: '❌';
} }
.video-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 50%;
max-width: 100%;
max-height: 100%;
}
.video-container video {
width: 100%;
height: 100%;
border-radius: calc(.5em / 8);
object-fit: contain;
}
.play-overlay {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
width: 4em;
height: 4em;
background: rgba(0, 0, 0, 0.2);
border-radius: 50%;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.play-overlay svg {
width: 2em;
height: 2em;
fill: white;
margin-left: 0.25em; /* Visual centering for play triangle */
}
.video-container:hover .play-overlay {
transform: scale(1.1);
}
video[data-playing] + .play-overlay {
opacity: 0;
}
</style> </style>
@@ -0,0 +1,265 @@
<template>
<ModalDialog name=usermgmt title="Admin Settings">
<div v-if="loading" class="loading">Loading...</div>
<div v-else>
<h3>Server Settings</h3>
<div class="form-row">
<input
id="publicServer"
type="checkbox"
v-model="serverSettings.public"
@change="updateServerSettings"
/>
<label for="publicServer">Publicly accessible without any user account.</label>
</div>
<h3>Users</h3>
<button @click="addUser" class="button" title="Add new user"> Add User</button>
<div v-if="success" class="success-message" @click="copySuccess(false)">
{{ success }}
<button v-if="success.includes('Password:') || success.includes('New password:')" @click.stop="copySuccess(true)" class="button small" title="Copy to clipboard">{{ copyButtonText }}</button>
</div>
<table class="user-table">
<thead>
<tr>
<th>Username</th>
<th>Admin</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.username">
<td>{{ user.username }}</td>
<td>
<input
type="checkbox"
:checked="user.privileged"
@change="toggleAdmin(user, $event)"
:disabled="user.username === store.user.username"
/>
</td>
<td>
<button @click="renameUser(user)" class="button small" title="Rename user"></button>
<button @click="resetPassword(user)" class="button small" title="Reset password">🔑</button>
<button @click="deleteUserAction(user.username)" class="button small danger" :disabled="user.username === store.user.username" title="Delete user">🗑</button>
</td>
</tr>
</tbody>
</table>
<h3 class="error-text">{{ error || '\u00A0' }}</h3>
<div class="dialog-buttons">
<button @click="close" class="button">Close</button>
</div>
</div>
</ModalDialog>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { listUsers, createUser, updateUser, deleteUser, updatePublic } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
interface User {
username: string
privileged: boolean
lastSeen: number
}
const store = useMainStore()
const loading = ref(true)
const users = ref<User[]>([])
const error = ref('')
const success = ref('')
const copyButtonText = ref('📋')
const serverSettings = reactive({
public: false
})
const close = () => {
store.dialog = ''
error.value = ''
success.value = ''
}
const loadUsers = async () => {
try {
loading.value = true
const data = await listUsers()
users.value = data.users
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to load users'
} finally {
loading.value = false
}
}
const addUser = async () => {
const username = window.prompt('Enter username for new user:')
if (!username || !username.trim()) return
try {
error.value = ''
success.value = ''
const result = await createUser(username.trim(), undefined, false)
await loadUsers()
if (result.password) {
success.value = `User ${username.trim()} created. Password: ${result.password}`
}
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to add user'
}
}
const toggleAdmin = async (user: User, event: Event) => {
const target = event.target as HTMLInputElement
try {
error.value = ''
await updateUser(user.username, { privileged: target.checked })
user.privileged = target.checked
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to update user'
target.checked = user.privileged // revert
}
}
const renameUser = async (user: User) => {
const newName = window.prompt('Enter new username:', user.username)
if (!newName || !newName.trim() || newName.trim() === user.username) return
// For rename, we need to create new user and delete old, or have a rename endpoint
// Since no rename endpoint, perhaps delete and create
try {
error.value = ''
success.value = ''
const result = await createUser(newName.trim(), undefined, user.privileged)
await deleteUser(user.username)
await loadUsers()
if (result.password) {
success.value = `User renamed to ${newName.trim()}. New password: ${result.password}`
}
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to rename user'
}
}
const resetPassword = async (user: User) => {
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return
try {
error.value = ''
success.value = ''
const result = await updateUser(user.username, { password: "" })
if (result.password) {
success.value = `Password reset for ${user.username}. New password: ${result.password}`
}
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to reset password'
}
}
const deleteUserAction = async (username: string) => {
if (!confirm(`Delete user ${username}?`)) return
try {
error.value = ''
await deleteUser(username)
await loadUsers()
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to delete user'
}
}
const copySuccess = async (isButtonClick: boolean = false) => {
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/)
if (passwordMatch) {
await navigator.clipboard.writeText(passwordMatch[1])
if (isButtonClick) {
// Show "Copied!" indication on button
copyButtonText.value = '✅ Copied!'
// Hide password and button immediately after copying
const baseMessage = success.value.replace(/(?:Password|New password): .+/, 'Password copied to clipboard!')
success.value = baseMessage
// Hide the entire message after 3 seconds
setTimeout(() => {
success.value = ''
copyButtonText.value = '📋'
}, 3000)
} else {
// Just hide the message when clicking elsewhere
success.value = ''
}
}
}
const updateServerSettings = async () => {
try {
error.value = ''
success.value = ''
await updatePublic(serverSettings.public)
// Update store
store.server.public = serverSettings.public
success.value = 'Server settings updated'
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to update settings'
}
}
onMounted(() => {
serverSettings.public = store.server.public
loadUsers()
})
watch(() => store.server.public, (newVal) => {
serverSettings.public = newVal
})
</script>
<style scoped>
.user-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.user-table th, .user-table td {
border: 1px solid var(--border-color);
padding: 0.5rem;
text-align: left;
}
.user-table th {
background: var(--soft-color);
}
.button.small {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
margin-right: 0.25rem;
}
.button.danger {
background: var(--red-color);
color: white;
}
.button.danger:hover {
background: #d00;
}
.form-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.form-row label {
min-width: 100px;
}
.success-message {
background: var(--accent-color);
color: white;
padding: 0.5rem;
border-radius: 0.25rem;
margin-top: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>
+50
View File
@@ -1,4 +1,20 @@
class ClientClass { class ClientClass {
async get(url: string): Promise<any> {
const res = await fetch(url, {
method: 'GET',
headers: {
accept: 'application/json'
}
})
let msg
try {
msg = await res.json()
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
}
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
}
async post(url: string, data?: Record<string, any>): Promise<any> { async post(url: string, data?: Record<string, any>): Promise<any> {
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
@@ -17,6 +33,40 @@ class ClientClass {
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message) if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg return msg
} }
async put(url: string, data?: Record<string, any>): Promise<any> {
const res = await fetch(url, {
method: 'PUT',
headers: {
accept: 'application/json',
'content-type': 'application/json'
},
body: data !== undefined ? JSON.stringify(data) : undefined
})
let msg
try {
msg = await res.json()
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
}
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
}
async delete(url: string): Promise<any> {
const res = await fetch(url, {
method: 'DELETE',
headers: {
accept: 'application/json'
}
})
let msg
try {
msg = await res.json()
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
}
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
}
} }
export const Client = new ClientClass() export const Client = new ClientClass()
+31
View File
@@ -24,3 +24,34 @@ export async function changePassword(username: string, passwordChange: string, p
}) })
return data return data
} }
export const url_users = '/users'
export async function listUsers() {
const data = await Client.get(url_users)
return data
}
export async function createUser(username: string, password?: string, privileged?: boolean) {
const data = await Client.post(url_users, {
username,
password,
privileged
})
return data
}
export async function updateUser(username: string, changes: { password?: string, privileged?: boolean }) {
const data = await Client.put(`${url_users}/${username}`, changes)
return data
}
export async function deleteUser(username: string) {
const data = await Client.delete(`${url_users}/${username}`)
return data
}
export async function updatePublic(publicFlag: boolean) {
const data = await Client.put('/config/public', { public: publicFlag })
return data
}
+1 -1
View File
@@ -18,7 +18,7 @@ export const useMainStore = defineStore({
connected: false, connected: false,
cursor: '' as string, cursor: '' as string,
server: {} as Record<string, any>, server: {} as Record<string, any>,
dialog: '' as '' | 'login' | 'settings', dialog: '' as '' | 'login' | 'settings' | 'usermgmt',
uprogress: {} as any, uprogress: {} as any,
dprogress: {} as any, dprogress: {} as any,
prefs: { prefs: {
+47
View File
@@ -13,3 +13,50 @@ export const sorted = (documents: Doc[], order: SortOrder) => {
sorted.sort(ordering[order]) sorted.sort(ordering[order])
return sorted return sorted
} }
/**
* Sort documents while keeping files grouped by their folder.
* - name: folders sorted by folder path, items within by name
* - modified: folders sorted by newest item within results, items within by mtime
* - size: folders sorted by largest file within results, items within by size
*/
export const sortedGrouped = (documents: Doc[], order: SortOrder) => {
if (!order) return documents
const compare = ordering[order]
// Group documents by their folder location
const byFolder = new Map<string, Doc[]>()
for (const doc of documents) {
const folder = doc.loc
if (!byFolder.has(folder)) byFolder.set(folder, [])
byFolder.get(folder)!.push(doc)
}
// Sort items within each folder
for (const docs of byFolder.values()) {
docs.sort(compare)
}
// Find the "best" item in each folder (first after sorting = best according to criteria)
const folderBest = new Map<string, Doc>()
for (const [folder, docs] of byFolder) {
folderBest.set(folder, docs[0])
}
// Sort folders: by path for name sort, by best item for modified/size
const sortedFolders = [...byFolder.keys()].sort((a, b) => {
if (order === 'name') {
return collator.compare(a, b)
}
return compare(folderBest.get(a)!, folderBest.get(b)!)
})
// Flatten back into a single array with folder grouping preserved
const result: Doc[] = []
for (const folder of sortedFolders) {
result.push(...byFolder.get(folder)!)
}
return result
}
+8 -5
View File
@@ -21,7 +21,7 @@ import { watchEffect, ref, computed, watch } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import Router from '@/router/index' import Router from '@/router/index'
import { needleFormat, localeIncludes, collator } from '@/utils' import { needleFormat, localeIncludes, collator } from '@/utils'
import { sorted } from '@/utils/docsort' import { sorted, sortedGrouped } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue' import FileExplorer from '@/components/FileExplorer.vue'
const store = useMainStore() const store = useMainStore()
@@ -49,9 +49,9 @@ const documents = computed(() => {
} }
} }
const locsub = loc + '/' const locsub = loc + '/'
// Custom sort override in effect? // Custom sort override in effect? Use grouped sorting to keep folders together
const order = store.prefs.sortFiltered const order = store.prefs.sortFiltered
if (order) return sorted(docs, order) if (order) return sortedGrouped(docs, order)
// Sort by relevance - current folder, then subfolders, then others // Sort by relevance - current folder, then subfolders, then others
docs.sort((a, b) => ( docs.sort((a, b) => (
// @ts-ignore // @ts-ignore
@@ -73,8 +73,11 @@ watchEffect(() => {
store.query = props.query store.query = props.query
}) })
watch(documents, (docs) => { // Only auto-switch gallery mode when entering a new folder or on initial file list load
store.prefs.gallery = docs.some(d => d.previewable) watch([() => props.path.join('/'), () => store.document.length], ([path, len], [oldPath, oldLen]) => {
// React to path change or initial document load (0 → non-zero)
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
store.prefs.gallery = documents.value.some(d => d.previewable)
}, { immediate: true }) }, { immediate: true })
</script> </script>
+2 -5
View File
@@ -7,11 +7,8 @@ import vue from '@vitejs/plugin-vue'
import svgLoader from 'vite-svg-loader' import svgLoader from 'vite-svg-loader'
import Components from 'unplugin-vue-components/vite' import Components from 'unplugin-vue-components/vite'
// Development mode:
// bun run dev # Run frontend that proxies to dev_backend
// cista -l :8000 --dev # Run backend
const dev_backend = { const dev_backend = {
target: "http://localhost:8000", target: process.env.CISTA_BACKEND_URL || "http://localhost:8989",
changeOrigin: false, // Use frontend "host" to match "origin" from browser changeOrigin: false, // Use frontend "host" to match "origin" from browser
ws: true, ws: true,
} }
@@ -48,7 +45,7 @@ export default defineConfig({
} }
}, },
build: { build: {
outDir: "../cista/wwwroot", outDir: "../cista/frontend-build",
emptyOutDir: true, emptyOutDir: true,
} }
}) })
+6 -4
View File
@@ -27,7 +27,6 @@ dependencies = [
"argon2-cffi>=25.1.0", "argon2-cffi>=25.1.0",
"av>=15.0.0", "av>=15.0.0",
"blake3>=1.0.5", "blake3>=1.0.5",
"brotli>=1.1.0",
"docopt>=0.6.2", "docopt>=0.6.2",
"inotify>=0.2.12", "inotify>=0.2.12",
"msgspec>=0.19.0", "msgspec>=0.19.0",
@@ -38,10 +37,11 @@ dependencies = [
"pillow-heif>=1.1.0", "pillow-heif>=1.1.0",
"pyjwt>=2.10.1", "pyjwt>=2.10.1",
"pymupdf>=1.26.3", "pymupdf>=1.26.3",
"sanic>=25.3.0", "sanic>=25.12.0",
"setproctitle>=1.3.6", "setproctitle>=1.3.6",
"stream-zip>=0.0.83", "stream-zip>=0.0.83",
"tomli_w>=1.2.0", "tomli_w>=1.2.0",
"zstandard>=0.24.0",
] ]
[project.urls] [project.urls]
@@ -71,8 +71,8 @@ docs = [
source = "vcs" source = "vcs"
[tool.hatch.build] [tool.hatch.build]
artifacts = ["cista/wwwroot"] artifacts = ["cista/frontend-build"]
targets.sdist.hooks.custom.path = "scripts/build-frontend.py" targets.sdist.hooks.custom.path = "scripts/fastapi-vue/build-frontend.py"
targets.sdist.include = [ targets.sdist.include = [
"/cista", "/cista",
] ]
@@ -82,6 +82,7 @@ hooks.vcs.template = """
__version__ = {version!r} __version__ = {version!r}
""" """
only-packages = true only-packages = true
packages = ["cista"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = [ addopts = [
@@ -119,6 +120,7 @@ dev = [
"ruff>=0.8.0", "ruff>=0.8.0",
"mypy>=1.13.0", "mypy>=1.13.0",
"pre-commit>=4.0.0", "pre-commit>=4.0.0",
"httpx>=0.28.1",
] ]
test = [ test = [
"pytest>=8.4.1", "pytest>=8.4.1",
-37
View File
@@ -1,37 +0,0 @@
# noqa: INP001
import os
import shutil
import subprocess
from sys import stderr
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomBuildHook(BuildHookInterface):
def initialize(self, version, build_data):
super().initialize(version, build_data)
stderr.write(">>> Building Cista frontend\n")
npm = None
bun = shutil.which("bun")
if bun is None:
npm = shutil.which("npm")
if npm is None:
raise RuntimeError(
"Bun or NodeJS `npm` is required for building but neither was found\n Visit https://bun.com/"
)
# npm --prefix doesn't work on Windows, so we chdir instead
os.chdir("frontend")
try:
if npm:
stderr.write("### npm install\n")
subprocess.run([npm, "install"], check=True) # noqa: S603
stderr.write("\n### npm run build\n")
subprocess.run([npm, "run", "build"], check=True) # noqa: S603
else:
assert bun
stderr.write("### bun install\n")
subprocess.run([bun, "install"], check=True) # noqa: S603
stderr.write("\n### bun run build\n")
subprocess.run([bun, "run", "build"], check=True) # noqa: S603
finally:
os.chdir("..")
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env -S uv run
"""Run Vite development server for frontend and Cista backend with auto-reload.
Usage:
uv run scripts/devserver.py [-l <listen>]
Options:
-l LISTEN Listen address for backend (default: from config, or :8000)
Environment:
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
"""
import asyncio
import contextlib
import os
import sys
from pathlib import Path
from sys import stderr
import httpx
from cista import config
from cista.serve import parse_listen
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
DEFAULT_VITE_PORT = 5173
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
BUN_BUG = """\
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
┃ Consider using deno or npm instead for development.
"""
def resolve_frontend_tools(vite_port: int) -> tuple[list[str], list[str], str]:
"""Resolve frontend install and dev commands.
Returns (install_cmd, dev_cmd, tool_name).
Raises SystemExit if tools are not available.
"""
if not (FRONTEND_PATH / "package.json").exists():
stderr.write(f"┃ ⚠️ Frontend source not found at {FRONTEND_PATH}\n")
raise SystemExit(1)
install_cmd, build_cmd = find_build_tool() # noqa # type: ignore
dev_cmd, name = find_dev_tool() # noqa # type: ignore
if dev_cmd is None:
if not os.environ.get("JS_RUNTIME"):
stderr.write("┃ ⚠️ deno, npm or bun needed to run the frontend server.\n")
raise SystemExit(1)
dev_cmd = [*dev_cmd, "--clearScreen=false", f"--port={vite_port}"]
if name == "bun":
stderr.write(BUN_BUG)
return install_cmd, dev_cmd, name
async def wait_for_backend(host: str, port: int):
"""Wait for the backend to be ready by polling the health endpoint."""
max_attempts = 50
url = f"http://{host}:{port}"
async with httpx.AsyncClient() as client:
for attempt in range(max_attempts):
try:
await client.get(url, timeout=1.0)
stderr.write("✓ Backend ready!\n")
return True
except httpx.RequestError:
if attempt == max_attempts - 1:
stderr.write("┃ ⚠️ Backend didn't start in time\n")
return False
await asyncio.sleep(0.1)
return False
async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> None:
"""Gracefully terminate a subprocess."""
if proc.returncode is not None:
return
try:
proc.terminate()
except ProcessLookupError:
return
try:
await asyncio.wait_for(proc.wait(), timeout=2)
except TimeoutError:
try:
proc.kill()
except ProcessLookupError:
return
await proc.wait()
async def run_devserver(backend_port: int, cista_args: list[str]) -> None:
"""Run the development server with install, backend, and frontend."""
vite_port = DEFAULT_VITE_PORT
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(vite_port)
# Tell the backend where the Vite dev server is (not used yet)
os.environ["CISTA_DEV_FRONTEND_URL"] = f"http://localhost:{vite_port}"
backend_cmd = ["cista", "--dev", *cista_args]
cwd = str(Path(__file__).parent.parent)
frontend_cwd = str(FRONTEND_PATH)
backend_proc: asyncio.subprocess.Process | None = None
install_proc: asyncio.subprocess.Process | None = None
frontend_proc: asyncio.subprocess.Process | None = None
try:
# Start install (concurrent with backend)
stderr.write(f">>> {tool_name} {' '.join(install_cmd[1:])}\n")
install_proc = await asyncio.create_subprocess_exec(
*install_cmd, cwd=frontend_cwd
)
await asyncio.sleep(0.1)
# Start backend (concurrent with install)
stderr.write(f">>> {' '.join(backend_cmd)}\n")
backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd)
# Wait for install to complete and backend to be ready
install_task = asyncio.create_task(install_proc.wait(), name="install")
backend_ready_task = asyncio.create_task(
wait_for_backend("localhost", backend_port), name="backend_ready"
)
done, pending = await asyncio.wait(
{install_task, backend_ready_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in done:
if task.get_name() == "install":
if task.result() != 0:
stderr.write("┃ ⚠️ Install failed\n")
raise SystemExit(1)
elif task.get_name() == "backend_ready" and not task.result():
raise SystemExit(1)
if pending:
done2, _ = await asyncio.wait(pending)
for task in done2:
if task.get_name() == "install":
if task.result() != 0:
stderr.write("┃ ⚠️ Install failed\n")
raise SystemExit(1)
elif task.get_name() == "backend_ready" and not task.result():
raise SystemExit(1)
install_proc = None
# Start Vite dev server
stderr.write(f">>> {tool_name} {' '.join(dev_cmd[1:])}\n")
frontend_proc = await asyncio.create_subprocess_exec(*dev_cmd, cwd=frontend_cwd)
# Wait for either process to exit
done, pending = await asyncio.wait(
{
asyncio.create_task(backend_proc.wait(), name="backend"),
asyncio.create_task(frontend_proc.wait(), name="frontend"),
},
return_when=asyncio.FIRST_COMPLETED,
)
for t in done:
t.result()
for t in pending:
t.cancel()
except asyncio.CancelledError:
stderr.write("\n✓ Shutting down...\n")
finally:
if frontend_proc is not None:
await _terminate_process(frontend_proc, "frontend")
if install_proc is not None:
await _terminate_process(install_proc, "install")
if backend_proc is not None:
await _terminate_process(backend_proc, "backend")
def main():
# Pass all arguments to cista, parse -l to determine backend port
cista_args = sys.argv[1:]
listen_arg = None
if "-l" in cista_args:
idx = cista_args.index("-l")
if idx + 1 < len(cista_args):
listen_arg = cista_args[idx + 1]
# Load config to get the backend port
config.load_config()
listen = listen_arg or config.config.listen or ":8000"
_, opts = parse_listen(listen)
backend_port = opts.get("port", 8000)
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(run_devserver(backend_port, cista_args))
if __name__ == "__main__":
main()
+34
View File
@@ -0,0 +1,34 @@
"""Hatch build hook for building Vue frontend during package build."""
import subprocess
from pathlib import Path
from sys import stderr
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102
def run(cmd, **kwargs):
"""Run a command and display it."""
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
stderr.write(f"### {' '.join(display_cmd)}\n")
subprocess.run(cmd, check=True, **kwargs)
class CustomBuildHook(BuildHookInterface):
"""Build hook that compiles Vue frontend before packaging."""
def initialize(self, version, build_data):
super().initialize(version, build_data)
stderr.write(">>> Building the frontend\n")
install_cmd, build_cmd = find_build_tool() # noqa # type: ignore
try:
run(install_cmd, cwd="frontend")
stderr.write("\n")
run(build_cmd, cwd="frontend")
except Exception as e:
stderr.write(f"Error occurred while building frontend: {e}\n")
raise
+87
View File
@@ -0,0 +1,87 @@
"""Shared utilities for build and dev scripts."""
import os
import shutil
from pathlib import Path
from sys import stderr
def find_js_runtime() -> tuple[str, str] | None:
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
Returns None if no runtime is found.
"""
options = ["deno", "npm", "bun"]
# Check for JS_RUNTIME environment variable
if js_runtime_env := os.environ.get("JS_RUNTIME"):
js_runtime = js_runtime_env
js_path = Path(js_runtime)
runtime_name = js_path.name
# Map node to npm
if runtime_name == "node":
runtime_name = "npm"
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
for option in options:
if option == runtime_name or runtime_name.startswith(option):
tool = shutil.which(js_runtime)
if tool is None:
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not found\n")
return None
return tool, option
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not recognized\n")
return None
# Auto-detect
for option in options:
if tool := shutil.which(option):
return tool, option
return None
def find_build_tool():
"""Find JavaScript runtime and construct install/build commands.
Returns (install_cmd, build_cmd) tuples of command lists.
Raises RuntimeError if no runtime is found.
"""
install = {
"deno": ("install", "--allow-scripts=npm:vue-demi"),
"npm": ("install",),
"bun": ("--bun", "install"),
}
# Run vite directly for deno to avoid npm-run-all2/run-p issues
build = {
"deno": ("run", "-A", "npm:vite", "build"),
"npm": ("run", "build"),
"bun": ("--bun", "run", "build"),
}
result = find_js_runtime()
if result is None:
raise RuntimeError(
"Deno, npm or Bun is required for building but none was found"
)
tool, name = result
return [tool, *install[name]], [tool, *build[name]]
def find_dev_tool():
"""Find JavaScript runtime and construct dev command.
Returns (dev_cmd, tool_name) or (None, None) if not found.
"""
dev_args = {
"deno": ("run", "dev", "--"),
"npm": ("--silent", "run", "dev", "--"),
"bun": ("run", "dev", "--"),
}
result = find_js_runtime()
if result is None:
return None, None
tool, name = result
return [tool, *dev_args[name]], name