Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1e16b7abe | ||
|
|
0ebff0ec17 | ||
|
|
c8ab06d864 | ||
|
|
d5ff7757c8 | ||
|
|
cd604eb10a | ||
|
|
abcf5d9940 | ||
|
|
0190bda853 | ||
|
|
fc48500412 | ||
|
|
d31ad0b525 | ||
|
|
da4bba95be | ||
|
|
e07ab220cb | ||
|
|
3da2f6e6c3 | ||
|
|
ded7ce65bc | ||
|
|
17550be698 | ||
|
|
0b269aef7f | ||
|
|
497de296f2 | ||
|
|
134b216f4c | ||
|
|
06759b3c12 | ||
|
|
c51552ea29 | ||
|
|
00645fc8ff | ||
|
|
760f7bc35d | ||
|
|
8480a73839 | ||
|
|
302ed684e7 | ||
|
|
af35e0480a | ||
|
|
5717486197 | ||
|
|
0061fc54ae | ||
|
|
4eefe83072 | ||
|
|
f578a50007 | ||
|
|
f40d9c1abd | ||
|
|
3d8845cf99 | ||
|
|
87e1443e7d | ||
|
|
f45c57e901 | ||
|
|
41686d1dd1 | ||
|
|
cc351bb992 | ||
|
|
c3abbe0a3b | ||
|
|
127caeedea | ||
|
|
113bc56351 | ||
|
|
c7727c72d9 | ||
|
|
85b3aa6b81 | ||
|
|
62b44ddb43 | ||
|
|
60a53ef3d3 | ||
|
|
acd38c2235 | ||
|
|
2f38f15afa | ||
|
|
0fc28e56bb | ||
|
|
22b0e503e0 | ||
|
|
e3a4ecdcc2 | ||
|
|
9f363e3f66 | ||
|
|
8270dd0cc2 | ||
|
|
1af6cd82fe | ||
|
|
0bc2a12cfa | ||
|
|
9e07a3f40d | ||
|
|
2867b1075d | ||
|
|
4fc8e29cc1 | ||
|
|
55f9117b3e | ||
|
|
2b6746c55e | ||
|
|
1ed3779933 | ||
|
|
a95cf7be94 | ||
|
|
d18501f633 | ||
|
|
ed697db871 | ||
|
|
856e8c4cc8 | ||
|
|
f8a6eacb06 | ||
|
|
e9a82e84ad | ||
|
|
ccd05b53f4 | ||
|
|
032ad13b97 | ||
|
|
4eae75c84b | ||
|
|
252b31a293 | ||
|
|
feff202161 | ||
|
|
d15bfc86c4 | ||
|
|
f94ccc01c6 | ||
|
|
bc355d991d | ||
|
|
e7ebad3c36 | ||
|
|
036c4342de | ||
|
|
160f929e0c | ||
|
|
1970d40d8a | ||
|
|
1a164e0a08 | ||
|
|
46d222006a | ||
|
|
98949e6b30 | ||
|
|
8f76d770ee | ||
|
|
232fd92b22 | ||
|
|
be69164c8f | ||
|
|
4f39875786 | ||
|
|
21250a1a2d | ||
|
|
849b1a6868 | ||
|
|
7be02e951d | ||
|
|
bb38328c24 | ||
|
|
146497d731 | ||
|
|
442816a0ae | ||
|
|
d32afa6016 | ||
|
|
fa60c962c4 | ||
|
|
e55e11b399 | ||
|
|
b6c21152e7 | ||
|
|
f354fc5c71 | ||
|
|
5bda809921 | ||
|
|
2cc92cd786 | ||
|
|
ba6380e71e | ||
|
|
0d853032bf | ||
|
|
1cb512e65d | ||
|
|
972aaee9fe | ||
|
|
055eaa8a21 | ||
|
|
05fb81c36d | ||
|
|
6639174e8f | ||
|
|
5e2e71eafb | ||
|
|
7e4c5bc911 | ||
|
|
bfcce1b80e | ||
|
|
2bd8d4a323 | ||
|
|
1d97d48fed | ||
|
|
f627890e55 | ||
|
|
5a5b47346f | ||
|
|
64de17a74b | ||
|
|
55133e82f3 | ||
|
|
211da1cdda | ||
|
|
d8972cee5d | ||
|
|
2110284b76 | ||
|
|
fa0588837e | ||
|
|
83bc1bea81 | ||
|
|
afc6c37d78 | ||
|
|
1d6319d900 | ||
|
|
ec9fda3586 | ||
|
|
136d2c2421 | ||
|
|
27aefc50d4 | ||
|
|
dd95e5a858 | ||
|
|
cdb22f9228 | ||
|
|
876ecd8978 | ||
|
|
d0ef32c9e9 | ||
|
|
58579b50c4 | ||
|
|
587268c14b | ||
|
|
ee65f8afb7 | ||
|
|
50642231e5 | ||
|
|
dfd4a15cc9 | ||
|
|
907fdb9baf | ||
|
|
4b49f454ba | ||
|
|
d939c1ddbb | ||
|
|
66698869d6 | ||
|
|
fee66ce7ed | ||
|
|
c72e3d91ee | ||
|
|
c5f611bc4c | ||
|
|
6dfb93e0dc | ||
|
|
9724f1095e | ||
|
|
fb565a95d6 | ||
|
|
218f9a5b26 | ||
|
|
09e35acf87 | ||
|
|
c84c570d2e | ||
|
|
a52167bb5e | ||
|
|
a04b2794d6 | ||
|
|
d721de8811 | ||
|
|
651ce7b6a7 | ||
|
|
64eb9896a9 | ||
|
|
f16a738ec2 | ||
|
|
763991f86e | ||
|
|
548e67338d | ||
|
|
ea4edc6e4f | ||
|
|
1d47397506 | ||
|
|
fc2c961d10 | ||
|
|
9895d7bf6c | ||
|
|
35a6b2740b | ||
|
|
1ed4a8db94 | ||
|
|
ffd8086b03 | ||
|
|
0cd7249712 | ||
|
|
5deeaae5bd | ||
|
|
4613581759 | ||
|
|
890fa6ec06 | ||
|
|
2d15a0b76c | ||
|
|
879ad09677 | ||
|
|
8bff541297 | ||
|
|
14ea4d51f2 | ||
|
|
0d24d807ac | ||
|
|
dd4a82aaaa | ||
|
|
c5b5e3a76c | ||
|
|
c578f84753 | ||
|
|
4c9d802b65 | ||
|
|
479b4a13d0 | ||
|
|
626afc07fa | ||
|
|
551dfb61e8 | ||
|
|
a3e4c50707 | ||
|
|
d5cc4ebcb8 | ||
|
|
4a2d6c1299 | ||
|
|
08a018f382 | ||
|
|
4747c1cb7e |
+2
-1
@@ -1,7 +1,8 @@
|
||||
.*
|
||||
*.lock
|
||||
!.gitignore
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
/cista/_version.py
|
||||
/cista/wwwroot/*
|
||||
/cista/frontend-build/
|
||||
/dist
|
||||
|
||||
@@ -1,27 +1,175 @@
|
||||
# Web File Storage
|
||||
# Cista Web Storage
|
||||
|
||||
Run directly from repository with Hatch (or use pip install as usual):
|
||||
<img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.webp" align=left width=250>
|
||||
|
||||
```sh
|
||||
hatch run cista -l :3000 /path/to/files
|
||||
Cista takes its name from the ancient *cistae*, metal containers used by Greeks and Egyptians to safeguard valuable items. This modern application provides a browser interface for secure and accessible file storage, echoing the trust and reliability of its historical namesake.
|
||||
|
||||
This is a cutting-edge **file and document server** designed for speed, efficiency, and unparalleled ease of use. Experience **lightning-fast browsing**, thanks to the file list maintained directly in your browser and updated from server filesystem events, coupled with our highly optimized code. Fully **keyboard-navigable** and with a responsive layout, Cista flawlessly adapts to your devices, providing a seamless experience wherever you are. Our powerful **instant search** means you're always just a few keystrokes away from finding exactly what you need. Press **1/2/3** to switch ordering, navigate with all four arrow keys (+Shift to select). Or click your way around on **breadcrumbs that remember where you were**.
|
||||
|
||||
**Built-in document and media previews** let you quickly view files without downloading them. Cista shows PDF and other documents, video and image thumbnails, with **HDR10 support** video previews and image formats, including HEIC and AVIF. It also has a player for music and video files.
|
||||
|
||||
The Cista project started as an inevitable remake of [Droppy](https://github.com/droppyjs/droppy) which was not being developed at the time. Now they have picked up pace too, feel free to try both and compare.
|
||||
|
||||
All of this is wrapped in an intuitive interface with automatic light and dark themes, making Cista Storage the ideal choice for anyone seeking a reliable, versatile, and quick file storage solution. Quickly setup your own Cista where your files are just a click away, safe, and always accessible.
|
||||
|
||||
Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and perhaps upload something...
|
||||
|
||||
|
||||
## Getting Started
|
||||
### Running the Server
|
||||
|
||||
We recommend using [UV](https://docs.astral.sh/uv/getting-started/installation/) to directly run Cista:
|
||||
|
||||
Try it out locally at http://localhost:8000 (serves the current directory):
|
||||
```fish
|
||||
uvx cista
|
||||
```
|
||||
|
||||
Settings incl. these arguments are stored to config file on the first startup and later `hatch run cista` is sufficient. If the `cista` script is missing, consider `pip install -e .` (within `hatch shell`) or some other trickery (known issue with installs made prior to adding the startup script).
|
||||
|
||||
Create your user account:
|
||||
|
||||
```sh
|
||||
hatch run cista --user admin --privileged
|
||||
Create an account: (otherwise the server is public for all)
|
||||
```fish
|
||||
uvx cista --user yourname --privileged
|
||||
```
|
||||
|
||||
## Build frontend
|
||||
Serve your files at http://localhost:8000:
|
||||
```fish
|
||||
uvx cista -l :8000 /path/to/files
|
||||
```
|
||||
|
||||
Frontend needs to be built before using and after any frontend changes:
|
||||
Alternatively, you can install with `pip` or `uv pip`. This enables using the `cista` command directly without `uvx` or `uv run`.
|
||||
|
||||
```sh
|
||||
```fish
|
||||
pip install cista --break-system-packages
|
||||
```
|
||||
|
||||
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
|
||||
|
||||
## Authentication
|
||||
|
||||
Cista supports two authenticatioon mode, each of which supporting ordinary and privileged users. Either one can be combined with the public mode.
|
||||
|
||||
### Public Mode
|
||||
|
||||
In public mode, anyone can read, send and even delete files without without logging in. Users entering the service won't be asked to authenticate. Privileged users can still log in via the menu to access admin settings, from where the public mode can be toggled on or off.
|
||||
|
||||
### Built-in Password Authentication (default)
|
||||
|
||||
User accounts are managed directly by Cista. Create users with the `--user` flag:
|
||||
|
||||
```fish
|
||||
uvx cista --user admin --privileged # Create admin user
|
||||
uvx cista --user guest # Create regular user
|
||||
```
|
||||
|
||||
Privileged users can manage other users and change settings via the Admin Settings menu.
|
||||
|
||||
### Passkey Authentication and SSO
|
||||
|
||||
For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. This allows user account and permission management at the corporate level, without bothering Cista with it.
|
||||
|
||||
Set the `PASKIA_BACKEND_URL` environment variable:
|
||||
|
||||
```fish
|
||||
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
|
||||
```
|
||||
|
||||
Run the Paskia backend on the same machine (to use that default URL):
|
||||
```fish
|
||||
uvx paskia
|
||||
```
|
||||
|
||||
In Paskia mode:
|
||||
- All `/auth/*` requests are proxied to the Paskia backend
|
||||
- Cista backend verifies access by `/auth/api/validate` endpoint and shows a login dialog if needed
|
||||
- Users with `cista:login` permission can access files
|
||||
- Users with `cista:admin` permission get privileged access (Admin Settings)
|
||||
|
||||
### Internet Access
|
||||
|
||||
Most admins find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains.
|
||||
|
||||
`/etc/caddy/Caddyfile`:
|
||||
|
||||
```Caddyfile
|
||||
cista.example.com {
|
||||
reverse_proxy :8000
|
||||
}
|
||||
```
|
||||
|
||||
Nxing or other proxy may be similarly used, or alternatively you can place cert and key in cista config dir and run `cista -l cista.example.com`
|
||||
|
||||
## System Deployment
|
||||
|
||||
This setup allows easy addition of storages, each with its own domain, configuration, and files.
|
||||
|
||||
Assuming a restricted user account `storage` for serving files and that UV is installed system-wide or on this account. Only UV is required: this does not use git or javascript runtimes.
|
||||
|
||||
Create (edit) a systemd unit:
|
||||
|
||||
```fish
|
||||
sudo systemctl edit --force --full cista@.service
|
||||
```
|
||||
|
||||
Paste the following:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Cista storage %i
|
||||
|
||||
[Service]
|
||||
User=storage
|
||||
ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
|
||||
Restart=always
|
||||
#Environment=PASKIA_BACKEND_URL=http://localhost:4401
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
This setup supports multiple storages, each under `/media/storage/<domain>` for files and `/srv/cista/<domain>/` for configuration. UNIX sockets are used instead of numeric ports for convenience.
|
||||
|
||||
```fish
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now cista@foo.example.com
|
||||
systemctl enable --now cista@bar.example.com
|
||||
```
|
||||
|
||||
Public exposure is easiest using the Caddy web server.
|
||||
|
||||
`/etc/caddy/Caddyfile`:
|
||||
|
||||
```Caddyfile
|
||||
foo.example.com, bar.example.com {
|
||||
reverse_proxy unix//srv/cista/{host}/socket
|
||||
}
|
||||
```
|
||||
|
||||
## Development setup
|
||||
|
||||
For rapid development, we use the Vite development server for the Vue frontend, while running the backend on port 8000 that Vite proxies backend requests to. Each server live reloads whenever its code or configuration are modified.
|
||||
|
||||
Make sure you have git, uv and bun (or npm) installed.
|
||||
|
||||
Backend (Python) – setup and run:
|
||||
|
||||
```fish
|
||||
git clone https://git.zi.fi/Vasanko/cista-storage.git
|
||||
cd cista-storage
|
||||
uv sync --dev
|
||||
uv run cista --dev -l :8000 /path/to/files
|
||||
```
|
||||
|
||||
Frontend (Vue/Vite) – run the dev server in another terminal:
|
||||
|
||||
```fish
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
This will place the front in `cista/wwwroot` from where the backend server delivers it, and that also gets included in the Python package built via `hatch build`.
|
||||
Building the package for release (frontend + Python wheel/sdist):
|
||||
|
||||
```fish
|
||||
uv build
|
||||
```
|
||||
|
||||
Vue is used to build files in `cista/frontend-build`, included prebuilt in the Python package. `uv build` runs the project build hooks to bundle the frontend and produce a NodeJS-independent Python package.
|
||||
|
||||
+122
-27
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -9,29 +10,89 @@ from cista.util import pwgen
|
||||
|
||||
del app, server80.app # Only import needed, for Sanic multiprocessing
|
||||
|
||||
doc = f"""Cista {cista.__version__} - A file storage for the web.
|
||||
|
||||
def create_banner():
|
||||
"""Create a framed banner with the Cista version."""
|
||||
title = f"Cista {cista.__version__}"
|
||||
subtitle = "A file storage for the web"
|
||||
width = max(len(title), len(subtitle)) + 4
|
||||
|
||||
return f"""\
|
||||
╭{"─" * width}╮
|
||||
│{title:^{width}}│
|
||||
│{subtitle:^{width}}│
|
||||
╰{"─" * width}╯
|
||||
"""
|
||||
|
||||
|
||||
def create_startup_box(
|
||||
*, folder, url, unix=None, dev=False, paskia_url=None, public=False
|
||||
):
|
||||
"""Create a framed startup box with server information."""
|
||||
title = f"Cista {cista.__version__}"
|
||||
listen = unix if unix else url
|
||||
location = f"{folder} @ {listen}"
|
||||
lines = [title, location]
|
||||
# Auth line: Paskia <url> or Password, with optional Public suffix
|
||||
if paskia_url:
|
||||
auth_line = f"Auth: Paskia {paskia_url}"
|
||||
else:
|
||||
auth_line = "Auth: Password"
|
||||
if public:
|
||||
auth_line += ", Public"
|
||||
lines.append(auth_line)
|
||||
if dev:
|
||||
lines.append("dev mode")
|
||||
|
||||
# Calculate width based on content
|
||||
inner_width = max(len(line) for line in lines) + 2
|
||||
|
||||
# Build the box
|
||||
box = [f"╭{'─' * inner_width}╮"]
|
||||
for line in lines:
|
||||
box.append(f"│ {line:<{inner_width - 1}}│")
|
||||
box.append(f"╰{'─' * inner_width}╯")
|
||||
return "\n".join(box) + "\n"
|
||||
|
||||
|
||||
banner = create_banner()
|
||||
|
||||
doc = """\
|
||||
Usage:
|
||||
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
|
||||
cista [-c <confdir>] --user <name> [--privileged] [--password]
|
||||
cista --version
|
||||
|
||||
Options:
|
||||
-c CONFDIR Custom config directory
|
||||
-l LISTEN-ADDR Listen on
|
||||
:8000 (localhost port, plain http)
|
||||
-l, --listen LISTEN-ADDR
|
||||
Listen on
|
||||
:8989 (localhost port, plain http)
|
||||
<addr>:3000 (bind another address, port)
|
||||
/path/to/unix.sock (unix socket)
|
||||
example.com (run on 80 and 443 with LetsEncrypt)
|
||||
--import-droppy Import Droppy config from ~/.droppy/config
|
||||
--dev Developer mode (reloads, friendlier crashes, more logs)
|
||||
|
||||
Listen address, path and imported options are preserved in config, and only
|
||||
custom config dir and dev mode need to be specified on subsequent runs.
|
||||
Listen address and path are preserved in config,
|
||||
and only config dir and dev mode need to be specified on subsequent runs.
|
||||
|
||||
User management:
|
||||
--user NAME Create or modify user
|
||||
--privileged Give the user full admin rights
|
||||
--password Reset password
|
||||
|
||||
Environment:
|
||||
PASKIA_BACKEND_URL Paskia single sign-on (e.g. http://localhost:4401)
|
||||
https://git.zi.fi/leovasanko/paskia
|
||||
"""
|
||||
|
||||
first_time_help = """\
|
||||
No config file found! Get started with:
|
||||
cista --user yourname --privileged # If you want user accounts
|
||||
cista -l :8989 /path/to/files # Run the server on localhost:8989
|
||||
|
||||
See cista --help for other options!
|
||||
"""
|
||||
|
||||
|
||||
@@ -43,15 +104,22 @@ def main():
|
||||
try:
|
||||
return _main()
|
||||
except Exception as e:
|
||||
print("Error:", e)
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
|
||||
|
||||
def _main():
|
||||
# The banner printing differs by mode, and needs to be done before docopt() printing its messages
|
||||
if any(arg in sys.argv for arg in ("--help", "-h")):
|
||||
sys.stdout.write(banner)
|
||||
elif "--version" in sys.argv:
|
||||
sys.stdout.write(f"cista {cista.__version__}\n")
|
||||
return 0
|
||||
# Don't print banner yet for normal startup - we'll print the startup box later
|
||||
args = docopt(doc)
|
||||
if args["--user"]:
|
||||
return _user(args)
|
||||
listen = args["-l"]
|
||||
listen = args["--listen"]
|
||||
# Validate arguments first
|
||||
if args["<path>"]:
|
||||
path = Path(args["<path>"]).resolve()
|
||||
@@ -62,13 +130,10 @@ def _main():
|
||||
_confdir(args)
|
||||
exists = config.conffile.exists()
|
||||
import_droppy = args["--import-droppy"]
|
||||
necessary_opts = exists or import_droppy or path and listen
|
||||
necessary_opts = exists or import_droppy or path
|
||||
if not necessary_opts:
|
||||
# Maybe run without arguments
|
||||
print(doc)
|
||||
print(
|
||||
"No config file found! Get started with:\n cista -l :8000 /path/to/files, or\n cista -l example.com --import-droppy # Uses Droppy files\n",
|
||||
)
|
||||
sys.stderr.write(first_time_help)
|
||||
return 1
|
||||
settings = {}
|
||||
if import_droppy:
|
||||
@@ -77,22 +142,34 @@ def _main():
|
||||
f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}",
|
||||
)
|
||||
settings = droppy.readconf()
|
||||
# Droppy's public flag is kept as-is (same name in our config)
|
||||
if path:
|
||||
settings["path"] = path
|
||||
elif not exists:
|
||||
settings["path"] = Path.home() / "Downloads"
|
||||
if listen:
|
||||
settings["listen"] = listen
|
||||
operation = config.update_config(settings)
|
||||
print(f"Config {operation}: {config.conffile}")
|
||||
elif not exists:
|
||||
settings["listen"] = ":8989"
|
||||
config.update_config(settings)
|
||||
# Prepare to serve
|
||||
unix = None
|
||||
url, _ = serve.parse_listen(config.config.listen)
|
||||
url, opts = serve.parse_listen(config.config.listen)
|
||||
if not config.config.path.is_dir():
|
||||
raise ValueError(f"No such directory: {config.config.path}")
|
||||
extra = f" ({unix})" if unix else ""
|
||||
dev = args["--dev"]
|
||||
if dev:
|
||||
extra += " (dev mode)"
|
||||
print(f"Serving {config.config.path} at {url}{extra}")
|
||||
# Check for Paskia SSO
|
||||
from cista.sso import PASKIA_BACKEND_URL
|
||||
|
||||
# Print startup box
|
||||
startup_box = create_startup_box(
|
||||
folder=config.config.path,
|
||||
url=url,
|
||||
unix=opts.get("unix"),
|
||||
dev=dev,
|
||||
paskia_url=PASKIA_BACKEND_URL or None,
|
||||
public=config.config.public,
|
||||
)
|
||||
sys.stderr.write(startup_box)
|
||||
# Run the server
|
||||
serve.run(dev=dev)
|
||||
return 0
|
||||
@@ -107,16 +184,29 @@ def _confdir(args):
|
||||
raise ValueError("Config path is not a directory")
|
||||
# Accidentally pointed to the db.toml, use parent
|
||||
confdir = confdir.parent
|
||||
config.conffile = confdir / config.conffile.name
|
||||
os.environ["CISTA_HOME"] = confdir.as_posix()
|
||||
config.init_confdir() # Uses environ if available
|
||||
|
||||
|
||||
def _user(args):
|
||||
_confdir(args)
|
||||
config.load_config()
|
||||
if config.conffile.exists():
|
||||
config.load_config()
|
||||
operation = False
|
||||
else:
|
||||
# Defaults for new config when user is created
|
||||
operation = config.update_config(
|
||||
{
|
||||
"listen": ":8989",
|
||||
"path": Path.home() / "Downloads",
|
||||
"public": False,
|
||||
}
|
||||
)
|
||||
sys.stderr.write(f"Config {operation}: {config.conffile}\n\n")
|
||||
|
||||
name = args["--user"]
|
||||
if not name or not name.isidentifier():
|
||||
raise ValueError("Invalid username")
|
||||
config.load_config()
|
||||
u = config.config.users.get(name)
|
||||
info = f"User {name}" if u else f"New user {name}"
|
||||
changes = {}
|
||||
@@ -128,11 +218,16 @@ def _user(args):
|
||||
info += " (admin)" if oldadmin else ""
|
||||
if args["--password"] or not u:
|
||||
changes["password"] = pw = pwgen.generate()
|
||||
info += f"\n Password: {pw}"
|
||||
res = config.update_user(args["--user"], changes)
|
||||
print(info)
|
||||
info += f"\n Password: {pw}\n"
|
||||
res = config.update_user(name, changes)
|
||||
sys.stderr.write(f"{info}\n")
|
||||
if res == "read":
|
||||
print(" No changes")
|
||||
sys.stderr.write(" No changes\n")
|
||||
|
||||
if operation == "created":
|
||||
sys.stderr.write(
|
||||
"Now you can run the server:\n cista # defaults set: -l :8989 ~/Downloads\n"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+99
-16
@@ -1,11 +1,13 @@
|
||||
import asyncio
|
||||
import typing
|
||||
from pathlib import PurePosixPath
|
||||
from secrets import token_bytes
|
||||
|
||||
import msgspec
|
||||
from sanic import Blueprint
|
||||
from sanic import Blueprint, json
|
||||
from sanic.exceptions import BadRequest
|
||||
|
||||
from cista import __version__, config, watching
|
||||
from cista import __version__, auth, config, sso, watching
|
||||
from cista.fileio import FileServer
|
||||
from cista.protocol import ControlTypes, FileRange, StatusMsg
|
||||
from cista.util.apphelpers import asend, websocket_wrapper
|
||||
@@ -15,12 +17,12 @@ fileserver = FileServer()
|
||||
|
||||
|
||||
@bp.before_server_start
|
||||
async def start_fileserver(app, _):
|
||||
async def start_fileserver(app):
|
||||
await fileserver.start()
|
||||
|
||||
|
||||
@bp.after_server_stop
|
||||
async def stop_fileserver(app, _):
|
||||
async def stop_fileserver(app):
|
||||
await fileserver.stop()
|
||||
|
||||
|
||||
@@ -52,6 +54,9 @@ async def upload(req, ws):
|
||||
if pos != req.end:
|
||||
d = f"{len(data)} bytes" if isinstance(data, bytes) else data
|
||||
raise ValueError(f"Expected {req.end - pos} more bytes, got {d}")
|
||||
# Signal the watcher about the uploaded file and its parent directories
|
||||
path = PurePosixPath(req.name)
|
||||
watching.notify_change(path, *path.parents)
|
||||
# Report success
|
||||
res = StatusMsg(status="ack", req=req)
|
||||
await asend(ws, res)
|
||||
@@ -86,12 +91,36 @@ async def control(req, ws):
|
||||
while True:
|
||||
cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes)
|
||||
await asyncio.to_thread(cmd)
|
||||
# Signal the watcher about affected paths
|
||||
watching.notify_change(*cmd.affected_paths())
|
||||
await asend(ws, StatusMsg(status="ack", req=cmd))
|
||||
|
||||
|
||||
@bp.websocket("watch")
|
||||
@websocket_wrapper
|
||||
async def watch(req, ws):
|
||||
# Build user info from either built-in auth or SSO
|
||||
user_info = None
|
||||
if sso.paskia_enabled():
|
||||
# SSO auth: call validation to get user info (don't enforce auth in public mode)
|
||||
try:
|
||||
await sso.validate_sso_request(req)
|
||||
except Exception:
|
||||
pass # Ignore auth errors, user_info stays None
|
||||
if sso_user := getattr(req.ctx, "sso_user", None):
|
||||
ctx = sso_user.get("ctx", {})
|
||||
perms = ctx.get("permissions", [])
|
||||
user_info = {
|
||||
"username": ctx.get("user", {}).get("display_name", ""),
|
||||
"privileged": "cista:admin" in perms,
|
||||
}
|
||||
elif req.ctx.user:
|
||||
# Built-in auth: use local user database
|
||||
user_info = {
|
||||
"username": req.ctx.username,
|
||||
"privileged": req.ctx.user.privileged,
|
||||
}
|
||||
|
||||
await ws.send(
|
||||
msgspec.json.encode(
|
||||
{
|
||||
@@ -99,25 +128,79 @@ async def watch(req, ws):
|
||||
"name": config.config.name or config.config.path.name,
|
||||
"version": __version__,
|
||||
"public": config.config.public,
|
||||
"paskia": sso.paskia_enabled(),
|
||||
},
|
||||
"user": {
|
||||
"username": req.ctx.username,
|
||||
"privileged": req.ctx.user.privileged,
|
||||
}
|
||||
if req.ctx.user
|
||||
else None,
|
||||
"user": user_info,
|
||||
}
|
||||
).decode()
|
||||
)
|
||||
uuid = token_bytes(16)
|
||||
try:
|
||||
with watching.state.lock:
|
||||
q = watching.pubsub[uuid] = asyncio.Queue()
|
||||
# Init with disk usage and full tree
|
||||
await ws.send(watching.format_space(watching.state.space))
|
||||
await ws.send(watching.format_root(watching.state.root))
|
||||
q, space, root = await asyncio.get_event_loop().run_in_executor(
|
||||
req.app.ctx.threadexec, subscribe, uuid, ws
|
||||
)
|
||||
await ws.send(space)
|
||||
await ws.send(root)
|
||||
# Send updates
|
||||
while True:
|
||||
await ws.send(await q.get())
|
||||
except RuntimeError as e:
|
||||
if str(e) == "cannot schedule new futures after shutdown":
|
||||
return # Server shutting down, drop the WebSocket
|
||||
raise
|
||||
finally:
|
||||
del watching.pubsub[uuid]
|
||||
watching.pubsub.pop(uuid, None) # Remove whether it got added yet or not
|
||||
|
||||
|
||||
def subscribe(uuid, ws):
|
||||
with watching.state.lock:
|
||||
q = watching.pubsub[uuid] = asyncio.Queue()
|
||||
# Init with disk usage and full tree
|
||||
return (
|
||||
q,
|
||||
watching.format_space(watching.state.space),
|
||||
watching.format_root(watching.state.root),
|
||||
)
|
||||
|
||||
|
||||
@bp.get("config")
|
||||
async def get_config(request):
|
||||
await auth.verify(request, privileged=True)
|
||||
return json(
|
||||
{
|
||||
"name": config.config.name,
|
||||
"public": config.config.public,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.put("config/public")
|
||||
async def update_public(request):
|
||||
await auth.verify(request, privileged=True)
|
||||
try:
|
||||
public = request.json["public"]
|
||||
if not isinstance(public, bool):
|
||||
raise ValueError("public must be a boolean")
|
||||
except KeyError:
|
||||
raise BadRequest("Missing public field") from None
|
||||
except ValueError as e:
|
||||
raise BadRequest(str(e)) from None
|
||||
config.update_config({"public": public})
|
||||
return json({"message": "Public access setting updated", "public": public})
|
||||
|
||||
|
||||
@bp.put("config/name")
|
||||
async def update_name(request):
|
||||
await auth.verify(request, privileged=True)
|
||||
try:
|
||||
name = request.json["name"]
|
||||
if not isinstance(name, str):
|
||||
raise ValueError("name must be a string")
|
||||
except KeyError:
|
||||
raise BadRequest("Missing name field") from None
|
||||
except ValueError as e:
|
||||
raise BadRequest(str(e)) from None
|
||||
config.update_config({"name": name})
|
||||
# Return the effective name (fallback to path.name if empty)
|
||||
effective_name = name or config.config.path.name
|
||||
return json({"message": "Server name updated", "name": effective_name})
|
||||
|
||||
+133
-61
@@ -1,50 +1,77 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import mimetypes
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from multiprocessing import cpu_count
|
||||
from pathlib import Path, PurePath, PurePosixPath
|
||||
from stat import S_IFDIR, S_IFREG
|
||||
from urllib.parse import unquote
|
||||
from wsgiref.handlers import format_date_time
|
||||
|
||||
import brotli
|
||||
import sanic.helpers
|
||||
from blake3 import blake3
|
||||
from sanic import Blueprint, Sanic, empty, raw
|
||||
from sanic.exceptions import Forbidden, NotFound, ServerError
|
||||
from sanic.log import logging
|
||||
from sanic import Blueprint, Sanic, empty, raw, redirect
|
||||
from sanic.exceptions import Forbidden, NotFound
|
||||
from sanic.log import logger
|
||||
from setproctitle import setproctitle
|
||||
from stream_zip import ZIP_AUTO, stream_zip
|
||||
from zstandard import ZstdCompressor
|
||||
|
||||
from cista import auth, config, session, watching
|
||||
from cista import auth, config, preview, session, sso, watching
|
||||
from cista.preview import shutdown_preview_workers, start_preview_workers
|
||||
from cista.api import bp
|
||||
from cista.sanic_logging import configure_access_logging, configure_main_logging, format_access_log
|
||||
from cista.sanic_logging import logger as access_logger
|
||||
from cista.util.apphelpers import handle_sanic_exception
|
||||
|
||||
# Workaround until Sanic PR #2824 is merged
|
||||
sanic.helpers._ENTITY_HEADERS = frozenset()
|
||||
|
||||
configure_access_logging()
|
||||
|
||||
app = Sanic("cista", strict_slashes=True)
|
||||
app.blueprint(auth.bp)
|
||||
configure_main_logging()
|
||||
# Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL
|
||||
if sso.paskia_enabled():
|
||||
app.blueprint(sso.bp) # SSO proxy for /auth/* routes
|
||||
else:
|
||||
app.blueprint(auth.bp) # Built-in auth routes
|
||||
app.blueprint(preview.bp)
|
||||
app.blueprint(bp)
|
||||
app.exception(Exception)(handle_sanic_exception)
|
||||
|
||||
|
||||
setproctitle("cista-main")
|
||||
|
||||
|
||||
@app.before_server_start
|
||||
async def main_start(app, loop):
|
||||
async def main_start(app):
|
||||
config.load_config()
|
||||
await watching.start(app, loop)
|
||||
setproctitle(f"cista {config.config.path.name}")
|
||||
app.ctx.threadexec = ThreadPoolExecutor(
|
||||
max_workers=8, thread_name_prefix="cista-ioworker"
|
||||
max_workers=4, thread_name_prefix="cista-worker"
|
||||
)
|
||||
# Larger pool for long-running but low-memory zip operations
|
||||
app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip")
|
||||
await start_preview_workers()
|
||||
watching.start(app)
|
||||
|
||||
|
||||
@app.after_server_stop
|
||||
async def main_stop(app, loop):
|
||||
await watching.stop(app, loop)
|
||||
# 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 shutdown_preview_workers()
|
||||
app.ctx.threadexec.shutdown()
|
||||
app.ctx.zipexec.shutdown(cancel_futures=True)
|
||||
await sso.close_client()
|
||||
logger.debug("Cista worker threads all finished")
|
||||
|
||||
|
||||
@app.on_request
|
||||
async def use_session(req):
|
||||
req.ctx._log_start = time.perf_counter()
|
||||
req.ctx.session = session.get(req)
|
||||
try:
|
||||
req.ctx.username = req.ctx.session["username"] # type: ignore
|
||||
@@ -62,10 +89,44 @@ async def use_session(req):
|
||||
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
|
||||
|
||||
|
||||
@app.on_response
|
||||
async def log_access(req, res):
|
||||
"""Log HTTP access in a clean single-line format."""
|
||||
if req.headers.get("upgrade", "").lower() == "websocket":
|
||||
return res
|
||||
start = getattr(req.ctx, "_log_start", None)
|
||||
duration_ms = (time.perf_counter() - start) * 1000 if start is not None else 0.0
|
||||
client = req.client_ip or "-"
|
||||
host = req.host or "-"
|
||||
path = req.path
|
||||
if req.query_string:
|
||||
qs = req.query_string
|
||||
if isinstance(qs, bytes):
|
||||
qs = qs.decode(errors="replace")
|
||||
path = f"{path}?{qs}"
|
||||
extra = getattr(req.ctx, "_log_extra", None)
|
||||
line = format_access_log(client, res.status, req.method, host, path, duration_ms, extra=extra)
|
||||
access_logger.info(line)
|
||||
return res
|
||||
|
||||
|
||||
@app.on_response
|
||||
async def forward_sso_cookies(req, res):
|
||||
"""Forward Set-Cookie headers from SSO validation to client."""
|
||||
if cookies := getattr(req.ctx, "sso_cookies", None):
|
||||
for cookie in cookies:
|
||||
res.headers.add("set-cookie", cookie)
|
||||
|
||||
|
||||
@app.before_server_start
|
||||
def http_fileserver(app, _):
|
||||
def http_fileserver(app):
|
||||
bp = Blueprint("fileserver")
|
||||
bp.on_request(auth.verify)
|
||||
|
||||
@bp.on_request
|
||||
async def verify_fileserver(request):
|
||||
"""Verify access to file server routes."""
|
||||
await auth.verify(request)
|
||||
|
||||
bp.static(
|
||||
"/files/",
|
||||
config.config.path,
|
||||
@@ -81,8 +142,9 @@ www = {}
|
||||
|
||||
def _load_wwwroot(www):
|
||||
wwwnew = {}
|
||||
base = Path(__file__).with_name("wwwroot")
|
||||
base = Path(__file__).with_name("frontend-build")
|
||||
paths = [PurePath()]
|
||||
zstd = ZstdCompressor(level=18)
|
||||
while paths:
|
||||
path = paths.pop(0)
|
||||
current = base / path
|
||||
@@ -114,25 +176,33 @@ def _load_wwwroot(www):
|
||||
else "no-cache",
|
||||
"content-type": mime,
|
||||
}
|
||||
# Precompress with Brotli
|
||||
br = brotli.compress(data)
|
||||
if len(br) >= len(data):
|
||||
br = False
|
||||
wwwnew[name] = data, br, headers
|
||||
# Precompress with ZSTD
|
||||
zs = zstd.compress(data)
|
||||
if len(zs) >= len(data):
|
||||
zs = False
|
||||
wwwnew[name] = data, zs, headers
|
||||
if not wwwnew:
|
||||
raise ServerError(
|
||||
"Web frontend missing. Did you forget npm run build?",
|
||||
extra={"wwwroot": str(base)},
|
||||
quiet=True,
|
||||
msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
|
||||
if not www:
|
||||
logger.warning(msg)
|
||||
if not app.debug:
|
||||
msg = "Web frontend missing. Cista installation is broken.\n"
|
||||
wwwnew[""] = (
|
||||
msg.encode(),
|
||||
False,
|
||||
{
|
||||
"etag": "error",
|
||||
"content-type": "text/plain",
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
)
|
||||
return wwwnew
|
||||
|
||||
|
||||
@app.before_server_start
|
||||
async def start(app):
|
||||
await load_wwwroot(app)
|
||||
if app.debug:
|
||||
app.add_task(refresh_wwwroot())
|
||||
if not app.debug:
|
||||
await load_wwwroot(app)
|
||||
|
||||
|
||||
async def load_wwwroot(app):
|
||||
@@ -142,46 +212,34 @@ async def load_wwwroot(app):
|
||||
)
|
||||
|
||||
|
||||
async def refresh_wwwroot():
|
||||
while True:
|
||||
await asyncio.sleep(0.5)
|
||||
try:
|
||||
wwwold = www
|
||||
await load_wwwroot(app)
|
||||
changes = ""
|
||||
for name in sorted(www):
|
||||
attr = www[name]
|
||||
if wwwold.get(name) == attr:
|
||||
continue
|
||||
headers = attr[2]
|
||||
changes += f"{headers['last-modified']} {headers['etag']} /{name}\n"
|
||||
for name in sorted(set(wwwold) - set(www)):
|
||||
changes += f"Deleted /{name}\n"
|
||||
if changes:
|
||||
print(f"Updated wwwroot:\n{changes}", end="", flush=True)
|
||||
except Exception as e:
|
||||
print("Error loading wwwroot", e)
|
||||
if not app.debug:
|
||||
return
|
||||
|
||||
|
||||
@app.route("/<path:path>", methods=["GET", "HEAD"])
|
||||
async def wwwroot(req, path=""):
|
||||
"""Frontend files only"""
|
||||
if app.debug:
|
||||
raise NotFound(
|
||||
"Dev mode: frontend-build is not served on backend (you should connect vite)",
|
||||
extra={"name": path},
|
||||
)
|
||||
name = unquote(path)
|
||||
if name not in www:
|
||||
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"]:
|
||||
# The client has it cached, respond 304 Not Modified
|
||||
return empty(304, headers=headers)
|
||||
# Brotli compressed?
|
||||
if br and "br" in req.headers.accept_encoding.split(", "):
|
||||
headers = {**headers, "content-encoding": "br"}
|
||||
data = br
|
||||
# Zstandard compressed?
|
||||
if zs and "zstd" in req.headers.accept_encoding.split(", "):
|
||||
headers = {**headers, "content-encoding": "zstd"}
|
||||
data = zs
|
||||
return raw(data, headers=headers)
|
||||
|
||||
|
||||
@app.route("/favicon.ico", methods=["GET", "HEAD"])
|
||||
async def favicon(req):
|
||||
# Browsers keep asking for it when viewing files (not HTML with icon link)
|
||||
return redirect("/assets/logo-ctv8tVwU.svg", status=308)
|
||||
|
||||
|
||||
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
||||
loc = PurePosixPath()
|
||||
idx = 0
|
||||
@@ -207,6 +265,7 @@ def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
||||
@app.get("/zip/<keys>/<zipfile:ext=zip>")
|
||||
async def zip_download(req, keys, zipfile, ext):
|
||||
"""Download a zip archive of the given keys"""
|
||||
await auth.verify(req)
|
||||
|
||||
wanted = set(keys.split("+"))
|
||||
files = get_files(wanted)
|
||||
@@ -237,27 +296,40 @@ async def zip_download(req, keys, zipfile, ext):
|
||||
yield chunk
|
||||
assert size == 0
|
||||
|
||||
pending_put = None # Current queue.put future, can be cancelled
|
||||
|
||||
def worker():
|
||||
nonlocal pending_put
|
||||
try:
|
||||
for chunk in stream_zip(local_files(files)):
|
||||
asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result()
|
||||
future = asyncio.run_coroutine_threadsafe(queue.put(chunk), loop)
|
||||
pending_put = future
|
||||
future.result() # Blocks until queue has space
|
||||
except asyncio.CancelledError:
|
||||
logger.info("ZIP download cancelled by client disconnect")
|
||||
except Exception:
|
||||
logging.exception("Error streaming ZIP")
|
||||
logger.exception("Error streaming ZIP")
|
||||
raise
|
||||
finally:
|
||||
pending_put = None
|
||||
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
|
||||
|
||||
# Don't block the event loop: run in a thread
|
||||
# Don't block the event loop: run in a thread (use larger zip pool)
|
||||
queue = asyncio.Queue(maxsize=1)
|
||||
loop = asyncio.get_event_loop()
|
||||
thread = loop.run_in_executor(app.ctx.threadexec, worker)
|
||||
thread = loop.run_in_executor(app.ctx.zipexec, worker)
|
||||
|
||||
# Stream the response
|
||||
res = await req.respond(
|
||||
content_type="application/zip",
|
||||
headers={"cache-control": "no-store"},
|
||||
)
|
||||
while chunk := await queue.get():
|
||||
await res.send(chunk)
|
||||
try:
|
||||
while chunk := await queue.get():
|
||||
await res.send(chunk)
|
||||
finally:
|
||||
# Cancel any pending put to unblock and stop the worker
|
||||
if pending_put:
|
||||
pending_put.cancel()
|
||||
|
||||
await thread # If it raises, the response will fail download
|
||||
|
||||
+381
-47
@@ -10,6 +10,175 @@ from sanic import Blueprint, html, json, redirect
|
||||
from sanic.exceptions import BadRequest, Forbidden, Unauthorized
|
||||
|
||||
from cista import config, session
|
||||
from cista.util import pwgen
|
||||
|
||||
_LOGIN_PAGE_CSS = """\
|
||||
/* ===========================================
|
||||
LOGIN PAGE STYLES
|
||||
Must match ModalDialog.vue global styles.
|
||||
=========================================== */
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Roboto', system-ui, -apple-system, sans-serif;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
}
|
||||
.login-card {
|
||||
background: #ddd;
|
||||
color: #000;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0 1rem #0008;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
h1 {
|
||||
background: #146;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: normal;
|
||||
border-radius: 0.5rem 0.5rem 0 0;
|
||||
}
|
||||
.content {
|
||||
padding: 1rem;
|
||||
}
|
||||
.message {
|
||||
color: #444;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
form {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
label {
|
||||
font-size: 1rem;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem;
|
||||
border: 2px solid #888;
|
||||
border-radius: 0.25rem;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
min-width: 0;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #f80;
|
||||
}
|
||||
.button-row {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #146;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #f80; }
|
||||
button:disabled {
|
||||
background: #888;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error {
|
||||
grid-column: 1 / -1;
|
||||
color: #c00;
|
||||
font-size: 0.875rem;
|
||||
min-height: 1.2em;
|
||||
margin: 0;
|
||||
}
|
||||
"""
|
||||
|
||||
_LOGIN_PAGE_JS = """\
|
||||
const form = document.getElementById('loginForm');
|
||||
const error = document.getElementById('error');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const usernameField = document.getElementById('username');
|
||||
const passwordField = document.getElementById('password');
|
||||
const isInIframe = window.parent !== window;
|
||||
|
||||
// Focus username field on load
|
||||
usernameField.focus();
|
||||
|
||||
const showError = (msg) => {
|
||||
error.textContent = msg;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Log in';
|
||||
// Focus and select the relevant field
|
||||
if (msg.toLowerCase().includes('password')) {
|
||||
passwordField.focus();
|
||||
passwordField.select();
|
||||
} else {
|
||||
usernameField.focus();
|
||||
usernameField.select();
|
||||
}
|
||||
};
|
||||
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
error.textContent = '';
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Logging in...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: usernameField.value,
|
||||
password: passwordField.value
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
if (isInIframe) {
|
||||
window.parent.postMessage({type: 'auth-success'}, '*');
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
} else {
|
||||
const data = await res.json();
|
||||
showError(data.message || data.detail || 'Login failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError('Connection error. Please try again.');
|
||||
}
|
||||
};
|
||||
"""
|
||||
|
||||
# Import for SSO validation (lazily loaded to avoid circular imports)
|
||||
_sso_module = None
|
||||
|
||||
|
||||
def _get_sso():
|
||||
global _sso_module
|
||||
if _sso_module is None:
|
||||
from cista import sso
|
||||
|
||||
_sso_module = sso
|
||||
return _sso_module
|
||||
|
||||
|
||||
_argon = argon2.PasswordHasher()
|
||||
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
|
||||
@@ -62,62 +231,106 @@ class LoginResponse(msgspec.Struct):
|
||||
error: str = ""
|
||||
|
||||
|
||||
def verify(request, *, privileged=False):
|
||||
"""Raise Unauthorized or Forbidden if the request is not authorized"""
|
||||
if privileged:
|
||||
if request.ctx.user:
|
||||
if request.ctx.user.privileged:
|
||||
return
|
||||
raise Forbidden("Access Forbidden: Only for privileged users", quiet=True)
|
||||
elif config.config.public or request.ctx.user:
|
||||
async def verify(request, *, privileged=False):
|
||||
"""Verify that the request is authorized.
|
||||
|
||||
For paskia mode (PASKIA_BACKEND_URL set), validates against the SSO backend.
|
||||
For built-in mode, checks session-based authentication.
|
||||
For public mode (config.public=True), skips auth unless privileged is required.
|
||||
|
||||
Args:
|
||||
request: The Sanic request object
|
||||
privileged: If True, requires admin privileges (always enforced even in public mode)
|
||||
|
||||
Raises:
|
||||
Unauthorized: If authentication is required
|
||||
Forbidden: If access is denied
|
||||
"""
|
||||
# Public mode: skip auth unless privileged access is required
|
||||
if config.config.public and not privileged:
|
||||
return
|
||||
raise Unauthorized("Login required", "cookie", quiet=True)
|
||||
|
||||
sso = _get_sso()
|
||||
if sso.paskia_enabled():
|
||||
perm = "cista:admin" if privileged else "cista:login"
|
||||
await sso.validate_sso_request(request, perm=perm)
|
||||
return
|
||||
|
||||
bp = Blueprint("auth")
|
||||
|
||||
|
||||
@bp.get("/login")
|
||||
async def login_page(request):
|
||||
doc = Document("Cista Login")
|
||||
with doc.div(id="login"):
|
||||
with doc.form(method="POST", autocomplete="on"):
|
||||
doc.h1("Login")
|
||||
doc.input(
|
||||
name="username",
|
||||
placeholder="Username",
|
||||
autocomplete="username",
|
||||
required=True,
|
||||
).br
|
||||
doc.input(
|
||||
type="password",
|
||||
name="password",
|
||||
placeholder="Password",
|
||||
autocomplete="current-password",
|
||||
required=True,
|
||||
).br
|
||||
doc.input(type="submit", value="Login")
|
||||
s = session.get(request)
|
||||
if s:
|
||||
name = s["username"]
|
||||
with doc.form(method="POST", action="/logout"):
|
||||
doc.input(type="submit", value=f"Logout {name}")
|
||||
flash = request.cookies.message
|
||||
if flash:
|
||||
doc.dialog(
|
||||
flash,
|
||||
id="flash",
|
||||
open=True,
|
||||
style="position: fixed; top: 0; left: 0; width: 100%; opacity: .8",
|
||||
user = getattr(request.ctx, "user", None)
|
||||
if privileged:
|
||||
if user and user.privileged:
|
||||
return
|
||||
raise Forbidden(
|
||||
"Access Forbidden: Only for privileged users",
|
||||
quiet=True,
|
||||
)
|
||||
if user:
|
||||
return
|
||||
raise Unauthorized(
|
||||
f"Login required for {request.path}",
|
||||
"cookie",
|
||||
context={"auth": {"iframe": "/auth/restricted/"}},
|
||||
quiet=True,
|
||||
)
|
||||
|
||||
|
||||
# Blueprint for built-in auth (only registered when paskia is NOT enabled)
|
||||
bp = Blueprint("auth", url_prefix="/auth")
|
||||
|
||||
|
||||
@bp.get("/restricted/")
|
||||
async def login_page(request):
|
||||
"""Login page that works both standalone and in paskia iframe."""
|
||||
s = session.get(request)
|
||||
|
||||
# Check if already logged in
|
||||
if s:
|
||||
# Already authenticated - signal success if in iframe
|
||||
return html(_login_success_page(s["username"]))
|
||||
|
||||
doc = Document("Cista - Login")
|
||||
# Add paskia-compatible styling and scripts
|
||||
doc.style(_LOGIN_PAGE_CSS)
|
||||
with doc.div(class_="login-card"):
|
||||
doc.h1("Authentication Required")
|
||||
with doc.div(class_="content"):
|
||||
with doc.form(method="POST", id="loginForm", autocomplete="on"):
|
||||
doc.label("Username:", for_="username")
|
||||
doc.input(
|
||||
type="text",
|
||||
id="username",
|
||||
name="username",
|
||||
autocomplete="username webauthn",
|
||||
required=True,
|
||||
)
|
||||
doc.label("Password:", for_="password")
|
||||
doc.input(
|
||||
type="password",
|
||||
id="password",
|
||||
name="password",
|
||||
autocomplete="current-password webauthn",
|
||||
required=True,
|
||||
)
|
||||
with doc.div(class_="button-row"):
|
||||
doc.button("Log in", type="submit", id="submitBtn")
|
||||
doc.p("", class_="error", id="error")
|
||||
|
||||
# JavaScript for AJAX login and postMessage communication
|
||||
doc.script_(_LOGIN_PAGE_JS)
|
||||
|
||||
res = html(doc)
|
||||
if flash:
|
||||
res.cookies.delete_cookie("flash")
|
||||
if s is False:
|
||||
session.delete(res)
|
||||
return res
|
||||
|
||||
|
||||
def _login_success_page(username: str) -> str:
|
||||
"""Minimal page that signals auth-success to parent iframe."""
|
||||
return str(
|
||||
Document().script_("window.parent.postMessage({type:'auth-success'},'*')")
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/login")
|
||||
async def login_post(request):
|
||||
try:
|
||||
@@ -148,7 +361,7 @@ async def login_post(request):
|
||||
return res
|
||||
|
||||
|
||||
@bp.post("/logout")
|
||||
@bp.post("/api/logout")
|
||||
async def logout_post(request):
|
||||
s = request.ctx.session
|
||||
msg = "Logged out" if s else "Not logged in"
|
||||
@@ -159,3 +372,124 @@ async def logout_post(request):
|
||||
res = json({"message": msg})
|
||||
session.delete(res)
|
||||
return res
|
||||
|
||||
|
||||
@bp.post("/password-change")
|
||||
async def change_password(request):
|
||||
try:
|
||||
if request.headers.content_type == "application/json":
|
||||
username = request.json["username"]
|
||||
pwchange = request.json["passwordChange"]
|
||||
password = request.json["password"]
|
||||
else:
|
||||
username = request.form["username"][0]
|
||||
pwchange = request.form["passwordChange"][0]
|
||||
password = request.form["password"][0]
|
||||
if not username or not password:
|
||||
raise KeyError
|
||||
except KeyError:
|
||||
raise BadRequest(
|
||||
"Missing username, passwordChange or password",
|
||||
) from None
|
||||
try:
|
||||
user = login(username, password)
|
||||
set_password(user, pwchange)
|
||||
except ValueError as e:
|
||||
raise Forbidden(str(e), context={"redirect": "/login"}) from e
|
||||
|
||||
if "text/html" in request.headers.accept:
|
||||
res = redirect("/")
|
||||
session.flash(res, "Password updated")
|
||||
else:
|
||||
res = json({"message": "Password updated"})
|
||||
session.create(res, username)
|
||||
return res
|
||||
|
||||
|
||||
@bp.get("/users")
|
||||
async def list_users(request):
|
||||
await 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):
|
||||
await 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):
|
||||
await 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):
|
||||
await 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"})
|
||||
|
||||
+71
-22
@@ -1,12 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from functools import wraps
|
||||
from hashlib import sha256
|
||||
from pathlib import Path, PurePath
|
||||
from time import time
|
||||
from time import sleep, time
|
||||
from typing import Callable, Concatenate, Literal, ParamSpec
|
||||
|
||||
import msgspec
|
||||
import msgspec.toml
|
||||
|
||||
|
||||
class Config(msgspec.Struct):
|
||||
@@ -19,6 +24,13 @@ class Config(msgspec.Struct):
|
||||
links: dict[str, Link] = {}
|
||||
|
||||
|
||||
# Typing: arguments for config-modifying functions
|
||||
P = ParamSpec("P")
|
||||
ResultStr = Literal["modified", "created", "read"]
|
||||
RawModifyFunc = Callable[Concatenate[Config, P], Config]
|
||||
ModifyPublic = Callable[P, ResultStr]
|
||||
|
||||
|
||||
class User(msgspec.Struct, omit_defaults=True):
|
||||
privileged: bool = False
|
||||
hash: str = ""
|
||||
@@ -31,8 +43,24 @@ class Link(msgspec.Struct, omit_defaults=True):
|
||||
expires: int = 0
|
||||
|
||||
|
||||
config = None
|
||||
conffile = Path.home() / ".local/share/cista/db.toml"
|
||||
# Global variables - initialized during application startup
|
||||
config: Config
|
||||
conffile: Path
|
||||
|
||||
|
||||
def init_confdir() -> None:
|
||||
global conffile
|
||||
if p := os.environ.get("CISTA_HOME"):
|
||||
home = Path(p)
|
||||
else:
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
home = (
|
||||
Path(xdg).expanduser() / "cista" if xdg else Path.home() / ".config/cista"
|
||||
)
|
||||
if not home.is_dir():
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
home.chmod(0o700)
|
||||
conffile = home / "db.toml"
|
||||
|
||||
|
||||
def derived_secret(*params, len=8) -> bytes:
|
||||
@@ -58,10 +86,10 @@ def dec_hook(typ, obj):
|
||||
raise TypeError
|
||||
|
||||
|
||||
def config_update(modify):
|
||||
def config_update(
|
||||
modify: RawModifyFunc,
|
||||
) -> ResultStr | Literal["collision"]:
|
||||
global config
|
||||
if not conffile.exists():
|
||||
conffile.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmpname = conffile.with_suffix(".tmp")
|
||||
try:
|
||||
f = tmpname.open("xb")
|
||||
@@ -75,12 +103,8 @@ def config_update(modify):
|
||||
old = conffile.read_bytes()
|
||||
c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook)
|
||||
except FileNotFoundError:
|
||||
# No existing config file, make sure we have a folder...
|
||||
confdir = conffile.parent
|
||||
confdir.mkdir(parents=True, exist_ok=True)
|
||||
confdir.chmod(0o700)
|
||||
old = b""
|
||||
c = None
|
||||
c = Config(path=Path(), listen="", secret=secrets.token_hex(12))
|
||||
c = modify(c)
|
||||
new = msgspec.toml.encode(c, enc_hook=enc_hook)
|
||||
if old == new:
|
||||
@@ -90,6 +114,10 @@ def config_update(modify):
|
||||
return "read"
|
||||
f.write(new)
|
||||
f.close()
|
||||
if sys.platform == "win32":
|
||||
# Windows doesn't support atomic replace
|
||||
with suppress(FileNotFoundError):
|
||||
conffile.unlink()
|
||||
tmpname.rename(conffile) # Atomic replace
|
||||
except:
|
||||
f.close()
|
||||
@@ -99,17 +127,23 @@ def config_update(modify):
|
||||
return "modified" if old else "created"
|
||||
|
||||
|
||||
def modifies_config(modify):
|
||||
"""Decorator for functions that modify the config file"""
|
||||
def modifies_config(
|
||||
modify: Callable[Concatenate[Config, P], Config],
|
||||
) -> Callable[P, ResultStr]:
|
||||
"""Decorator for functions that modify the config file
|
||||
|
||||
The decorated function takes as first arg Config and returns it modified.
|
||||
The wrapper handles atomic modification and returns a string indicating the result.
|
||||
"""
|
||||
|
||||
@wraps(modify)
|
||||
def wrapper(*args, **kwargs):
|
||||
def m(c):
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> ResultStr:
|
||||
def m(c: Config) -> Config:
|
||||
return modify(c, *args, **kwargs)
|
||||
|
||||
# Retry modification in case of write collision
|
||||
while (c := config_update(m)) == "collision":
|
||||
time.sleep(0.01)
|
||||
sleep(0.01)
|
||||
return c
|
||||
|
||||
return wrapper
|
||||
@@ -117,14 +151,23 @@ def modifies_config(modify):
|
||||
|
||||
def load_config():
|
||||
global config
|
||||
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
|
||||
init_confdir()
|
||||
raw = conffile.read_bytes()
|
||||
config = msgspec.toml.decode(raw, type=Config, dec_hook=dec_hook)
|
||||
# Migrate from old authentication field if present
|
||||
raw_dict = msgspec.toml.decode(raw)
|
||||
if "authentication" in raw_dict and "public" not in raw_dict:
|
||||
# Old config with authentication mode: migrate to public bool
|
||||
new_public = raw_dict["authentication"] == "none"
|
||||
config = msgspec.structs.replace(config, public=new_public)
|
||||
update_config({}) # Save the migrated config
|
||||
|
||||
|
||||
@modifies_config
|
||||
def update_config(conf: Config, changes: dict) -> Config:
|
||||
"""Create/update the config with new values, respecting changes done by others."""
|
||||
# Encode into dict, update values with new, convert to Config
|
||||
settings = {} if conf is None else msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||
settings.update(changes)
|
||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||
|
||||
@@ -134,7 +177,12 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
|
||||
"""Create/update a user with new values, respecting changes done by others."""
|
||||
# Encode into dict, update values with new, convert to Config
|
||||
try:
|
||||
u = conf.users[name].__copy__()
|
||||
# Copy user by converting to dict and back
|
||||
u = msgspec.convert(
|
||||
msgspec.to_builtins(conf.users[name], enc_hook=enc_hook),
|
||||
User,
|
||||
dec_hook=dec_hook,
|
||||
)
|
||||
except KeyError:
|
||||
u = User()
|
||||
if "password" in changes:
|
||||
@@ -152,6 +200,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
|
||||
@modifies_config
|
||||
def del_user(conf: Config, name: str) -> Config:
|
||||
"""Delete named user account."""
|
||||
ret = conf.__copy__()
|
||||
ret.users.pop(name)
|
||||
return ret
|
||||
# Create a copy by converting to dict and back
|
||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||
settings["users"].pop(name)
|
||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
import asyncio
|
||||
import gc
|
||||
import io
|
||||
import mimetypes
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import urllib.parse
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from multiprocessing import cpu_count
|
||||
from pathlib import PurePosixPath
|
||||
from time import perf_counter
|
||||
from urllib.parse import unquote
|
||||
from wsgiref.handlers import format_date_time
|
||||
|
||||
import msgspec
|
||||
|
||||
import av
|
||||
import fitz # PyMuPDF
|
||||
import numpy as np
|
||||
import pillow_heif
|
||||
import pyvips
|
||||
from blake3 import blake3
|
||||
from PIL import Image
|
||||
from sanic import Blueprint, empty, raw, redirect
|
||||
from sanic.exceptions import NotFound
|
||||
from sanic.log import logger
|
||||
|
||||
from cista import auth, config
|
||||
from cista.preview_worker import PreviewRequest, PreviewResponse
|
||||
from cista.util.filename import sanitize
|
||||
|
||||
pillow_heif.register_heif_opener()
|
||||
|
||||
bp = Blueprint("preview", url_prefix="/preview")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CachedPreview:
|
||||
"""Cached preview with headers and body."""
|
||||
|
||||
headers: dict[str, str]
|
||||
body: bytes
|
||||
|
||||
|
||||
class PreviewCache:
|
||||
"""Thread-safe LRU cache for preview responses."""
|
||||
|
||||
def __init__(self, capacity: int = 500):
|
||||
self.capacity = capacity
|
||||
self._cache: OrderedDict[str, CachedPreview] = OrderedDict()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self, key: str) -> CachedPreview | None:
|
||||
"""Get cached preview, moving it to end (most recently used)."""
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
self._cache.move_to_end(key)
|
||||
return self._cache[key]
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: CachedPreview) -> None:
|
||||
"""Cache preview, evicting oldest if at capacity."""
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
self._cache.move_to_end(key)
|
||||
else:
|
||||
if len(self._cache) >= self.capacity:
|
||||
self._cache.popitem(last=False)
|
||||
self._cache[key] = value
|
||||
|
||||
def __len__(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._cache)
|
||||
|
||||
|
||||
# Global preview cache instance
|
||||
_preview_cache = PreviewCache(capacity=500)
|
||||
|
||||
PREVIEW_TIMEOUT = 3.0 # seconds until preview subprocess is killed
|
||||
PREVIEW_WORKERS = max(2, min(8, cpu_count()))
|
||||
_active_procs: set[asyncio.subprocess.Process] = set()
|
||||
_preview_pool = None
|
||||
_preview_pool_lock = asyncio.Lock()
|
||||
AVIF_FAST_EFFORT = 0
|
||||
FORCE_PIL = os.environ.get("CISTA_PIL") == "1"
|
||||
WORKER_CHECKSUM_BYTES = 32
|
||||
WORKER_MAX_JSON_BYTES = 1_000_000
|
||||
|
||||
|
||||
class WorkerChecksumError(Exception):
|
||||
"""Raised when worker response checksum does not match the packet."""
|
||||
|
||||
|
||||
class WorkerProtocolError(Exception):
|
||||
"""Raised when worker response packet is malformed."""
|
||||
|
||||
|
||||
class _PreviewWorker:
|
||||
def __init__(self, proc: asyncio.subprocess.Process):
|
||||
self.proc = proc
|
||||
|
||||
async def request(self, filepath, quality: int, maxsize: int, maxzoom: float):
|
||||
if self.proc.returncode is not None:
|
||||
raise WorkerProtocolError("worker already exited")
|
||||
if self.proc.stdin is None or self.proc.stdout is None:
|
||||
raise WorkerProtocolError("worker streams not available")
|
||||
|
||||
line = (
|
||||
msgspec.json.encode(
|
||||
PreviewRequest(
|
||||
path=str(filepath),
|
||||
quality=quality,
|
||||
maxsize=maxsize,
|
||||
maxzoom=maxzoom,
|
||||
)
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
self.proc.stdin.write(line)
|
||||
await self.proc.stdin.drain()
|
||||
|
||||
checksum = await self.proc.stdout.readexactly(WORKER_CHECKSUM_BYTES)
|
||||
header = await self.proc.stdout.readexactly(8)
|
||||
json_size, data_size = struct.unpack("<II", header)
|
||||
if json_size > WORKER_MAX_JSON_BYTES:
|
||||
raise WorkerProtocolError(f"worker JSON too large: {json_size}")
|
||||
meta_raw = await self.proc.stdout.readexactly(json_size)
|
||||
payload = await self.proc.stdout.readexactly(data_size)
|
||||
packet = header + meta_raw + payload
|
||||
if blake3(packet).digest() != checksum:
|
||||
raise WorkerChecksumError("worker checksum mismatch")
|
||||
|
||||
resp = msgspec.json.decode(meta_raw, type=PreviewResponse)
|
||||
if not resp.ok:
|
||||
raise PreviewError(resp.error or "preview worker error")
|
||||
return payload or None, resp
|
||||
|
||||
async def kill(self) -> None:
|
||||
if self.proc.returncode is None:
|
||||
try:
|
||||
self.proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
await self.proc.wait()
|
||||
_active_procs.discard(self.proc)
|
||||
|
||||
|
||||
class _PreviewWorkerPool:
|
||||
def __init__(self, size: int):
|
||||
self.size = size
|
||||
self._idle: asyncio.Queue[_PreviewWorker] = asyncio.Queue()
|
||||
self._workers: set[_PreviewWorker] = set()
|
||||
self._closed = False
|
||||
|
||||
async def _spawn_worker(self) -> _PreviewWorker:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"cista.preview_worker",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
_active_procs.add(proc)
|
||||
return _PreviewWorker(proc)
|
||||
|
||||
async def _add_worker(self) -> None:
|
||||
worker = await self._spawn_worker()
|
||||
self._workers.add(worker)
|
||||
await self._idle.put(worker)
|
||||
|
||||
async def _replace_worker(self, worker: _PreviewWorker) -> None:
|
||||
self._workers.discard(worker)
|
||||
await worker.kill()
|
||||
if self._closed:
|
||||
return
|
||||
try:
|
||||
await self._add_worker()
|
||||
except Exception:
|
||||
logger.exception("Failed to replace preview worker")
|
||||
|
||||
async def start(self) -> None:
|
||||
for _ in range(self.size):
|
||||
await self._add_worker()
|
||||
|
||||
async def run(self, filepath, quality: int, maxsize: int, maxzoom: float):
|
||||
if self._closed:
|
||||
raise PreviewError("preview worker pool closed")
|
||||
worker = await self._idle.get()
|
||||
replace = False
|
||||
try:
|
||||
out, resp = await asyncio.wait_for(
|
||||
worker.request(filepath, quality, maxsize, maxzoom),
|
||||
timeout=PREVIEW_TIMEOUT,
|
||||
)
|
||||
return out, resp
|
||||
except asyncio.TimeoutError:
|
||||
replace = True
|
||||
logger.warning(
|
||||
"Preview timeout (%ds) for %s", int(PREVIEW_TIMEOUT), filepath.name
|
||||
)
|
||||
raise PreviewTimeout(filepath.name)
|
||||
except WorkerChecksumError:
|
||||
replace = True
|
||||
logger.error("Preview checksum mismatch for %s", filepath.name)
|
||||
raise PreviewError(filepath.name)
|
||||
except PreviewError:
|
||||
raise
|
||||
except (
|
||||
WorkerProtocolError,
|
||||
asyncio.IncompleteReadError,
|
||||
BrokenPipeError,
|
||||
ConnectionResetError,
|
||||
OSError,
|
||||
ValueError,
|
||||
msgspec.json.DecodeError,
|
||||
) as e:
|
||||
replace = True
|
||||
logger.warning(
|
||||
"Preview worker protocol failure for %s: %s", filepath.name, e
|
||||
)
|
||||
raise PreviewError(filepath.name)
|
||||
finally:
|
||||
if replace:
|
||||
await self._replace_worker(worker)
|
||||
else:
|
||||
if worker.proc.returncode is None:
|
||||
await self._idle.put(worker)
|
||||
else:
|
||||
await self._replace_worker(worker)
|
||||
|
||||
async def close(self) -> None:
|
||||
self._closed = True
|
||||
workers = list(self._workers)
|
||||
self._workers.clear()
|
||||
while not self._idle.empty():
|
||||
try:
|
||||
self._idle.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
await asyncio.gather(
|
||||
*(worker.kill() for worker in workers), return_exceptions=True
|
||||
)
|
||||
|
||||
|
||||
async def start_preview_workers() -> None:
|
||||
"""Warm up persistent preview workers during server startup."""
|
||||
global _preview_pool
|
||||
if _preview_pool is not None:
|
||||
return
|
||||
async with _preview_pool_lock:
|
||||
if _preview_pool is not None:
|
||||
return
|
||||
pool = _PreviewWorkerPool(PREVIEW_WORKERS)
|
||||
await pool.start()
|
||||
_preview_pool = pool
|
||||
logger.info("Started %d persistent preview workers", PREVIEW_WORKERS)
|
||||
|
||||
|
||||
async def shutdown_preview_workers() -> None:
|
||||
"""Kill persistent preview workers (called during server shutdown)."""
|
||||
global _preview_pool
|
||||
async with _preview_pool_lock:
|
||||
pool = _preview_pool
|
||||
_preview_pool = None
|
||||
if pool is not None:
|
||||
await pool.close()
|
||||
if not _active_procs:
|
||||
return
|
||||
for proc in list(_active_procs):
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
await asyncio.gather(
|
||||
*(proc.wait() for proc in list(_active_procs)), return_exceptions=True
|
||||
)
|
||||
_active_procs.clear()
|
||||
|
||||
|
||||
@bp.on_request
|
||||
async def verify_preview(request):
|
||||
"""Verify access to preview routes."""
|
||||
await auth.verify(request)
|
||||
|
||||
|
||||
class PreviewTimeout(Exception):
|
||||
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
|
||||
|
||||
|
||||
class PreviewError(Exception):
|
||||
"""Raised when the preview subprocess exits with a non-zero status."""
|
||||
|
||||
|
||||
async def _run_preview_process(
|
||||
filepath, quality: int, maxsize: int, maxzoom: float
|
||||
) -> tuple[bytes | None, PreviewResponse | None]:
|
||||
"""Run preview request in a persistent worker process."""
|
||||
await start_preview_workers()
|
||||
if _preview_pool is None:
|
||||
raise PreviewError(filepath.name)
|
||||
return await _preview_pool.run(filepath, quality, maxsize, maxzoom)
|
||||
|
||||
|
||||
# Map EXIF Orientation value to a corresponding PIL transpose
|
||||
EXIF_ORI = {
|
||||
2: Image.Transpose.FLIP_LEFT_RIGHT,
|
||||
3: Image.Transpose.ROTATE_180,
|
||||
4: Image.Transpose.FLIP_TOP_BOTTOM,
|
||||
5: Image.Transpose.TRANSPOSE,
|
||||
6: Image.Transpose.ROTATE_270,
|
||||
7: Image.Transpose.TRANSVERSE,
|
||||
8: Image.Transpose.ROTATE_90,
|
||||
}
|
||||
|
||||
|
||||
DOC_PREVIEW_SUFFIXES = {".pdf", ".xps", ".epub", ".mobi"}
|
||||
|
||||
|
||||
def is_previewable_path(path) -> bool:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in DOC_PREVIEW_SUFFIXES:
|
||||
return True
|
||||
mime_type, _ = mimetypes.guess_type(path.name)
|
||||
if not mime_type:
|
||||
return False
|
||||
return mime_type.startswith("image/") or mime_type.startswith("video/")
|
||||
|
||||
|
||||
@bp.get("/<path:path>")
|
||||
async def preview(req, path):
|
||||
"""Preview a file"""
|
||||
maxsize = int(req.args.get("px", 1024))
|
||||
maxzoom = float(req.args.get("zoom", 2.0))
|
||||
quality = int(req.args.get("q", 60))
|
||||
rel = PurePosixPath(sanitize(unquote(path)))
|
||||
filepath = config.config.path / rel
|
||||
try:
|
||||
stat = filepath.lstat()
|
||||
except FileNotFoundError:
|
||||
raise NotFound() from None
|
||||
|
||||
if not is_previewable_path(filepath):
|
||||
return empty(415)
|
||||
|
||||
etag = config.derived_secret(
|
||||
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
||||
).hex()
|
||||
|
||||
if req.headers.if_none_match == etag:
|
||||
# The client has it cached, respond 304 Not Modified
|
||||
return empty(304, headers={"etag": etag})
|
||||
|
||||
# Check in-memory cache first (includes headers)
|
||||
cached = _preview_cache.get(etag)
|
||||
if cached is not None:
|
||||
logger.debug(f"Preview cache hit: {rel}")
|
||||
return raw(cached.body, headers=cached.headers)
|
||||
|
||||
# Generate preview
|
||||
try:
|
||||
img, preview_resp = await _run_preview_process(
|
||||
filepath, quality, maxsize, maxzoom
|
||||
)
|
||||
except PreviewTimeout:
|
||||
return empty(504)
|
||||
except PreviewError:
|
||||
return empty(422)
|
||||
if preview_resp and preview_resp.backend:
|
||||
if preview_resp.timings:
|
||||
timing_detail = "/".join(
|
||||
str(int(round(value))) for value in preview_resp.timings
|
||||
)
|
||||
req.ctx._log_extra = f"{preview_resp.backend} {timing_detail} ➛"
|
||||
else:
|
||||
req.ctx._log_extra = preview_resp.backend
|
||||
if not img:
|
||||
# Preview generation failed, redirect to the file itself
|
||||
return redirect(f"/files/{path}", status=303)
|
||||
|
||||
# Build headers and cache the full response
|
||||
preview_mime = (
|
||||
preview_resp.mime
|
||||
if preview_resp is not None and preview_resp.mime is not None
|
||||
else "image/avif"
|
||||
)
|
||||
savename = PurePosixPath(filepath.name).with_suffix(".avif")
|
||||
headers = {
|
||||
"etag": etag,
|
||||
"last-modified": format_date_time(stat.st_mtime),
|
||||
"cache-control": "max-age=604800, immutable"
|
||||
+ ("" if config.config.public else ", private"),
|
||||
"content-type": preview_mime,
|
||||
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
|
||||
}
|
||||
_preview_cache.set(etag, CachedPreview(headers=headers, body=img))
|
||||
|
||||
return raw(img, headers=headers)
|
||||
|
||||
|
||||
def dispatch(path, quality, maxsize, maxzoom):
|
||||
try:
|
||||
if path.suffix.lower() in DOC_PREVIEW_SUFFIXES:
|
||||
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
||||
mime_type, _ = mimetypes.guess_type(path.name)
|
||||
if mime_type and mime_type.startswith("video/"):
|
||||
return process_video(path, quality=quality, maxsize=maxsize)
|
||||
if mime_type and mime_type.startswith("image/"):
|
||||
return process_image(path, quality=quality, maxsize=maxsize)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Cannot generate preview for {path}: {e}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error generating preview for {path}: {e}")
|
||||
return None, PreviewResponse(ok=False)
|
||||
|
||||
|
||||
def process_image(path, *, maxsize, quality):
|
||||
return process_image_with_timing(path, maxsize=maxsize, quality=quality)
|
||||
|
||||
|
||||
def process_image_with_timing(path, *, maxsize, quality):
|
||||
if FORCE_PIL:
|
||||
return process_image_pillow(path, maxsize=maxsize, quality=quality)
|
||||
return process_image_pyvips(path, maxsize=maxsize, quality=quality)
|
||||
|
||||
|
||||
def process_image_pyvips(path, *, maxsize, quality):
|
||||
t_start = perf_counter()
|
||||
img = pyvips.Image.new_from_file(str(path), access="sequential")
|
||||
img = img.autorot()
|
||||
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
||||
if scale < 1.0:
|
||||
img = img.resize(scale)
|
||||
ret = img.write_to_buffer(
|
||||
".avif",
|
||||
Q=quality,
|
||||
effort=AVIF_FAST_EFFORT,
|
||||
strip=True,
|
||||
)
|
||||
t_end = perf_counter()
|
||||
|
||||
return ret, PreviewResponse(
|
||||
ok=True,
|
||||
mime="image/avif",
|
||||
backend="pyvips",
|
||||
timings=[round((t_end - t_start) * 1000, 1)],
|
||||
)
|
||||
|
||||
|
||||
def process_image_pillow(path, *, maxsize, quality):
|
||||
t_load = perf_counter()
|
||||
with Image.open(path) as img:
|
||||
# Force decode to include I/O in load timing
|
||||
img.load()
|
||||
t_proc = perf_counter()
|
||||
# Resize
|
||||
w, h = img.size
|
||||
img.thumbnail((min(w, maxsize), min(h, maxsize)))
|
||||
# Transpose pixels according to EXIF Orientation
|
||||
orientation = img.getexif().get(274, 1)
|
||||
if orientation in EXIF_ORI:
|
||||
img = img.transpose(EXIF_ORI[orientation])
|
||||
# Save as AVIF
|
||||
imgdata = io.BytesIO()
|
||||
t_save = perf_counter()
|
||||
img.save(
|
||||
imgdata,
|
||||
format="avif",
|
||||
quality=quality,
|
||||
speed=10,
|
||||
max_threads=1,
|
||||
avif=1,
|
||||
)
|
||||
|
||||
t_end = perf_counter()
|
||||
ret = imgdata.getvalue()
|
||||
|
||||
load_ms = (t_proc - t_load) * 1000
|
||||
proc_ms = (t_save - t_proc) * 1000
|
||||
save_ms = (t_end - t_save) * 1000
|
||||
return ret, PreviewResponse(
|
||||
ok=True,
|
||||
mime="image/avif",
|
||||
backend="pillow",
|
||||
timings=[round(load_ms, 1), round(proc_ms, 1), round(save_ms, 1)],
|
||||
)
|
||||
|
||||
|
||||
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
||||
t_load_start = perf_counter()
|
||||
pdf = fitz.open(path)
|
||||
page = pdf.load_page(page_number)
|
||||
w, h = page.rect[2:4]
|
||||
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
||||
mat = fitz.Matrix(zoom, zoom)
|
||||
pix = page.get_pixmap(matrix=mat)
|
||||
t_load_end = perf_counter()
|
||||
|
||||
t_save_start = perf_counter()
|
||||
if FORCE_PIL:
|
||||
ret = pix.pil_tobytes(
|
||||
format="avif", quality=quality, speed=10, max_threads=1, avif=1
|
||||
)
|
||||
backend = "pdf"
|
||||
else:
|
||||
img = pyvips.Image.new_from_memory(
|
||||
pix.samples_mv, pix.width, pix.height, pix.n, "uchar"
|
||||
)
|
||||
ret = img.write_to_buffer(
|
||||
".avif", Q=quality, effort=AVIF_FAST_EFFORT, strip=True
|
||||
)
|
||||
backend = "pdf+pyvips"
|
||||
t_save_end = perf_counter()
|
||||
|
||||
return ret, PreviewResponse(
|
||||
ok=True,
|
||||
mime="image/avif",
|
||||
backend=backend,
|
||||
timings=[
|
||||
round((t_load_end - t_load_start) * 1000, 1),
|
||||
round((t_save_end - t_save_start) * 1000, 1),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def process_video(path, *, maxsize, quality):
|
||||
frame = None
|
||||
imgdata = io.BytesIO()
|
||||
istream = ostream = icc = occ = frame = None
|
||||
t_load_start = perf_counter()
|
||||
# Initialize to avoid "possibly unbound" in static analysis when exceptions occur
|
||||
t_load_end = t_load_start
|
||||
t_save_start = t_load_start
|
||||
t_save_end = t_load_start
|
||||
with (
|
||||
av.open(
|
||||
str(path),
|
||||
options={
|
||||
"analyzeduration": "1000000", # 1 second (in microseconds)
|
||||
"fflags": "fastseek",
|
||||
},
|
||||
) as icontainer,
|
||||
av.open(imgdata, "w", format="avif") as ocontainer,
|
||||
):
|
||||
istream = icontainer.streams.video[0]
|
||||
istream.codec_context.skip_frame = "NONKEY"
|
||||
icontainer.seek((icontainer.duration or 0) // 8)
|
||||
for frame in icontainer.decode(istream):
|
||||
if frame.dts is not None:
|
||||
break
|
||||
else:
|
||||
raise RuntimeError("No frames found in video")
|
||||
|
||||
# Resize frame to thumbnail size
|
||||
if frame.width > maxsize or frame.height > maxsize:
|
||||
scale_factor = min(maxsize / frame.width, maxsize / frame.height)
|
||||
new_width = int(frame.width * scale_factor)
|
||||
new_height = int(frame.height * scale_factor)
|
||||
frame = frame.reformat(width=new_width, height=new_height)
|
||||
|
||||
# Apply EXIF rotation if present
|
||||
if frame.rotation:
|
||||
# frame.rotation indicates clockwise rotation needed to display correctly
|
||||
# np.rot90 rotates counter-clockwise, so we negate k
|
||||
k = (frame.rotation // 90) % 4 # Convert to counter-clockwise rotations
|
||||
if k == 2:
|
||||
# 180° rotation can be done in YUV420p, preserving HDR
|
||||
try:
|
||||
fplanes = frame.to_ndarray()
|
||||
# Split into Y, U, V planes of proper dimensions
|
||||
planes = [
|
||||
fplanes[: frame.height],
|
||||
fplanes[
|
||||
frame.height : frame.height + frame.height // 4
|
||||
].reshape(frame.height // 2, frame.width // 2),
|
||||
fplanes[frame.height + frame.height // 4 :].reshape(
|
||||
frame.height // 2, frame.width // 2
|
||||
),
|
||||
]
|
||||
# Rotate each plane by 180°
|
||||
planes = [np.rot90(p, 2) for p in planes]
|
||||
# Restore PyAV format
|
||||
planes = np.hstack([p.flat for p in planes]).reshape(
|
||||
-1, planes[0].shape[1]
|
||||
)
|
||||
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}"
|
||||
)
|
||||
t_load_end = perf_counter()
|
||||
|
||||
t_save_start = perf_counter()
|
||||
crf = str(int(63 * (1 - quality / 100) ** 2)) # Closely matching PIL quality-%
|
||||
ostream = ocontainer.add_stream(
|
||||
"av1",
|
||||
options={
|
||||
"crf": crf,
|
||||
"usage": "realtime",
|
||||
"cpu-used": "8",
|
||||
"threads": "1",
|
||||
},
|
||||
)
|
||||
assert isinstance(ostream, av.VideoStream)
|
||||
ostream.width = frame.width
|
||||
ostream.height = frame.height
|
||||
ostream.pix_fmt = frame.format.name
|
||||
icc = istream.codec_context
|
||||
occ = ostream.codec_context
|
||||
|
||||
# Copy HDR metadata from input video stream
|
||||
occ.color_primaries = icc.color_primaries
|
||||
occ.color_trc = icc.color_trc
|
||||
occ.colorspace = icc.colorspace
|
||||
occ.color_range = icc.color_range
|
||||
|
||||
ocontainer.mux(ostream.encode(frame))
|
||||
ocontainer.mux(ostream.encode(None)) # Flush the stream
|
||||
t_save_end = perf_counter()
|
||||
|
||||
# Capture result before cleanup
|
||||
ret = imgdata.getvalue()
|
||||
resp = PreviewResponse(
|
||||
ok=True,
|
||||
mime="image/avif",
|
||||
backend="video",
|
||||
timings=[
|
||||
round((t_load_end - t_load_start) * 1000, 1),
|
||||
round((t_save_end - t_save_start) * 1000, 1),
|
||||
],
|
||||
)
|
||||
del imgdata, istream, ostream, icc, occ, frame
|
||||
gc.collect()
|
||||
return ret, resp
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Preview generation worker subprocess.
|
||||
|
||||
Two modes are supported:
|
||||
1) Legacy one-shot mode: argv has path/quality/maxsize/maxzoom.
|
||||
2) Long-lived mode: read JSONL commands from stdin and write framed responses.
|
||||
|
||||
Framed response format:
|
||||
(blake3(packet))(uint32 json size)(uint32 payload size)(json)(binary payload)
|
||||
where packet = (uint32 json size)(uint32 payload size)(json)(binary payload).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
from blake3 import blake3
|
||||
|
||||
|
||||
class PreviewRequest(msgspec.Struct, omit_defaults=True):
|
||||
path: str
|
||||
quality: int
|
||||
maxsize: int
|
||||
maxzoom: float
|
||||
|
||||
|
||||
class PreviewResponse(msgspec.Struct, omit_defaults=True):
|
||||
ok: bool
|
||||
mime: str | None = None
|
||||
backend: str | None = None
|
||||
timings: list[float] | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
_enc = msgspec.json.Encoder()
|
||||
_dec_req = msgspec.json.Decoder(PreviewRequest)
|
||||
|
||||
|
||||
def _write_response(resp: PreviewResponse, payload: bytes) -> None:
|
||||
meta_bytes = _enc.encode(resp)
|
||||
packet = struct.pack("<II", len(meta_bytes), len(payload)) + meta_bytes + payload
|
||||
checksum = blake3(packet).digest()
|
||||
sys.stdout.buffer.write(checksum)
|
||||
sys.stdout.buffer.write(packet)
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
|
||||
def _run_once() -> None:
|
||||
if len(sys.argv) != 5:
|
||||
sys.stderr.write(f"Usage: {sys.argv[0]} <path> <quality> <maxsize> <maxzoom>\n")
|
||||
sys.exit(1)
|
||||
|
||||
from cista.preview import dispatch
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
quality = int(sys.argv[2])
|
||||
maxsize = int(sys.argv[3])
|
||||
maxzoom = float(sys.argv[4])
|
||||
result, _ = dispatch(path, quality, maxsize, maxzoom)
|
||||
if result:
|
||||
sys.stdout.buffer.write(result)
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
|
||||
def _run_loop() -> None:
|
||||
from cista.preview import dispatch
|
||||
|
||||
while True:
|
||||
line = sys.stdin.buffer.readline()
|
||||
if not line:
|
||||
return
|
||||
try:
|
||||
req = _dec_req.decode(line)
|
||||
result, resp = dispatch(
|
||||
Path(req.path), req.quality, req.maxsize, req.maxzoom
|
||||
)
|
||||
_write_response(resp, result or b"")
|
||||
except Exception as e:
|
||||
_write_response(PreviewResponse(ok=False, error=str(e)), b"")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Configure all log output to stderr before any imports that may emit logs.
|
||||
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
|
||||
if len(sys.argv) > 1:
|
||||
_run_once()
|
||||
return
|
||||
_run_loop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+37
-17
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
import msgspec
|
||||
@@ -16,6 +17,10 @@ class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
|
||||
def __call__(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
"""Return list of paths affected by this operation for change notification."""
|
||||
return []
|
||||
|
||||
|
||||
class MkDir(ControlBase):
|
||||
path: str
|
||||
@@ -24,6 +29,9 @@ class MkDir(ControlBase):
|
||||
path = config.config.path / filename.sanitize(self.path)
|
||||
path.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
return [filename.sanitize(self.path)]
|
||||
|
||||
|
||||
class Rename(ControlBase):
|
||||
path: str
|
||||
@@ -36,6 +44,11 @@ class Rename(ControlBase):
|
||||
path = config.config.path / filename.sanitize(self.path)
|
||||
path.rename(path.with_name(to))
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
sanitized = filename.sanitize(self.path)
|
||||
new_path = str(PurePosixPath(sanitized).with_name(filename.sanitize(self.to)))
|
||||
return [sanitized, new_path]
|
||||
|
||||
|
||||
class Rm(ControlBase):
|
||||
sel: list[str]
|
||||
@@ -49,6 +62,9 @@ class Rm(ControlBase):
|
||||
else:
|
||||
p.unlink()
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
return [filename.sanitize(p) for p in self.sel]
|
||||
|
||||
|
||||
class Mv(ControlBase):
|
||||
sel: list[str]
|
||||
@@ -63,6 +79,13 @@ class Mv(ControlBase):
|
||||
for p in sel:
|
||||
shutil.move(p, dst)
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
dst = filename.sanitize(self.dst)
|
||||
paths = [filename.sanitize(p) for p in self.sel]
|
||||
# Include new locations in dst
|
||||
paths.extend(f"{dst}/{PurePosixPath(p).name}" for p in self.sel)
|
||||
return paths
|
||||
|
||||
|
||||
class Cp(ControlBase):
|
||||
sel: list[str]
|
||||
@@ -86,6 +109,11 @@ class Cp(ControlBase):
|
||||
else:
|
||||
shutil.copy2(p, dst)
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
dst = filename.sanitize(self.dst)
|
||||
# Only destinations are new (sources unchanged)
|
||||
return [f"{dst}/{PurePosixPath(filename.sanitize(p)).name}" for p in self.sel]
|
||||
|
||||
|
||||
ControlTypes = MkDir | Rename | Rm | Mv | Cp
|
||||
|
||||
@@ -112,20 +140,23 @@ class ErrorMsg(msgspec.Struct):
|
||||
## Directory listings
|
||||
|
||||
|
||||
class FileEntry(msgspec.Struct, array_like=True):
|
||||
class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
||||
level: int
|
||||
name: str
|
||||
key: str
|
||||
mtime: int
|
||||
size: int
|
||||
allocated: int
|
||||
isfile: int
|
||||
|
||||
def __repr__(self):
|
||||
def __str__(self):
|
||||
return self.key or "FileEntry()"
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.name} ({self.size}, {self.mtime})"
|
||||
|
||||
class Update(msgspec.Struct, array_like=True):
|
||||
...
|
||||
|
||||
class Update(msgspec.Struct, array_like=True): ...
|
||||
|
||||
|
||||
class UpdKeep(Update, tag="k"):
|
||||
@@ -147,17 +178,6 @@ class UpdateMessage(msgspec.Struct):
|
||||
class Space(msgspec.Struct):
|
||||
disk: int
|
||||
free: int
|
||||
usage: int
|
||||
used: int
|
||||
storage: int
|
||||
|
||||
|
||||
def make_dir_data(root):
|
||||
if len(root) == 3:
|
||||
return FileEntry(*root)
|
||||
id_, size, mtime, listing = root
|
||||
converted = {}
|
||||
for name, data in listing.items():
|
||||
converted[name] = make_dir_data(data)
|
||||
sz = sum(x.size for x in converted.values())
|
||||
mt = max(x.mtime for x in converted.values())
|
||||
return DirEntry(id_, sz, max(mt, mtime), converted)
|
||||
allocated: int
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Custom access logging middleware for Sanic."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import unicodedata
|
||||
from ipaddress import IPv6Address
|
||||
|
||||
logger = logging.getLogger("cista.access")
|
||||
|
||||
_RESET = "\033[0m"
|
||||
_STATUS_INFO = "\033[32m" # 1xx (green)
|
||||
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
|
||||
_STATUS_REDIRECT = "\033[32m" # 3xx (green)
|
||||
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
|
||||
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
|
||||
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
|
||||
_METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
|
||||
_HOST = "\033[38;5;242m" # hostname (dark grey)
|
||||
_PATH = "\033[38;5;250m" # path (light grey)
|
||||
_TIMING = "\033[38;5;242m" # timing (dark grey)
|
||||
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
|
||||
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
|
||||
_WS_STATUS = "\033[38;5;250m" # WebSocket close status (normal white)
|
||||
|
||||
|
||||
def format_ipv6_network(ip: str) -> str:
|
||||
"""Format IPv6 address to show only network part (first 64 bits)."""
|
||||
try:
|
||||
ip = ip.strip("[]")
|
||||
if "%" in ip:
|
||||
ip = ip.split("%")[0]
|
||||
addr = IPv6Address(ip)
|
||||
if addr.is_loopback:
|
||||
return "::1"
|
||||
if addr.is_unspecified:
|
||||
return "::"
|
||||
if addr.ipv4_mapped:
|
||||
return str(addr.ipv4_mapped)
|
||||
if addr.is_link_local:
|
||||
return str(addr)
|
||||
network_int = int(addr) >> 64
|
||||
groups = []
|
||||
for _ in range(4):
|
||||
groups.insert(0, format(network_int & 0xFFFF, "x"))
|
||||
network_int >>= 16
|
||||
result = ":".join(groups) + "::"
|
||||
return str(IPv6Address(result + "0")).removesuffix("::")
|
||||
except Exception:
|
||||
return ip
|
||||
|
||||
|
||||
def format_client_ip(ip: str) -> str:
|
||||
"""Format client IP, compressing IPv6 to network part only."""
|
||||
if not ip or ip == "-":
|
||||
return "-"
|
||||
stripped = ip.strip("[]")
|
||||
if ":" in stripped:
|
||||
return format_ipv6_network(stripped)
|
||||
return stripped
|
||||
|
||||
|
||||
def status_color(status: int) -> str:
|
||||
if status < 200:
|
||||
return _STATUS_INFO
|
||||
if status < 300:
|
||||
return _STATUS_OK
|
||||
if status < 400:
|
||||
return _STATUS_REDIRECT
|
||||
if status < 500:
|
||||
return _STATUS_CLIENT_ERR
|
||||
return _STATUS_SERVER_ERR
|
||||
|
||||
|
||||
def method_color(method: str) -> str:
|
||||
if method in ("GET", "HEAD", "OPTIONS"):
|
||||
return _METHOD_READ
|
||||
return _METHOD_WRITE
|
||||
|
||||
|
||||
def format_duration_ms(duration_ms: float) -> str:
|
||||
rounded_ms = round(duration_ms)
|
||||
if rounded_ms < 2000:
|
||||
return f"{rounded_ms}ms"
|
||||
total_s = round(duration_ms / 1000)
|
||||
if total_s < 60:
|
||||
return f"{total_s}s"
|
||||
if total_s <= 3600:
|
||||
minutes, seconds = divmod(total_s, 60)
|
||||
return f"{minutes}m{seconds}s"
|
||||
hours, remainder = divmod(total_s, 3600)
|
||||
minutes = round(remainder / 60)
|
||||
if minutes == 60:
|
||||
hours += 1
|
||||
minutes = 0
|
||||
return f"{hours}h{minutes}m"
|
||||
|
||||
|
||||
def _display_width(text: str) -> int:
|
||||
width = 0
|
||||
for char in text:
|
||||
width += 2 if unicodedata.east_asian_width(char) in {"F", "W"} else 1
|
||||
return width
|
||||
|
||||
|
||||
def _format_left(label: str) -> str:
|
||||
return label[:19].ljust(19)
|
||||
|
||||
|
||||
def _format_method_label(label: str, *, color: str | None = None) -> str:
|
||||
color_value = _METHOD_WRITE if color is None else color
|
||||
padding = max(0, 7 - _display_width(label))
|
||||
return f"{color_value}{label}{' ' * padding}{_RESET}"
|
||||
|
||||
|
||||
def format_access_log(
|
||||
client: str, status: int, method: str, host: str, path: str, duration_ms: float,
|
||||
extra: str | None = None,
|
||||
) -> str:
|
||||
ip = _format_left(format_client_ip(client))
|
||||
status_str = f"{status_color(status)}{str(status).rjust(3)}{_RESET}"
|
||||
method_str = _format_method_label(method, color=method_color(method))
|
||||
host_str = f"{_HOST}{host}{_RESET}"
|
||||
path_str = f"{_PATH}{path}{_RESET}"
|
||||
timing_str = f"{_TIMING}{format_duration_ms(duration_ms)}{_RESET}"
|
||||
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
||||
return f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
|
||||
|
||||
|
||||
_ws_counter = 1
|
||||
|
||||
|
||||
def _next_ws_id() -> int:
|
||||
global _ws_counter
|
||||
ws_id = _ws_counter
|
||||
_ws_counter += 1
|
||||
return ws_id
|
||||
|
||||
|
||||
def _format_ws_id(ws_id: int, *, bright: bool = False) -> str:
|
||||
value = str(ws_id) if ws_id >= 100 else f"{ws_id:02d}"
|
||||
color = _WS_OPEN if bright else _WS_CLOSE
|
||||
return f"{color}{value.rjust(3)}{_RESET}"
|
||||
|
||||
|
||||
def log_ws_open(request, extra: str | None = None) -> int:
|
||||
"""Log WebSocket connection open. Returns connection ID for use in log_ws_close."""
|
||||
ws_id = _next_ws_id()
|
||||
|
||||
client = request.client_ip or "-"
|
||||
host = request.host or "-"
|
||||
path = request.path
|
||||
origin = request.headers.get("origin")
|
||||
|
||||
ip = _format_left(format_client_ip(client))
|
||||
id_str = _format_ws_id(ws_id, bright=True)
|
||||
|
||||
origin_host = origin.split("://", 1)[-1] if origin else None
|
||||
show_origin = origin_host and origin_host != host
|
||||
|
||||
method_str = _format_method_label("🔌", color=_WS_OPEN)
|
||||
host_str = f"{_HOST}{host}{_RESET}"
|
||||
path_str = f"{_PATH}{path}{_RESET}"
|
||||
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
|
||||
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
||||
|
||||
logger.info(
|
||||
"%s %s %s %s%s%s",
|
||||
ip,
|
||||
id_str,
|
||||
method_str,
|
||||
host_str,
|
||||
path_str,
|
||||
origin_str + extra_str,
|
||||
)
|
||||
return ws_id
|
||||
|
||||
|
||||
WS_CLOSE_CODES = {
|
||||
1000: "ok",
|
||||
1001: "going away",
|
||||
1002: "protocol error",
|
||||
1003: "unsupported",
|
||||
1005: "no status",
|
||||
1006: "abnormal",
|
||||
1007: "invalid data",
|
||||
1008: "policy violation",
|
||||
1009: "too large",
|
||||
1010: "extension required",
|
||||
1011: "server error",
|
||||
1012: "restarting",
|
||||
1013: "try again",
|
||||
1014: "bad gateway",
|
||||
1015: "tls error",
|
||||
}
|
||||
|
||||
|
||||
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
||||
"""Log WebSocket connection close with duration and status."""
|
||||
id_str = _format_ws_id(ws_id)
|
||||
timing = format_duration_ms(duration * 1000)
|
||||
|
||||
if close_code is None:
|
||||
code = "----"
|
||||
status = "unknown"
|
||||
else:
|
||||
code = str(close_code)
|
||||
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
|
||||
|
||||
method_str = _format_method_label("closed", color=_TIMING)
|
||||
status_str = f"{_WS_STATUS}{code} {status}{_RESET}"
|
||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||
|
||||
logger.info("%s %s %s %s %s", " " * 19, id_str, method_str, status_str, timing_str)
|
||||
|
||||
|
||||
def configure_access_logging() -> None:
|
||||
"""Configure the cista.access logger to output to stderr."""
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
|
||||
|
||||
_LEVEL_EMOJI = {
|
||||
logging.DEBUG: "🔍",
|
||||
logging.INFO: "ℹ️",
|
||||
logging.WARNING: "⚠️",
|
||||
logging.ERROR: "🛑",
|
||||
logging.CRITICAL: "🛑",
|
||||
}
|
||||
|
||||
|
||||
class _EmojiFormatter(logging.Formatter):
|
||||
"""Compact formatter: emoji + message, no timestamp/level text/logger name."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
emoji = _LEVEL_EMOJI.get(record.levelno, "▪️")
|
||||
return f"{emoji} {record.getMessage()}"
|
||||
|
||||
|
||||
def configure_main_logging() -> None:
|
||||
"""Replace Sanic's verbose 'Main yyyy-mm-dd INFO:' prefix with emoji-only format.
|
||||
|
||||
Patches LOGGING_CONFIG_DEFAULTS so the formatter survives every dictConfig
|
||||
call Sanic makes during serve_single() / serve().
|
||||
"""
|
||||
from sanic.log import LOGGING_CONFIG_DEFAULTS
|
||||
|
||||
LOGGING_CONFIG_DEFAULTS["formatters"]["generic"] = {
|
||||
"class": "cista.sanic_logging._EmojiFormatter",
|
||||
}
|
||||
# Also reformat any handlers already attached (covers the initial Sanic() call)
|
||||
for name in ("sanic.root", "sanic.error", "sanic.server", "sanic.websockets"):
|
||||
for handler in logging.getLogger(name).handlers:
|
||||
handler.setFormatter(_EmojiFormatter())
|
||||
+18
-13
@@ -2,6 +2,7 @@ import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
from sanic import Sanic
|
||||
|
||||
from cista import config, server80
|
||||
@@ -26,8 +27,7 @@ def run(*, dev=False):
|
||||
motd=False,
|
||||
dev=dev,
|
||||
auto_reload=dev,
|
||||
reload_dir={confdir},
|
||||
access_log=True,
|
||||
access_log=False,
|
||||
) # type: ignore
|
||||
if dev:
|
||||
Sanic.serve()
|
||||
@@ -45,18 +45,23 @@ def check_cert(certdir, domain):
|
||||
|
||||
|
||||
def parse_listen(listen):
|
||||
if listen.startswith("/"):
|
||||
unix = Path(listen).resolve()
|
||||
# Domain name (e.g. example.com) -> HTTPS with LetsEncrypt
|
||||
if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
|
||||
return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
|
||||
|
||||
# Use fastapi_vue's parse_endpoint for everything else
|
||||
endpoints = parse_endpoint(listen, default_port=8989)
|
||||
ep = endpoints[0]
|
||||
|
||||
if "uds" in ep:
|
||||
unix = Path(ep["uds"]).resolve()
|
||||
if not unix.parent.exists():
|
||||
raise ValueError(
|
||||
f"Directory for unix socket does not exist: {unix.parent}/",
|
||||
)
|
||||
return "http://localhost", {"unix": unix}
|
||||
if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
|
||||
return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
|
||||
try:
|
||||
addr, _port = listen.split(":", 1)
|
||||
port = int(_port)
|
||||
except Exception:
|
||||
raise ValueError(f"Invalid listen address: {listen}") from None
|
||||
return f"http://localhost:{port}", {"host": addr, "port": port}
|
||||
return "http://localhost", {"unix": unix.as_posix()}
|
||||
|
||||
host, port = ep["host"], ep["port"]
|
||||
if len(endpoints) > 1:
|
||||
return f"http://localhost:{port}", {"host": host, "port": port}
|
||||
return f"http://{host}:{port}", {"host": host, "port": port}
|
||||
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
"""SSO (paskia) authentication proxy and validation module.
|
||||
|
||||
When paskia mode is enabled (PASKIA_BACKEND_URL is set):
|
||||
- Backend validates requests against PASKIA_BACKEND_URL/auth/api/validate?perm=cista:login
|
||||
- All /auth/* requests are proxied to the paskia backend
|
||||
|
||||
Environment variables:
|
||||
PASKIA_BACKEND_URL - URL of the paskia auth server (e.g., http://localhost:4401)
|
||||
Must include scheme (http/https), no trailing slash
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from sanic import Blueprint, json
|
||||
from sanic import raw as raw_response
|
||||
from sanic.exceptions import Forbidden, SanicException, Unauthorized
|
||||
from sanic.log import logger
|
||||
|
||||
# Auth backend URL for SSO validation (from env, no trailing slash)
|
||||
_raw_url = os.environ.get("PASKIA_BACKEND_URL", "").rstrip("/")
|
||||
|
||||
# Validate and set PASKIA_BACKEND_URL
|
||||
if _raw_url:
|
||||
if not re.match(r"^https?://[^\s/]+$", _raw_url):
|
||||
raise ValueError(
|
||||
f"Invalid PASKIA_BACKEND_URL: {_raw_url!r} - "
|
||||
"must be http(s)://host[:port] with no path or trailing slash"
|
||||
)
|
||||
PASKIA_BACKEND_URL = _raw_url
|
||||
else:
|
||||
PASKIA_BACKEND_URL = ""
|
||||
|
||||
|
||||
def paskia_enabled() -> bool:
|
||||
"""Check if paskia SSO mode is enabled (PASKIA_BACKEND_URL is set)."""
|
||||
return bool(PASKIA_BACKEND_URL)
|
||||
|
||||
|
||||
# Shared httpx client for SSO requests (reused for connection pooling)
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
async def get_client() -> httpx.AsyncClient:
|
||||
"""Get or create the shared httpx client."""
|
||||
global _client
|
||||
if _client is None or _client.is_closed:
|
||||
_client = httpx.AsyncClient(timeout=1.0)
|
||||
if "user-agent" in _client.headers:
|
||||
del _client.headers["user-agent"] # No httpx UA
|
||||
return _client
|
||||
|
||||
|
||||
async def close_client():
|
||||
"""Close the shared httpx client."""
|
||||
global _client
|
||||
if _client is not None and not _client.is_closed:
|
||||
await _client.aclose()
|
||||
_client = None
|
||||
|
||||
|
||||
async def validate_sso_request(request, *, perm: str = "cista:login") -> dict | None:
|
||||
"""Validate an SSO request against the auth backend.
|
||||
|
||||
Args:
|
||||
request: The Sanic request object
|
||||
perm: Permission to validate (default: cista:login, privileged also cista:admin)
|
||||
|
||||
Returns:
|
||||
User info dict if valid, None if validation fails with auth required response
|
||||
|
||||
Raises:
|
||||
Forbidden: If access is denied (403)
|
||||
Unauthorized: If authentication is required (401)
|
||||
"""
|
||||
if not paskia_enabled():
|
||||
return None
|
||||
|
||||
client = await get_client()
|
||||
|
||||
headers = {}
|
||||
if "host" in request.headers:
|
||||
headers["host"] = request.headers["host"]
|
||||
if "cookie" in request.headers:
|
||||
headers["cookie"] = request.headers["cookie"]
|
||||
if "authorization" in request.headers:
|
||||
headers["authorization"] = request.headers["authorization"]
|
||||
headers["accept"] = "application/json"
|
||||
headers["x-forwarded-for"] = request.client_ip
|
||||
headers["x-forwarded-host"] = request.host
|
||||
headers["x-forwarded-proto"] = request.scheme
|
||||
|
||||
url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}"
|
||||
|
||||
try:
|
||||
response = await client.post(
|
||||
url,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
data = response.json()
|
||||
request.ctx.sso_user = data
|
||||
if "set-cookie" in response.headers:
|
||||
request.ctx.sso_cookies = response.headers.get_list("set-cookie")
|
||||
return data
|
||||
except Exception:
|
||||
request.ctx.sso_user = {}
|
||||
return {}
|
||||
|
||||
try:
|
||||
error_data = response.json()
|
||||
except Exception:
|
||||
error_data = {"detail": response.text or "Authentication error"}
|
||||
|
||||
if response.status_code == 401:
|
||||
if "auth" in error_data and "iframe" in error_data["auth"]:
|
||||
error_data["auth"]["iframe"] += "&theme=light"
|
||||
raise Unauthorized(
|
||||
error_data.get("detail", "Authentication required"),
|
||||
"cookie",
|
||||
context=error_data,
|
||||
quiet=True,
|
||||
)
|
||||
elif response.status_code == 403:
|
||||
raise Forbidden(
|
||||
error_data.get("detail", "Access denied"),
|
||||
context=error_data,
|
||||
quiet=True,
|
||||
)
|
||||
else:
|
||||
detail = error_data.get("detail", "")
|
||||
logger.warning(
|
||||
f"SSO validation {url} returned {response.status_code}: {detail}"
|
||||
)
|
||||
raise Forbidden(
|
||||
detail or "Authentication error",
|
||||
context=error_data,
|
||||
quiet=True,
|
||||
)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"SSO validation {url} network error: {e}")
|
||||
raise SanicException(
|
||||
"Authentication service unavailable",
|
||||
status_code=502,
|
||||
quiet=True,
|
||||
)
|
||||
|
||||
|
||||
async def proxy_auth_request(request):
|
||||
"""Proxy a request to the auth backend.
|
||||
|
||||
All requests under /auth/ are proxied when paskia mode is enabled.
|
||||
"""
|
||||
client = await get_client()
|
||||
|
||||
path = request.path
|
||||
query_string = request.query_string
|
||||
url = f"{PASKIA_BACKEND_URL}{path}"
|
||||
if query_string:
|
||||
url = f"{url}?{query_string}"
|
||||
|
||||
skip_headers = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"transfer-encoding",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
"proxy-authorization",
|
||||
"proxy-authenticate",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-proto",
|
||||
"forwarded",
|
||||
}
|
||||
|
||||
headers = [
|
||||
(key, value)
|
||||
for key, value in request.headers.items()
|
||||
if key.lower() not in skip_headers
|
||||
]
|
||||
|
||||
# Set Forwarded headers (strip IPv6 brackets for x-forwarded-for)
|
||||
headers.append(("x-forwarded-for", request.client_ip.strip("[]")))
|
||||
headers.append(("x-forwarded-host", request.host))
|
||||
headers.append(("x-forwarded-proto", request.scheme))
|
||||
headers.append(
|
||||
(
|
||||
"forwarded",
|
||||
f"by=cista;for={request.client_ip};host={request.host};proto={request.scheme}",
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
async with client.stream(
|
||||
method=request.method,
|
||||
url=url,
|
||||
headers=headers,
|
||||
content=request.body if request.body else None,
|
||||
) as response:
|
||||
raw_content = b"".join([chunk async for chunk in response.aiter_raw()])
|
||||
|
||||
resp_hop_by_hop = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"transfer-encoding",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
}
|
||||
|
||||
resp_headers = [
|
||||
(key, value)
|
||||
for key, value in response.headers.multi_items()
|
||||
if key.lower() not in resp_hop_by_hop
|
||||
]
|
||||
|
||||
return raw_response(
|
||||
raw_content,
|
||||
status=response.status_code,
|
||||
headers=resp_headers,
|
||||
content_type=response.headers.get("content-type", "application/json"),
|
||||
)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Auth proxy request failed: {e}")
|
||||
|
||||
return json(
|
||||
{"detail": "Authentication service unavailable"},
|
||||
status=503,
|
||||
)
|
||||
|
||||
|
||||
async def proxy_auth_websocket(request, ws):
|
||||
"""Proxy a WebSocket connection to the auth backend."""
|
||||
url = f"ws{PASKIA_BACKEND_URL.removeprefix('http')}{request.path}"
|
||||
if request.query_string:
|
||||
url = f"{url}?{request.query_string}"
|
||||
|
||||
additional_headers = {}
|
||||
if "cookie" in request.headers:
|
||||
additional_headers["cookie"] = request.headers["cookie"]
|
||||
if "authorization" in request.headers:
|
||||
additional_headers["authorization"] = request.headers["authorization"]
|
||||
if "host" in request.headers:
|
||||
additional_headers["host"] = request.headers["host"]
|
||||
if "origin" in request.headers:
|
||||
additional_headers["origin"] = request.headers["origin"]
|
||||
if "user-agent" in request.headers:
|
||||
additional_headers["user-agent"] = request.headers["user-agent"]
|
||||
additional_headers["x-forwarded-for"] = request.client_ip.strip("[]")
|
||||
additional_headers["x-forwarded-host"] = request.host
|
||||
additional_headers["x-forwarded-proto"] = request.scheme
|
||||
|
||||
try:
|
||||
async with websockets.connect(
|
||||
url, additional_headers=additional_headers
|
||||
) as backend_ws:
|
||||
|
||||
async def forward_to_backend():
|
||||
try:
|
||||
async for message in ws:
|
||||
await backend_ws.send(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def forward_to_client():
|
||||
try:
|
||||
async for message in backend_ws:
|
||||
await ws.send(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.gather(
|
||||
forward_to_backend(),
|
||||
forward_to_client(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket proxy to {url} failed: {e}")
|
||||
|
||||
|
||||
# Blueprint for auth proxy routes (only registered when paskia_enabled())
|
||||
bp = Blueprint("sso", url_prefix="/auth")
|
||||
|
||||
|
||||
@bp.websocket("/ws/<path:path>")
|
||||
async def auth_websocket_proxy(request, ws, path=""):
|
||||
"""Proxy WebSocket connections to the auth backend."""
|
||||
await proxy_auth_websocket(request, ws)
|
||||
|
||||
|
||||
@bp.websocket("/ws/")
|
||||
async def auth_websocket_proxy_root(request, ws):
|
||||
"""Proxy root WebSocket connections to the auth backend."""
|
||||
await proxy_auth_websocket(request, ws)
|
||||
|
||||
|
||||
@bp.route(
|
||||
"/<path:path>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
||||
)
|
||||
async def auth_proxy(request, path=""):
|
||||
"""Proxy all auth requests to the auth backend."""
|
||||
return await proxy_auth_request(request)
|
||||
|
||||
|
||||
@bp.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||
async def auth_proxy_root(request):
|
||||
"""Proxy root auth requests to the auth backend."""
|
||||
return await proxy_auth_request(request)
|
||||
@@ -1,3 +1,4 @@
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
import msgspec
|
||||
@@ -8,6 +9,7 @@ from sanic.response import raw, redirect
|
||||
|
||||
from cista import auth
|
||||
from cista.protocol import ErrorMsg
|
||||
from cista.sanic_logging import log_ws_close, log_ws_open
|
||||
|
||||
|
||||
def asend(ws, msg):
|
||||
@@ -21,7 +23,6 @@ def jres(data, **kwargs):
|
||||
|
||||
|
||||
async def handle_sanic_exception(request, e):
|
||||
logger.exception(e)
|
||||
context, code = {}, 500
|
||||
message = str(e)
|
||||
if isinstance(e, SanicException):
|
||||
@@ -30,10 +31,15 @@ async def handle_sanic_exception(request, e):
|
||||
if not message or not request.app.debug and code == 500:
|
||||
message = "Internal Server Error"
|
||||
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
||||
if code == 500:
|
||||
logger.exception(e)
|
||||
# Non-browsers get JSON errors
|
||||
if "text/html" not in request.headers.accept:
|
||||
# Include auth context if present (for SSO auth required responses)
|
||||
# Auth must be at top level for paskia library to detect it
|
||||
response_data = {"code": code, "message": message, "detail": message, **context}
|
||||
return jres(
|
||||
ErrorMsg({"code": code, "message": message, **context}),
|
||||
response_data,
|
||||
status=code,
|
||||
)
|
||||
# Redirections flash the error message via cookies
|
||||
@@ -42,7 +48,7 @@ async def handle_sanic_exception(request, e):
|
||||
res.cookies.add_cookie("message", message, max_age=5)
|
||||
return res
|
||||
# Otherwise use Sanic's default error page
|
||||
return errorpages.HTMLRenderer(request, e, debug=request.app.debug).full()
|
||||
return errorpages.HTMLRenderer(request, e, debug=request.app.debug).render()
|
||||
|
||||
|
||||
def websocket_wrapper(handler):
|
||||
@@ -50,17 +56,36 @@ def websocket_wrapper(handler):
|
||||
|
||||
@wraps(handler)
|
||||
async def wrapper(request, ws, *args, **kwargs):
|
||||
username = getattr(request.ctx, "username", None)
|
||||
extra = username if username else None
|
||||
start = time.perf_counter()
|
||||
ws_id = log_ws_open(request, extra=extra)
|
||||
try:
|
||||
auth.verify(request)
|
||||
await auth.verify(request)
|
||||
await handler(request, ws, *args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
context, code, message = {}, 500, str(e) or "Internal Server Error"
|
||||
if isinstance(e, SanicException):
|
||||
context = e.context or {}
|
||||
code = e.status_code
|
||||
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
||||
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
|
||||
if not getattr(e, "quiet", False) or code == 500:
|
||||
logger.exception(f"{code} {e!r}")
|
||||
raise
|
||||
finally:
|
||||
duration = time.perf_counter() - start
|
||||
close_code = None
|
||||
try:
|
||||
p = ws.ws_proto
|
||||
if p.close_rcvd is not None:
|
||||
close_code = p.close_rcvd.code
|
||||
elif p.close_sent is not None:
|
||||
close_code = p.close_sent.code
|
||||
elif getattr(p, "close_code", None) is not None:
|
||||
close_code = p.close_code
|
||||
except AttributeError:
|
||||
pass
|
||||
log_ws_close(ws_id, close_code, duration)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from time import monotonic
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class LRUCache:
|
||||
@@ -12,7 +13,7 @@ class LRUCache:
|
||||
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.
|
||||
|
||||
@@ -50,7 +51,6 @@ class LRUCache:
|
||||
# Add/restore to end of cache
|
||||
self.cache.insert(0, (key, f, monotonic()))
|
||||
self.expire_items()
|
||||
print(self.cache)
|
||||
return f
|
||||
|
||||
def expire_items(self):
|
||||
|
||||
+699
-223
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,2 @@
|
||||
audit=false
|
||||
fund=false
|
||||
+46
-40
@@ -1,40 +1,46 @@
|
||||
# cista-front
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
||||
|
||||
1. Disable the built-in TypeScript Extension
|
||||
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
||||
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
||||
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
# Cista Vue Frontend
|
||||
|
||||
The frontend is a Single-Page App implemented with Vue 3. Development uses the Vite server together with the main Python backend, but in production the latter also serves the prebuilt frontend files.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
||||
|
||||
1. Disable the built-in TypeScript Extension
|
||||
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
||||
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
||||
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
||||
|
||||
## Hot-Reload for Development
|
||||
|
||||
### Run the backend
|
||||
|
||||
```fish
|
||||
uv sync --dev
|
||||
uv run cista --dev -l :8000
|
||||
```
|
||||
|
||||
### And the Vite server (in another terminal)
|
||||
|
||||
```fish
|
||||
cd frontend
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
Browse to Vite, which will proxy API requests to port 8000. Both servers live reload changes.
|
||||
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
This is also called by `uv build` during Python packaging:
|
||||
|
||||
```fish
|
||||
bun run build
|
||||
```
|
||||
|
||||
|
||||
Vendored
+6
@@ -1 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
+2
-3
@@ -2,11 +2,10 @@
|
||||
<html lang=en>
|
||||
<meta charset=UTF-8>
|
||||
<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="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@400;700&display=swap" rel="stylesheet">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
<div id="app"></div>
|
||||
<body id="app">
|
||||
|
||||
+34
-31
@@ -12,41 +12,44 @@
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@imengyu/vue3-context-menu": "^1.3.3",
|
||||
"@vueuse/core": "^10.4.1",
|
||||
"esbuild": "^0.19.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"pinia": "^2.1.6",
|
||||
"pinia-plugin-persistedstate": "^3.2.0",
|
||||
"unplugin-vue-components": "^0.25.2",
|
||||
"vite-plugin-rewrite-all": "^1.0.1",
|
||||
"vite-svg-loader": "^4.0.0",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.4"
|
||||
"@imengyu/vue3-context-menu": "^1.5.3",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"esbuild": "^0.27.2",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash-es": "^4.17.23",
|
||||
"paskia": "^0.1.2",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite-svg-loader": "^5.1.0",
|
||||
"vue": "^3.5.27",
|
||||
"vue-router": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.3.3",
|
||||
"@tsconfig/node18": "^18.2.2",
|
||||
"@types/jsdom": "^21.1.3",
|
||||
"@types/lodash-es": "^4.17.10",
|
||||
"@types/node": "^18.17.17",
|
||||
"@vitejs/plugin-vue": "^4.3.4",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/test-utils": "^2.4.1",
|
||||
"@vue/tsconfig": "^0.4.0",
|
||||
"@rushstack/eslint-patch": "^1.15.0",
|
||||
"@tsconfig/node18": "^18.2.6",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^25.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^8.52.0",
|
||||
"eslint-plugin-vue": "^9.18.1",
|
||||
"jsdom": "^22.1.0",
|
||||
"npm-run-all2": "^6.0.6",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "~5.2.0",
|
||||
"vite": "^4.4.9",
|
||||
"vitest": "^0.34.4",
|
||||
"vue-tsc": "^1.8.11"
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-vue": "^10.7.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18",
|
||||
"vue-tsc": "^3.2.4"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
|
||||
+202
-72
@@ -1,14 +1,25 @@
|
||||
<template>
|
||||
<LoginModal />
|
||||
<div v-if="store.toast" class="toast-message" @click="store.clearToast()">
|
||||
{{ store.toast }}
|
||||
</div>
|
||||
<div v-else-if="store.error && !store.authInProgress" class="toast-message status" @click="store.error = ''">
|
||||
{{ store.error }}
|
||||
</div>
|
||||
<SettingsModal />
|
||||
<UserManagementModal />
|
||||
<AccessDeniedModal />
|
||||
<header>
|
||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
||||
<HeaderSelected :path="path.pathList" />
|
||||
</HeaderMain>
|
||||
<BreadCrumb :path="path.pathList" tabindex="-1"/>
|
||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
|
||||
<BreadCrumb :path="path.pathList" primary />
|
||||
</header>
|
||||
<main>
|
||||
<RouterView :path="path.pathList" :query="path.query" />
|
||||
</main>
|
||||
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
|
||||
<SelectionToolbar :path="path.pathList" />
|
||||
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
|
||||
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -17,29 +28,34 @@ import type { ComputedRef } from 'vue'
|
||||
import type HeaderMain from '@/components/HeaderMain.vue'
|
||||
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import Router from '@/router/index'
|
||||
import type { SortOrder } from './utils/docsort'
|
||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||
import UserManagementModal from './components/UserManagementModal.vue'
|
||||
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||
import SelectionToolbar from './components/SelectionToolbar.vue'
|
||||
|
||||
interface Path {
|
||||
path: string
|
||||
pathList: string[]
|
||||
query: string
|
||||
}
|
||||
const documentStore = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
const path: ComputedRef<Path> = computed(() => {
|
||||
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
|
||||
const pathList = p[0].split('/').filter(value => value !== '')
|
||||
const pathList = (p[0] ?? '').split('/').filter(value => value !== '')
|
||||
const query = p.slice(1).join('//')
|
||||
return {
|
||||
path: p[0],
|
||||
path: p[0] ?? '',
|
||||
pathList,
|
||||
query
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || documentStore.server.name || 'Cista Storage'
|
||||
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || store.server.name || 'Cista Storage'
|
||||
})
|
||||
onMounted(loadSession)
|
||||
onMounted(watchConnect)
|
||||
@@ -47,80 +63,194 @@ onUnmounted(watchDisconnect)
|
||||
const headerMain = ref<typeof HeaderMain | null>(null)
|
||||
let vert = 0
|
||||
let timer: any = null
|
||||
|
||||
const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
const fileExplorer = documentStore.fileExplorer as any
|
||||
if (!fileExplorer) return
|
||||
const c = fileExplorer.isCursor()
|
||||
const keyup = event.type === 'keyup'
|
||||
if (event.repeat) {
|
||||
if (
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
(c && event.code === 'Space')
|
||||
) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
//console.log("key pressed", event)
|
||||
// For up/down implement custom fast repeat
|
||||
if (event.key === 'ArrowUp') vert = keyup ? 0 : event.altKey ? -10 : -1
|
||||
else if (event.key === 'ArrowDown') vert = keyup ? 0 : event.altKey ? 10 : 1
|
||||
// Find: process on keydown so that we can bypass the built-in search hotkey
|
||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
||||
headerMain.value!.toggleSearchInput()
|
||||
}
|
||||
// Select all (toggle); keydown to prevent builtin
|
||||
else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
|
||||
fileExplorer.toggleSelectAll()
|
||||
}
|
||||
// Keys 1-3 to sort columns
|
||||
else if (
|
||||
c &&
|
||||
keyup &&
|
||||
(event.key === '1' || event.key === '2' || event.key === '3')
|
||||
) {
|
||||
fileExplorer.toggleSortColumn(+event.key)
|
||||
}
|
||||
// Rename
|
||||
else if (c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
|
||||
fileExplorer.cursorRename()
|
||||
}
|
||||
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
|
||||
else if (c && event.code === 'Space') {
|
||||
if (keyup && !event.altKey && !event.ctrlKey)
|
||||
fileExplorer.cursorSelect()
|
||||
} else return
|
||||
event.preventDefault()
|
||||
if (!vert) {
|
||||
if (store.dialog) {
|
||||
if (timer) {
|
||||
clearTimeout(timer) // Good for either timeout or interval
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!timer) {
|
||||
const fileExplorer = store.fileExplorer as any
|
||||
if (!fileExplorer) return
|
||||
const c = fileExplorer.isCursor()
|
||||
const input = (event.target as HTMLElement).tagName === 'INPUT'
|
||||
const keyup = event.type === 'keyup'
|
||||
|
||||
// Always clear repeat timer on arrow keyup, even if focus moved to input
|
||||
if (keyup && event.key.startsWith('Arrow') && timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
if (event.repeat) {
|
||||
if (
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
(c && event.code === 'Space')
|
||||
) {
|
||||
if (!input) event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
//console.log("key pressed", event)
|
||||
/// Long if-else machina for all keys we handle here
|
||||
let arrow = ''
|
||||
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
|
||||
const searchInput = inHeader && input
|
||||
const searchHasText = searchInput && (event.target as HTMLInputElement).value
|
||||
if (event.key.startsWith("Arrow")) {
|
||||
const dir = event.key.slice(5).toLowerCase()
|
||||
// In search with text: left/right move cursor, up/down navigate
|
||||
if (searchHasText && (dir === 'left' || dir === 'right')) {
|
||||
return // Let browser handle cursor movement
|
||||
}
|
||||
arrow = dir
|
||||
}
|
||||
if (arrow) {
|
||||
// Arrow 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)) {
|
||||
headerMain.value!.toggleSearchInput()
|
||||
}
|
||||
// Search also on / (UNIX style) - use code to support any keyboard layout
|
||||
else if (!input && keyup && event.code === 'Slash') {
|
||||
// Record the actual character for display (varies by keyboard layout)
|
||||
if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) {
|
||||
store.prefs.searchHotkey = event.key
|
||||
}
|
||||
headerMain.value!.toggleSearchInput()
|
||||
}
|
||||
// Globally close search, clear errors on Escape
|
||||
else if (keyup && event.key === 'Escape') {
|
||||
store.error = ''
|
||||
store.clearToast()
|
||||
headerMain.value!.clearSearch(event)
|
||||
store.focusBreadcrumb()
|
||||
}
|
||||
else if (!input && keyup && event.key === 'Backspace') {
|
||||
Router.back()
|
||||
}
|
||||
// Select all (toggle); keydown to precede and prevent builtin
|
||||
else if (!input && !keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
|
||||
fileExplorer.toggleSelectAll()
|
||||
}
|
||||
// G toggles Gallery
|
||||
else if (!input && keyup && event.key === 'g') {
|
||||
store.prefs.gallery = !store.prefs.gallery
|
||||
}
|
||||
// Keys Backquote-1-2-3 to sort columns
|
||||
else if (
|
||||
!input &&
|
||||
keyup &&
|
||||
(event.code === 'Backquote' || event.key === '1' || event.key === '2' || event.key === '3')
|
||||
) {
|
||||
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
|
||||
}
|
||||
// Rename
|
||||
else if (!input && c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
|
||||
fileExplorer.cursorRename()
|
||||
}
|
||||
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
|
||||
else if (!input && c && event.code === 'Space') {
|
||||
if (keyup && !event.altKey && !event.ctrlKey)
|
||||
fileExplorer.cursorSelect()
|
||||
}
|
||||
else return
|
||||
/// We are handling this!
|
||||
event.preventDefault()
|
||||
if (timer) {
|
||||
clearTimeout(timer) // Good for either timeout or interval
|
||||
timer = null
|
||||
}
|
||||
let f: any
|
||||
// Arrow navigation - always use fileExplorer for repeatable movement
|
||||
if (arrow && !keyup) {
|
||||
const focusSearch = () => (document.querySelector('.headermain input[type="search"]') as HTMLElement)?.focus()
|
||||
const focusBreadcrumb = () => (document.querySelector('.breadcrumb') as HTMLElement)?.focus()
|
||||
|
||||
if (inBreadcrumb) {
|
||||
// Breadcrumb: up→header (no repeat), down→files (with repeat)
|
||||
if (arrow === 'up') { focusSearch(); f = null }
|
||||
else if (arrow === 'down') { fileExplorer.focusFirst?.(); f = null }
|
||||
} else if (inHeader) {
|
||||
// Header: left/right navigate focusable items (buttons without tabindex=-1, search input, disk space)
|
||||
const items = Array.from(document.querySelectorAll('.headermain button:not([tabindex=\"-1\"]), .headermain input[type=\"search\"], .headermain [tabindex=\"0\"]')) as HTMLElement[]
|
||||
const idx = items.indexOf(document.activeElement as HTMLElement)
|
||||
if (arrow === 'left' && idx > 0) { items[idx - 1]?.focus(); f = null }
|
||||
else if (arrow === 'right' && idx < items.length - 1) { items[idx + 1]?.focus(); f = null }
|
||||
else if (arrow === 'up') f = () => fileExplorer.up({ shiftKey: false })
|
||||
else if (arrow === 'down') { focusBreadcrumb(); f = null }
|
||||
} else {
|
||||
// File explorer: normal navigation with repeat
|
||||
switch (arrow) {
|
||||
case 'up': f = () => fileExplorer.up(event); break
|
||||
case 'down': f = () => fileExplorer.down(event); break
|
||||
case 'left': f = () => fileExplorer.left(event); break
|
||||
case 'right': f = () => fileExplorer.right(event); break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (f) {
|
||||
// Initial move, then t0 delay until repeats at tr intervals
|
||||
const select = event.shiftKey
|
||||
fileExplorer.cursorMove(vert, select)
|
||||
const t0 = 200,
|
||||
tr = 30
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
(timer = setInterval(() => {
|
||||
fileExplorer.cursorMove(vert, select)
|
||||
}, tr)),
|
||||
t0 - tr
|
||||
)
|
||||
const t0 = 200, tr = event.altKey ? 20 : 100
|
||||
f()
|
||||
timer = setTimeout(() => { timer = setInterval(f, tr) }, t0 - tr)
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', globalShortcutHandler)
|
||||
window.addEventListener('keyup', globalShortcutHandler)
|
||||
// Use capture phase to handle events before they reach target elements
|
||||
window.addEventListener('keydown', globalShortcutHandler, true)
|
||||
window.addEventListener('keyup', globalShortcutHandler, true)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', globalShortcutHandler)
|
||||
window.removeEventListener('keyup', globalShortcutHandler)
|
||||
window.removeEventListener('keydown', globalShortcutHandler, true)
|
||||
window.removeEventListener('keyup', globalShortcutHandler, true)
|
||||
})
|
||||
export type { Path }
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Toast notifications - fixed at top center of viewport */
|
||||
.toast-message {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2000;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent-color);
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
max-width: 90vw;
|
||||
text-align: center;
|
||||
}
|
||||
.toast-message.status {
|
||||
background: #555;
|
||||
color: #fff;
|
||||
}
|
||||
footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 50;
|
||||
}
|
||||
footer > * {
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
--transition-time: 0.2s;
|
||||
/* The following are overridden by responsive layouts */
|
||||
--root-font-size: 1rem;
|
||||
--header-font-size: 1rem;
|
||||
--header-height: calc(6.5 * var(--header-font-size));
|
||||
--header-font-size: clamp(0.7rem, 2.5vw, 1rem);
|
||||
--header-height: clamp(2.8rem, 10vw, 4rem);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
@@ -36,12 +36,7 @@
|
||||
@media screen and (min-width: 1000px) {
|
||||
:root {
|
||||
--root-font-size: calc(8px + 8 * 100vw / 1000);
|
||||
}
|
||||
header .buttons:has(input[type='search']) > div {
|
||||
display: none;
|
||||
}
|
||||
header .buttons > div:has(input[type='search']) {
|
||||
display: inherit;
|
||||
--header-font-size: 16px;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 2000px) {
|
||||
@@ -49,47 +44,14 @@
|
||||
--root-font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
/* Low (landscape) screens: smaller header */
|
||||
@media screen and (max-height: 600px) {
|
||||
:root {
|
||||
--header-font-size: calc(10px + 10 * 100vh / 600); /* 20px at 600px height */
|
||||
--root-font-size: 0.8rem;
|
||||
}
|
||||
header .breadcrumb > * {
|
||||
padding-top: calc(8 + 8 * 100vh / 600) !important;
|
||||
padding-bottom: calc(8 + 8 * 100vh / 600) !important;
|
||||
}
|
||||
}
|
||||
@media screen and (max-height: 300px) {
|
||||
:root {
|
||||
--header-font-size: 15px; /* Don't go smaller than this, no benefit */
|
||||
--header-height: calc(1.75 * 16px);
|
||||
--root-font-size: 0.6rem;
|
||||
}
|
||||
header .breadcrumb > * {
|
||||
padding-top: 14px !important;
|
||||
padding-bottom: 14px !important;
|
||||
}
|
||||
}
|
||||
@media screen and (orientation: landscape) and (min-width: 700px) {
|
||||
/* Breadcrumbs and buttons side by side */
|
||||
:root {
|
||||
--header-font-size: calc(8px + 8 * 100vh / 600); /* 16px (1rem nominal) at 600px height */
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
}
|
||||
header .breadcrumb {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
header .breadcrumb > * {
|
||||
flex-shrink: 1;
|
||||
padding-top: 1rem !important;
|
||||
padding-bottom: 1rem !important;
|
||||
}
|
||||
header .headermain { order: 1; }
|
||||
header .breadcrumb { align-self: stretch; }
|
||||
}
|
||||
@media print {
|
||||
:root {
|
||||
@@ -98,10 +60,10 @@
|
||||
--header-background: none;
|
||||
--header-color: black;
|
||||
}
|
||||
nav,
|
||||
.headermain,
|
||||
.menu,
|
||||
.rename-button {
|
||||
display: none;
|
||||
display: none !important;
|
||||
}
|
||||
.breadcrumb > a {
|
||||
color: black !important;
|
||||
@@ -116,16 +78,32 @@
|
||||
}
|
||||
.breadcrumb svg {
|
||||
fill: black !important;
|
||||
margin: 0 .5rem 0 1rem !important;
|
||||
}
|
||||
body#app {
|
||||
position: static !important;
|
||||
height: auto !important;
|
||||
}
|
||||
main {
|
||||
height: auto !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
thead tr {
|
||||
font-size: 1rem !important;
|
||||
position: static !important;
|
||||
background: none !important;
|
||||
border-bottom: 1pt solid black !important;
|
||||
}
|
||||
audio::-webkit-media-controls-timeline,
|
||||
video::-webkit-media-controls-timeline {
|
||||
display: none;
|
||||
}
|
||||
audio::-webkit-media-controls,
|
||||
video::-webkit-media-controls {
|
||||
display: none;
|
||||
}
|
||||
tr, figure {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.selection {
|
||||
min-width: 0 !important;
|
||||
padding: 0 !important;
|
||||
@@ -142,14 +120,13 @@
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
font-size: var(--root-font-size);
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Hide scrollbar for all browsers */
|
||||
main::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
main {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
@@ -160,12 +137,18 @@ body {
|
||||
font-family: 'Roboto';
|
||||
color: var(--primary-color);
|
||||
margin: 0;
|
||||
/* Prevent any scrolling on body */
|
||||
overflow: hidden;
|
||||
/* Fallback for older browsers */
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
tbody .size,
|
||||
tbody .modified {
|
||||
font-family: 'Roboto Mono';
|
||||
}
|
||||
header {
|
||||
flex: 0 0 auto;
|
||||
background-color: var(--header-background);
|
||||
color: var(--header-color);
|
||||
font-size: var(--header-font-size);
|
||||
@@ -207,62 +190,35 @@ table {
|
||||
border: 0;
|
||||
gap: 0;
|
||||
}
|
||||
#app {
|
||||
height: 100%;
|
||||
body#app {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
main {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0; /* Allow flex child to shrink below content size */
|
||||
padding-bottom: 3em; /* convenience space on the bottom */
|
||||
overflow-y: scroll;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
header nav.headermain {
|
||||
/* Position so that tooltips can appear on top of other positioned elements */
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
main {
|
||||
height: calc(100svh - var(--header-height));
|
||||
padding-bottom: 3em; /* convenience space on the bottom */
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.spacer { flex-grow: 1 }
|
||||
.smallgap { flex-shrink: 1; width: 2em }
|
||||
|
||||
[data-tooltip]:hover:after {
|
||||
z-index: 101;
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
padding: .5rem 1rem;
|
||||
border-radius: 3rem 0 3rem 0;
|
||||
box-shadow: 0 0 1rem var(--accent-color);
|
||||
transform: translate(calc(1rem + -50%), 150%);
|
||||
background-color: var(--accent-color);
|
||||
color: var(--primary-color);
|
||||
white-space: pre;
|
||||
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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
opacity: 0;
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
}
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: .5em;
|
||||
font-weight: bold;
|
||||
background: var(--accent-color);
|
||||
color: #000;
|
||||
}
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// SVG icon index - all icons bundled together
|
||||
import AddFile from './add-file.svg'
|
||||
import AddFolder from './add-folder.svg'
|
||||
import Arrow from './arrow.svg'
|
||||
import ArrowsH from './arrows-h.svg'
|
||||
import ArrowsV from './arrows-v.svg'
|
||||
import Check from './check.svg'
|
||||
import Code from './code.svg'
|
||||
import Cog from './cog.svg'
|
||||
import Copy from './copy.svg'
|
||||
import CreateFile from './create-file.svg'
|
||||
import CreateFolder from './create-folder.svg'
|
||||
import Cross from './cross.svg'
|
||||
import Disk from './disk.svg'
|
||||
import Download from './download.svg'
|
||||
import Exclamation from './exclamation.svg'
|
||||
import Eye from './eye.svg'
|
||||
import Find from './find.svg'
|
||||
import Fullscreen from './fullscreen.svg'
|
||||
import Github from './github.svg'
|
||||
import Home from './home.svg'
|
||||
import Info from './info.svg'
|
||||
import Link from './link.svg'
|
||||
import Logo from './logo.svg'
|
||||
import Loop from './loop.svg'
|
||||
import Menu from './menu.svg'
|
||||
import Next from './next.svg'
|
||||
import Open from './open.svg'
|
||||
import Paste from './paste.svg'
|
||||
import Pause from './pause.svg'
|
||||
import Pencil from './pencil.svg'
|
||||
import Play from './play.svg'
|
||||
import Plus from './plus.svg'
|
||||
import Previous from './previous.svg'
|
||||
import Reload from './reload.svg'
|
||||
import Rename from './rename.svg'
|
||||
import Scissors from './scissors.svg'
|
||||
import Shuffle from './shuffle.svg'
|
||||
import Signin from './signin.svg'
|
||||
import Signout from './signout.svg'
|
||||
import Skip from './skip.svg'
|
||||
import Spinner from './spinner.svg'
|
||||
import Stop from './stop.svg'
|
||||
import Trash from './trash.svg'
|
||||
import Triangle from './triangle.svg'
|
||||
import Unfullscreen from './unfullscreen.svg'
|
||||
import UpArrow from './up-arrow.svg'
|
||||
import UploadCloud from './upload-cloud.svg'
|
||||
import UserCog from './user-cog.svg'
|
||||
import User from './user.svg'
|
||||
import VolumeHigh from './volume-high.svg'
|
||||
import VolumeLow from './volume-low.svg'
|
||||
import VolumeMedium from './volume-medium.svg'
|
||||
import VolumeMute from './volume-mute.svg'
|
||||
import WindowCross from './window-cross.svg'
|
||||
import Window from './window.svg'
|
||||
import Wordwrap from './wordwrap.svg'
|
||||
import Zoomin from './zoomin.svg'
|
||||
import Zoomout from './zoomout.svg'
|
||||
|
||||
// Named exports for direct imports
|
||||
export {
|
||||
AddFile, AddFolder, Arrow, ArrowsH, ArrowsV,
|
||||
Check, Code, Cog, Copy, CreateFile, CreateFolder, Cross,
|
||||
Disk, Download, Exclamation, Eye, Find, Fullscreen,
|
||||
Github, Home, Info, Link, Logo, Loop, Menu,
|
||||
Next, Open, Paste, Pause, Pencil, Play, Plus, Previous,
|
||||
Reload, Rename, Scissors, Shuffle, Signin, Signout, Skip,
|
||||
Spinner, Stop, Trash, Triangle, Unfullscreen, UpArrow,
|
||||
UploadCloud, UserCog, User, VolumeHigh, VolumeLow,
|
||||
VolumeMedium, VolumeMute, WindowCross, Window, Wordwrap,
|
||||
Zoomin, Zoomout
|
||||
}
|
||||
|
||||
// Icon lookup by kebab-case name (for SvgButton compatibility)
|
||||
export const icons = {
|
||||
'add-file': AddFile,
|
||||
'add-folder': AddFolder,
|
||||
'arrow': Arrow,
|
||||
'arrows-h': ArrowsH,
|
||||
'arrows-v': ArrowsV,
|
||||
'check': Check,
|
||||
'code': Code,
|
||||
'cog': Cog,
|
||||
'copy': Copy,
|
||||
'create-file': CreateFile,
|
||||
'create-folder': CreateFolder,
|
||||
'cross': Cross,
|
||||
'disk': Disk,
|
||||
'download': Download,
|
||||
'exclamation': Exclamation,
|
||||
'eye': Eye,
|
||||
'find': Find,
|
||||
'fullscreen': Fullscreen,
|
||||
'github': Github,
|
||||
'home': Home,
|
||||
'info': Info,
|
||||
'link': Link,
|
||||
'logo': Logo,
|
||||
'loop': Loop,
|
||||
'menu': Menu,
|
||||
'next': Next,
|
||||
'open': Open,
|
||||
'paste': Paste,
|
||||
'pause': Pause,
|
||||
'pencil': Pencil,
|
||||
'play': Play,
|
||||
'plus': Plus,
|
||||
'previous': Previous,
|
||||
'reload': Reload,
|
||||
'rename': Rename,
|
||||
'scissors': Scissors,
|
||||
'shuffle': Shuffle,
|
||||
'signin': Signin,
|
||||
'signout': Signout,
|
||||
'skip': Skip,
|
||||
'spinner': Spinner,
|
||||
'stop': Stop,
|
||||
'trash': Trash,
|
||||
'triangle': Triangle,
|
||||
'unfullscreen': Unfullscreen,
|
||||
'up-arrow': UpArrow,
|
||||
'upload-cloud': UploadCloud,
|
||||
'user-cog': UserCog,
|
||||
'user': User,
|
||||
'volume-high': VolumeHigh,
|
||||
'volume-low': VolumeLow,
|
||||
'volume-medium': VolumeMedium,
|
||||
'volume-mute': VolumeMute,
|
||||
'window-cross': WindowCross,
|
||||
'window': Window,
|
||||
'wordwrap': Wordwrap,
|
||||
'zoomin': Zoomin,
|
||||
'zoomout': Zoomout,
|
||||
} as const
|
||||
|
||||
export type IconName = keyof typeof icons
|
||||
@@ -0,0 +1,46 @@
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { holdGlobalBackdrop } from 'paskia'
|
||||
import { watchEffect } from 'vue'
|
||||
|
||||
const store = useMainStore()
|
||||
|
||||
const reload = () => {
|
||||
location.reload()
|
||||
}
|
||||
|
||||
// Keep backdrop active when this dialog shows
|
||||
watchEffect(() => {
|
||||
if (store.dialog === 'accessdenied') {
|
||||
holdGlobalBackdrop()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.access-denied {
|
||||
text-align: center;
|
||||
padding: 2rem !important;
|
||||
}
|
||||
.access-denied .icon {
|
||||
font-size: 4rem;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
.access-denied .message {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -2,16 +2,24 @@
|
||||
<nav
|
||||
class="breadcrumb"
|
||||
aria-label="Breadcrumb"
|
||||
@keyup.left.stop="move(-1)"
|
||||
@keyup.right.stop="move(1)"
|
||||
@focus="move(0)"
|
||||
@keydown.left.stop="move(-1)"
|
||||
@keydown.right.stop="move(1)"
|
||||
@keyup.enter="move(0)"
|
||||
@focus=focusCurrent
|
||||
tabindex=0
|
||||
>
|
||||
<a href="#/"
|
||||
:ref="el => setLinkRef(0, el)"
|
||||
class="home"
|
||||
:class="{ current: !!isCurrent(0) }"
|
||||
:aria-current="isCurrent(0)"
|
||||
@click.prevent="navigate(0)"
|
||||
@mouseenter="homeTooltip?.startHover"
|
||||
@mousemove="homeTooltip?.updatePosition"
|
||||
@mouseleave="homeTooltip?.endHover"
|
||||
>
|
||||
<component :is="home" />
|
||||
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
|
||||
</a>
|
||||
<template v-for="(location, index) in longest" :key="index">
|
||||
<a :href="`/#/${longest.slice(0, index + 1).join('/')}/`"
|
||||
@@ -19,38 +27,64 @@
|
||||
:aria-current="isCurrent(index + 1)"
|
||||
@click.prevent="navigate(index + 1)"
|
||||
:ref="el => setLinkRef(index + 1, el)"
|
||||
>{{ location }}</a>
|
||||
@mouseenter="pathTooltips.get(index)?.startHover"
|
||||
@mousemove="pathTooltips.get(index)?.updatePosition"
|
||||
@mouseleave="pathTooltips.get(index)?.endHover"
|
||||
>{{ location }}<CursorTooltip :ref="el => setPathTooltipRef(index, el)" :text="`/${longest.slice(0, index + 1).join('/')}`">{{ `/${longest.slice(0, index + 1).join('/')}` }}</CursorTooltip></a>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import home from '@/assets/svg/home.svg'
|
||||
import { onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||
import { Home } from '@/assets/svg'
|
||||
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { exists } from '@/utils/fileutil'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
const home = Home
|
||||
const router = useRouter()
|
||||
|
||||
const links = [] as Array<HTMLElement>
|
||||
const setLinkRef = (index: number, el: any) => { if (el) links[index] = el }
|
||||
onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home
|
||||
|
||||
const homeTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
const pathTooltips = ref<Map<number, InstanceType<typeof CursorTooltip>>>(new Map())
|
||||
const setPathTooltipRef = (index: number, el: any) => {
|
||||
if (el) pathTooltips.value.set(index, el)
|
||||
else pathTooltips.value.delete(index)
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
primary?: boolean
|
||||
}>()
|
||||
|
||||
const longest = ref<Array<string>>([])
|
||||
|
||||
const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined
|
||||
|
||||
const focusCurrent = () => {
|
||||
nextTick(() => {
|
||||
const index = props.path.length
|
||||
if (index < links.length) links[index]!.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const navigate = (index: number) => {
|
||||
const link = links[index]
|
||||
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
|
||||
const url = `/${longest.value.slice(0, index).join('/')}/`
|
||||
const here = `/${longest.value.join('/')}/`
|
||||
link.focus()
|
||||
if (here.startsWith(location.hash.slice(1))) router.replace(url)
|
||||
else router.push(url)
|
||||
const url = index ? `/${longest.value.slice(0, index).join('/')}/` : '/'
|
||||
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
|
||||
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
|
||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
// Clicking on current link clears the rest of the path and adds new history
|
||||
if (isCurrent(index)) { longest.value.splice(index); router.push(u) }
|
||||
// Moving along breadcrumbs doesn't create new history
|
||||
else if (long.startsWith(browser)) router.replace(u)
|
||||
// Nornal navigation from elsewhere (e.g. search result breadcrumbs)
|
||||
else router.push(u)
|
||||
}
|
||||
|
||||
const move = (dir: number) => {
|
||||
@@ -62,13 +96,25 @@ const move = (dir: number) => {
|
||||
watchEffect(() => {
|
||||
const longcut = longest.value.slice(0, props.path.length)
|
||||
const same = longcut.every((value, index) => value === props.path[index])
|
||||
// Navigated out of previous path, reset longest to current
|
||||
if (!same) longest.value = props.path
|
||||
else if (props.path.length > longcut.length) {
|
||||
longest.value = longcut.concat(props.path.slice(longcut.length))
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (links.length) navigate(props.path.length)
|
||||
else {
|
||||
// Prune deleted folders from longest
|
||||
for (let i = props.path.length; i < longest.value.length; ++i) {
|
||||
if (!exists(longest.value.slice(0, i + 1))) {
|
||||
longest.value = longest.value.slice(0, i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// If needed, focus primary navigation to new location
|
||||
if (props.primary) nextTick(() => {
|
||||
const act = document.activeElement as HTMLElement
|
||||
if (!act || [...links, document.body].includes(act)) focusCurrent()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -83,31 +129,36 @@ watchEffect(() => {
|
||||
--breadcrumb-transtime: 0.3s;
|
||||
}
|
||||
.breadcrumb {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
list-style: none;
|
||||
min-width: 20%;
|
||||
max-width: 100%;
|
||||
min-height: 2em;
|
||||
margin: 0;
|
||||
padding: 0 1em 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.breadcrumb > a {
|
||||
flex: 0 4 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 -0.5em 0 -0.5em;
|
||||
padding: 0;
|
||||
max-width: 8em;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
height: 1.5em;
|
||||
color: var(--breadcrumb-color);
|
||||
padding: 0.3em 1.5em;
|
||||
clip-path: polygon(0 0, 1em 50%, 0 100%, 100% 100%, 100% 0, 0 0);
|
||||
transition: all var(--breadcrumb-transtime);
|
||||
}
|
||||
.breadcrumb a:first-child {
|
||||
margin-left: 0;
|
||||
padding-left: .2em;
|
||||
.breadcrumb > a:first-child {
|
||||
flex: 0 0 auto;
|
||||
padding-left: 1.5em;
|
||||
padding-right: 1.7em;
|
||||
clip-path: none;
|
||||
}
|
||||
.breadcrumb a:last-child {
|
||||
max-width: none;
|
||||
.breadcrumb > a:last-child {
|
||||
clip-path: polygon(
|
||||
0 0,
|
||||
calc(100% - 1em) 0,
|
||||
@@ -118,7 +169,7 @@ watchEffect(() => {
|
||||
0 0
|
||||
);
|
||||
}
|
||||
.breadcrumb a:only-child {
|
||||
.breadcrumb > a:only-child {
|
||||
clip-path: polygon(
|
||||
0 0,
|
||||
calc(100% - 1em) 0,
|
||||
@@ -130,9 +181,9 @@ watchEffect(() => {
|
||||
}
|
||||
.breadcrumb svg {
|
||||
/* FIXME: Custom positioning to align it well; needs proper solution */
|
||||
padding-left: 0.8em;
|
||||
width: 1.3em;
|
||||
height: 1.3em;
|
||||
margin: -.5em;
|
||||
fill: var(--breadcrumb-color);
|
||||
transition: fill var(--breadcrumb-transtime);
|
||||
}
|
||||
@@ -152,6 +203,6 @@ watchEffect(() => {
|
||||
}
|
||||
.breadcrumb a:hover { color: var(--breadcrumb-hover-color) }
|
||||
.breadcrumb a:hover svg { fill: var(--breadcrumb-hover-color) }
|
||||
.breadcrumb a.current { color: var(--accent-color) }
|
||||
.breadcrumb a.current { color: var(--accent-color); max-width: none; flex: 0 1 auto; }
|
||||
.breadcrumb a.current svg { fill: var(--accent-color) }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" ref="tooltipEl" 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
|
||||
// Track recent touch to suppress touch-triggered mouse events
|
||||
let lastTouchTime = 0
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
text: string
|
||||
delay?: number
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const mouseX = ref(0)
|
||||
const mouseY = ref(0)
|
||||
const tooltipWidth = ref(0)
|
||||
const tooltipHeight = ref(0)
|
||||
const tooltipEl = ref<HTMLElement | null>(null)
|
||||
let settleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let lastMoveX = 0
|
||||
let lastMoveY = 0
|
||||
|
||||
// Movement threshold (pixels) - cursor must settle within this radius
|
||||
const SETTLE_THRESHOLD = 8
|
||||
|
||||
const tooltipStyle = computed(() => {
|
||||
// Constrain to viewport
|
||||
const pad = 8
|
||||
let x = mouseX.value
|
||||
let y = mouseY.value
|
||||
|
||||
// Only constrain if we've measured the tooltip
|
||||
if (tooltipWidth.value > 0 && tooltipHeight.value > 0) {
|
||||
// Adjust horizontal position if tooltip would overflow right edge
|
||||
if (x + tooltipWidth.value + pad > window.innerWidth) {
|
||||
x = window.innerWidth - tooltipWidth.value - pad
|
||||
}
|
||||
// Adjust vertical position if tooltip would overflow bottom edge
|
||||
if (y + tooltipHeight.value + pad > window.innerHeight) {
|
||||
y = window.innerHeight - tooltipHeight.value - pad
|
||||
}
|
||||
// Don't go past left/top edges
|
||||
x = Math.max(pad, x)
|
||||
y = Math.max(pad, y)
|
||||
}
|
||||
|
||||
return {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
}
|
||||
})
|
||||
|
||||
// Track touch events globally to suppress touch-simulated mouse events
|
||||
const onTouchStart = () => { lastTouchTime = Date.now() }
|
||||
onMounted(() => document.addEventListener('touchstart', onTouchStart, { passive: true }))
|
||||
onUnmounted(() => document.removeEventListener('touchstart', onTouchStart))
|
||||
|
||||
// Check if event is likely from touch (touch happened within last 500ms)
|
||||
const isTouchEvent = () => Date.now() - lastTouchTime < 500
|
||||
|
||||
const showTooltip = () => {
|
||||
visible.value = true
|
||||
globalActive = true
|
||||
// Measure tooltip after it renders
|
||||
requestAnimationFrame(() => {
|
||||
if (tooltipEl.value) {
|
||||
tooltipWidth.value = tooltipEl.value.offsetWidth
|
||||
tooltipHeight.value = tooltipEl.value.offsetHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const scheduleTooltip = () => {
|
||||
if (settleTimer) clearTimeout(settleTimer)
|
||||
if (globalDeactivateTimer) {
|
||||
clearTimeout(globalDeactivateTimer)
|
||||
globalDeactivateTimer = null
|
||||
}
|
||||
const delay = globalActive ? 0 : (props.delay ?? 900)
|
||||
settleTimer = setTimeout(showTooltip, delay)
|
||||
}
|
||||
|
||||
const startHover = (e: MouseEvent) => {
|
||||
// Ignore touch-simulated mouse events
|
||||
if (isTouchEvent()) return
|
||||
|
||||
mouseX.value = e.clientX
|
||||
mouseY.value = e.clientY
|
||||
lastMoveX = e.clientX
|
||||
lastMoveY = e.clientY
|
||||
}
|
||||
|
||||
const updatePosition = (e: MouseEvent) => {
|
||||
// Ignore touch-simulated mouse events
|
||||
if (isTouchEvent()) return
|
||||
|
||||
mouseX.value = e.clientX
|
||||
mouseY.value = e.clientY
|
||||
|
||||
// If tooltip is already visible, just update position
|
||||
if (visible.value) return
|
||||
|
||||
const dx = e.clientX - lastMoveX
|
||||
const dy = e.clientY - lastMoveY
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
// If cursor moved beyond threshold, reset settle timer
|
||||
if (distance > SETTLE_THRESHOLD) {
|
||||
lastMoveX = e.clientX
|
||||
lastMoveY = e.clientY
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer)
|
||||
settleTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule tooltip when cursor settles
|
||||
if (!settleTimer) {
|
||||
scheduleTooltip()
|
||||
}
|
||||
}
|
||||
|
||||
const endHover = () => {
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer)
|
||||
settleTimer = null
|
||||
}
|
||||
visible.value = false
|
||||
// Deactivate global state after a short delay if no new tooltip started
|
||||
if (globalDeactivateTimer) clearTimeout(globalDeactivateTimer)
|
||||
globalDeactivateTimer = setTimeout(() => {
|
||||
globalActive = false
|
||||
}, 400)
|
||||
}
|
||||
|
||||
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 rgba(0, 0, 0, 0.5);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
color: #fff;
|
||||
pointer-events: none;
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,426 @@
|
||||
<template>
|
||||
<div class="disk-space-container" ref="containerRef" tabindex="0" @keydown.enter="handleClick" @keydown.space.prevent="handleClick">
|
||||
<div
|
||||
ref="widgetRef"
|
||||
class="disk-space-widget"
|
||||
:class="{ expanded: isExpanded }"
|
||||
>
|
||||
<svg viewBox="0 0 150 150" class="pie-svg" preserveAspectRatio="xMidYMid meet">
|
||||
<defs>
|
||||
<filter id="pieShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="rgba(0,0,0,0.4)" />
|
||||
</filter>
|
||||
<radialGradient id="storageGradient" cx="30%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#93e" />
|
||||
<stop offset="100%" stop-color="#82d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="otherGradient" cx="30%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#d9f" />
|
||||
<stop offset="100%" stop-color="#c8e" />
|
||||
</radialGradient>
|
||||
<radialGradient id="highlightOverlay" cx="35%" cy="35%" r="65%">
|
||||
<stop offset="0%" stop-color="rgba(255,255,255,0.15)" />
|
||||
<stop offset="60%" stop-color="rgba(255,255,255,0)" />
|
||||
<stop offset="100%" stop-color="rgba(0,0,0,0.08)" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<g :filter="isExpanded ? 'url(#pieShadow)' : 'none'">
|
||||
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#otherGradient)" :stroke-width="ringWidth" />
|
||||
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="freeColor" :stroke-width="ringWidth" :stroke-dasharray="pieFreeDash" :stroke-dashoffset="pieFreeOffsetVal" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
|
||||
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#storageGradient)" :stroke-width="ringWidth" :stroke-dasharray="pieStorageDash" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
|
||||
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#highlightOverlay)" :stroke-width="ringWidth" />
|
||||
<circle :r="holeRadius" :cx="pieCx" :cy="pieCy" fill="rgba(0,0,0,0.5)" />
|
||||
<text ref="centerLabelRef" :x="pieCx" :y="pieCy" dy="0.35em" class="pie-center-label" text-anchor="middle">GB</text>
|
||||
<circle :r="pieRadius" :cx="pieCx" :cy="pieCy" fill="transparent" class="pie-hitarea" @click="handleClick" />
|
||||
</g>
|
||||
|
||||
<g ref="labelsRef" class="pie-labels">
|
||||
<text :x="storageInnerPos.x" :y="storageInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.storage.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.storage.angle)} ${storageInnerPos.x} ${storageInnerPos.y})`">{{ fmtSize(store.space.allocated, sectorInfo.storage.angle) }}</text>
|
||||
<text :x="freeInnerPos.x" :y="freeInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.free.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.free.angle)} ${freeInnerPos.x} ${freeInnerPos.y})`">{{ fmtSize(store.space.free, sectorInfo.free.angle) }}</text>
|
||||
<text :x="otherInnerPos.x" :y="otherInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.other.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.other.angle)} ${otherInnerPos.x} ${otherInnerPos.y})`">{{ fmtSize(store.space.used - store.space.allocated, sectorInfo.other.angle) }}</text>
|
||||
|
||||
<defs>
|
||||
<path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" />
|
||||
<path :id="freeLabelPath.id" :d="freeLabelPath.d" fill="none" />
|
||||
<path :id="otherLabelPath.id" :d="otherLabelPath.d" fill="none" />
|
||||
</defs>
|
||||
|
||||
<text class="pie-label-sub" fill="#93e">
|
||||
<textPath :href="'#' + storageLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">{{ storageName }}</textPath>
|
||||
</text>
|
||||
<text class="pie-label-sub" :fill="freeColor">
|
||||
<textPath :href="'#' + freeLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">free</textPath>
|
||||
</text>
|
||||
<text class="pie-label-sub" fill="#d9f">
|
||||
<textPath :href="'#' + otherLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">other</textPath>
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
|
||||
const store = useMainStore()
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const widgetRef = ref<HTMLDivElement | null>(null)
|
||||
const labelsRef = ref<SVGGElement | null>(null)
|
||||
const centerLabelRef = ref<SVGTextElement | null>(null)
|
||||
|
||||
const isExpanded = ref(false)
|
||||
let animationFrame: number | null = null
|
||||
|
||||
const BASE_SIZE = 48
|
||||
const EXPANDED_SCALE = 320 / 48
|
||||
const ANIM_DURATION = 200
|
||||
const containerPos = ref({ top: 0, left: 0, width: 0 })
|
||||
|
||||
const formatGB = (bytes: number) => {
|
||||
const gb = bytes / (1024 * 1024 * 1024)
|
||||
return gb < 10 ? gb.toFixed(1) : `${Math.round(gb)}`
|
||||
}
|
||||
|
||||
// Add dot suffix for ambiguous angles (within 15° of horizontal) on numbers that look same upside down
|
||||
const fmtSize = (bytes: number, angle: number) => {
|
||||
const s = formatGB(bytes)
|
||||
const a = Math.abs(angle % 180)
|
||||
return (Math.min(a, 180 - a) < 15 && /^[0689]+$/.test(s)) ? `${s}.` : s
|
||||
}
|
||||
|
||||
const truncateLabel = (name: string, maxLen = 10): string => {
|
||||
if (name.length <= maxLen) return name
|
||||
const parts = name.split(/[\s\-_.,;:!?()\[\]{}]+/)
|
||||
if (parts[0] && parts[0].length <= maxLen) return parts[0]
|
||||
return name.slice(0, maxLen - 1) + '…'
|
||||
}
|
||||
|
||||
// Calculate max label length based on angular gap to neighbor labels
|
||||
const storageMaxLen = computed(() => {
|
||||
const s = store.space
|
||||
if (!s.disk) return 10
|
||||
// Sector spans in degrees
|
||||
const storageSpan = (s.allocated / s.disk) * 360
|
||||
const freeSpan = (s.free / s.disk) * 360
|
||||
const otherSpan = ((s.used - s.allocated) / s.disk) * 360
|
||||
// Angular gap from storage label midpoint to neighbor label midpoints
|
||||
const gapToFree = (storageSpan + freeSpan) / 2
|
||||
const gapToOther = (storageSpan + otherSpan) / 2
|
||||
const minGap = Math.min(gapToFree, gapToOther)
|
||||
// Allow longer names when there's sufficient gap to both neighbors
|
||||
if (minGap > 70) return 18
|
||||
if (minGap > 55) return 14
|
||||
return 10
|
||||
})
|
||||
|
||||
const storageName = computed(() => {
|
||||
const name = store.server.name || 'stored'
|
||||
const maxLen = storageMaxLen.value
|
||||
// Use full name if it fits within the available space
|
||||
if (name.length <= maxLen) return name
|
||||
return truncateLabel(name, 10)
|
||||
})
|
||||
|
||||
const TAU = 2 * Math.PI
|
||||
|
||||
const pieCx = 75
|
||||
const pieCy = 75
|
||||
const pieRadius = 55
|
||||
const holeRadius = pieRadius * 0.38
|
||||
const ringWidth = pieRadius - holeRadius
|
||||
const midRadius = (pieRadius + holeRadius) / 2
|
||||
const CIRC = TAU * midRadius
|
||||
|
||||
const pieStorageDash = computed(() => {
|
||||
const s = store.space
|
||||
if (!s.disk) return `0 ${CIRC}`
|
||||
return `${(s.allocated / s.disk) * CIRC} ${CIRC}`
|
||||
})
|
||||
|
||||
const pieFreeDash = computed(() => {
|
||||
const s = store.space
|
||||
if (!s.disk) return `0 ${CIRC}`
|
||||
return `${(s.free / s.disk) * CIRC} ${CIRC}`
|
||||
})
|
||||
|
||||
const pieFreeOffsetVal = computed(() => {
|
||||
const s = store.space
|
||||
if (!s.disk) return 0
|
||||
return -(s.allocated / s.disk) * CIRC
|
||||
})
|
||||
|
||||
const freeColor = computed(() => {
|
||||
const s = store.space
|
||||
if (!s.disk) return '#6c6'
|
||||
const freePct = s.free / s.disk
|
||||
if (freePct > 0.25) return '#5b5'
|
||||
if (freePct > 0.10) return '#ff0'
|
||||
return '#f00'
|
||||
})
|
||||
|
||||
const PIE_RADIUS = 55
|
||||
const LABEL_RADIUS = 62
|
||||
|
||||
const getPoint = (angle: number, radius: number) => {
|
||||
const rad = TAU * (angle - 90) / 360
|
||||
return { x: pieCx + radius * Math.cos(rad), y: pieCy + radius * Math.sin(rad) }
|
||||
}
|
||||
|
||||
const sectorInfo = computed(() => {
|
||||
const s = store.space
|
||||
if (!s.disk) return {
|
||||
storage: { angle: 45, pct: 0.25 },
|
||||
free: { angle: 180, pct: 0.5 },
|
||||
other: { angle: 270, pct: 0.25 }
|
||||
}
|
||||
|
||||
const storagePct = s.allocated / s.disk
|
||||
const freePct = s.free / s.disk
|
||||
const otherPct = (s.used - s.allocated) / s.disk
|
||||
|
||||
const storageAngle = storagePct * 180 // midpoint of storage sector
|
||||
const freeStart = storagePct * 360
|
||||
const freeAngle = freeStart + freePct * 180
|
||||
const otherStart = (storagePct + freePct) * 360
|
||||
const otherAngle = otherStart + otherPct * 180
|
||||
|
||||
return {
|
||||
storage: { angle: storageAngle, pct: storagePct },
|
||||
free: { angle: freeAngle, pct: freePct },
|
||||
other: { angle: otherAngle, pct: otherPct }
|
||||
}
|
||||
})
|
||||
|
||||
const rawAngles = computed(() => ({
|
||||
storage: sectorInfo.value.storage.angle,
|
||||
free: sectorInfo.value.free.angle,
|
||||
other: sectorInfo.value.other.angle
|
||||
}))
|
||||
|
||||
const getSizeRotation = (angle: number) => angle < 180 ? angle - 90 : angle + 90
|
||||
const getSizeAnchor = (angle: number) => angle < 180 ? 'end' : 'start'
|
||||
|
||||
const INNER_LABEL_RADIUS = PIE_RADIUS * 0.95
|
||||
const storageInnerPos = computed(() => getPoint(sectorInfo.value.storage.angle, INNER_LABEL_RADIUS))
|
||||
const freeInnerPos = computed(() => getPoint(sectorInfo.value.free.angle, INNER_LABEL_RADIUS))
|
||||
const otherInnerPos = computed(() => getPoint(sectorInfo.value.other.angle, INNER_LABEL_RADIUS))
|
||||
|
||||
// Collision avoidance for curved name labels
|
||||
const labelLengths = computed(() => ({
|
||||
storage: storageName.value.length,
|
||||
free: 4,
|
||||
other: 5
|
||||
}))
|
||||
|
||||
const getGapForPair = (len1: number, len2: number) => {
|
||||
return 35 + Math.max(0, len1 + len2 - 8) * 2.5
|
||||
}
|
||||
|
||||
const adjustedLabelAngles = computed(() => {
|
||||
const angles = rawAngles.value
|
||||
const lens = labelLengths.value
|
||||
const labels = [
|
||||
{ id: 'storage', angle: angles.storage, len: lens.storage },
|
||||
{ id: 'free', angle: angles.free, len: lens.free },
|
||||
{ id: 'other', angle: angles.other, len: lens.other }
|
||||
]
|
||||
labels.sort((a, b) => a.angle - b.angle)
|
||||
|
||||
for (let iterations = 0; iterations < 15; iterations++) {
|
||||
let moved = false
|
||||
for (let i = 0; i < labels.length; i++) {
|
||||
const current = labels[i]!
|
||||
const next = labels[(i + 1) % labels.length]!
|
||||
let angleDiff = next.angle - current.angle
|
||||
if (angleDiff < 0) angleDiff += 360
|
||||
const requiredGap = getGapForPair(current.len, next.len)
|
||||
if (angleDiff < requiredGap) {
|
||||
const push = (requiredGap - angleDiff) / 2
|
||||
current.angle = (current.angle - push + 360) % 360
|
||||
next.angle = (next.angle + push) % 360
|
||||
moved = true
|
||||
}
|
||||
}
|
||||
if (!moved) break
|
||||
}
|
||||
|
||||
const result: Record<string, number> = {}
|
||||
for (const l of labels) result[l.id] = l.angle
|
||||
return result
|
||||
})
|
||||
|
||||
// Arc path for curved text labels (CW for top half, CCW for bottom half)
|
||||
const createArcPath = (centerAngle: number, id: string, labelLen: number) => {
|
||||
const radius = LABEL_RADIUS
|
||||
// Scale arc span based on label length: ~6° per character, minimum 45°
|
||||
const arcSpan = Math.max(45, labelLen * 6)
|
||||
const isBottom = centerAngle > 90 && centerAngle <= 270
|
||||
const startAngle = isBottom ? centerAngle + arcSpan / 2 : centerAngle - arcSpan / 2
|
||||
const endAngle = isBottom ? centerAngle - arcSpan / 2 : centerAngle + arcSpan / 2
|
||||
const start = getPoint(startAngle, radius)
|
||||
const end = getPoint(endAngle, radius)
|
||||
const sweep = isBottom ? 0 : 1
|
||||
return {
|
||||
id: `label-path-${id}`,
|
||||
d: `M ${start.x} ${start.y} A ${radius} ${radius} 0 0 ${sweep} ${end.x} ${end.y}`
|
||||
}
|
||||
}
|
||||
|
||||
const storageLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.storage!, 'storage', storageName.value.length))
|
||||
const freeLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.free!, 'free', 4))
|
||||
const otherLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.other!, 'other', 5))
|
||||
|
||||
const handleClick = () => isExpanded.value ? collapse() : expand()
|
||||
|
||||
const applyAnimState = (t: number, opacity: number) => {
|
||||
const widget = widgetRef.value
|
||||
const labels = labelsRef.value
|
||||
const centerLabel = centerLabelRef.value
|
||||
if (!widget) return
|
||||
|
||||
const scale = 1 + (EXPANDED_SCALE - 1) * t
|
||||
// Move top-right corner of widget to top-right corner of viewport
|
||||
const targetX = window.innerWidth - containerPos.value.left - containerPos.value.width
|
||||
const targetY = -containerPos.value.top
|
||||
|
||||
widget.style.transform = `translate(${targetX * t}px, ${targetY * t}px) scale(${scale})`
|
||||
if (labels) labels.style.opacity = String(opacity)
|
||||
if (centerLabel) centerLabel.style.opacity = String(opacity)
|
||||
}
|
||||
|
||||
const animate = (duration: number, expanding: boolean, onComplete?: () => void) => {
|
||||
const startTime = performance.now()
|
||||
const tick = (now: number) => {
|
||||
const elapsed = now - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
const eased = 1 - Math.pow(1 - progress, 3) // easeOutCubic
|
||||
const t = expanding ? eased : 1 - eased
|
||||
applyAnimState(t, t) // opacity follows position
|
||||
if (progress < 1) {
|
||||
animationFrame = requestAnimationFrame(tick)
|
||||
} else {
|
||||
animationFrame = null
|
||||
onComplete?.()
|
||||
}
|
||||
}
|
||||
animationFrame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const expand = () => {
|
||||
if (animationFrame) cancelAnimationFrame(animationFrame)
|
||||
if (containerRef.value) {
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
containerPos.value = { top: rect.top, left: rect.left, width: rect.width }
|
||||
}
|
||||
isExpanded.value = true
|
||||
animate(ANIM_DURATION, true)
|
||||
}
|
||||
|
||||
const collapse = () => {
|
||||
if (animationFrame) cancelAnimationFrame(animationFrame)
|
||||
if (containerRef.value) {
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
containerPos.value = { top: rect.top, left: rect.left, width: rect.width }
|
||||
}
|
||||
animate(ANIM_DURATION, false, () => {
|
||||
isExpanded.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isExpanded.value) collapse()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
// Initialize labels as hidden
|
||||
if (labelsRef.value) labelsRef.value.style.opacity = '0'
|
||||
if (centerLabelRef.value) centerLabelRef.value.style.opacity = '0'
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
if (animationFrame) cancelAnimationFrame(animationFrame)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.disk-space-container {
|
||||
position: relative;
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.disk-space-container:focus .disk-space-widget:not(.expanded) {
|
||||
filter: brightness(1);
|
||||
}
|
||||
|
||||
.disk-space-widget {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
will-change: transform;
|
||||
filter: brightness(0.85);
|
||||
transition: filter 0.2s ease;
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
.disk-space-widget:hover,
|
||||
.disk-space-widget:focus {
|
||||
filter: brightness(1);
|
||||
}
|
||||
|
||||
.disk-space-widget.expanded {
|
||||
pointer-events: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.disk-space-widget.expanded:hover,
|
||||
.disk-space-widget.expanded:focus {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.pie-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pie-hitarea {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pie-label-inner {
|
||||
fill: #eee;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
stroke: #000;
|
||||
stroke-width: 0.5px;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
.pie-center-label {
|
||||
fill: #eee;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pie-label-sub {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-variant: small-caps;
|
||||
text-transform: lowercase;
|
||||
stroke: #000;
|
||||
stroke-width: 1px;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<SvgButton name="download" tooltip="Download" @click="download" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import type { SelectedItems } from '@/repositories/Document'
|
||||
import { zipName } from '@/utils/fileutil'
|
||||
|
||||
const store = useMainStore()
|
||||
|
||||
const status_init = {
|
||||
total: 0,
|
||||
xfer: 0,
|
||||
t0: 0,
|
||||
tlast: 0,
|
||||
statbytes: 0,
|
||||
statdur: 0,
|
||||
files: [] as string[],
|
||||
filestart: 0,
|
||||
fileidx: 0,
|
||||
filecount: 0,
|
||||
filename: '',
|
||||
filesize: 0,
|
||||
filepos: 0,
|
||||
status: 'idle',
|
||||
}
|
||||
store.dprogress = {...status_init}
|
||||
setInterval(() => {
|
||||
if (Date.now() - store.dprogress.tlast > 3000) {
|
||||
// Reset
|
||||
store.dprogress.statbytes = 0
|
||||
store.dprogress.statdur = 1
|
||||
} else {
|
||||
// Running average by decay
|
||||
store.dprogress.statbytes *= .9
|
||||
store.dprogress.statdur *= .9
|
||||
}
|
||||
}, 100)
|
||||
const statReset = () => {
|
||||
Object.assign(store.dprogress, status_init)
|
||||
store.dprogress.t0 = Date.now()
|
||||
store.dprogress.tlast = store.dprogress.t0 + 1
|
||||
}
|
||||
const cancelDownloads = () => {
|
||||
location.reload() // FIXME
|
||||
}
|
||||
|
||||
|
||||
const linkdl = (href: string) => {
|
||||
const a = document.createElement('a')
|
||||
a.href = href
|
||||
a.download = ''
|
||||
a.click()
|
||||
}
|
||||
|
||||
const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => {
|
||||
let hdir = ''
|
||||
let h = handle
|
||||
console.log('Downloading to filesystem', sel.recursive)
|
||||
for (const [rel, full, doc] of sel.recursive) {
|
||||
if (doc.dir) continue
|
||||
store.dprogress.files.push(rel)
|
||||
++store.dprogress.filecount
|
||||
store.dprogress.total += doc.size
|
||||
}
|
||||
for (const [rel, full, doc] of sel.recursive) {
|
||||
// Create any missing directories
|
||||
if (hdir && !rel.startsWith(hdir + '/')) {
|
||||
hdir = ''
|
||||
h = handle
|
||||
}
|
||||
const r = rel.slice(hdir.length)
|
||||
for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) {
|
||||
if (!dir) continue
|
||||
hdir += `${dir}/`
|
||||
try {
|
||||
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to create directory', hdir, error)
|
||||
throw new Error(`Failed to create directory ${hdir}: ${error}`)
|
||||
}
|
||||
console.log('Created', hdir)
|
||||
}
|
||||
if (doc.dir) continue // Target was a folder and was created
|
||||
const name = rel.split('/').pop()!.normalize('NFC')
|
||||
// Download file
|
||||
let fileHandle
|
||||
try {
|
||||
fileHandle = await h.getFileHandle(name, { create: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to create file', rel, full, hdir + name, error)
|
||||
throw new Error(`Failed to create file ${hdir + name}: ${error}`)
|
||||
}
|
||||
try {
|
||||
const writable = await fileHandle.createWritable()
|
||||
const url = `/files/${rel}`
|
||||
console.log('Fetching', url)
|
||||
const res = await apiFetch(url)
|
||||
if (!res.ok) {
|
||||
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
|
||||
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
if (res.body) {
|
||||
++store.dprogress.fileidx
|
||||
const reader = res.body.getReader()
|
||||
await writable.truncate(0)
|
||||
store.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}`)
|
||||
}
|
||||
}
|
||||
statReset()
|
||||
}
|
||||
|
||||
const zipdl = (sel: SelectedItems) => {
|
||||
const items = sel.keys.map(k => sel.docs[k]!)
|
||||
const name = zipName(items)
|
||||
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
||||
store.showToast(`Downloading ${name}.zip`)
|
||||
store.selected.clear()
|
||||
}
|
||||
|
||||
const download = async (e: MouseEvent) => {
|
||||
const sel = store.selectedFiles
|
||||
if (sel.keys.length === 0) {
|
||||
store.showToast('No existing files selected')
|
||||
store.selected.clear()
|
||||
return
|
||||
}
|
||||
// Single file: direct download
|
||||
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
|
||||
if (files.length === 1) {
|
||||
store.selected.clear()
|
||||
store.showToast(`Downloading ${files[0]![0].split('/').pop()}`)
|
||||
return linkdl(`/files/${files[0]![1]}`)
|
||||
}
|
||||
// Alt+click: download to folder (hidden feature)
|
||||
if (e.altKey && 'showDirectoryPicker' in window) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const handle = await window.showDirectoryPicker({ startIn: 'downloads', mode: 'readwrite' })
|
||||
await filesystemdl(sel, handle)
|
||||
store.selected.clear()
|
||||
} catch (e) {
|
||||
console.error('Download to folder failed', e)
|
||||
store.showToast('Download to folder failed')
|
||||
}
|
||||
return
|
||||
}
|
||||
// Default: ZIP download
|
||||
zipdl(sel)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div v-if="!props.path || documents.length === 0" class="empty-container">
|
||||
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
|
||||
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
||||
<p v-else-if="!store.connected">No Connection</p>
|
||||
<p v-else-if="store.documentCount === 0">Waiting for File List</p>
|
||||
<p v-else-if="store.query">No matches!</p>
|
||||
<p v-else-if="!exists(props.path)">Folder not found</p>
|
||||
<p v-else>Empty folder</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Cog } from '@/assets/svg'
|
||||
import { exists } from '@/utils/fileutil'
|
||||
|
||||
const cog = Cog
|
||||
const store = useMainStore()
|
||||
const props = defineProps<{
|
||||
path: string[],
|
||||
documents: Document[],
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes rotate {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
svg.cog {
|
||||
width: 10rem;
|
||||
height: 10rem;
|
||||
margin: 0 auto;
|
||||
animation: rotate 10s linear infinite;
|
||||
filter: drop-shadow(0 0 1rem black);
|
||||
fill: #888;
|
||||
}
|
||||
svg.cog.stopped {
|
||||
animation: none;
|
||||
}
|
||||
</style>
|
||||
@@ -5,9 +5,9 @@
|
||||
<th class="selection">
|
||||
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
|
||||
</th>
|
||||
<th class="sortcolumn" :class="{ sortactive: sort === 'name' }" @click="toggleSort('name')">Name</th>
|
||||
<th class="sortcolumn modified right" :class="{ sortactive: sort === 'modified' }" @click="toggleSort('modified')">Modified</th>
|
||||
<th class="sortcolumn size right" :class="{ sortactive: sort === 'size' }" @click="toggleSort('size')">Size</th>
|
||||
<th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
|
||||
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
|
||||
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
|
||||
<th class="menu"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -17,30 +17,30 @@
|
||||
<td class="name">
|
||||
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
|
||||
</td>
|
||||
<FileModified :doc=editing />
|
||||
<FileModified :doc=editing :now=nowkey />
|
||||
<FileSize :doc=editing />
|
||||
<td class="menu"></td>
|
||||
</tr>
|
||||
<template v-for="(doc, index) in sortedDocuments" :key="doc.key">
|
||||
<template v-for="(doc, index) in documents" :key="doc.key">
|
||||
<tr class="folder-change" v-if="showFolderBreadcrumb(index)">
|
||||
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
:id="`file-${doc.key}`"
|
||||
:class="{ file: !doc.dir, folder: doc.dir, cursor: cursor === doc }"
|
||||
@click="cursor = cursor === doc ? null : doc"
|
||||
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
|
||||
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
|
||||
@contextmenu.prevent="contextMenu($event, doc)"
|
||||
>
|
||||
<td class="selection" @click.up.stop="cursor = cursor === doc ? doc : null">
|
||||
<td class="selection" @click.up.stop="store.cursor = store.cursor === doc.key ? doc.key : ''">
|
||||
<input
|
||||
type="checkbox"
|
||||
tabindex="-1"
|
||||
:checked="documentStore.selected.has(doc.key)"
|
||||
:checked="store.selected.has(doc.key)"
|
||||
@change="
|
||||
($event.target as HTMLInputElement).checked
|
||||
? documentStore.selected.add(doc.key)
|
||||
: documentStore.selected.delete(doc.key)
|
||||
? store.selected.add(doc.key)
|
||||
: store.selected.delete(doc.key)
|
||||
"
|
||||
/>
|
||||
</td>
|
||||
@@ -49,22 +49,16 @@
|
||||
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<a
|
||||
:href="url_for(doc)"
|
||||
tabindex="-1"
|
||||
@contextmenu.prevent
|
||||
@focus.stop="cursor = doc"
|
||||
@keyup.left="router.back()"
|
||||
@keyup.right.stop="ev => { if (doc.dir) (ev.target as HTMLElement).click() }"
|
||||
>{{ doc.name }}</a
|
||||
>
|
||||
<button v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||
<a :href=doc.url tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
|
||||
{{ doc.name }}
|
||||
</a>
|
||||
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||
</template>
|
||||
</td>
|
||||
<FileModified :doc=doc />
|
||||
<FileModified :doc=doc :now=nowkey />
|
||||
<FileSize :doc=doc />
|
||||
<td class="menu">
|
||||
<button tabindex="-1" @click.stop="contextMenu($event, doc)">⋮</button>
|
||||
<button tabindex=-1 @click.stop="contextMenu($event, doc)">⋮</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -75,32 +69,27 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-container">Nothing to see here</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watchEffect, onMounted, onUnmounted } from 'vue'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import type { Document } from '@/repositories/Document'
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import FileRenameInput from './FileRenameInput.vue'
|
||||
import { connect, controlUrl } from '@/repositories/WS'
|
||||
import { collator, formatSize, formatUnixDate } from '@/utils'
|
||||
import { formatSize } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
documents: Document[]
|
||||
documents: Doc[]
|
||||
}>()
|
||||
const documentStore = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
const router = useRouter()
|
||||
const url_for = (doc: Document) => {
|
||||
const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
return doc.dir ? `#/${p}/` : `/files/${p}`
|
||||
}
|
||||
const cursor = ref<Document | null>(null)
|
||||
// File rename
|
||||
const editing = ref<Document | null>(null)
|
||||
const rename = (doc: Document, newName: string) => {
|
||||
const editing = shallowRef<Doc | null>(null)
|
||||
const rename = (doc: Doc, newName: string) => {
|
||||
const oldName = doc.name
|
||||
const control = connect(controlUrl, {
|
||||
message(ev: MessageEvent) {
|
||||
@@ -124,75 +113,88 @@ const rename = (doc: Document, newName: string) => {
|
||||
}
|
||||
doc.name = newName // We should get an update from watch but this is quicker
|
||||
}
|
||||
const sortedDocuments = computed(() => sorted(props.documents as Document[]))
|
||||
const showFolderBreadcrumb = (i: number) => {
|
||||
const docs = sortedDocuments.value
|
||||
const docloc = docs[i].loc
|
||||
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
|
||||
}
|
||||
defineExpose({
|
||||
newFolder() {
|
||||
const now = Date.now() / 1000
|
||||
editing.value = {
|
||||
console.log("New folder")
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
editing.value = new Doc({
|
||||
loc: loc.value,
|
||||
key: 'new',
|
||||
name: 'New Folder',
|
||||
dir: true,
|
||||
mtime: now,
|
||||
size: 0,
|
||||
sizedisp: formatSize(0),
|
||||
modified: formatUnixDate(now),
|
||||
haystack: '',
|
||||
}
|
||||
console.log("New")
|
||||
allocated: 0,
|
||||
})
|
||||
store.cursor = editing.value.key
|
||||
},
|
||||
toggleSelectAll() {
|
||||
console.log('Select')
|
||||
allSelected.value = !allSelected.value
|
||||
},
|
||||
toggleSortColumn(column: number) {
|
||||
const columns = ['', 'name', 'modified', 'size', '']
|
||||
toggleSort(columns[column])
|
||||
},
|
||||
isCursor() {
|
||||
return cursor.value !== null && editing.value === null
|
||||
return store.cursor && editing.value === null
|
||||
},
|
||||
focusFirst() {
|
||||
const docs = props.documents
|
||||
if (docs.length > 0) {
|
||||
store.cursor = docs[0]!.key
|
||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||
nextTick(() => {
|
||||
const a = document.querySelector(`#file-${store.cursor} .name a`) as HTMLAnchorElement | null
|
||||
if (a) a.focus()
|
||||
})
|
||||
}
|
||||
},
|
||||
cursorRename() {
|
||||
editing.value = cursor.value
|
||||
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
||||
},
|
||||
cursorSelect() {
|
||||
const doc = cursor.value
|
||||
if (!doc) return
|
||||
if (documentStore.selected.has(doc.key)) {
|
||||
documentStore.selected.delete(doc.key)
|
||||
const key = store.cursor
|
||||
if (!key) return
|
||||
if (store.selected.has(key)) {
|
||||
store.selected.delete(key)
|
||||
} else {
|
||||
documentStore.selected.add(doc.key)
|
||||
store.selected.add(key)
|
||||
}
|
||||
this.cursorMove(1)
|
||||
this.cursorMove(1, null)
|
||||
},
|
||||
cursorMove(d: number, select = false) {
|
||||
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
||||
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
||||
left(ev: KeyboardEvent) {
|
||||
// Only go back if we're in a subfolder (not at root)
|
||||
if (props.path.length > 0) {
|
||||
router.back()
|
||||
}
|
||||
},
|
||||
right(ev: KeyboardEvent) {
|
||||
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
|
||||
if (a) a.click()
|
||||
},
|
||||
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||
const select = !!ev?.shiftKey
|
||||
// Move cursor up or down (keyboard navigation)
|
||||
const documents = sortedDocuments.value
|
||||
if (documents.length === 0) {
|
||||
cursor.value = null
|
||||
const docs = props.documents
|
||||
if (docs.length === 0) {
|
||||
store.cursor = ''
|
||||
return
|
||||
}
|
||||
const N = documents.length
|
||||
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 =
|
||||
cursor.value !== null ? documents.indexOf(cursor.value) : documents.length
|
||||
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : docs.length
|
||||
const moveto = increment(index, d)
|
||||
cursor.value = documents[moveto] ?? null
|
||||
const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null
|
||||
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 = documents[p].key
|
||||
if (documentStore.selected.has(key)) documentStore.selected.delete(key)
|
||||
else documentStore.selected.add(key)
|
||||
const key = docs[p]!.key
|
||||
if (store.selected.has(key)) store.selected.delete(key)
|
||||
else store.selected.add(key)
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
@@ -204,9 +206,17 @@ defineExpose({
|
||||
scrolltimer = null
|
||||
}, 300)
|
||||
}
|
||||
if (moveto === N) focusBreadcrumb()
|
||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
||||
if (moveto === N) {
|
||||
if (d < 0) focusBreadcrumb()
|
||||
else focusHeader()
|
||||
}
|
||||
}
|
||||
})
|
||||
const focusHeader = () => {
|
||||
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
const focusBreadcrumb = () => {
|
||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
@@ -214,29 +224,36 @@ const focusBreadcrumb = () => {
|
||||
let scrolltimer: any = null
|
||||
let scrolltr: any = null
|
||||
watchEffect(() => {
|
||||
if (cursor.value && cursor.value !== editing.value) editing.value = null
|
||||
if (editing.value) cursor.value = editing.value
|
||||
if (cursor.value) {
|
||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
||||
if (editing.value) store.cursor = editing.value?.key
|
||||
if (store.cursor) {
|
||||
const a = document.querySelector(
|
||||
`#file-${cursor.value.key} .name a`
|
||||
`#file-${store.cursor} .name a`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) a.focus()
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (!props.documents.length && cursor.value) {
|
||||
cursor.value = null
|
||||
if (!props.documents.length && store.cursor && !store.query) {
|
||||
store.cursor = ''
|
||||
focusBreadcrumb()
|
||||
}
|
||||
})
|
||||
// Update human-readable x seconds ago messages from mtimes
|
||||
let nowkey = ref(0)
|
||||
let modifiedTimer: any = null
|
||||
const updateModified = () => {
|
||||
for (const doc of props.documents) doc.modified = formatUnixDate(doc.mtime)
|
||||
nowkey.value = Math.floor(Date.now() / 1000)
|
||||
}
|
||||
onMounted(() => { updateModified(); modifiedTimer = setInterval(updateModified, 1000) })
|
||||
onMounted(() => {
|
||||
updateModified(); modifiedTimer = setInterval(updateModified, 1000)
|
||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||
active.focus()
|
||||
}
|
||||
})
|
||||
onUnmounted(() => { clearInterval(modifiedTimer) })
|
||||
const mkdir = (doc: Document, name: string) => {
|
||||
const mkdir = (doc: Doc, name: string) => {
|
||||
const control = connect(controlUrl, {
|
||||
open() {
|
||||
control.send(
|
||||
@@ -253,34 +270,25 @@ const mkdir = (doc: Document, name: string) => {
|
||||
editing.value = null
|
||||
} else {
|
||||
console.log('mkdir', msg)
|
||||
router.push(doc.loc ? `/${doc.loc}/${name}/` : `/${name}/`)
|
||||
router.push(doc.urlrouter)
|
||||
}
|
||||
}
|
||||
})
|
||||
doc.name = name // We should get an update from watch but this is quicker
|
||||
doc.name = name
|
||||
doc.key = crypto.randomUUID()
|
||||
store.addGhost(doc)
|
||||
editing.value = null
|
||||
}
|
||||
|
||||
// Column sort
|
||||
const toggleSort = (name: string) => {
|
||||
sort.value = sort.value === name ? '' : name
|
||||
}
|
||||
const sort = ref<string>('')
|
||||
const sortCompare = {
|
||||
name: (a: Document, b: Document) => collator.compare(a.name, b.name),
|
||||
modified: (a: Document, b: Document) => b.mtime - a.mtime,
|
||||
size: (a: Document, b: Document) => b.size - a.size
|
||||
}
|
||||
const sorted = (documents: Document[]) => {
|
||||
const cmp = sortCompare[sort.value as keyof typeof sortCompare]
|
||||
const sorted = [...documents]
|
||||
if (cmp) sorted.sort(cmp)
|
||||
return sorted
|
||||
const showFolderBreadcrumb = (i: number) => {
|
||||
const docs = props.documents
|
||||
const docloc = docs[i]!.loc
|
||||
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1]!.loc
|
||||
}
|
||||
const selectionIndeterminate = computed({
|
||||
get: () => {
|
||||
return (
|
||||
props.documents.length > 0 &&
|
||||
props.documents.some((doc: Document) => documentStore.selected.has(doc.key)) &&
|
||||
props.documents.some((doc: Doc) => store.selected.has(doc.key)) &&
|
||||
!allSelected.value
|
||||
)
|
||||
},
|
||||
@@ -291,16 +299,16 @@ const allSelected = computed({
|
||||
get: () => {
|
||||
return (
|
||||
props.documents.length > 0 &&
|
||||
props.documents.every((doc: Document) => documentStore.selected.has(doc.key))
|
||||
props.documents.every((doc: Doc) => store.selected.has(doc.key))
|
||||
)
|
||||
},
|
||||
set: (value: boolean) => {
|
||||
console.log('Setting allSelected', value)
|
||||
for (const doc of props.documents) {
|
||||
if (value) {
|
||||
documentStore.selected.add(doc.key)
|
||||
store.selected.add(doc.key)
|
||||
} else {
|
||||
documentStore.selected.delete(doc.key)
|
||||
store.selected.delete(doc.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,9 +316,96 @@ const allSelected = computed({
|
||||
|
||||
const loc = computed(() => props.path.join('/'))
|
||||
|
||||
const contextMenu = (ev: Event, doc: Document) => {
|
||||
cursor.value = doc
|
||||
console.log('Context menu', ev, doc)
|
||||
const downloadFile = (doc: Doc) => {
|
||||
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
if (doc.dir) {
|
||||
// Download folder as ZIP
|
||||
const a = document.createElement('a')
|
||||
a.href = `/zip/${doc.key}/${doc.name}.zip`
|
||||
a.download = ''
|
||||
a.click()
|
||||
store.showToast(`Downloading ${doc.name}.zip`)
|
||||
} else {
|
||||
// Download single file
|
||||
const a = document.createElement('a')
|
||||
a.href = `/files/${path}`
|
||||
a.download = ''
|
||||
a.click()
|
||||
store.showToast(`Downloading ${doc.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
const copyLink = async (doc: Doc) => {
|
||||
const url = new URL(doc.url, window.location.origin).href
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
store.showToast('📋 Link copied!')
|
||||
} catch {
|
||||
store.showToast('Failed to copy link')
|
||||
}
|
||||
}
|
||||
|
||||
const copyImage = async (doc: Doc) => {
|
||||
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
try {
|
||||
store.showToast('Copying image...')
|
||||
const res = await fetch(`/files/${path}`)
|
||||
const blob = await res.blob()
|
||||
// Convert to PNG if needed (clipboard only supports PNG)
|
||||
if (blob.type !== 'image/png') {
|
||||
const img = new Image()
|
||||
img.src = URL.createObjectURL(blob)
|
||||
await new Promise(r => img.onload = r)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
canvas.getContext('2d')!.drawImage(img, 0, 0)
|
||||
const pngBlob = await new Promise<Blob>(r => canvas.toBlob(b => r(b!), 'image/png'))
|
||||
URL.revokeObjectURL(img.src)
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||||
} else {
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
}
|
||||
store.showToast('📋 Image copied!')
|
||||
} catch (e) {
|
||||
console.error('Copy image failed', e)
|
||||
store.showToast('Failed to copy image')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFile = (doc: Doc) => {
|
||||
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
store.hideDoc(path)
|
||||
const control = connect(controlUrl, {
|
||||
message(ev: MessageEvent) {
|
||||
const res = JSON.parse(ev.data)
|
||||
if ('error' in res) {
|
||||
console.error('Delete failed', res.error)
|
||||
store.unhideDoc(path)
|
||||
store.showToast(res.error.message || 'Delete failed')
|
||||
} else if (res.status === 'ack') {
|
||||
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||
control.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
control.onopen = () => {
|
||||
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
|
||||
}
|
||||
}
|
||||
|
||||
const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||
store.cursor = doc.key
|
||||
const items = [
|
||||
{ label: '📥 Download', onClick: () => downloadFile(doc) },
|
||||
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) },
|
||||
]
|
||||
if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
|
||||
items.push(
|
||||
{ label: '✏️ Rename', onClick: () => { editing.value = doc } },
|
||||
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) },
|
||||
)
|
||||
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -328,29 +423,36 @@ tbody tr {
|
||||
position: relative;
|
||||
z-index: auto;
|
||||
}
|
||||
table thead input[type='checkbox'] {
|
||||
table thead .selection input[type='checkbox'] {
|
||||
position: inherit;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
padding: 0.5rem 0.5em;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
}
|
||||
table tbody input[type='checkbox'] {
|
||||
table tbody .selection input[type='checkbox'] {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
table .selection {
|
||||
width: 2rem;
|
||||
width: 3rem;
|
||||
text-align: center;
|
||||
text-overflow: clip;
|
||||
padding: 0;
|
||||
}
|
||||
table .selection input {
|
||||
margin: auto;
|
||||
}
|
||||
table .modified {
|
||||
width: 9em;
|
||||
width: 10rem;
|
||||
text-overflow: clip;
|
||||
}
|
||||
table .size {
|
||||
width: 5em;
|
||||
width: 7rem;
|
||||
text-overflow: clip;
|
||||
}
|
||||
table .menu {
|
||||
width: 1rem;
|
||||
width: 2rem;
|
||||
}
|
||||
tbody td {
|
||||
font-size: 1.2rem;
|
||||
@@ -385,7 +487,7 @@ table td {
|
||||
}
|
||||
}
|
||||
thead tr {
|
||||
font-size: var(--header-font-size);
|
||||
font-size: 0.8rem;
|
||||
background: linear-gradient(to bottom, #eee, #fff 30%, #ddd);
|
||||
color: #000;
|
||||
box-shadow: 0 0 .2rem black;
|
||||
@@ -399,16 +501,15 @@ tbody tr.cursor {
|
||||
.sortcolumn:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.sortcolumn:hover::after {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.sortcolumn {
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
.sortcolumn::after {
|
||||
font-size: 1rem;
|
||||
content: '▸';
|
||||
color: #888;
|
||||
margin-left: 0.5em;
|
||||
margin-left: 0.5rem;
|
||||
margin-top: -.2rem;
|
||||
position: absolute;
|
||||
transition: all var(--transition-time) linear;
|
||||
}
|
||||
@@ -458,3 +559,4 @@ tbody .selection input {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@/stores/main
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
<template>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Document } from '@/repositories/Document'
|
||||
import { computed } from 'vue'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
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(() =>
|
||||
new Date(1000 * props.doc.mtime).toISOString().replace('.000Z', 'Z')
|
||||
)
|
||||
|
||||
const tooltip = computed(() =>
|
||||
datetime.value.replace('T', '\n').replace('Z', ' UTC')
|
||||
const tooltipText = computed(() =>
|
||||
datetime.value.replace('T', ' ').replace('Z', ' UTC')
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Document
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Document } from '@/repositories/Document'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
@@ -28,8 +28,8 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Document
|
||||
rename: (doc: Document, newName: string) => void
|
||||
doc: Doc
|
||||
rename: (doc: Doc, newName: string) => void
|
||||
exit: () => void
|
||||
}>()
|
||||
|
||||
@@ -56,4 +56,10 @@ input#FileRenameInput {
|
||||
outline: none;
|
||||
font: inherit;
|
||||
}
|
||||
.gallery input#FileRenameInput {
|
||||
padding: .75em;
|
||||
font-weight: 600;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,22 +1,44 @@
|
||||
<template>
|
||||
<td class="size right" :class=sizeClass>{{ doc.sizedisp }}</td>
|
||||
<td
|
||||
class="size right"
|
||||
:class="sizeClass"
|
||||
@mouseenter="doc.sparseIndicator && tooltip?.startHover($event)"
|
||||
@mousemove="doc.sparseIndicator && tooltip?.updatePosition($event)"
|
||||
@mouseleave="doc.sparseIndicator && tooltip?.endHover()"
|
||||
>
|
||||
<SparseIndicator :doc="doc" class="before-size" />{{ doc.sizedisp }}
|
||||
<CursorTooltip v-if="doc.sparseIndicator" ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Document } from '@/repositories/Document'
|
||||
import { computed } from 'vue'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatSize } from '@/utils'
|
||||
import SparseIndicator from './SparseIndicator.vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc
|
||||
}>()
|
||||
|
||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]
|
||||
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
|
||||
return +unit ? "bytes" : unit
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Document
|
||||
}>()
|
||||
const tooltipText = computed(() => {
|
||||
const { size, allocated } = props.doc
|
||||
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.before-size {
|
||||
margin-right: 0.2em;
|
||||
}
|
||||
.size.empty { color: #555 }
|
||||
.size.bytes { color: #77a }
|
||||
.size.kB { color: #474 }
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
|
||||
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
|
||||
<template v-for="(doc, index) in documents" :key=doc.key>
|
||||
<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) }" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { connect, controlUrl } from '@/repositories/WS'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import type { SortOrder } from '@/utils/docsort'
|
||||
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
documents: Doc[]
|
||||
}>()
|
||||
const store = useMainStore()
|
||||
const router = useRouter()
|
||||
// File rename
|
||||
const editing = shallowRef<Doc | null>(null)
|
||||
const exit = () => { editing.value = null }
|
||||
const rename = (doc: Doc, newName: string) => {
|
||||
const oldName = doc.name
|
||||
const control = connect(controlUrl, {
|
||||
message(ev: MessageEvent) {
|
||||
const msg = JSON.parse(ev.data)
|
||||
if ('error' in msg) {
|
||||
console.error('Rename failed', msg.error.message, msg.error)
|
||||
doc.name = oldName
|
||||
} else {
|
||||
console.log('Rename succeeded', msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
control.onopen = () => {
|
||||
control.send(
|
||||
JSON.stringify({
|
||||
op: 'rename',
|
||||
path: `${doc.loc}/${oldName}`,
|
||||
to: newName
|
||||
})
|
||||
)
|
||||
}
|
||||
doc.name = newName // We should get an update from watch but this is quicker
|
||||
}
|
||||
const gallery = ref<HTMLElement>()
|
||||
const columnCount = ref(1)
|
||||
const updateColumns = () => {
|
||||
if (!gallery.value) return
|
||||
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
|
||||
}
|
||||
const columns = computed(() => columnCount.value)
|
||||
defineExpose({
|
||||
newFolder() {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
editing.value = new Doc({
|
||||
loc: loc.value,
|
||||
key: 'new',
|
||||
name: 'New Folder',
|
||||
dir: true,
|
||||
mtime: now,
|
||||
size: 0,
|
||||
allocated: 0,
|
||||
})
|
||||
store.cursor = editing.value.key
|
||||
},
|
||||
toggleSelectAll() {
|
||||
console.log('Select')
|
||||
allSelected.value = !allSelected.value
|
||||
},
|
||||
toggleSortColumn(column: number) {
|
||||
const order = ['', 'name', 'modified', 'size', ''][column]
|
||||
if (order) store.toggleSort(order as SortOrder)
|
||||
},
|
||||
isCursor() {
|
||||
return store.cursor && editing.value === null
|
||||
},
|
||||
focusFirst() {
|
||||
const docs = props.documents
|
||||
if (docs.length > 0) {
|
||||
store.cursor = docs[0]!.key
|
||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||
nextTick(() => {
|
||||
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
|
||||
if (a) a.focus()
|
||||
})
|
||||
}
|
||||
},
|
||||
cursorRename() {
|
||||
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
||||
},
|
||||
cursorSelect() {
|
||||
const key = store.cursor
|
||||
if (!key) return
|
||||
if (store.selected.has(key)) {
|
||||
store.selected.delete(key)
|
||||
} else {
|
||||
store.selected.add(key)
|
||||
}
|
||||
this.cursorMove(1, null)
|
||||
},
|
||||
up(ev: KeyboardEvent) { this.cursorMove(-columns.value, ev) },
|
||||
down(ev: KeyboardEvent) { this.cursorMove(columns.value, ev) },
|
||||
left(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
||||
right(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
||||
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||
const select = !!ev?.shiftKey
|
||||
// Move cursor up or down (keyboard navigation)
|
||||
const docs = props.documents
|
||||
if (docs.length === 0) {
|
||||
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 =
|
||||
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
|
||||
// Stop navigation sideways away from the grid (only with up/down)
|
||||
if (ev && index === 0 && ev.key === "ArrowLeft") return
|
||||
if (ev && index === N - 1 && ev.key === "ArrowRight") return
|
||||
// Calculate new position
|
||||
let moveto
|
||||
if (index === N) moveto = d > 0 ? 0 : N - 1
|
||||
else {
|
||||
moveto = increment(index, d)
|
||||
// Wrapping either end, just land outside the list
|
||||
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
|
||||
}
|
||||
store.cursor = docs[moveto]?.key ?? ''
|
||||
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
||||
if (select) {
|
||||
// Go forwards, possibly wrapping over the end; the last entry is not toggled
|
||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||
if (p === N) continue
|
||||
const key = docs[p]!.key
|
||||
if (store.selected.has(key)) store.selected.delete(key)
|
||||
else store.selected.add(key)
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
scrolltr = tr
|
||||
if (!scrolltimer) {
|
||||
scrolltimer = setTimeout(() => {
|
||||
if (scrolltr)
|
||||
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
scrolltimer = null
|
||||
}, 300)
|
||||
}
|
||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
||||
if (moveto === N) {
|
||||
if (d < 0) focusBreadcrumb()
|
||||
else focusHeader()
|
||||
}
|
||||
}
|
||||
})
|
||||
const focusHeader = () => {
|
||||
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
const focusBreadcrumb = () => {
|
||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
let scrolltimer: any = null
|
||||
let scrolltr: any = null
|
||||
watchEffect(() => {
|
||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
||||
if (editing.value) store.cursor = editing.value.key
|
||||
if (store.cursor) {
|
||||
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
|
||||
if (a) { a.focus(); a.scrollIntoView({ block: 'center', behavior: 'smooth' }) }
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (!props.documents.length && store.cursor && !store.query) {
|
||||
store.cursor = ''
|
||||
focusBreadcrumb()
|
||||
}
|
||||
})
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
onMounted(() => {
|
||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||
active.focus()
|
||||
}
|
||||
updateColumns()
|
||||
if (gallery.value) {
|
||||
resizeObserver = new ResizeObserver(updateColumns)
|
||||
resizeObserver.observe(gallery.value)
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
const mkdir = (doc: Doc, name: string) => {
|
||||
const control = connect(controlUrl, {
|
||||
open() {
|
||||
control.send(
|
||||
JSON.stringify({
|
||||
op: 'mkdir',
|
||||
path: `${doc.loc}/${name}`
|
||||
})
|
||||
)
|
||||
},
|
||||
message(ev: MessageEvent) {
|
||||
const msg = JSON.parse(ev.data)
|
||||
if ('error' in msg) {
|
||||
console.error('Mkdir failed', msg.error.message, msg.error)
|
||||
editing.value = null
|
||||
} else {
|
||||
console.log('mkdir', msg)
|
||||
router.push(doc.urlrouter)
|
||||
}
|
||||
}
|
||||
})
|
||||
doc.name = name
|
||||
doc.key = crypto.randomUUID()
|
||||
store.addGhost(doc)
|
||||
editing.value = null
|
||||
}
|
||||
const showFolderBreadcrumb = (i: number) => {
|
||||
const docs = props.documents
|
||||
const docloc = docs[i]!.loc
|
||||
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1]!.loc
|
||||
}
|
||||
|
||||
const allSelected = computed({
|
||||
get: () => {
|
||||
return (
|
||||
props.documents.length > 0 &&
|
||||
props.documents.every((doc: Doc) => store.selected.has(doc.key))
|
||||
)
|
||||
},
|
||||
set: (value: boolean) => {
|
||||
console.log('Setting allSelected', value)
|
||||
for (const doc of props.documents) {
|
||||
if (value) {
|
||||
store.selected.add(doc.key)
|
||||
} else {
|
||||
store.selected.delete(doc.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const loc = computed(() => props.path.join('/'))
|
||||
|
||||
const downloadFile = (doc: Doc) => {
|
||||
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
if (doc.dir) {
|
||||
// Download folder as ZIP
|
||||
const a = document.createElement('a')
|
||||
a.href = `/zip/${doc.key}/${doc.name}.zip`
|
||||
a.download = ''
|
||||
a.click()
|
||||
store.showToast(`Downloading ${doc.name}.zip`)
|
||||
} else {
|
||||
// Download single file
|
||||
const a = document.createElement('a')
|
||||
a.href = `/files/${path}`
|
||||
a.download = ''
|
||||
a.click()
|
||||
store.showToast(`Downloading ${doc.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
const copyLink = async (doc: Doc) => {
|
||||
const url = new URL(doc.url, window.location.origin).href
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
store.showToast('📋 Link copied!')
|
||||
} catch {
|
||||
store.showToast('Failed to copy link')
|
||||
}
|
||||
}
|
||||
|
||||
const copyImage = async (doc: Doc) => {
|
||||
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
try {
|
||||
store.showToast('Copying image...')
|
||||
const res = await fetch(`/files/${path}`)
|
||||
const blob = await res.blob()
|
||||
// Convert to PNG if needed (clipboard only supports PNG)
|
||||
if (blob.type !== 'image/png') {
|
||||
const img = new Image()
|
||||
img.src = URL.createObjectURL(blob)
|
||||
await new Promise(r => img.onload = r)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
canvas.getContext('2d')!.drawImage(img, 0, 0)
|
||||
const pngBlob = await new Promise<Blob>(r => canvas.toBlob(b => r(b!), 'image/png'))
|
||||
URL.revokeObjectURL(img.src)
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||||
} else {
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
}
|
||||
store.showToast('📋 Image copied!')
|
||||
} catch (e) {
|
||||
console.error('Copy image failed', e)
|
||||
store.showToast('Failed to copy image')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFile = (doc: Doc) => {
|
||||
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
store.hideDoc(path)
|
||||
const control = connect(controlUrl, {
|
||||
message(ev: MessageEvent) {
|
||||
const res = JSON.parse(ev.data)
|
||||
if ('error' in res) {
|
||||
console.error('Delete failed', res.error)
|
||||
store.unhideDoc(path)
|
||||
store.showToast(res.error.message || 'Delete failed')
|
||||
} else if (res.status === 'ack') {
|
||||
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||
control.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
control.onopen = () => {
|
||||
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
|
||||
}
|
||||
}
|
||||
|
||||
const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||
store.cursor = doc.key
|
||||
const items = [
|
||||
{ label: '📥 Download', onClick: () => downloadFile(doc) },
|
||||
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) },
|
||||
]
|
||||
if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
|
||||
items.push(
|
||||
{ label: '✏️ Rename', onClick: () => { editing.value = doc } },
|
||||
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) },
|
||||
)
|
||||
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gallery {
|
||||
padding: 1em;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: .5em;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
||||
align-items: end;
|
||||
}
|
||||
.folder-indicator {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.folder-start {
|
||||
grid-column-start: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<a :id="`file-${doc.key}`" :href=doc.url tabindex=-1
|
||||
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
|
||||
@contextmenu.stop
|
||||
@focus.stop="store.cursor = doc.key"
|
||||
@click=onclick
|
||||
@mouseenter="tooltip?.startHover"
|
||||
@mousemove="tooltip?.updatePosition"
|
||||
@mouseleave="tooltip?.endHover"
|
||||
>
|
||||
<figure>
|
||||
<slot></slot>
|
||||
<MediaPreview ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
|
||||
<div class="titlespacer"></div>
|
||||
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
|
||||
<template v-if="editing">
|
||||
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
|
||||
</template>
|
||||
<template v-else>
|
||||
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||
<span>{{ doc.name }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
||||
<div class=namespacer></div>
|
||||
</template>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<CursorTooltip ref="tooltip" :text="tooltipText">
|
||||
<div class="tooltip-name">{{ doc.name }}</div>
|
||||
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
||||
<div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
|
||||
</CursorTooltip>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup lang=ts>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { formatSize } from '@/utils'
|
||||
import MediaPreview from '@/components/MediaPreview.vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
import SparseIndicator from './SparseIndicator.vue'
|
||||
|
||||
const store = useMainStore()
|
||||
type EditingProp = {
|
||||
rename: (name: string) => void;
|
||||
exit: () => void;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc,
|
||||
editing?: EditingProp,
|
||||
}>()
|
||||
const m = ref<typeof MediaPreview | null>(null)
|
||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
const tooltipText = computed(() => props.doc.key)
|
||||
|
||||
const sparseText = computed(() => {
|
||||
const { size, allocated } = props.doc
|
||||
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
||||
})
|
||||
|
||||
const onclick = (ev: Event) => {
|
||||
if (m.value!.play()) ev.preventDefault()
|
||||
store.cursor = props.doc.key
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tooltip-name {
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.tooltip-details {
|
||||
text-align: center;
|
||||
}
|
||||
.tooltip-sparse {
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.after-name {
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
figure {
|
||||
max-height: 15em;
|
||||
position: relative;
|
||||
border-radius: .5em;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
figure > article {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
figure :deep(.video-container) {
|
||||
height: 15em;
|
||||
}
|
||||
.titlespacer {
|
||||
flex-shrink: 100000;
|
||||
width: 100%;
|
||||
height: 2em;
|
||||
}
|
||||
figcaption {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
figcaption input[type='checkbox'] {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
margin: .25em 0 .25em .25em;
|
||||
opacity: 0;
|
||||
flex-shrink: 0;
|
||||
transition: opacity var(--transition-time) ease-in-out;
|
||||
}
|
||||
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 figcaption span {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
figcaption .namespacer {
|
||||
flex-shrink: 100000;
|
||||
height: 2em;
|
||||
width: 2em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,81 +1,126 @@
|
||||
<template>
|
||||
<nav class="headermain">
|
||||
<div class="buttons">
|
||||
<template v-if="documentStore.error">
|
||||
<div class="error-message" @click="documentStore.error = ''">{{ documentStore.error }}</div>
|
||||
<div class="smallgap"></div>
|
||||
</template>
|
||||
<UploadButton :path="props.path" />
|
||||
<SvgButton
|
||||
name="create-folder"
|
||||
data-tooltip="New folder"
|
||||
@click="() => documentStore.fileExplorer!.newFolder()"
|
||||
<nav class="headermain buttons">
|
||||
<UploadButton :path="props.path" />
|
||||
<SvgButton
|
||||
name="create-folder"
|
||||
tooltip="New folder"
|
||||
@click="() => { store.fileExplorer!.newFolder() }"
|
||||
/>
|
||||
<div class="smallgap"></div>
|
||||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
||||
<div class="search-group">
|
||||
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
|
||||
<input
|
||||
ref="search"
|
||||
type="search"
|
||||
:value="query"
|
||||
@input="updateSearch"
|
||||
@keydown.escape="clearSearch"
|
||||
/>
|
||||
<slot></slot>
|
||||
<div class="spacer smallgap"></div>
|
||||
<template v-if="showSearchInput">
|
||||
<input
|
||||
ref="search"
|
||||
type="search"
|
||||
:value="query"
|
||||
@blur="ev => { if (!query) closeSearch(ev) }"
|
||||
@input="updateSearch"
|
||||
placeholder="Search words"
|
||||
class="margin-input"
|
||||
@keyup.escape="closeSearch"
|
||||
/>
|
||||
</template>
|
||||
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
|
||||
<SvgButton name="cog" @click="settingsMenu" />
|
||||
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
||||
</div>
|
||||
<div class="spacer smallgap"></div>
|
||||
<DiskSpace v-if="store.space.disk" />
|
||||
<SvgButton name="cog" @click="settingsMenu" />
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { ref, nextTick, watchEffect } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { useSsoAuthStore } from '@/stores/ssoAuth'
|
||||
import { ref } from 'vue'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import { showAuthIframe } from 'paskia'
|
||||
import { resumeWatching } from '@/repositories/WS'
|
||||
import router from '@/router';
|
||||
import DiskSpace from './DiskSpace.vue'
|
||||
|
||||
const documentStore = useDocumentStore()
|
||||
const showSearchInput = ref<boolean>(false)
|
||||
const store = useMainStore()
|
||||
const ssoStore = useSsoAuthStore()
|
||||
const search = ref<HTMLInputElement | null>()
|
||||
const searchButton = ref<HTMLButtonElement | null>()
|
||||
|
||||
const closeSearch = (ev: Event) => {
|
||||
if (!showSearchInput.value) return // Already closing
|
||||
showSearchInput.value = false
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
query: string
|
||||
}>()
|
||||
|
||||
const clearSearch = (ev: Event) => {
|
||||
const input = search.value
|
||||
if (input) {
|
||||
input.value = ''
|
||||
updateSearch(ev)
|
||||
}
|
||||
const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement
|
||||
breadcrumb.focus()
|
||||
updateSearch(ev)
|
||||
}
|
||||
|
||||
const focusSearch = () => {
|
||||
search.value?.focus()
|
||||
}
|
||||
|
||||
// Track pending route update
|
||||
let pendingRouteUpdate: number | null = null
|
||||
|
||||
const updateSearch = (ev: Event) => {
|
||||
const q = (ev.target as HTMLInputElement).value
|
||||
let p = props.path.join('/')
|
||||
p = p ? `/${p}` : ''
|
||||
const url = q ? `${p}//${q}` : (p || '/')
|
||||
console.log("Update search", url)
|
||||
if (!props.query && q) router.push(url)
|
||||
else router.replace(url)
|
||||
}
|
||||
const toggleSearchInput = (ev: Event) => {
|
||||
showSearchInput.value = !showSearchInput.value
|
||||
if (!showSearchInput.value) return closeSearch(ev)
|
||||
nextTick(() => {
|
||||
const input = search.value
|
||||
if (input) input.focus()
|
||||
const loc = props.path.join('/')
|
||||
|
||||
// Start search immediately via store (worker handles it async)
|
||||
store.search(q, loc)
|
||||
|
||||
// Cancel any pending route update
|
||||
if (pendingRouteUpdate !== null) {
|
||||
cancelAnimationFrame(pendingRouteUpdate)
|
||||
}
|
||||
|
||||
// Schedule route update - will be cancelled if user types again
|
||||
pendingRouteUpdate = requestAnimationFrame(() => {
|
||||
pendingRouteUpdate = null
|
||||
let p = loc
|
||||
p = p ? `/${p}` : ''
|
||||
const url = q ? `${p}//${q}` : (p || '/')
|
||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
// Use replace to avoid building up history for each keystroke
|
||||
router.replace(u)
|
||||
})
|
||||
}
|
||||
watchEffect(() => {
|
||||
if (props.query) showSearchInput.value = true
|
||||
})
|
||||
|
||||
const toggleSearchInput = () => {
|
||||
search.value?.focus()
|
||||
}
|
||||
const settingsMenu = (e: Event) => {
|
||||
// show the context menu
|
||||
const items = []
|
||||
if (documentStore.user.isLoggedIn) {
|
||||
items.push({ label: `Logout ${documentStore.user.username ?? ''}`, onClick: () => documentStore.logout() })
|
||||
} else {
|
||||
items.push({ label: 'Login', onClick: () => documentStore.loginDialog() })
|
||||
|
||||
// For external auth, show user name as link to /auth/
|
||||
if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
||||
items.push({
|
||||
label: '👤 ' + (store.user.username || 'User Account'),
|
||||
onClick: () => { window.location.href = '/auth/' }
|
||||
})
|
||||
}
|
||||
|
||||
// Only show password change for non-SSO users
|
||||
if (!ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
||||
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) {
|
||||
items.push({ label: '🚪 Logout', onClick: () => store.logout() })
|
||||
} else if (store.server.public) {
|
||||
// Show login option only in public mode (non-public modes trigger auth automatically)
|
||||
items.push({ label: '🔐 Login', onClick: async () => {
|
||||
try {
|
||||
await showAuthIframe('/auth/restricted/#theme=light')
|
||||
resumeWatching()
|
||||
} catch (e) {
|
||||
console.log('Login cancelled')
|
||||
}
|
||||
}})
|
||||
}
|
||||
ContextMenu.showContextMenu({
|
||||
// @ts-ignore
|
||||
@@ -83,36 +128,89 @@ const settingsMenu = (e: Event) => {
|
||||
items,
|
||||
})
|
||||
}
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
query: string
|
||||
}>()
|
||||
|
||||
defineExpose({
|
||||
toggleSearchInput,
|
||||
closeSearch,
|
||||
clearSearch,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.buttons {
|
||||
flex: 1000 0 auto;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 3.5em;
|
||||
z-index: 10;
|
||||
min-height: 3em;
|
||||
}
|
||||
.buttons > * {
|
||||
flex-shrink: 1;
|
||||
.search-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 1.5em;
|
||||
padding: 0 0.3em;
|
||||
transition: background 0.2s ease;
|
||||
flex: 1 1 auto;
|
||||
min-width: 2.5em;
|
||||
max-width: 20em;
|
||||
}
|
||||
input[type='search'] {
|
||||
background: var(--input-background);
|
||||
color: var(--input-color);
|
||||
border: 0;
|
||||
border-radius: 0.1em;
|
||||
padding: 0.5em;
|
||||
.search-group:hover,
|
||||
.search-group:focus-within {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.search-group:focus-within {
|
||||
box-shadow: 0 0 0 2px var(--accent-color, #f80);
|
||||
}
|
||||
.search-group:hover :deep(button.action-button),
|
||||
.search-group:focus-within :deep(button.action-button) {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.search-group:hover :deep(button.action-button svg),
|
||||
.search-group:focus-within :deep(button.action-button svg) {
|
||||
fill: #fff;
|
||||
}
|
||||
.search-group:focus-within .search-hint {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.search-group :deep(.action-button) {
|
||||
width: 2.2em;
|
||||
height: 2.2em;
|
||||
min-width: 1.5em;
|
||||
min-height: 1.5em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.search-group input[type='search'] {
|
||||
background: transparent;
|
||||
color: var(--header-color);
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1.5em;
|
||||
max-width: 30vw;
|
||||
padding: 0.2em 0.5em 0.2em 0;
|
||||
font-size: inherit;
|
||||
flex: 1 1 3em;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.search-hint {
|
||||
position: absolute;
|
||||
right: 0.5em;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 1em;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
background: #ccc;
|
||||
border: 1px solid #999;
|
||||
border-radius: 0.3em;
|
||||
padding: 0 0.45em;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
display: none;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.search-hint {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
<template>
|
||||
<template v-if="documentStore.selected.size">
|
||||
<div class="smallgap"></div>
|
||||
<p class="select-text">{{ documentStore.selected.size }} selected ➤</p>
|
||||
<SvgButton name="download" data-tooltip="Download" @click="download" />
|
||||
<SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" />
|
||||
<SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" />
|
||||
<SvgButton name="trash" data-tooltip="Delete ⚠️" @click="op('rm')" />
|
||||
<button class="action-button unselect" data-tooltip="Unselect all" @click="documentStore.selected.clear()">❌</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {connect, controlUrl} from '@/repositories/WS'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { computed } from 'vue'
|
||||
import type { SelectedItems } from '@/repositories/Document'
|
||||
|
||||
const documentStore = useDocumentStore()
|
||||
const props = defineProps({
|
||||
path: Array<string>
|
||||
})
|
||||
|
||||
const dst = computed(() => props.path!.join('/'))
|
||||
const op = (op: string, dst?: string) => {
|
||||
const sel = documentStore.selectedFiles
|
||||
const msg = {
|
||||
op,
|
||||
sel: sel.keys.map(key => {
|
||||
const doc = sel.docs[key]
|
||||
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
})
|
||||
}
|
||||
// @ts-ignore
|
||||
if (dst !== undefined) msg.dst = dst
|
||||
const control = connect(controlUrl, {
|
||||
message(ev: MessageEvent) {
|
||||
const res = JSON.parse(ev.data)
|
||||
if ('error' in res) {
|
||||
console.error('Control socket error', msg, res.error)
|
||||
documentStore.error = res.error.message
|
||||
return
|
||||
} else if (res.status === 'ack') {
|
||||
console.log('Control ack OK', res)
|
||||
control.close()
|
||||
documentStore.selected.clear()
|
||||
return
|
||||
} else console.log('Unknown control response', msg, res)
|
||||
}
|
||||
})
|
||||
control.onopen = () => {
|
||||
control.send(JSON.stringify(msg))
|
||||
}
|
||||
}
|
||||
|
||||
const linkdl = (href: string) => {
|
||||
const a = document.createElement('a')
|
||||
a.href = href
|
||||
a.download = ''
|
||||
a.click()
|
||||
}
|
||||
|
||||
const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => {
|
||||
let hdir = ''
|
||||
let h = handle
|
||||
console.log('Downloading to filesystem', sel.recursive)
|
||||
for (const [rel, full, doc] of sel.recursive) {
|
||||
// Create any missing directories
|
||||
if (hdir && !rel.startsWith(hdir + '/')) {
|
||||
hdir = ''
|
||||
h = handle
|
||||
}
|
||||
const r = rel.slice(hdir.length)
|
||||
for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) {
|
||||
hdir += `${dir}/`
|
||||
try {
|
||||
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to create directory', hdir, error)
|
||||
return
|
||||
}
|
||||
console.log('Created', hdir)
|
||||
}
|
||||
if (doc.dir) continue // Target was a folder and was created
|
||||
const name = rel.split('/').pop()!.normalize('NFC')
|
||||
// Download file
|
||||
let fileHandle
|
||||
try {
|
||||
fileHandle = await h.getFileHandle(name, { create: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to create file', rel, full, hdir + name, error)
|
||||
return
|
||||
}
|
||||
const writable = await fileHandle.createWritable()
|
||||
const url = `/files/${rel}`
|
||||
console.log('Fetching', url)
|
||||
const res = await fetch(url)
|
||||
if (!res.ok)
|
||||
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
|
||||
if (res.body) await res.body.pipeTo(writable)
|
||||
else {
|
||||
// Zero-sized files don't have a body, so we need to create an empty file
|
||||
await writable.truncate(0)
|
||||
await writable.close()
|
||||
}
|
||||
console.log('Saved', hdir + name)
|
||||
}
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
const sel = documentStore.selectedFiles
|
||||
console.log('Download', sel)
|
||||
if (sel.keys.length === 0) {
|
||||
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
|
||||
documentStore.selected.clear()
|
||||
return
|
||||
}
|
||||
// Plain old a href download if only one file (ignoring any folders)
|
||||
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
|
||||
if (files.length === 1) {
|
||||
documentStore.selected.clear()
|
||||
return linkdl(`/files/${files[0][1]}`)
|
||||
}
|
||||
// Use FileSystem API if multiple files and the browser supports it
|
||||
if ('showDirectoryPicker' in window) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const handle = await window.showDirectoryPicker({
|
||||
startIn: 'downloads',
|
||||
mode: 'readwrite'
|
||||
})
|
||||
filesystemdl(sel, handle).then(() => {
|
||||
documentStore.selected.clear()
|
||||
})
|
||||
return
|
||||
} catch (e) {
|
||||
console.error('Download to folder aborted', e)
|
||||
}
|
||||
}
|
||||
// Otherwise, zip and download
|
||||
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
|
||||
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
||||
documentStore.selected.clear()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.select-text {
|
||||
color: var(--accent-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -1,101 +0,0 @@
|
||||
<template>
|
||||
<ModalDialog v-if="store.user.isOpenLoginModal" title="Authentication required" @blur="store.user.isOpenLoginModal = false">
|
||||
<form @submit.prevent="login">
|
||||
<div class="login-container">
|
||||
<label for="username">Username:</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
required
|
||||
v-model="loginForm.username"
|
||||
/>
|
||||
<label for="password">Password:</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
required
|
||||
v-model="loginForm.password"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="error-text">
|
||||
{{ loginForm.error || '\u00A0' }}
|
||||
</h3>
|
||||
<div class="dialog-buttons">
|
||||
<div class="spacer"></div>
|
||||
<input id="submit" type="submit" value="Login" class="button-login" />
|
||||
</div>
|
||||
</form>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { loginUser } from '@/repositories/User'
|
||||
import type { ISimpleError } from '@/repositories/Client'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
|
||||
const confirmLoading = ref<boolean>(false)
|
||||
const store = useDocumentStore()
|
||||
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
error: ''
|
||||
})
|
||||
|
||||
const login = async () => {
|
||||
try {
|
||||
loginForm.error = ''
|
||||
confirmLoading.value = true
|
||||
const msg = await loginUser(loginForm.username, loginForm.password)
|
||||
store.login(msg.data.username, !!msg.data.privileged)
|
||||
} catch (error) {
|
||||
const httpError = error as ISimpleError
|
||||
loginForm.error = httpError.message || '🛑 Unknown error'
|
||||
} finally {
|
||||
confirmLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.button-login {
|
||||
color: #fff;
|
||||
background: var(--soft-color);
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
border: 0;
|
||||
border-radius: .5rem;
|
||||
padding: .5rem 2rem;
|
||||
margin-left: auto;
|
||||
transition: all var(--transition-time) linear;
|
||||
}
|
||||
.button-login:hover, .button-login:focus {
|
||||
background: var(--accent-color);
|
||||
box-shadow: 0 0 .3rem #000;
|
||||
}
|
||||
.error-text {
|
||||
color: var(--red-color);
|
||||
height: 1em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div v-if=showProgress() class="preview-progress" aria-label="Preview pending">
|
||||
<SpinnerIcon />
|
||||
</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>
|
||||
<video v-else ref=vid :src=doc.url 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">
|
||||
<audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio>
|
||||
</div>
|
||||
<span v-else-if=archive() class="archive icon"></span>
|
||||
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
|
||||
</template>
|
||||
|
||||
<script setup lang=ts>
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Doc } from '@/repositories/Document'
|
||||
import { Play as PlayIcon, Spinner as SpinnerIcon } from '@/assets/svg'
|
||||
|
||||
const aud = ref<HTMLAudioElement | null>(null)
|
||||
const vid = ref<HTMLVideoElement | null>(null)
|
||||
const media = computed(() => aud.value || vid.value)
|
||||
const props = defineProps<{
|
||||
doc: Doc
|
||||
quality: string
|
||||
}>()
|
||||
const previewSrc = computed(() => props.doc.previewurl ? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}` : '')
|
||||
|
||||
const onplay = () => {
|
||||
if (!media.value) return
|
||||
media.value.controls = true
|
||||
media.value.setAttribute('data-playing', '')
|
||||
}
|
||||
const onpaused = () => {
|
||||
if (!media.value) return
|
||||
media.value.controls = false
|
||||
media.value.removeAttribute('data-playing')
|
||||
}
|
||||
const applyPoster = (el: HTMLVideoElement) => {
|
||||
if (props.doc.complete) {
|
||||
el.poster = previewSrc.value
|
||||
} else {
|
||||
el.removeAttribute('poster')
|
||||
}
|
||||
}
|
||||
let fscurrent: HTMLVideoElement | null = null
|
||||
const next = () => {
|
||||
if (!media.value) return
|
||||
media.value.load() // Restore poster
|
||||
const medias = Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]
|
||||
if (medias.length === 0) return
|
||||
let el: HTMLAudioElement | HTMLVideoElement | null = null
|
||||
for (const i in medias) {
|
||||
if (medias[i] === (fscurrent || media.value)) {
|
||||
el = medias[+i + 1] ?? medias[0] ?? null
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!el) return
|
||||
if (el.tagName === "VIDEO" && document.fullscreenElement === media.value) {
|
||||
// Fullscreen needs to use the current video element for the next video
|
||||
// because we are not allowed to fullscreen the next one.
|
||||
// FIXME: Write our own player to avoid this problem...
|
||||
const elem = media.value as HTMLVideoElement
|
||||
const playing = el as HTMLVideoElement
|
||||
if (elem === playing) {
|
||||
playing.play() // Only one video, just replay
|
||||
return
|
||||
}
|
||||
if (!fscurrent) {
|
||||
elem.addEventListener('fullscreenchange', ev => {
|
||||
if (!fscurrent) return
|
||||
// Restore the original video element and continue with the one that was playing
|
||||
fscurrent.currentTime = elem.currentTime
|
||||
fscurrent.click()
|
||||
if (!elem.paused) fscurrent.play()
|
||||
fscurrent = null
|
||||
elem.src = props.doc.url
|
||||
applyPoster(elem)
|
||||
onpaused()
|
||||
}, {once: true})
|
||||
}
|
||||
fscurrent = playing
|
||||
elem.src = playing.src
|
||||
elem.poster = ''
|
||||
elem.play()
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
el.click()
|
||||
}
|
||||
}
|
||||
defineExpose({
|
||||
play() {
|
||||
if (!media.value) return false
|
||||
if (media.value.paused) {
|
||||
media.value.play()
|
||||
for (const el of Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]) {
|
||||
if (el === media.value) continue
|
||||
el.pause()
|
||||
}
|
||||
} else {
|
||||
media.value.pause()
|
||||
}
|
||||
return true
|
||||
},
|
||||
media,
|
||||
})
|
||||
|
||||
|
||||
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext)
|
||||
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
|
||||
const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
|
||||
const showProgress = () => !props.doc.complete && (preview() || props.doc.img)
|
||||
const preview = () => (
|
||||
['bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', 'pdf', 'epub', 'mobi'].includes(props.doc.ext) ||
|
||||
props.doc.size > 500000 &&
|
||||
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext)
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
img, embed, .icon, audio, video {
|
||||
font-size: 8em;
|
||||
overflow: hidden;
|
||||
min-width: 50%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: calc(.5em / 8);
|
||||
}
|
||||
.preview-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 50%;
|
||||
max-width: 100%;
|
||||
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;
|
||||
}
|
||||
@keyframes media-preview-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.folder::before {
|
||||
content: '📁';
|
||||
}
|
||||
.folder:hover::before, .cursor .folder::before {
|
||||
content: '📂';
|
||||
}
|
||||
.archive::before {
|
||||
content: '📦';
|
||||
}
|
||||
.file::before {
|
||||
content: '📄';
|
||||
}
|
||||
.ext-img::before {
|
||||
content: '💿';
|
||||
}
|
||||
.ext-exe::before, .ext-msi::before, .ext-dmg::before, .ext-pkg::before {
|
||||
content: '⚙️';
|
||||
}
|
||||
.ext-torrent::before {
|
||||
content: '🏴☠️';
|
||||
}
|
||||
.audio audio {
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-time) ease-in-out;
|
||||
}
|
||||
.audio:hover audio {
|
||||
opacity: 1;
|
||||
}
|
||||
.audio.icon::before {
|
||||
width: 100%;
|
||||
content: '🔈';
|
||||
}
|
||||
.audio.icon:has(audio[data-playing])::before {
|
||||
position: absolute;
|
||||
content: '🔊';
|
||||
bottom: 0;
|
||||
}
|
||||
.icon {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
figure.cursor .icon {
|
||||
filter: brightness(1);
|
||||
}
|
||||
img::before {
|
||||
/* broken image */
|
||||
text-shadow: 0 0 .5rem #000;
|
||||
filter: grayscale(1);
|
||||
content: '❌';
|
||||
}
|
||||
.video-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 50%;
|
||||
min-height: 6em;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.video-container.pending {
|
||||
background: color-mix(in srgb, var(--header-bg) 55%, transparent);
|
||||
}
|
||||
.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>
|
||||
@@ -1,79 +1,279 @@
|
||||
<template>
|
||||
<dialog ref="dialog">
|
||||
<h1 v-if="props.title">{{ props.title }}</h1>
|
||||
<div>
|
||||
<slot>
|
||||
Dialog with no content
|
||||
<button onclick="dialog.close()">OK</button>
|
||||
</slot>
|
||||
<div v-if="store.dialog === name" class="modal-overlay" @click.self="close" @keydown.escape="close" tabindex="-1" ref="overlay">
|
||||
<div class="modal-dialog" :id="props.name" ref="dialog">
|
||||
<h1 v-if="props.title">{{ props.title }}</h1>
|
||||
<div class="modal-content">
|
||||
<slot>
|
||||
Dialog with no content
|
||||
<button @click="close">OK</button>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, watchEffect, nextTick } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||
|
||||
const dialog = ref<HTMLDialogElement | null>(null)
|
||||
const overlay = ref<HTMLDivElement | null>(null)
|
||||
const dialog = ref<HTMLDivElement | null>(null)
|
||||
const store = useMainStore()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
}>(),
|
||||
{
|
||||
title: ''
|
||||
}
|
||||
)
|
||||
const show = () => {
|
||||
dialog.value!.showModal()
|
||||
const close = () => {
|
||||
store.dialog = ''
|
||||
releaseGlobalBackdrop()
|
||||
}
|
||||
defineExpose({ show })
|
||||
onMounted(() => {
|
||||
show()
|
||||
|
||||
const props = defineProps<{
|
||||
title: string,
|
||||
name: typeof store.dialog,
|
||||
}>()
|
||||
|
||||
const show = () => {
|
||||
store.dialog = props.name
|
||||
holdGlobalBackdrop()
|
||||
nextTick(() => {
|
||||
overlay.value?.focus()
|
||||
const input = dialog.value?.querySelector('input')
|
||||
if (input) input.focus()
|
||||
})
|
||||
}
|
||||
defineExpose({ show, close })
|
||||
watchEffect(() => {
|
||||
if (overlay.value) {
|
||||
overlay.value.focus()
|
||||
const input = dialog.value?.querySelector('input')
|
||||
if (input) input.focus()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Style for the background */
|
||||
dialog::backdrop {
|
||||
content: '';
|
||||
display: block;
|
||||
/* ===========================================
|
||||
MODAL DIALOG GLOBAL STYLES
|
||||
Shared styling for all modal dialogs.
|
||||
Login page (auth.py) has matching CSS.
|
||||
=========================================== */
|
||||
|
||||
/* Overlay - covers entire viewport */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0008;
|
||||
backdrop-filter: blur(0.4em);
|
||||
z-index: 1000;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* No backdrop - paskia handles that */
|
||||
}
|
||||
|
||||
/* Hide the dialog by default */
|
||||
dialog[open] {
|
||||
/* Dialog container */
|
||||
.modal-dialog {
|
||||
background: #ddd;
|
||||
color: black;
|
||||
display: block;
|
||||
color: #000;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0.2rem 0.2rem 1rem #000;
|
||||
padding: 1rem;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1001;
|
||||
box-shadow: 0 0 1rem #0008;
|
||||
padding: 0;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
font-size: 1rem;
|
||||
}
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
dialog[open] > h1 {
|
||||
background: var(--soft-color);
|
||||
|
||||
/* Dialog title bar */
|
||||
.modal-dialog > h1 {
|
||||
background: #146;
|
||||
color: #fff;
|
||||
font-size: 1.2rem;
|
||||
margin: -1rem -1rem 0 -1rem;
|
||||
padding: 0.5rem 1rem 0.5rem 1rem;
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
dialog[open] > div {
|
||||
padding: 1em 0;
|
||||
/* Dialog content area */
|
||||
.modal-dialog > .modal-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Section headings inside dialog */
|
||||
.modal-dialog h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
}
|
||||
.modal-dialog h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.modal-dialog a {
|
||||
color: #146;
|
||||
}
|
||||
.modal-dialog a:hover {
|
||||
color: #f80;
|
||||
}
|
||||
|
||||
/* Form inputs */
|
||||
.modal-dialog input[type="text"],
|
||||
.modal-dialog input[type="password"],
|
||||
.modal-dialog select {
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem;
|
||||
border: 2px solid #888;
|
||||
border-radius: 0.25rem;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.modal-dialog input[type="text"]:focus,
|
||||
.modal-dialog input[type="password"]:focus,
|
||||
.modal-dialog select:focus {
|
||||
outline: none;
|
||||
border-color: #f80;
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
.modal-dialog label {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.modal-dialog button,
|
||||
.modal-dialog input[type="submit"],
|
||||
.modal-dialog input[type="reset"],
|
||||
.modal-dialog .button {
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #146;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-dialog button:hover,
|
||||
.modal-dialog input[type="submit"]:hover,
|
||||
.modal-dialog input[type="reset"]:hover,
|
||||
.modal-dialog .button:hover {
|
||||
background: #f80;
|
||||
}
|
||||
|
||||
.modal-dialog button:disabled,
|
||||
.modal-dialog input[type="submit"]:disabled,
|
||||
.modal-dialog input[type="reset"]:disabled,
|
||||
.modal-dialog .button:disabled {
|
||||
background: #888;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Small button variant */
|
||||
.modal-dialog .button.small {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Danger button variant */
|
||||
.modal-dialog .button.danger {
|
||||
background: #c00;
|
||||
}
|
||||
.modal-dialog .button.danger:hover:not(:disabled) {
|
||||
background: #f00;
|
||||
}
|
||||
|
||||
/* Form row layout (label + input side by side) */
|
||||
.modal-dialog .form-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Form grid for multiple label+input pairs */
|
||||
.modal-dialog .form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Dialog button row (footer) */
|
||||
.modal-dialog .dialog-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Error text */
|
||||
.modal-dialog .error-text {
|
||||
color: #c00;
|
||||
font-size: 0.875rem;
|
||||
min-height: 1.2em;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Success message */
|
||||
.modal-dialog .success-message {
|
||||
background: #f80;
|
||||
color: #000;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
margin: 0.5rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Data tables inside dialogs */
|
||||
.modal-dialog table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.modal-dialog th,
|
||||
.modal-dialog td {
|
||||
border: 1px solid #888;
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.modal-dialog th {
|
||||
background: #146;
|
||||
color: #fff;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.modal-dialog td {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Checkbox alignment in tables */
|
||||
.modal-dialog td input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Paragraph text */
|
||||
.modal-dialog p {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.modal-dialog .loading {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<input type=checkbox tabindex=-1 :checked="store.selected.has(doc.key)" @click.stop
|
||||
@change="ev => {
|
||||
if ((ev.target as HTMLInputElement).checked) {
|
||||
store.selected.add(doc.key)
|
||||
} else {
|
||||
store.selected.delete(doc.key)
|
||||
}
|
||||
}"
|
||||
>
|
||||
</template>
|
||||
|
||||
<script setup lang=ts>
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import type { Doc } from '@/repositories/Document'
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc
|
||||
}>()
|
||||
const store = useMainStore()
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="selection-bar" v-if="store.selected.size">
|
||||
<div class="select-info">
|
||||
<template v-if="selectionDisplay.folders.length <= 5">
|
||||
<span class="select-folders">
|
||||
<template v-for="(folder, i) in selectionDisplay.folders" :key="folder.path">
|
||||
<span v-if="i > 0" class="folder-sep">, </span>
|
||||
<a :href="'/#/' + folder.path" class="folder-link" @click.prevent="navigateTo(folder.path)">{{ folder.name }}</a>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="select-count">{{ store.selected.size }} items from {{ selectionDisplay.numFolders }} folders</span>
|
||||
</template>
|
||||
</div>
|
||||
<span class="select-size">{{ selectionDisplay.size }}</span>
|
||||
<DownloadButton />
|
||||
<SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" />
|
||||
<SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" />
|
||||
<SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" />
|
||||
<button
|
||||
class="action-button unselect"
|
||||
@click="store.selected.clear()"
|
||||
@mouseenter="unselectTooltip?.startHover"
|
||||
@mousemove="unselectTooltip?.updatePosition"
|
||||
@mouseleave="unselectTooltip?.endHover"
|
||||
>✖ selection</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {connect, controlUrl} from '@/repositories/WS'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatSize } from '@/utils'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
import router from '@/router'
|
||||
|
||||
const unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
const store = useMainStore()
|
||||
const props = defineProps({
|
||||
path: Array<string>
|
||||
})
|
||||
|
||||
const dst = computed(() => props.path!.join('/'))
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
router.push('/' + path)
|
||||
}
|
||||
|
||||
// Truncate long names to reasonable length
|
||||
const truncateName = (name: string, maxLen = 20): string => {
|
||||
if (name.length <= maxLen) return name
|
||||
return name.slice(0, maxLen - 1) + '…'
|
||||
}
|
||||
|
||||
interface FolderInfo {
|
||||
name: string
|
||||
path: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface SelectionDisplay {
|
||||
folders: FolderInfo[]
|
||||
numFolders: number
|
||||
size: string
|
||||
}
|
||||
|
||||
const selectionDisplay = computed<SelectionDisplay>(() => {
|
||||
const sel = store.selectedFiles
|
||||
|
||||
// Calculate total size
|
||||
const totalSize = sel.keys.reduce((sum, key) => {
|
||||
const doc = sel.docs[key]
|
||||
return sum + (doc ? doc.size : 0)
|
||||
}, 0)
|
||||
const sizeStr = formatSize(totalSize)
|
||||
|
||||
// Group by folder location, storing file names
|
||||
const folderGroups = new Map<string, string[]>()
|
||||
for (const key of sel.keys) {
|
||||
const doc = sel.docs[key]
|
||||
if (!doc) continue
|
||||
const loc = doc.loc || ''
|
||||
if (!folderGroups.has(loc)) folderGroups.set(loc, [])
|
||||
folderGroups.get(loc)!.push(doc.name)
|
||||
}
|
||||
|
||||
const numFolders = folderGroups.size
|
||||
|
||||
const folders = Array.from(folderGroups.entries())
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([loc, names]) => {
|
||||
const count = names.length
|
||||
// For single file, display truncated filename; for multiple, display folder name with count
|
||||
let displayName: string
|
||||
if (count === 1) {
|
||||
displayName = truncateName(names[0]!)
|
||||
} else {
|
||||
const folderName = loc ? loc.split('/').pop()! : (store.server.name || 'Root')
|
||||
displayName = `${truncateName(folderName)} (${count})`
|
||||
}
|
||||
return {
|
||||
name: displayName,
|
||||
path: loc,
|
||||
count
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
folders,
|
||||
numFolders,
|
||||
size: sizeStr
|
||||
}
|
||||
})
|
||||
|
||||
const op = (opName: string, dst?: string) => {
|
||||
const sel = store.selectedFiles
|
||||
const paths = sel.keys.map(key => {
|
||||
const doc = sel.docs[key]!
|
||||
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
})
|
||||
const msg = {
|
||||
op: opName,
|
||||
sel: paths
|
||||
}
|
||||
// @ts-ignore
|
||||
if (dst !== undefined) msg.dst = dst
|
||||
// Hide items being deleted or moved (optimistic update)
|
||||
if (opName === 'rm' || opName === 'mv') {
|
||||
for (const path of paths) store.hideDoc(path)
|
||||
}
|
||||
const control = connect(controlUrl, {
|
||||
message(ev: MessageEvent) {
|
||||
const res = JSON.parse(ev.data)
|
||||
if ('error' in res) {
|
||||
console.error('Control socket error', msg, res.error)
|
||||
store.error = res.error.message
|
||||
// Restore hidden items on error
|
||||
if (opName === 'rm' || opName === 'mv') {
|
||||
for (const path of paths) store.unhideDoc(path)
|
||||
}
|
||||
return
|
||||
} else if (res.status === 'ack') {
|
||||
console.log('Control ack OK', res)
|
||||
control.close()
|
||||
store.selected.clear()
|
||||
return
|
||||
} else console.log('Unknown control response', msg, res)
|
||||
}
|
||||
})
|
||||
control.onopen = () => {
|
||||
control.send(JSON.stringify(msg))
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.selection-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.3em 0.5em;
|
||||
background: transparent;
|
||||
color: var(--header-color);
|
||||
font-size: var(--header-font-size);
|
||||
gap: 0.3em;
|
||||
flex-wrap: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
.select-info {
|
||||
color: var(--accent-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.select-count {
|
||||
font-weight: 500;
|
||||
}
|
||||
.select-folders {
|
||||
display: inline;
|
||||
}
|
||||
.folder-link,
|
||||
.folder-link:link,
|
||||
.folder-link:visited,
|
||||
.folder-link:active {
|
||||
color: var(--accent-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.folder-link:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.folder-sep {
|
||||
color: var(--header-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.select-size {
|
||||
color: var(--header-color);
|
||||
opacity: 0.8;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 0.9em;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<ModalDialog name=settings title="Settings">
|
||||
<form>
|
||||
<template v-if="store.user.isLoggedIn">
|
||||
<h3>Update your authentication</h3>
|
||||
<div class="form-grid">
|
||||
<label for="passwordChange">New password:</label>
|
||||
<input
|
||||
ref="passwordChange"
|
||||
id="passwordChange"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
v-model="form.passwordChange"
|
||||
/>
|
||||
<label for="password">Current password:</label>
|
||||
<input
|
||||
ref="password"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
v-model="form.password"
|
||||
/>
|
||||
</div>
|
||||
<div class="dialog-buttons">
|
||||
<input id="close" type="reset" value="Close" class="button" @click=close />
|
||||
<div class="spacer"></div>
|
||||
<input id="submit" type="submit" value="Submit" class="button" @click.prevent="submit" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>No settings are available because you have not logged in.</p>
|
||||
<div class="dialog-buttons">
|
||||
<div class="spacer"></div>
|
||||
<input id="close" type="reset" value="Close" class="button" @click=close />
|
||||
</div>
|
||||
</template>
|
||||
</form>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { changePassword } from '@/repositories/User'
|
||||
import type { ISimpleError } from '@/repositories/Client'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
|
||||
const confirmLoading = ref<boolean>(false)
|
||||
const store = useMainStore()
|
||||
|
||||
const passwordChange = ref()
|
||||
const password = ref()
|
||||
|
||||
const form = reactive({
|
||||
passwordChange: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const close = () => {
|
||||
form.passwordChange = ''
|
||||
form.password = ''
|
||||
store.dialog = ''
|
||||
}
|
||||
const submit = async (ev: Event) => {
|
||||
ev.preventDefault()
|
||||
try {
|
||||
if (form.passwordChange) {
|
||||
if (!form.password) {
|
||||
store.showToast('⚠️ Current password is required')
|
||||
password.value!.focus()
|
||||
return
|
||||
}
|
||||
await changePassword(store.user.username, form.passwordChange, form.password)
|
||||
}
|
||||
close()
|
||||
} catch (error) {
|
||||
const httpError = error as ISimpleError
|
||||
store.showToast(httpError.message || '🛑 Unknown error')
|
||||
} finally {
|
||||
confirmLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<span v-if="doc.sparseIndicator" class="sparse-indicator">{{ doc.sparseIndicator }}</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Doc } from '@/repositories/Document'
|
||||
|
||||
defineProps<{
|
||||
doc: Doc
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sparse-indicator {
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,30 @@
|
||||
<template>
|
||||
<button class="action-button">
|
||||
<component :is="icon" />
|
||||
<button
|
||||
class="action-button"
|
||||
:tabindex="tabindex"
|
||||
@mouseenter="tooltip?.startHover"
|
||||
@mousemove="tooltip?.updatePosition"
|
||||
@mouseleave="tooltip?.endHover"
|
||||
>
|
||||
<component :is="icons[name]" />
|
||||
<slot></slot>
|
||||
<CursorTooltip v-if="tooltipText" ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, defineProps } from 'vue'
|
||||
import { icons, type IconName } from '@/assets/svg'
|
||||
import { ref } from 'vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
name: IconName
|
||||
tooltip?: string
|
||||
tabindex?: string | number
|
||||
}>()
|
||||
|
||||
const icon = defineAsyncComponent(() => import(`@/assets/svg/${props.name}.svg`))
|
||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
const tooltipText = props.tooltip ?? ''
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -22,18 +34,29 @@ const icon = defineAsyncComponent(() => import(`@/assets/svg/${props.name}.svg`)
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0.2em;
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
margin: 0 0.2em;
|
||||
padding: 0;
|
||||
width: 2.7em;
|
||||
height: 2.7em;
|
||||
min-width: 1.9em;
|
||||
min-height: 1.9em;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.action-button:hover,
|
||||
.action-button:focus {
|
||||
color: #fff;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
svg {
|
||||
.action-button svg {
|
||||
fill: #ccc;
|
||||
transform: fill 0.2s ease;
|
||||
transition: fill 0.2s ease;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.action-button:hover svg,
|
||||
.action-button:focus svg {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="transferprogress" v-if="status.total" :style="`background: linear-gradient(to right, var(--bar) 0, var(--bar) ${percent}%, var(--nobar) ${percent}%, var(--nobar) 100%);`">
|
||||
<div class="statustext">
|
||||
<span v-if="status.filecount > 1" class="index">
|
||||
[{{ status.fileidx }}/{{ status.filecount }}]
|
||||
</span>
|
||||
<span class="filename">{{ status.filename.split('/').pop() }}
|
||||
<span v-if="status.filesize > 1e7" class="percent">
|
||||
{{ (status.filepos / status.filesize * 100).toFixed(0) + '\u202F%' }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="position" v-if="status.total > 1e7">
|
||||
{{ (status.xfer / 1e6).toFixed(0) + '\u202F/\u202F' + (status.total / 1e6).toFixed(0) + '\u202FMB' }}
|
||||
</span>
|
||||
<span class="speed">{{ speeddisp }}</span>
|
||||
<button class="close" @click="$emit('cancel')">❌</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineEmits(['cancel'])
|
||||
|
||||
const props = defineProps<{
|
||||
status: {
|
||||
total: number
|
||||
xfer: number
|
||||
filecount: number
|
||||
fileidx: number
|
||||
filesize: number
|
||||
filepos: number
|
||||
filename: string
|
||||
statbytes: number
|
||||
statdur: number
|
||||
tlast: number
|
||||
}
|
||||
}>()
|
||||
|
||||
const percent = computed(() => props.status.xfer / props.status.total * 100)
|
||||
const speed = computed(() => {
|
||||
let s = props.status.statbytes / props.status.statdur / 1e3
|
||||
const tsince = (Date.now() - props.status.tlast) / 1e3
|
||||
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
|
||||
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
|
||||
return s // "Current speed"
|
||||
})
|
||||
const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s': 'stalled')
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.transferprogress {
|
||||
--bar: var(--accent-color);
|
||||
--nobar: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
color: var(--primary-color);
|
||||
width: 100%;
|
||||
}
|
||||
.statustext {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 .5em;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
span {
|
||||
color: #ccc;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
.filename {
|
||||
color: #fff;
|
||||
flex: 1 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
}
|
||||
.index { min-width: 3.5em }
|
||||
.position { min-width: 4em }
|
||||
.speed { min-width: 4em }
|
||||
|
||||
.upload .statustext::before {
|
||||
font-size: 1.5em;
|
||||
content: '🔺'
|
||||
}
|
||||
.download .statustext::before {
|
||||
font-size: 1.5em;
|
||||
content: '🔻'
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,25 @@
|
||||
<template>
|
||||
<template>
|
||||
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
|
||||
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
|
||||
</template>
|
||||
<SvgButton name="add-file" tooltip="Upload files" @click="fileInput.click()" />
|
||||
<SvgButton name="add-folder" tooltip="Upload folder" @click="folderInput.click()" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { connect, uploadUrl } from '@/repositories/WS';
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { getDocuments } from '@/stores/documentStore'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { collator } from '@/utils';
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const fileInput = ref()
|
||||
const folderInput = ref()
|
||||
const documentStore = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
const props = defineProps({
|
||||
path: Array<string>
|
||||
})
|
||||
@@ -25,7 +38,7 @@ function pasteHandler(event: ClipboardEvent) {
|
||||
const entry = item.webkitGetAsEntry()
|
||||
if (entry?.isFile) {
|
||||
const file = item.getAsFile()
|
||||
infiles.push(file)
|
||||
if (file) infiles.push(file)
|
||||
} else if (entry?.isDirectory) {
|
||||
dirs.push(entry as FileSystemDirectoryEntry)
|
||||
}
|
||||
@@ -63,24 +76,53 @@ function uploadHandler(event: Event) {
|
||||
const uploadFiles = (infiles: File[]) => {
|
||||
const loc = props.path!.join('/')
|
||||
let files = []
|
||||
let folderName = ''
|
||||
for (const file of infiles) {
|
||||
const relPath = file.webkitRelativePath || file.name
|
||||
if (!folderName && file.webkitRelativePath) folderName = relPath.split('/')[0] ?? ''
|
||||
files.push({
|
||||
file,
|
||||
cloudName: loc + '/' + (file.webkitRelativePath || file.name),
|
||||
cloudName: loc + '/' + relPath,
|
||||
cloudPos: 0,
|
||||
})
|
||||
}
|
||||
uploadCloudFiles(files)
|
||||
if (folderName) router.push('/' + (loc ? loc + '/' : '') + folderName + '/')
|
||||
}
|
||||
const uploadCloudFiles = (files: CloudFile[]) => {
|
||||
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
|
||||
if (dotfiles.length) {
|
||||
documentStore.error = "Won't upload dotfiles"
|
||||
console.log("Dotfiles omitted", dotfiles)
|
||||
store.showToast("Won't upload dotfiles")
|
||||
files = files.filter(f => !f.cloudName.includes('/.'))
|
||||
}
|
||||
if (!files.length) return
|
||||
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
|
||||
// Optimistic update: ghost folders and files
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const docs = getDocuments()
|
||||
const byPath = new Map(docs.map(d => [d.loc ? `${d.loc}/${d.name}` : d.name, d]))
|
||||
// Also check existing ghosts
|
||||
for (const g of store.ghosts) {
|
||||
byPath.set(g.loc ? `${g.loc}/${g.name}` : g.name, g)
|
||||
}
|
||||
const added = new Set<string>()
|
||||
for (const f of files) {
|
||||
const lastSlash = f.cloudName.lastIndexOf('/')
|
||||
const loc = lastSlash > 0 ? f.cloudName.slice(0, lastSlash) : ''
|
||||
const name = f.cloudName.slice(lastSlash + 1)
|
||||
// Ghost folders for intermediate directories
|
||||
const parts = loc.split('/')
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const folderPath = parts.slice(0, i + 1).join('/')
|
||||
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
|
||||
store.addGhost(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, allocated: 0, mtime: now, dir: true }))
|
||||
added.add(folderPath)
|
||||
}
|
||||
}
|
||||
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
|
||||
const existing = byPath.get(f.cloudName)
|
||||
if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, allocated: 0, mtime: now, dir: false }))
|
||||
}
|
||||
// @ts-ignore
|
||||
upqueue = [...upqueue, ...files]
|
||||
statsAdd(files)
|
||||
@@ -89,12 +131,13 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
||||
|
||||
const cancelUploads = () => {
|
||||
upqueue = []
|
||||
blockQueue = []
|
||||
statReset()
|
||||
}
|
||||
|
||||
const uprogress_init = {
|
||||
total: 0,
|
||||
uploaded: 0,
|
||||
xfer: 0,
|
||||
t0: 0,
|
||||
tlast: 0,
|
||||
statbytes: 0,
|
||||
@@ -108,76 +151,129 @@ const uprogress_init = {
|
||||
filepos: 0,
|
||||
status: 'idle',
|
||||
}
|
||||
const uprogress = reactive({...uprogress_init})
|
||||
const percent = computed(() => uprogress.uploaded / uprogress.total * 100)
|
||||
const speed = computed(() => {
|
||||
let s = uprogress.statbytes / uprogress.statdur / 1e3
|
||||
const tsince = (Date.now() - uprogress.tlast) / 1e3
|
||||
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
|
||||
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
|
||||
return s // "Current speed"
|
||||
})
|
||||
const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s': 'stalled')
|
||||
store.uprogress = {...uprogress_init}
|
||||
setInterval(() => {
|
||||
if (Date.now() - uprogress.tlast > 3000) {
|
||||
if (Date.now() - store.uprogress.tlast > 3000) {
|
||||
// Reset
|
||||
uprogress.statbytes = 0
|
||||
uprogress.statdur = 1
|
||||
store.uprogress.statbytes = 0
|
||||
store.uprogress.statdur = 1
|
||||
} else {
|
||||
// Running average by decay
|
||||
uprogress.statbytes *= .9
|
||||
uprogress.statdur *= .9
|
||||
store.uprogress.statbytes *= .9
|
||||
store.uprogress.statdur *= .9
|
||||
}
|
||||
}, 100)
|
||||
// Track uploaded bytes for each file to handle out-of-order uploads
|
||||
const uploadedBytes = new Map<string, Set<number>>()
|
||||
|
||||
const statUpdate = ({name, size, start, end}: {name: string, size: number, start: number, end: number}) => {
|
||||
if (name !== uprogress.filename) return // If stats have been reset
|
||||
if (name !== store.uprogress.filename) return // If stats have been reset
|
||||
const now = Date.now()
|
||||
uprogress.uploaded = uprogress.filestart + end
|
||||
uprogress.filepos = end
|
||||
uprogress.statbytes += end - start
|
||||
uprogress.statdur += now - uprogress.tlast
|
||||
uprogress.tlast = now
|
||||
// File finished?
|
||||
if (end === size) {
|
||||
uprogress.filestart += size
|
||||
|
||||
// Track which bytes have been uploaded (using start to end range)
|
||||
if (!uploadedBytes.has(name)) uploadedBytes.set(name, new Set())
|
||||
const uploaded = uploadedBytes.get(name)!
|
||||
const blockSize = 1 << 20
|
||||
|
||||
// Mark all bytes in this block as uploaded
|
||||
for (let i = start; i < end; i += blockSize) {
|
||||
uploaded.add(i)
|
||||
}
|
||||
|
||||
// Calculate total uploaded bytes for progress
|
||||
let totalUploaded = 0
|
||||
for (let i = 0; i < size; i += blockSize) {
|
||||
if (uploaded.has(i)) totalUploaded += blockSize
|
||||
}
|
||||
|
||||
store.uprogress.xfer = store.uprogress.filestart + totalUploaded
|
||||
store.uprogress.filepos = totalUploaded
|
||||
store.uprogress.statbytes += end - start
|
||||
store.uprogress.statdur += now - store.uprogress.tlast
|
||||
store.uprogress.tlast = now
|
||||
|
||||
// Check if file is fully uploaded by examining the block queue
|
||||
const currentUpload = blockQueue[0]
|
||||
if (!currentUpload) return
|
||||
|
||||
if (currentUpload.file.cloudName === name && currentUpload.blockIndex >= currentUpload.blocks.length) {
|
||||
// All blocks for this file have been uploaded
|
||||
uploadedBytes.delete(name) // Clean up tracking
|
||||
store.uprogress.filestart += size
|
||||
statNextFile()
|
||||
if (++uprogress.fileidx >= uprogress.filecount) statReset()
|
||||
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
|
||||
}
|
||||
}
|
||||
const statNextFile = () => {
|
||||
const f = uprogress.files.shift()
|
||||
const f = store.uprogress.files.shift()
|
||||
if (!f) return statReset()
|
||||
uprogress.filepos = 0
|
||||
uprogress.filesize = f.file.size
|
||||
uprogress.filename = f.cloudName
|
||||
store.uprogress.filepos = 0
|
||||
store.uprogress.filesize = f.file.size
|
||||
store.uprogress.filename = f.cloudName
|
||||
}
|
||||
const statReset = () => {
|
||||
Object.assign(uprogress, uprogress_init)
|
||||
uprogress.t0 = Date.now()
|
||||
uprogress.tlast = uprogress.t0 + 1
|
||||
Object.assign(store.uprogress, uprogress_init)
|
||||
store.uprogress.t0 = Date.now()
|
||||
store.uprogress.tlast = store.uprogress.t0 + 1
|
||||
}
|
||||
const statsAdd = (f: CloudFile[]) => {
|
||||
if (uprogress.files.length === 0) statReset()
|
||||
uprogress.total += f.reduce((a, b) => a + b.file.size, 0)
|
||||
uprogress.filecount += f.length
|
||||
uprogress.files = [...uprogress.files, ...f]
|
||||
if (store.uprogress.files.length === 0) statReset()
|
||||
store.uprogress.total += f.reduce((a, b) => a + b.file.size, 0)
|
||||
store.uprogress.filecount += f.length
|
||||
store.uprogress.files = [...store.uprogress.files, ...f]
|
||||
statNextFile()
|
||||
}
|
||||
let upqueue = [] as CloudFile[]
|
||||
|
||||
// Helper function to get upload blocks for a file, prioritizing final 4 blocks if file >= 32 MiB
|
||||
const getUploadBlocks = (file: CloudFile): {start: number, end: number}[] => {
|
||||
const BLOCK_SIZE = 1 << 20 // 1 MiB
|
||||
const MIN_SIZE_FOR_REORDER = 32 * BLOCK_SIZE // 32 MiB = 33554432 bytes
|
||||
const FINAL_BLOCKS_COUNT = 2
|
||||
|
||||
const fileSize = file.file.size
|
||||
const blocks: {start: number, end: number}[] = []
|
||||
|
||||
if (fileSize >= MIN_SIZE_FOR_REORDER) {
|
||||
// File is large enough, prioritize final blocks
|
||||
const finalBlocksStart = fileSize - (FINAL_BLOCKS_COUNT * BLOCK_SIZE)
|
||||
|
||||
// Add final blocks first
|
||||
for (let i = 0; i < FINAL_BLOCKS_COUNT; i++) {
|
||||
const start = finalBlocksStart + (i * BLOCK_SIZE)
|
||||
const end = Math.min(start + BLOCK_SIZE, fileSize)
|
||||
blocks.push({start, end})
|
||||
}
|
||||
|
||||
// Add remaining blocks from beginning
|
||||
for (let start = 0; start < finalBlocksStart; start += BLOCK_SIZE) {
|
||||
const end = Math.min(start + BLOCK_SIZE, finalBlocksStart)
|
||||
blocks.push({start, end})
|
||||
}
|
||||
} else {
|
||||
// File is smaller, use sequential upload
|
||||
for (let start = 0; start < fileSize; start += BLOCK_SIZE) {
|
||||
const end = Math.min(start + BLOCK_SIZE, fileSize)
|
||||
blocks.push({start, end})
|
||||
}
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
// TODO: Rewrite as WebSocket class
|
||||
const WSCreate = async () => await new Promise<WebSocket>(resolve => {
|
||||
const ws = connect(uploadUrl, {
|
||||
open(ev: Event) { resolve(ws) },
|
||||
error(ev: Event) {
|
||||
console.error('Upload socket error', ev)
|
||||
documentStore.error = 'Upload socket error'
|
||||
store.error = 'Upload socket error'
|
||||
},
|
||||
message(ev: MessageEvent) {
|
||||
const res = JSON.parse(ev!.data)
|
||||
if ('error' in res) {
|
||||
console.error('Upload socket error', res.error)
|
||||
documentStore.error = res.error.message
|
||||
store.error = res.error.message
|
||||
return
|
||||
}
|
||||
if (res.status === 'ack') {
|
||||
@@ -190,7 +286,7 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
|
||||
// @ts-ignore
|
||||
ws.sendData = async (data: any) => {
|
||||
// Wait until the WS is ready to send another message
|
||||
uprogress.status = "uploading"
|
||||
store.uprogress.status = "uploading"
|
||||
await new Promise(resolve => {
|
||||
const t = setInterval(() => {
|
||||
if (ws.bufferedAmount > 1<<20) return
|
||||
@@ -198,35 +294,62 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
|
||||
clearInterval(t)
|
||||
}, 1)
|
||||
})
|
||||
uprogress.status = "processing"
|
||||
store.uprogress.status = "processing"
|
||||
ws.send(data)
|
||||
}
|
||||
})
|
||||
|
||||
type BlockUpload = {
|
||||
file: CloudFile
|
||||
blocks: {start: number, end: number}[]
|
||||
blockIndex: number
|
||||
}
|
||||
|
||||
let blockQueue = [] as BlockUpload[]
|
||||
|
||||
const worker = async () => {
|
||||
const ws = await WSCreate()
|
||||
while (upqueue.length) {
|
||||
const f = upqueue[0]
|
||||
const start = f.cloudPos
|
||||
const end = Math.min(f.file.size, start + (1<<20))
|
||||
const control = { name: f.cloudName, size: f.file.size, start, end }
|
||||
const data = f.file.slice(start, end)
|
||||
f.cloudPos = end
|
||||
while (blockQueue.length) {
|
||||
const upload = blockQueue[0]!
|
||||
const f = upload.file
|
||||
const block = upload.blocks[upload.blockIndex]!
|
||||
|
||||
const control = { name: f.cloudName, size: f.file.size, start: block.start, end: block.end }
|
||||
const data = f.file.slice(block.start, block.end)
|
||||
|
||||
// Note: files may get modified during I/O
|
||||
// @ts-ignore FIXME proper WebSocket class, avoid attaching functions to WebSocket object
|
||||
ws.sendMsg(control)
|
||||
// @ts-ignore
|
||||
await ws.sendData(data)
|
||||
if (f.cloudPos === f.file.size) upqueue.shift()
|
||||
|
||||
// Move to next block
|
||||
upload.blockIndex++
|
||||
if (upload.blockIndex >= upload.blocks.length) {
|
||||
// File upload complete
|
||||
blockQueue.shift()
|
||||
}
|
||||
}
|
||||
if (upqueue.length) startWorker()
|
||||
uprogress.status = "idle"
|
||||
if (blockQueue.length) startWorker()
|
||||
store.uprogress.status = "idle"
|
||||
workerRunning = false
|
||||
}
|
||||
let workerRunning: any = false
|
||||
const startWorker = () => {
|
||||
if (workerRunning === false) workerRunning = setTimeout(() => {
|
||||
workerRunning = true
|
||||
worker()
|
||||
// Convert new CloudFile entries to BlockUpload entries
|
||||
while (upqueue.length) {
|
||||
const file = upqueue.shift()!
|
||||
const blocks = getUploadBlocks(file)
|
||||
blockQueue.push({ file, blocks, blockIndex: 0 })
|
||||
}
|
||||
|
||||
if (blockQueue.length) {
|
||||
workerRunning = true
|
||||
worker()
|
||||
} else {
|
||||
workerRunning = false
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
@@ -242,63 +365,3 @@ onUnmounted(() => {
|
||||
removeEventListener('drop', uploadHandler)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<template>
|
||||
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
|
||||
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
|
||||
</template>
|
||||
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" />
|
||||
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderInput.click()" />
|
||||
<div class="uploadprogress" v-if="uprogress.total" :style="`background: linear-gradient(to right, var(--bar) 0, var(--bar) ${percent}%, var(--nobar) ${percent}%, var(--nobar) 100%);`">
|
||||
<div class="statustext">
|
||||
<span v-if="uprogress.filecount > 1" class="index">
|
||||
[{{ uprogress.fileidx }}/{{ uprogress.filecount }}]
|
||||
</span>
|
||||
<span class="filename">{{ uprogress.filename.split('/').pop() }}
|
||||
<span v-if="uprogress.filesize > 1e7" class="percent">
|
||||
{{ (uprogress.filepos / uprogress.filesize * 100).toFixed(0) + '\u202F%' }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="position" v-if="uprogress.total > 1e7">
|
||||
{{ (uprogress.uploaded / 1e6).toFixed(0) + '\u202F/\u202F' + (uprogress.total / 1e6).toFixed(0) + '\u202FMB' }}
|
||||
</span>
|
||||
<span class="speed">{{ speeddisp }}</span>
|
||||
<button class="close" @click="cancelUploads">❌</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.uploadprogress {
|
||||
--bar: var(--accent-color);
|
||||
--nobar: var(--header-background);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--primary-color);
|
||||
position: fixed;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100vw;
|
||||
}
|
||||
.statustext {
|
||||
display: flex;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
span {
|
||||
color: #ccc;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
.filename {
|
||||
color: #fff;
|
||||
flex: 1 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
}
|
||||
.index { min-width: 3.5em }
|
||||
.position { min-width: 4em }
|
||||
.speed { min-width: 4em }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
<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">
|
||||
<label for="serverName">Server name</label>
|
||||
<div class="input-with-hint">
|
||||
<input
|
||||
type="text"
|
||||
id="serverName"
|
||||
v-model="serverSettings.name"
|
||||
@input="debouncedUpdateServerName"
|
||||
:placeholder="store.server.name"
|
||||
/>
|
||||
<small>Leave empty to use the share folder name</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="publicAccess">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="publicAccess"
|
||||
v-model="serverSettings.public"
|
||||
@change="updateServerSettings"
|
||||
/>
|
||||
Public access (anyone can read and write)
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="store.server.paskia">
|
||||
<h3>User Management</h3>
|
||||
<p>See <a href="/auth/admin/">Paskia Admin</a>.</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
<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, updateServerName, getServerConfig } 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 success = ref('')
|
||||
const copyButtonText = ref('📋')
|
||||
const serverSettings = reactive({
|
||||
public: false,
|
||||
name: '',
|
||||
})
|
||||
|
||||
let nameDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const close = () => {
|
||||
store.dialog = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const data = await listUsers()
|
||||
users.value = data.users
|
||||
} catch (e) {
|
||||
const httpError = e as ISimpleError
|
||||
store.showToast(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 {
|
||||
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
|
||||
store.showToast(httpError.message || 'Failed to add user')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAdmin = async (user: User, event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
try {
|
||||
await updateUser(user.username, { privileged: target.checked })
|
||||
user.privileged = target.checked
|
||||
} catch (e) {
|
||||
const httpError = e as ISimpleError
|
||||
store.showToast(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 {
|
||||
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
|
||||
store.showToast(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 {
|
||||
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
|
||||
store.showToast(httpError.message || 'Failed to reset password')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteUserAction = async (username: string) => {
|
||||
if (!confirm(`Delete user ${username}?`)) return
|
||||
try {
|
||||
await deleteUser(username)
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
const httpError = e as ISimpleError
|
||||
store.showToast(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 {
|
||||
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
|
||||
store.showToast(httpError.message || 'Failed to update settings')
|
||||
}
|
||||
}
|
||||
|
||||
const updateServerNameSetting = async () => {
|
||||
try {
|
||||
const result = await updateServerName(serverSettings.name)
|
||||
// Update store with the effective name returned by the server
|
||||
store.server.name = result.name
|
||||
} catch (e) {
|
||||
const httpError = e as ISimpleError
|
||||
store.showToast(httpError.message || 'Failed to update server name')
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedUpdateServerName = () => {
|
||||
if (nameDebounceTimer) clearTimeout(nameDebounceTimer)
|
||||
nameDebounceTimer = setTimeout(updateServerNameSetting, 400)
|
||||
}
|
||||
|
||||
// Load server config from admin API
|
||||
const loadServerConfig = async () => {
|
||||
try {
|
||||
const config = await getServerConfig()
|
||||
serverSettings.name = config.name
|
||||
serverSettings.public = config.public
|
||||
} catch (e) {
|
||||
// Fallback to store values if API fails
|
||||
serverSettings.public = store.server.public || false
|
||||
serverSettings.name = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
serverSettings.public = store.server.public || false
|
||||
serverSettings.name = ''
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
// Load users and config when dialog opens
|
||||
watch(() => store.dialog, (newVal) => {
|
||||
if (newVal === 'usermgmt') {
|
||||
loadServerConfig()
|
||||
if (!store.server.paskia) {
|
||||
loadUsers()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => store.server.public, (newVal) => {
|
||||
serverSettings.public = newVal || false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
|
||||
.input-with-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.input-with-hint small {
|
||||
color: #666;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +1,71 @@
|
||||
import { apiJson, apiFetch, AuthCancelledError } from 'paskia'
|
||||
|
||||
// Type for API error responses
|
||||
interface ApiError {
|
||||
error: {
|
||||
code: number
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
function hasError(msg: unknown): msg is ApiError {
|
||||
return typeof msg === 'object' && msg !== null && 'error' in msg
|
||||
}
|
||||
|
||||
class ClientClass {
|
||||
async post(url: string, data?: Record<string, any>): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: data !== undefined ? JSON.stringify(data) : undefined
|
||||
})
|
||||
let msg
|
||||
async get(url: string): Promise<any> {
|
||||
try {
|
||||
msg = await res.json()
|
||||
const msg = await apiJson(url, { method: 'GET' })
|
||||
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
|
||||
return msg
|
||||
} catch (e) {
|
||||
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
||||
if (e instanceof AuthCancelledError) {
|
||||
throw new SimpleError(401, 'Authentication cancelled')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
async post(url: string, data?: Record<string, any>): Promise<any> {
|
||||
try {
|
||||
const msg = await apiJson(url, {
|
||||
method: 'POST',
|
||||
body: data
|
||||
})
|
||||
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
|
||||
return msg
|
||||
} catch (e) {
|
||||
if (e instanceof AuthCancelledError) {
|
||||
throw new SimpleError(401, 'Authentication cancelled')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
async put(url: string, data?: Record<string, any>): Promise<any> {
|
||||
try {
|
||||
const msg = await apiJson(url, {
|
||||
method: 'PUT',
|
||||
body: data
|
||||
})
|
||||
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
|
||||
return msg
|
||||
} catch (e) {
|
||||
if (e instanceof AuthCancelledError) {
|
||||
throw new SimpleError(401, 'Authentication cancelled')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
async delete(url: string): Promise<any> {
|
||||
try {
|
||||
const msg = await apiJson(url, { method: 'DELETE' })
|
||||
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
|
||||
return msg
|
||||
} catch (e) {
|
||||
if (e instanceof AuthCancelledError) {
|
||||
throw new SimpleError(401, 'Authentication cancelled')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,4 +82,5 @@ class SimpleError extends Error implements ISimpleError {
|
||||
}
|
||||
}
|
||||
|
||||
export { apiFetch }
|
||||
export default Client
|
||||
|
||||
@@ -1,17 +1,84 @@
|
||||
import { formatSize, formatUnixDate } from "@/utils"
|
||||
|
||||
export type FUID = string
|
||||
|
||||
export type Document = {
|
||||
export type DocProps = {
|
||||
loc: string
|
||||
name: string
|
||||
key: FUID
|
||||
size: number
|
||||
sizedisp: string
|
||||
allocated: number
|
||||
mtime: number
|
||||
modified: string
|
||||
haystack: string
|
||||
dir: boolean
|
||||
ghost?: boolean
|
||||
expires?: number // Unix timestamp for ghost expiry
|
||||
}
|
||||
|
||||
export class Doc {
|
||||
public loc: string = ""
|
||||
public key: FUID = ""
|
||||
public size: number = 0
|
||||
public allocated: number = 0
|
||||
public mtime: number = 0
|
||||
public dir: boolean = false
|
||||
public ghost: boolean = false
|
||||
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
|
||||
/** @internal Use the name getter/setter instead */
|
||||
public _name: string = ""
|
||||
|
||||
constructor(props: Partial<DocProps> = {}) {
|
||||
const { name, ...rest } = props
|
||||
Object.assign(this, rest)
|
||||
if (name) this._name = name // Skip validation/haystack for bulk loading
|
||||
}
|
||||
get name() { return this._name }
|
||||
set name(name: string) {
|
||||
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
||||
this._name = name
|
||||
}
|
||||
get sizedisp(): string { return formatSize(this.size) }
|
||||
/** Returns a sparse allocation indicator symbol, or empty string if fully allocated */
|
||||
get sparseIndicator(): string {
|
||||
if (this.dir || this.size <= this.allocated) return ''
|
||||
if (this.allocated === 0) return '⭕' // exactly zero
|
||||
const ratio = this.allocated / this.size
|
||||
// Round to nearest 25%: ◔◑◕⬤
|
||||
const rounded = Math.round(ratio * 4) // 0,1,2,3,4
|
||||
return ['◔', '◔', '◑', '◕', '⬤'][rounded]! // 0 maps to ◔ since we handled exact 0 above
|
||||
}
|
||||
get modified(): string { return formatUnixDate(this.mtime) }
|
||||
get url(): string {
|
||||
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
||||
return this.dir ? '/#/' + `${p}/`.replaceAll('#', '%23') : `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
}
|
||||
get urlrouter(): string {
|
||||
return this.url.replace(/^\/#/, '')
|
||||
}
|
||||
get img(): boolean {
|
||||
// Folders cannot be images
|
||||
if (this.dir) return false
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'heif', 'svg'].includes(this.ext)
|
||||
}
|
||||
get complete(): boolean {
|
||||
return !this.ghost && (this.dir || this.size <= this.allocated)
|
||||
}
|
||||
get previewable(): boolean {
|
||||
// Folders cannot be previewable
|
||||
if (this.dir) return false
|
||||
if (this.img) return true
|
||||
// Not a comprehensive list, but good enough for now
|
||||
return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].includes(this.ext)
|
||||
}
|
||||
get previewurl(): string {
|
||||
if (!this.complete || !this.previewable) return ''
|
||||
return this.url.replace(/^\/files/, '/preview')
|
||||
}
|
||||
get ext(): string {
|
||||
const dotIndex = this.name.lastIndexOf('.')
|
||||
if (dotIndex === -1 || dotIndex === this.name.length - 1) return ''
|
||||
return this.name.slice(dotIndex + 1).toLowerCase()
|
||||
}
|
||||
}
|
||||
export type errorEvent = {
|
||||
error: {
|
||||
code: number
|
||||
@@ -26,9 +93,10 @@ export type FileEntry = [
|
||||
number, // level
|
||||
string, // name
|
||||
FUID,
|
||||
number, //mtime
|
||||
number, // size
|
||||
number, // isfile
|
||||
number, // mtime
|
||||
number, // size
|
||||
number, // allocated (actual disk usage)
|
||||
number, // isfile
|
||||
]
|
||||
|
||||
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
|
||||
@@ -36,7 +104,7 @@ export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>
|
||||
// Helper structure for selections
|
||||
export interface SelectedItems {
|
||||
keys: FUID[]
|
||||
docs: Record<FUID, Document>
|
||||
recursive: Array<[string, string, Document]>
|
||||
docs: Record<FUID, Doc>
|
||||
recursive: Array<[string, string, Doc]>
|
||||
missing: Set<FUID>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Client from '@/repositories/Client'
|
||||
export const url_login = '/login'
|
||||
export const url_logout = '/logout '
|
||||
import { useMainStore } from '@/stores/main'
|
||||
export const url_login = '/auth/login'
|
||||
export const url_logout = '/auth/api/logout'
|
||||
export const url_password = '/auth/password-change'
|
||||
|
||||
export async function loginUser(username: string, password: string) {
|
||||
const user = await Client.post(url_login, {
|
||||
@@ -13,3 +15,53 @@ export async function logoutUser() {
|
||||
const data = await Client.post(url_logout)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function changePassword(username: string, passwordChange: string, password: string) {
|
||||
const data = await Client.post(url_password, {
|
||||
username,
|
||||
passwordChange,
|
||||
password
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export const url_users = '/auth/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(isPublic: boolean) {
|
||||
const data = await Client.put('/api/config/public', { public: isPublic })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateServerName(name: string) {
|
||||
const data = await Client.put('/api/config/name', { name })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getServerConfig() {
|
||||
const data = await Client.get('/api/config')
|
||||
return data as { name: string, public: boolean }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useDocumentStore } from "@/stores/documents"
|
||||
import { useMainStore } from "@/stores/main"
|
||||
import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia'
|
||||
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
|
||||
|
||||
export const controlUrl = '/api/control'
|
||||
@@ -6,22 +7,33 @@ export const uploadUrl = '/api/upload'
|
||||
export const watchUrl = '/api/watch'
|
||||
|
||||
let tree = [] as FileEntry[]
|
||||
let reconnectDuration = 500
|
||||
let reconnDelay = 500
|
||||
let wsWatch = null as WebSocket | null
|
||||
// Track when we're awaiting authentication to prevent reconnection loops
|
||||
let awaitingAuth = false
|
||||
|
||||
// Clear the local tree cache (called on logout/auth failure)
|
||||
export const clearTree = () => {
|
||||
tree = []
|
||||
}
|
||||
|
||||
export const loadSession = () => {
|
||||
const store = useDocumentStore()
|
||||
const s = localStorage['cista-files']
|
||||
if (!s) return false
|
||||
const store = useMainStore()
|
||||
try {
|
||||
tree = JSON.parse(sessionStorage["cista-files"])
|
||||
tree = JSON.parse(s)
|
||||
store.updateRoot(tree)
|
||||
console.log(`Loaded session with ${tree.length} items cached`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.log("Loading session failed", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const saveSession = () => {
|
||||
sessionStorage["cista-files"] = JSON.stringify(tree)
|
||||
localStorage["cista-files"] = JSON.stringify(tree)
|
||||
}
|
||||
|
||||
export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => {
|
||||
@@ -30,12 +42,50 @@ export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEv
|
||||
return webSocket
|
||||
}
|
||||
|
||||
// Handle auth error from WebSocket - show paskia iframe and reconnect on success
|
||||
async function handleWsAuthError(msg: any) {
|
||||
const iframe = msg.error?.auth?.iframe
|
||||
if (iframe) {
|
||||
// Clear sensitive data immediately on auth failure
|
||||
const store = useMainStore()
|
||||
store.clearSensitiveData()
|
||||
clearTree()
|
||||
// Stop reconnection attempts while showing auth dialog
|
||||
awaitingAuth = true
|
||||
store.authInProgress = true
|
||||
store.error = '' // Clear any connection message
|
||||
if (watchTimeout !== null) {
|
||||
clearTimeout(watchTimeout)
|
||||
watchTimeout = null
|
||||
}
|
||||
try {
|
||||
await showAuthIframe(iframe)
|
||||
// Auth succeeded - reconnect
|
||||
awaitingAuth = false
|
||||
store.authInProgress = false
|
||||
watchConnect()
|
||||
} catch (e) {
|
||||
awaitingAuth = false
|
||||
store.authInProgress = false
|
||||
if (e instanceof AuthCancelledError) {
|
||||
console.log('User cancelled authentication')
|
||||
// Show access denied dialog
|
||||
store.dialog = 'accessdenied'
|
||||
} else {
|
||||
console.error('Auth iframe error:', e)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const watchConnect = () => {
|
||||
if (watchTimeout !== null) {
|
||||
clearTimeout(watchTimeout)
|
||||
watchTimeout = null
|
||||
}
|
||||
const store = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
if (store.error !== 'Reconnecting...') store.error = 'Connecting...'
|
||||
console.log(store.error)
|
||||
|
||||
@@ -47,9 +97,9 @@ export const watchConnect = () => {
|
||||
if (store.connected) return
|
||||
const msg = JSON.parse(event.data)
|
||||
if ('error' in msg) {
|
||||
if (msg.error.code === 401) {
|
||||
store.user.isLoggedIn = false
|
||||
store.user.isOpenLoginModal = true
|
||||
if (msg.error.code === 401 || msg.error.code === 403) {
|
||||
// Show paskia auth iframe (works for both password and paskia modes)
|
||||
handleWsAuthError(msg)
|
||||
} else {
|
||||
store.error = msg.error.message
|
||||
}
|
||||
@@ -59,11 +109,10 @@ export const watchConnect = () => {
|
||||
console.log('Connected to backend', msg)
|
||||
store.server = msg.server
|
||||
store.connected = true
|
||||
reconnectDuration = 500
|
||||
reconnDelay = 500
|
||||
store.error = ''
|
||||
if (msg.user) store.login(msg.user.username, msg.user.privileged)
|
||||
else if (store.isUserLogged) store.logout()
|
||||
if (!msg.server.public && !msg.user) store.user.isOpenLoginModal = true
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -74,19 +123,34 @@ export const watchDisconnect = () => {
|
||||
wsWatch = null
|
||||
}
|
||||
|
||||
// Reset auth state and reconnect - call after successful authentication
|
||||
export const resumeWatching = () => {
|
||||
awaitingAuth = false
|
||||
if (watchTimeout !== null) {
|
||||
clearTimeout(watchTimeout)
|
||||
watchTimeout = null
|
||||
}
|
||||
watchConnect()
|
||||
}
|
||||
|
||||
let watchTimeout: any = null
|
||||
|
||||
const watchReconnect = (event: MessageEvent) => {
|
||||
const store = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
// Don't reconnect if we're awaiting authentication or auth iframe is showing
|
||||
if (awaitingAuth || isAuthIframeOpen()) {
|
||||
console.log('Skipping reconnect - awaiting authentication')
|
||||
return
|
||||
}
|
||||
if (store.connected) {
|
||||
console.warn("Disconnected from server", event)
|
||||
store.connected = false
|
||||
store.error = 'Reconnecting...'
|
||||
}
|
||||
reconnectDuration = Math.min(5000, reconnectDuration + 500)
|
||||
// The server closes the websocket after errors, so we need to reopen it
|
||||
if (watchTimeout !== null) clearTimeout(watchTimeout)
|
||||
watchTimeout = setTimeout(watchConnect, reconnectDuration)
|
||||
reconnDelay = Math.min(5000, reconnDelay + 500)
|
||||
// The server closes the websocket after errors, so we need to reopen it
|
||||
watchTimeout = setTimeout(watchConnect, reconnDelay)
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +164,8 @@ const handleWatchMessage = (event: MessageEvent) => {
|
||||
handleUpdateMessage(msg)
|
||||
break
|
||||
case !!msg.space:
|
||||
console.log('Watch space', msg.space)
|
||||
const store = useMainStore()
|
||||
store.space = msg.space
|
||||
break
|
||||
case !!msg.error:
|
||||
handleError(msg)
|
||||
@@ -110,7 +175,7 @@ const handleWatchMessage = (event: MessageEvent) => {
|
||||
}
|
||||
|
||||
function handleRootMessage({ root }: { root: FileEntry[] }) {
|
||||
const store = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
console.log('Watch root', root)
|
||||
store.updateRoot(root)
|
||||
tree = root
|
||||
@@ -118,7 +183,7 @@ function handleRootMessage({ root }: { root: FileEntry[] }) {
|
||||
}
|
||||
|
||||
function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
||||
const store = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
const update = updateData.update
|
||||
console.log('Watch update', update)
|
||||
if (!tree) return console.error('Watch update before root')
|
||||
@@ -142,10 +207,10 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
||||
}
|
||||
|
||||
function handleError(msg: errorEvent) {
|
||||
const store = useDocumentStore()
|
||||
if (msg.error.code === 401) {
|
||||
store.user.isOpenLoginModal = true
|
||||
store.user.isLoggedIn = false
|
||||
const store = useMainStore()
|
||||
if (msg.error.code === 401 || msg.error.code === 403) {
|
||||
// Show paskia auth iframe (works for both password and paskia modes)
|
||||
handleWsAuthError(msg as any)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Non-reactive document storage for the full file list
|
||||
// This avoids Vue reactivity overhead on large arrays
|
||||
|
||||
import type { Doc } from '@/repositories/Document'
|
||||
import { shallowRef, triggerRef } from 'vue'
|
||||
|
||||
// The main document list - shallowRef means only the reference is reactive, not the contents
|
||||
const documents = shallowRef<Doc[]>([])
|
||||
|
||||
// Version counter for manual reactivity triggering
|
||||
let version = 0
|
||||
|
||||
export function getDocuments(): Doc[] {
|
||||
return documents.value
|
||||
}
|
||||
|
||||
export function setDocuments(docs: Doc[]): void {
|
||||
documents.value = docs
|
||||
version++
|
||||
}
|
||||
|
||||
export function getVersion(): number {
|
||||
return version
|
||||
}
|
||||
|
||||
// Trigger reactivity manually (e.g., after modifications)
|
||||
export function triggerUpdate(): void {
|
||||
version++
|
||||
triggerRef(documents)
|
||||
}
|
||||
|
||||
// For computed dependencies that need to react to document changes
|
||||
export const documentRef = documents
|
||||
@@ -1,131 +0,0 @@
|
||||
import type { Document, FileEntry, FUID, SelectedItems } from '@/repositories/Document'
|
||||
import { formatSize, formatUnixDate, haystackFormat } from '@/utils'
|
||||
import { defineStore } from 'pinia'
|
||||
import { collator } from '@/utils'
|
||||
import { logoutUser } from '@/repositories/User'
|
||||
import { watchConnect } from '@/repositories/WS'
|
||||
|
||||
type FileData = { id: string; mtime: number; size: number; dir: DirectoryData }
|
||||
type DirectoryData = {
|
||||
[filename: string]: FileData
|
||||
}
|
||||
type User = {
|
||||
username: string
|
||||
privileged: boolean
|
||||
isOpenLoginModal: boolean
|
||||
isLoggedIn: boolean
|
||||
}
|
||||
|
||||
export const useDocumentStore = defineStore({
|
||||
id: 'documents',
|
||||
state: () => ({
|
||||
document: [] as Document[],
|
||||
selected: new Set<FUID>(),
|
||||
fileExplorer: null as any,
|
||||
error: '' as string,
|
||||
connected: false,
|
||||
server: {} as Record<string, any>,
|
||||
user: {
|
||||
username: '',
|
||||
privileged: false,
|
||||
isLoggedIn: false,
|
||||
isOpenLoginModal: false
|
||||
} as User
|
||||
}),
|
||||
actions: {
|
||||
updateRoot(root: FileEntry[]) {
|
||||
const docs = []
|
||||
let loc = [] as string[]
|
||||
for (const [level, name, key, mtime, size, isfile] of root) {
|
||||
loc = loc.slice(0, level - 1)
|
||||
docs.push({
|
||||
name,
|
||||
loc: level ? loc.join('/') : '/',
|
||||
key,
|
||||
size,
|
||||
sizedisp: formatSize(size),
|
||||
mtime,
|
||||
modified: formatUnixDate(mtime),
|
||||
haystack: haystackFormat(name),
|
||||
dir: !isfile,
|
||||
})
|
||||
loc.push(name)
|
||||
}
|
||||
this.document = docs as Document[]
|
||||
},
|
||||
login(username: string, privileged: boolean) {
|
||||
this.user.username = username
|
||||
this.user.privileged = privileged
|
||||
this.user.isLoggedIn = true
|
||||
this.user.isOpenLoginModal = false
|
||||
if (!this.connected) watchConnect()
|
||||
},
|
||||
loginDialog() {
|
||||
this.user.isOpenLoginModal = true
|
||||
},
|
||||
async logout() {
|
||||
console.log("Logout")
|
||||
await logoutUser()
|
||||
this.$reset()
|
||||
history.go() // Reload page
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
isUserLogged(): boolean {
|
||||
return this.user.isLoggedIn
|
||||
},
|
||||
recentDocuments(): Document[] {
|
||||
const ret = [...this.document]
|
||||
ret.sort((a, b) => b.mtime - a.mtime)
|
||||
return ret
|
||||
},
|
||||
largeDocuments(): Document[] {
|
||||
const ret = [...this.document]
|
||||
ret.sort((a, b) => b.size - a.size)
|
||||
return ret
|
||||
},
|
||||
selectedFiles(): SelectedItems {
|
||||
const selected = this.selected
|
||||
const found = new Set<FUID>()
|
||||
const ret: SelectedItems = {
|
||||
missing: new Set(),
|
||||
docs: {},
|
||||
keys: [],
|
||||
recursive: [],
|
||||
}
|
||||
for (const doc of this.document) {
|
||||
if (selected.has(doc.key)) {
|
||||
found.add(doc.key)
|
||||
ret.keys.push(doc.key)
|
||||
ret.docs[doc.key] = doc
|
||||
}
|
||||
}
|
||||
// What did we not select?
|
||||
for (const key of selected) if (!found.has(key)) ret.missing.add(key)
|
||||
// Build a flat list including contents recursively
|
||||
const relnames = new Set<string>()
|
||||
function add(rel: string, full: string, doc: Document) {
|
||||
if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`)
|
||||
relnames.add(rel)
|
||||
ret.recursive.push([rel, full, doc])
|
||||
}
|
||||
for (const key of ret.keys) {
|
||||
const base = ret.docs[key]
|
||||
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
|
||||
const nremove = base.loc.length
|
||||
add(base.name, basepath, base)
|
||||
for (const doc of this.document) {
|
||||
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
|
||||
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
const rel = full.slice(nremove)
|
||||
add(rel, full, doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort by rel (name stored as on download)
|
||||
ret.recursive.sort((a, b) => collator.compare(a[0], b[0]))
|
||||
|
||||
return ret
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,400 @@
|
||||
import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { defineStore, type StateTree } from 'pinia'
|
||||
import { collator } from '@/utils'
|
||||
import { watchConnect, resumeWatching } from '@/repositories/WS'
|
||||
import { sorted, type SortOrder } from '@/utils/docsort'
|
||||
import SearchWorker from '@/workers/searchWorker?worker'
|
||||
import { getDocuments, setDocuments, documentRef } from './documentStore'
|
||||
|
||||
// Singleton search worker instance
|
||||
let searchWorker: Worker | null = null
|
||||
let searchId = 0
|
||||
let searchStore: ReturnType<typeof useMainStore> | null = null
|
||||
let loadingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let clearOldResultsTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let lastResultUpdate = 0
|
||||
|
||||
function getSearchWorker(): Worker {
|
||||
if (!searchWorker) {
|
||||
searchWorker = new SearchWorker()
|
||||
// Set up message handler once
|
||||
searchWorker.onmessage = (e) => {
|
||||
if (!searchStore || e.data.id !== searchId) return // Stale result
|
||||
|
||||
// Convert plain data back to Doc instances
|
||||
const docs = e.data.docs.map((d: any) => new Doc(d))
|
||||
|
||||
// Cancel the clear-old-results timer since we have new results
|
||||
if (clearOldResultsTimer) {
|
||||
clearTimeout(clearOldResultsTimer)
|
||||
clearOldResultsTimer = null
|
||||
}
|
||||
|
||||
// Throttle rapid intermediate updates to reduce UI flicker
|
||||
const now = performance.now()
|
||||
if (!e.data.done && now - lastResultUpdate < 50) {
|
||||
return // Skip intermediate update if too recent
|
||||
}
|
||||
lastResultUpdate = now
|
||||
|
||||
searchStore.searchResults = docs
|
||||
|
||||
if (e.data.done) {
|
||||
// Clear the loading timer and hide spinner
|
||||
if (loadingTimer) {
|
||||
clearTimeout(loadingTimer)
|
||||
loadingTimer = null
|
||||
}
|
||||
searchStore.searchLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return searchWorker
|
||||
}
|
||||
|
||||
// Ghost expiry time in seconds
|
||||
const GHOST_TTL = 30
|
||||
|
||||
// Periodic cleanup interval
|
||||
let cleanupInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
export const useMainStore = defineStore('main', {
|
||||
state: () => ({
|
||||
// Ghosts are temporary optimistic-update files/folders shown until server confirms
|
||||
ghosts: [] as Doc[],
|
||||
// Hidden paths for optimistic delete (path -> expiry timestamp)
|
||||
hiddenPaths: new Map<string, number>(),
|
||||
// Version counter to trigger reactivity when external document list changes
|
||||
docVersion: 0,
|
||||
selected: new Set<FUID>([]),
|
||||
query: '' as string,
|
||||
searchResults: [] as Doc[],
|
||||
searchLoading: false,
|
||||
_searchRouteTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
fileExplorer: null as any,
|
||||
error: '' as string, // Permanent status message (e.g., "Reconnecting...")
|
||||
toast: '' as string, // Temporary toast (auto-dismisses)
|
||||
toastTimeout: null as ReturnType<typeof setTimeout> | null,
|
||||
connected: false,
|
||||
authInProgress: false,
|
||||
cursor: '' as string,
|
||||
server: {} as Record<string, any> & { public?: boolean, paskia?: boolean },
|
||||
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied',
|
||||
uprogress: {} as any,
|
||||
dprogress: {} as any,
|
||||
prefs: {
|
||||
gallery: false,
|
||||
sortListing: '' as SortOrder,
|
||||
sortFiltered: '' as SortOrder,
|
||||
searchHotkey: '/', // Character shown for search hotkey (Slash key)
|
||||
},
|
||||
user: {
|
||||
username: '' as string,
|
||||
privileged: false as boolean,
|
||||
isLoggedIn: false as boolean,
|
||||
},
|
||||
space: {
|
||||
disk: 0,
|
||||
free: 0,
|
||||
used: 0,
|
||||
storage: 0,
|
||||
allocated: 0,
|
||||
}
|
||||
}),
|
||||
persist: {
|
||||
pick: ['prefs', 'cursor', 'selected'],
|
||||
serializer: {
|
||||
deserialize: (data: string): StateTree => {
|
||||
const ret = JSON.parse(data)
|
||||
ret.selected = new Set(ret.selected)
|
||||
return ret
|
||||
},
|
||||
serialize: (tree: StateTree): string => {
|
||||
tree.selected = Array.from(tree.selected)
|
||||
return JSON.stringify(tree)
|
||||
}
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
updateRoot(root: FileEntry[]) {
|
||||
const docs = []
|
||||
let loc = [] as string[]
|
||||
for (const [level, name, key, mtime, size, allocated, isfile] of root) {
|
||||
loc = loc.slice(0, level - 1)
|
||||
docs.push(new Doc({
|
||||
name,
|
||||
loc: level ? loc.join('/') : '/',
|
||||
key,
|
||||
size,
|
||||
allocated,
|
||||
mtime,
|
||||
dir: !isfile,
|
||||
}))
|
||||
loc.push(name)
|
||||
}
|
||||
// Store in non-reactive external storage
|
||||
setDocuments(docs)
|
||||
// Clear ghosts that now exist in the real list
|
||||
const realPaths = new Set(docs.map(d => d.loc ? `${d.loc}/${d.name}` : d.name))
|
||||
this.ghosts = this.ghosts.filter(g => !realPaths.has(g.loc ? `${g.loc}/${g.name}` : g.name))
|
||||
// Clear hidden paths that no longer exist (deletion confirmed)
|
||||
for (const path of this.hiddenPaths.keys()) {
|
||||
if (!realPaths.has(path)) this.hiddenPaths.delete(path)
|
||||
}
|
||||
// Start cleanup timer if not running
|
||||
this.startCleanupTimer()
|
||||
// Bump version to trigger reactive updates
|
||||
this.docVersion++
|
||||
// Sync documents to search worker
|
||||
this.syncSearchWorker()
|
||||
},
|
||||
/** Add a ghost file/folder for optimistic UI updates */
|
||||
addGhost(doc: Doc) {
|
||||
doc.ghost = true
|
||||
doc.expires = Math.floor(Date.now() / 1000) + GHOST_TTL
|
||||
this.ghosts.push(doc)
|
||||
},
|
||||
/** Clear all ghosts (e.g., on navigation or refresh) */
|
||||
clearGhosts() {
|
||||
this.ghosts = []
|
||||
},
|
||||
/** Hide a document path (optimistic delete) */
|
||||
hideDoc(path: string) {
|
||||
this.hiddenPaths.set(path, Math.floor(Date.now() / 1000) + GHOST_TTL)
|
||||
},
|
||||
/** Unhide a document path (delete failed, restore visibility) */
|
||||
unhideDoc(path: string) {
|
||||
this.hiddenPaths.delete(path)
|
||||
},
|
||||
/** Start the periodic cleanup timer */
|
||||
startCleanupTimer() {
|
||||
if (cleanupInterval) return
|
||||
cleanupInterval = setInterval(() => this.cleanupExpired(), 5000)
|
||||
},
|
||||
/** Stop the cleanup timer */
|
||||
stopCleanupTimer() {
|
||||
if (cleanupInterval) {
|
||||
clearInterval(cleanupInterval)
|
||||
cleanupInterval = null
|
||||
}
|
||||
},
|
||||
/** Remove expired ghosts and hidden paths */
|
||||
cleanupExpired() {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const ghostsBefore = this.ghosts.length
|
||||
const hiddenBefore = this.hiddenPaths.size
|
||||
this.ghosts = this.ghosts.filter(g => g.expires > now)
|
||||
for (const [path, expires] of this.hiddenPaths) {
|
||||
if (expires <= now) this.hiddenPaths.delete(path)
|
||||
}
|
||||
// Stop timer if nothing to clean up
|
||||
if (this.ghosts.length === 0 && this.hiddenPaths.size === 0) {
|
||||
this.stopCleanupTimer()
|
||||
}
|
||||
},
|
||||
/** Show a temporary toast message that auto-dismisses */
|
||||
showToast(message: string, duration = 3000) {
|
||||
if (this.toastTimeout) {
|
||||
clearTimeout(this.toastTimeout)
|
||||
this.toastTimeout = null
|
||||
}
|
||||
this.toast = message
|
||||
this.toastTimeout = setTimeout(() => {
|
||||
this.toast = ''
|
||||
this.toastTimeout = null
|
||||
}, duration)
|
||||
},
|
||||
/** Clear the current toast immediately */
|
||||
clearToast() {
|
||||
if (this.toastTimeout) {
|
||||
clearTimeout(this.toastTimeout)
|
||||
this.toastTimeout = null
|
||||
}
|
||||
this.toast = ''
|
||||
},
|
||||
syncSearchWorker() {
|
||||
const worker = getSearchWorker()
|
||||
// Send plain data to worker (no class instances)
|
||||
const docs = getDocuments()
|
||||
const docData = docs.map(doc => ({
|
||||
loc: doc.loc,
|
||||
name: doc.name,
|
||||
key: doc.key,
|
||||
size: doc.size,
|
||||
allocated: doc.allocated,
|
||||
mtime: doc.mtime,
|
||||
dir: doc.dir,
|
||||
}))
|
||||
worker.postMessage({ type: 'update', documents: docData })
|
||||
},
|
||||
search(query: string, loc: string) {
|
||||
const worker = getSearchWorker()
|
||||
const id = ++searchId
|
||||
searchStore = this // Store reference for worker callback
|
||||
|
||||
// Update query immediately so watchers know we're handling this
|
||||
this.query = query
|
||||
|
||||
// Cancel pending timers
|
||||
if (loadingTimer) {
|
||||
clearTimeout(loadingTimer)
|
||||
loadingTimer = null
|
||||
}
|
||||
if (clearOldResultsTimer) {
|
||||
clearTimeout(clearOldResultsTimer)
|
||||
clearOldResultsTimer = null
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
// Clear results only when search is closed
|
||||
this.searchResults = []
|
||||
this.searchLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
// Keep old results briefly to avoid flicker on fast cached searches
|
||||
// But clear them after 50ms if no new results have arrived
|
||||
clearOldResultsTimer = setTimeout(() => {
|
||||
if (searchId === id) {
|
||||
this.searchResults = []
|
||||
}
|
||||
clearOldResultsTimer = null
|
||||
}, 50)
|
||||
|
||||
// Delay showing loading indicator to avoid flicker on fast searches
|
||||
loadingTimer = setTimeout(() => {
|
||||
if (searchId === id) { // Still the current search
|
||||
this.searchLoading = true
|
||||
}
|
||||
loadingTimer = null
|
||||
}, 100)
|
||||
|
||||
worker.postMessage({ type: 'search', query, loc, id })
|
||||
},
|
||||
login(username: string, privileged: boolean) {
|
||||
this.user.username = username
|
||||
this.user.privileged = privileged
|
||||
this.user.isLoggedIn = true
|
||||
this.dialog = ''
|
||||
if (!this.connected) resumeWatching()
|
||||
},
|
||||
clearSensitiveData() {
|
||||
// Clear all sensitive state on logout or auth failure
|
||||
localStorage.removeItem('cista-files')
|
||||
setDocuments([])
|
||||
this.ghosts = []
|
||||
this.hiddenPaths.clear()
|
||||
this.stopCleanupTimer()
|
||||
this.docVersion++
|
||||
this.selected.clear()
|
||||
this.user.username = ''
|
||||
this.user.privileged = false
|
||||
this.user.isLoggedIn = false
|
||||
this.connected = false
|
||||
this.dialog = ''
|
||||
this.cursor = ''
|
||||
},
|
||||
async logout() {
|
||||
console.log("Logout")
|
||||
try {
|
||||
const res = await fetch('/auth/api/logout', { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
this.error = data.message || data.detail || 'Logout failed'
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = 'Logout failed'
|
||||
return
|
||||
}
|
||||
this.clearSensitiveData()
|
||||
resumeWatching()
|
||||
},
|
||||
toggleSort(name: SortOrder) {
|
||||
const current = this.query ? this.prefs.sortFiltered : this.prefs.sortListing
|
||||
const newOrder = current === name ? '' : name
|
||||
if (this.query) this.prefs.sortFiltered = newOrder
|
||||
else this.prefs.sortListing = newOrder
|
||||
this.showSortToast(newOrder)
|
||||
},
|
||||
sort(name: SortOrder | '') {
|
||||
if (this.query) this.prefs.sortFiltered = name
|
||||
else this.prefs.sortListing = name
|
||||
this.showSortToast(name)
|
||||
},
|
||||
showSortToast(order: SortOrder | '') {
|
||||
const labels: Record<string, string> = {
|
||||
'': 'Folders first',
|
||||
'name': 'Alphabetical order',
|
||||
'modified': 'Newest first',
|
||||
'size': 'Largest first',
|
||||
}
|
||||
this.showToast(labels[order] || order, 1200)
|
||||
},
|
||||
focusBreadcrumb() {
|
||||
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
|
||||
},
|
||||
cancelDownloads() {
|
||||
location.reload() // FIXME
|
||||
},
|
||||
cancelUploads() {
|
||||
location.reload() // FIXME
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
|
||||
isUserLogged(): boolean { return this.user.isLoggedIn },
|
||||
/** Get documents count (triggers on docVersion change) */
|
||||
documentCount(): number {
|
||||
// Access docVersion to make this reactive
|
||||
void this.docVersion
|
||||
return getDocuments().length
|
||||
},
|
||||
recentDocuments(): Doc[] {
|
||||
// Access docVersion to make this reactive
|
||||
void this.docVersion
|
||||
return sorted(getDocuments(), 'modified')
|
||||
},
|
||||
selectedFiles(): SelectedItems {
|
||||
// Access docVersion to make this reactive
|
||||
void this.docVersion
|
||||
const docs = getDocuments()
|
||||
const selected = this.selected
|
||||
const found = new Set<FUID>()
|
||||
const ret: SelectedItems = {
|
||||
missing: new Set(),
|
||||
docs: {},
|
||||
keys: [],
|
||||
recursive: [],
|
||||
}
|
||||
for (const doc of docs) {
|
||||
if (selected.has(doc.key)) {
|
||||
found.add(doc.key)
|
||||
ret.keys.push(doc.key)
|
||||
ret.docs[doc.key] = doc
|
||||
}
|
||||
}
|
||||
// What did we not select?
|
||||
for (const key of selected) if (!found.has(key)) ret.missing.add(key)
|
||||
// Build a flat list including contents recursively
|
||||
for (const key of ret.keys) {
|
||||
const base = ret.docs[key]!
|
||||
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
|
||||
const nremove = base.loc.length
|
||||
ret.recursive.push([base.name, basepath, base])
|
||||
for (const doc of docs) {
|
||||
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
|
||||
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
const rel = full.slice(nremove)
|
||||
ret.recursive.push([rel, full, doc])
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort by rel (name stored as on download)
|
||||
ret.recursive.sort((a, b) => collator.compare(a[0], b[0]))
|
||||
|
||||
return ret
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useMainStore } from './main'
|
||||
import { clearTree } from '@/repositories/WS'
|
||||
|
||||
export const useSsoAuthStore = defineStore('ssoAuth', () => {
|
||||
const isExternalAuth = computed(() => {
|
||||
const mainStore = useMainStore()
|
||||
return mainStore.server?.paskia === true
|
||||
})
|
||||
|
||||
function clearDataOnUnauth() {
|
||||
const mainStore = useMainStore()
|
||||
mainStore.clearSensitiveData()
|
||||
clearTree()
|
||||
}
|
||||
|
||||
return { isExternalAuth, clearDataOnUnauth }
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { collator } from '@/utils'
|
||||
|
||||
export const ordering = {
|
||||
name: (a: Doc, b: Doc) => collator.compare(a.name, b.name),
|
||||
modified: (a: Doc, b: Doc) => b.mtime - a.mtime,
|
||||
size: (a: Doc, b: Doc) => b.size - a.size
|
||||
}
|
||||
export type SortOrder = keyof typeof ordering | ''
|
||||
export const sorted = (documents: Doc[], order: SortOrder) => {
|
||||
if (!order) return documents
|
||||
const sorted = [...documents]
|
||||
sorted.sort(ordering[order])
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { getDocuments } from '@/stores/documentStore'
|
||||
|
||||
|
||||
export const exists = (path: string[]) => {
|
||||
const store = useMainStore()
|
||||
// Access docVersion to make this reactive
|
||||
void store.docVersion
|
||||
const p = path.join('/')
|
||||
return getDocuments().some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
|
||||
}
|
||||
|
||||
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
|
||||
export const stripExt = (name: string): string => {
|
||||
// Common compound extensions
|
||||
const compoundExts = ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']
|
||||
const lower = name.toLowerCase()
|
||||
for (const ext of compoundExts) {
|
||||
if (lower.endsWith(ext)) return name.slice(0, -ext.length)
|
||||
}
|
||||
// Regular extension: only strip if the extension looks like one (2-5 chars, alphanumeric)
|
||||
const lastDot = name.lastIndexOf('.')
|
||||
if (lastDot > 0) {
|
||||
const ext = name.slice(lastDot + 1)
|
||||
if (ext.length >= 2 && ext.length <= 5 && /^[a-zA-Z0-9]+$/.test(ext)) {
|
||||
return name.slice(0, lastDot)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/** Generate a sensible zip filename for a selection of items */
|
||||
export const zipName = (items: { name: string; loc: string }[]): string => {
|
||||
const names = items.map(d => d.name)
|
||||
if (names.length === 1) {
|
||||
// Single item - use its name
|
||||
return stripExt(names[0]!)
|
||||
}
|
||||
// Check if all items share the same direct parent folder
|
||||
const locs = items.map(d => d.loc)
|
||||
const sameLoc = locs.every(loc => loc === locs[0])
|
||||
if (sameLoc && locs[0]) {
|
||||
// All items in same folder - use folder name
|
||||
return locs[0].split('/').pop()!
|
||||
}
|
||||
if (names.length <= 3) {
|
||||
// Few items from different folders - join basenames with dot
|
||||
return names.map(stripExt).join('.')
|
||||
}
|
||||
// Many items from different folders - first basename + indicator
|
||||
return `${stripExt(names[0]!)}.etc`
|
||||
}
|
||||
@@ -50,12 +50,11 @@ export function formatUnixDate(t: number) {
|
||||
}
|
||||
|
||||
export function getFileExtension(filename: string) {
|
||||
const parts = filename.split('.')
|
||||
if (parts.length > 1) {
|
||||
return parts[parts.length - 1]
|
||||
} else {
|
||||
return '' // No hay extensión
|
||||
const dotIndex = filename.lastIndexOf('.')
|
||||
if (dotIndex === -1 || dotIndex === filename.length - 1) {
|
||||
return '' // No extension
|
||||
}
|
||||
return filename.slice(dotIndex + 1)
|
||||
}
|
||||
interface FileTypes {
|
||||
[key: string]: string[]
|
||||
@@ -68,9 +67,10 @@ const filetypes: FileTypes = {
|
||||
}
|
||||
|
||||
export function getFileType(name: string): string {
|
||||
const ext = name.split('.').pop()?.toLowerCase()
|
||||
if (!ext || ext.length === name.length) return 'unknown'
|
||||
return Object.keys(filetypes).find(type => filetypes[type].includes(ext)) || 'unknown'
|
||||
const dotIndex = name.lastIndexOf('.')
|
||||
if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown'
|
||||
const ext = name.slice(dotIndex + 1).toLowerCase()
|
||||
return Object.keys(filetypes).find(type => filetypes[type]!.includes(ext)) || 'unknown'
|
||||
}
|
||||
|
||||
// Prebuilt for fast & consistent sorting
|
||||
@@ -86,7 +86,7 @@ export function haystackFormat(str: string) {
|
||||
// Preformat search string for faster search
|
||||
export function needleFormat(query: string) {
|
||||
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
return {based, words: based.split(/\W+/)}
|
||||
return {based, words: based.split(/\s+/)}
|
||||
}
|
||||
|
||||
// Test if haystack includes needle
|
||||
|
||||
@@ -1,58 +1,113 @@
|
||||
<template>
|
||||
<FileExplorer
|
||||
<Gallery
|
||||
v-if="store.prefs.gallery"
|
||||
ref="fileExplorer"
|
||||
:key="Router.currentRoute.value.path"
|
||||
:key="`gallery-${folderPath}`"
|
||||
:path="props.path"
|
||||
:documents="documents"
|
||||
v-if="props.path"
|
||||
/>
|
||||
<FileExplorer
|
||||
v-else
|
||||
ref="fileExplorer"
|
||||
:key="`explorer-${folderPath}`"
|
||||
:path="props.path"
|
||||
:documents="documents"
|
||||
/>
|
||||
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
|
||||
<EmptyFolder :documents=documents :path=props.path />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watchEffect, ref, computed } from 'vue'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import Router from '@/router/index'
|
||||
import { needleFormat, localeIncludes, collator } from '@/utils';
|
||||
import { watchEffect, ref, computed, watch } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { getDocuments } from '@/stores/documentStore'
|
||||
import { collator } from '@/utils'
|
||||
import { sorted, sortedGrouped } from '@/utils/docsort'
|
||||
import FileExplorer from '@/components/FileExplorer.vue'
|
||||
|
||||
const documentStore = useDocumentStore()
|
||||
const store = useMainStore()
|
||||
const fileExplorer = ref()
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
query: string
|
||||
}>()
|
||||
|
||||
// Folder path for component keys - only recreate component when folder changes, not search
|
||||
const folderPath = computed(() => props.path.join('/'))
|
||||
|
||||
// Handle route-based search changes (back/forward navigation, direct URL)
|
||||
// Skip if store.query already matches (means we triggered this via typing)
|
||||
watch(
|
||||
() => [props.query, props.path.join('/')] as const,
|
||||
([query, loc]) => {
|
||||
if (store.query === query) return // Already searching this query
|
||||
store.search(query, loc)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const documents = computed(() => {
|
||||
const loc = props.path.join('/')
|
||||
const query = props.query
|
||||
// List the current location
|
||||
if (!query) return documentStore.document.filter(doc => doc.loc === loc)
|
||||
// Find up to 100 newest documents that match the search
|
||||
const needle = needleFormat(query)
|
||||
let limit = 100
|
||||
let docs = []
|
||||
for (const doc of documentStore.recentDocuments) {
|
||||
if (localeIncludes(doc.haystack, needle)) {
|
||||
docs.push(doc)
|
||||
if (--limit === 0) break
|
||||
}
|
||||
|
||||
// List the current location (no search)
|
||||
if (!query) {
|
||||
// Access docVersion to make this reactive to document changes
|
||||
void store.docVersion
|
||||
const hidden = store.hiddenPaths
|
||||
const docs = getDocuments().filter(doc => doc.loc === loc && !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name))
|
||||
// Overlay ghosts for this location (excluding hidden ones)
|
||||
const ghosts = store.ghosts.filter(g => g.loc === loc && !hidden.has(g.loc ? `${g.loc}/${g.name}` : g.name))
|
||||
// Merge: ghosts that don't conflict with real docs
|
||||
const realNames = new Set(docs.map(d => d.name))
|
||||
const merged = [...docs, ...ghosts.filter(g => !realNames.has(g.name))]
|
||||
return sorted(merged, store.prefs.sortListing)
|
||||
}
|
||||
// Organize by folder, by relevance
|
||||
const locsub = loc + '/'
|
||||
docs.sort((a, b) => (
|
||||
// @ts-ignore
|
||||
(b.loc === loc) - (a.loc === loc) ||
|
||||
// @ts-ignore
|
||||
(b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) ||
|
||||
collator.compare(a.loc, b.loc) ||
|
||||
// @ts-ignore
|
||||
(a.type === 'file') - (b.type === 'file') ||
|
||||
// @ts-ignore
|
||||
b.name.includes(query) - a.name.includes(query) ||
|
||||
collator.compare(a.name, b.name)
|
||||
))
|
||||
|
||||
// Search results from worker (also filter hidden)
|
||||
const hidden = store.hiddenPaths
|
||||
const docs = store.searchResults.filter(doc => !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name))
|
||||
|
||||
// Custom sort override in effect? Use grouped sorting to keep folders together
|
||||
const order = store.prefs.sortFiltered
|
||||
if (order) return sortedGrouped(docs, order)
|
||||
|
||||
// Results are already sorted by relevance in the worker
|
||||
return docs
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
documentStore.fileExplorer = fileExplorer.value
|
||||
store.fileExplorer = fileExplorer.value
|
||||
})
|
||||
|
||||
// Only auto-switch gallery mode when entering a new folder or on initial file list load
|
||||
watch([() => props.path.join('/'), () => store.documentCount], ([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 })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 2rem;
|
||||
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.search-loading {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent-color, #007bff);
|
||||
color: white;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// Search worker - runs search in background thread
|
||||
// Receives document updates and search queries, returns incremental results
|
||||
|
||||
interface DocData {
|
||||
loc: string
|
||||
name: string
|
||||
key: string
|
||||
size: number
|
||||
allocated: number
|
||||
mtime: number
|
||||
dir: boolean
|
||||
}
|
||||
|
||||
interface WorkerDoc extends DocData {
|
||||
haystack: string
|
||||
}
|
||||
|
||||
interface SearchMessage {
|
||||
type: 'search'
|
||||
query: string
|
||||
loc: string
|
||||
id: number
|
||||
}
|
||||
|
||||
interface UpdateMessage {
|
||||
type: 'update'
|
||||
documents: DocData[]
|
||||
}
|
||||
|
||||
type IncomingMessage = SearchMessage | UpdateMessage
|
||||
|
||||
interface ResultMessage {
|
||||
type: 'results'
|
||||
docs: DocData[]
|
||||
id: number
|
||||
done: boolean
|
||||
}
|
||||
|
||||
// Worker state
|
||||
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
|
||||
let currentSearchId = 0
|
||||
|
||||
// Search result cache - cleared when documents change
|
||||
interface CacheEntry {
|
||||
query: string // Normalized query string
|
||||
results: WorkerDoc[] // Matched results (up to limit)
|
||||
complete: boolean // True if search scanned all documents
|
||||
}
|
||||
const searchCache: CacheEntry[] = []
|
||||
const MAX_CACHE_SIZE = 10
|
||||
const RESULT_LIMIT = 100
|
||||
|
||||
// Normalize string for search (remove diacritics, lowercase)
|
||||
// Haystack adds ^ and $ markers to allow matching start/end of name
|
||||
function normalizeHaystack(str: string): string {
|
||||
return '^' + str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() + '$'
|
||||
}
|
||||
|
||||
function normalizeQuery(str: string): string {
|
||||
return str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
// Test if document matches search query
|
||||
function matches(haystack: string, query: string, words: string[]): boolean {
|
||||
return haystack.includes(query) || words.every(word => haystack.includes(word))
|
||||
}
|
||||
|
||||
// Collator for sorting
|
||||
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true })
|
||||
|
||||
// Yield control to allow new messages to be processed
|
||||
const yieldControl = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
// Find best cache entry to filter from
|
||||
// Returns entry if new query's results are guaranteed to be a subset of cached results
|
||||
// Only valid if the cached search was complete (scanned all documents)
|
||||
function findCacheSubset(query: string): CacheEntry | null {
|
||||
// Look for a cached query that the new query starts with
|
||||
// e.g., cached "foo" can be used for "foobar" or "foo bar"
|
||||
// The longer the prefix, the better (fewer items to filter)
|
||||
// IMPORTANT: Only use complete cache entries - incomplete ones may have
|
||||
// missed results that would match the more specific query
|
||||
let best: CacheEntry | null = null
|
||||
for (const entry of searchCache) {
|
||||
if (entry.complete && query.startsWith(entry.query)) {
|
||||
if (!best || entry.query.length > best.query.length) {
|
||||
best = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// Add result to cache
|
||||
function addToCache(query: string, results: WorkerDoc[], complete: boolean) {
|
||||
// Remove existing entry for same query if any
|
||||
const idx = searchCache.findIndex(e => e.query === query)
|
||||
if (idx !== -1) searchCache.splice(idx, 1)
|
||||
// Add to front (most recent)
|
||||
searchCache.unshift({ query, results, complete })
|
||||
// Trim cache
|
||||
if (searchCache.length > MAX_CACHE_SIZE) searchCache.pop()
|
||||
}
|
||||
|
||||
// Clear cache (called when documents change)
|
||||
function clearCache() {
|
||||
searchCache.length = 0
|
||||
}
|
||||
|
||||
// Perform search with incremental results
|
||||
async function performSearch(rawQuery: string, loc: string, searchId: number) {
|
||||
const query = normalizeQuery(rawQuery)
|
||||
const words = query.split(/\s+/)
|
||||
const results: WorkerDoc[] = []
|
||||
let lastResultCount = 0
|
||||
|
||||
// Check cache for exact match
|
||||
const exactMatch = searchCache.find(e => e.query === query)
|
||||
if (exactMatch) {
|
||||
if (currentSearchId === searchId) {
|
||||
postResults(exactMatch.results, rawQuery, loc, searchId, true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we can filter from a cached superset
|
||||
const cacheEntry = findCacheSubset(query)
|
||||
if (cacheEntry) {
|
||||
// Fast path: filter from cached results (only used for complete cache entries)
|
||||
for (const doc of cacheEntry.results) {
|
||||
if (matches(doc.haystack, query, words)) {
|
||||
results.push(doc)
|
||||
}
|
||||
}
|
||||
// Cache entry was complete, so filtered results are also complete
|
||||
addToCache(query, results, true)
|
||||
|
||||
if (currentSearchId === searchId) {
|
||||
postResults(results, rawQuery, loc, searchId, true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Slow path: scan all documents
|
||||
const batchSize = 500
|
||||
for (let i = 0; i < recentDocuments.length && results.length < RESULT_LIMIT; i += batchSize) {
|
||||
if (currentSearchId !== searchId) return // Superseded
|
||||
|
||||
// Process batch
|
||||
const end = Math.min(i + batchSize, recentDocuments.length)
|
||||
for (let j = i; j < end && results.length < RESULT_LIMIT; j++) {
|
||||
const doc = recentDocuments[j]!
|
||||
if (matches(doc.haystack, query, words)) {
|
||||
results.push(doc)
|
||||
}
|
||||
}
|
||||
|
||||
// Post incremental results if we found new matches
|
||||
if (results.length > lastResultCount && currentSearchId === searchId) {
|
||||
lastResultCount = results.length
|
||||
postResults(results, rawQuery, loc, searchId, false)
|
||||
}
|
||||
|
||||
// Yield control between batches
|
||||
if (i + batchSize < recentDocuments.length && results.length < RESULT_LIMIT) {
|
||||
await yieldControl()
|
||||
}
|
||||
}
|
||||
|
||||
// Cache and post final results
|
||||
addToCache(query, results, results.length < RESULT_LIMIT)
|
||||
if (currentSearchId === searchId) {
|
||||
postResults(results, rawQuery, loc, searchId, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Post results to main thread
|
||||
function postResults(docs: WorkerDoc[], query: string, loc: string, id: number, done: boolean) {
|
||||
const sorted = sortResults(docs, query, loc)
|
||||
postMessage({
|
||||
type: 'results',
|
||||
docs: sorted.map(({ haystack, ...rest }) => rest),
|
||||
id,
|
||||
done
|
||||
} as ResultMessage)
|
||||
}
|
||||
|
||||
// Sort results by relevance
|
||||
function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] {
|
||||
const locsub = loc + '/'
|
||||
return [...docs].sort((a, b) => (
|
||||
// Current folder first
|
||||
Number(b.loc === loc) - Number(a.loc === loc) ||
|
||||
// Then subfolders
|
||||
Number(b.loc.startsWith(locsub)) - Number(a.loc.startsWith(locsub)) ||
|
||||
// Then by location
|
||||
collator.compare(a.loc, b.loc) ||
|
||||
// Folders before files
|
||||
Number(b.dir) - Number(a.dir) ||
|
||||
// Exact name match first
|
||||
Number(b.name.includes(query)) - Number(a.name.includes(query)) ||
|
||||
// Finally by name
|
||||
collator.compare(a.name, b.name)
|
||||
))
|
||||
}
|
||||
|
||||
// Handle incoming messages
|
||||
self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
|
||||
const msg = e.data
|
||||
|
||||
if (msg.type === 'update') {
|
||||
// Update document list with haystacks, sorted by mtime descending
|
||||
recentDocuments = msg.documents
|
||||
.map(doc => ({ ...doc, haystack: normalizeHaystack(doc.name) }))
|
||||
.sort((a, b) => b.mtime - a.mtime)
|
||||
clearCache()
|
||||
} else if (msg.type === 'search') {
|
||||
currentSearchId = msg.id
|
||||
if (msg.query) {
|
||||
await performSearch(msg.query, msg.loc, msg.id)
|
||||
} else {
|
||||
// Empty query - no results needed
|
||||
postMessage({ type: 'results', docs: [], id: msg.id, done: true } as ResultMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* FastAPI-Vue Vite Plugin
|
||||
*
|
||||
* Configures Vite for FastAPI backend integration:
|
||||
* - Proxies /api/* requests to the FastAPI backend
|
||||
* - Builds to the Python module's frontend-build directory
|
||||
*
|
||||
* Environment variables (with defaults):
|
||||
* FASTAPI_VUE_BACKEND_URL=http://localhost:8999 - Backend API URL for proxying
|
||||
*/
|
||||
|
||||
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:8999"
|
||||
|
||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
// Build proxy configuration for each path
|
||||
const proxy = {}
|
||||
for (const path of paths) {
|
||||
proxy[path] = {
|
||||
target: backendUrl,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: "fastapi-vite",
|
||||
config: () => ({
|
||||
server: { proxy },
|
||||
build: {
|
||||
outDir: "../cista/frontend-build",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
+14
-23
@@ -1,27 +1,19 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import fastapiVue from './vite-plugin-fastapi.js'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// @ts-ignore
|
||||
import pluginRewriteAll from 'vite-plugin-rewrite-all'
|
||||
import svgLoader from 'vite-svg-loader'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
|
||||
// Development mode:
|
||||
// npm run dev # Run frontend that proxies to dev_backend
|
||||
// cista -l :8000 --dev # Run backend
|
||||
const dev_backend = {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: false, // Use frontend "host" to match "origin" from browser
|
||||
ws: true,
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
// Note: fastapiVue() handles proxy and build output (uses FASTAPI_VUE_BACKEND_URL env)
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
fastapiVue({ paths: ["/api", "/auth", "/files", "/zip", "/preview"] }),
|
||||
vue(),
|
||||
pluginRewriteAll(),
|
||||
svgLoader(), // import svg files
|
||||
Components(), // auto import components
|
||||
],
|
||||
@@ -38,17 +30,16 @@ export default defineConfig({
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": dev_backend,
|
||||
"/files": dev_backend,
|
||||
"/login": dev_backend,
|
||||
"/logout": dev_backend,
|
||||
"/zip": dev_backend,
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: "../cista/wwwroot",
|
||||
emptyOutDir: true,
|
||||
}
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
// Bundle all SVG icons into a single chunk
|
||||
icons: [
|
||||
'/src/assets/svg/index.ts',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
+114
-57
@@ -10,94 +10,151 @@ readme = "README.md"
|
||||
authors = [
|
||||
{ name = "Vasanko" },
|
||||
]
|
||||
maintainers = [
|
||||
{ name = "Vasanko" },
|
||||
]
|
||||
keywords = ["file-server", "web-interface", "dropbox", "storage"]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Web Environment",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Intended Audience :: System Administrators",
|
||||
"License :: Public Domain",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
]
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"argon2-cffi",
|
||||
"blake3",
|
||||
"brotli",
|
||||
"docopt",
|
||||
"inotify",
|
||||
"msgspec",
|
||||
"natsort",
|
||||
"pathvalidate",
|
||||
"pyjwt",
|
||||
"sanic",
|
||||
"stream-zip",
|
||||
"tomli_w",
|
||||
"argon2-cffi>=25.1.0",
|
||||
"av>=15.0.0",
|
||||
"blake3>=1.0.5",
|
||||
"docopt-ng>=0.9.0",
|
||||
"fastapi-vue>=0.5.2",
|
||||
"fastapi[standard]>=0.128.0",
|
||||
"html5tagger>=1.3.0",
|
||||
"httpx>=0.28.0",
|
||||
"inotify>=0.2.12",
|
||||
"msgspec>=0.19.0",
|
||||
"natsort>=8.4.0",
|
||||
"numpy>=2.3.2",
|
||||
"pathvalidate>=3.3.1",
|
||||
"pillow>=11.3.0",
|
||||
"pillow-heif>=1.1.0",
|
||||
"pyjwt>=2.10.1",
|
||||
"pymupdf>=1.26.3",
|
||||
"pyvips[binary]>=3.1.1",
|
||||
"sanic>=25.12.0",
|
||||
"setproctitle>=1.3.6",
|
||||
"stream-zip>=0.0.83",
|
||||
"tomli_w>=1.2.0",
|
||||
"tracerite>=2.3.1",
|
||||
"zstandard>=0.24.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = ""
|
||||
Homepage = "https://git.zi.fi/Vasanko/cista-storage"
|
||||
|
||||
[project.scripts]
|
||||
cista = "cista.__main__:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest",
|
||||
"ruff",
|
||||
"pytest>=8.4.1",
|
||||
"ruff>=0.8.0",
|
||||
"mypy>=1.13.0",
|
||||
"pre-commit>=4.0.0",
|
||||
]
|
||||
test = [
|
||||
"pytest>=8.4.1",
|
||||
"pytest-cov>=6.0.0",
|
||||
"pytest-asyncio>=0.25.0",
|
||||
]
|
||||
docs = [
|
||||
"sphinx>=8.0.0",
|
||||
"sphinx-rtd-theme>=3.0.0",
|
||||
]
|
||||
|
||||
[tool.hatchling]
|
||||
# Build frontend
|
||||
pre_build = "npm run build --prefix cista-front"
|
||||
|
||||
[tool.hatch.version]
|
||||
source = "vcs"
|
||||
|
||||
[tool.hatch.build]
|
||||
artifacts = ["cista/frontend-build"]
|
||||
targets.sdist.hooks.custom.path = "scripts/fastapi-vue/build-frontend.py"
|
||||
targets.sdist.include = [
|
||||
"/cista",
|
||||
]
|
||||
hooks.vcs.version-file = "cista/_version.py"
|
||||
hooks.vcs.template = """
|
||||
# This file is automatically generated by hatch build.
|
||||
__version__ = {version!r}
|
||||
"""
|
||||
|
||||
targets.sdist.include = [
|
||||
"/cista",
|
||||
]
|
||||
only-packages = true
|
||||
packages = ["cista"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = [
|
||||
"--import-mode=importlib",
|
||||
"--verbosity=-1",
|
||||
"-p no:warnings",
|
||||
"--verbosity=2",
|
||||
"--strict-markers",
|
||||
"--strict-config",
|
||||
"--cov=cista",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=html",
|
||||
"--cov-branch",
|
||||
]
|
||||
testpaths = [
|
||||
"tests",
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py", "*_test.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
markers = [
|
||||
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||
"integration: marks tests as integration tests",
|
||||
]
|
||||
filterwarnings = [
|
||||
"error",
|
||||
"ignore::UserWarning",
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"A0",
|
||||
"ARG001",
|
||||
"ANN",
|
||||
"B018",
|
||||
"BLE001",
|
||||
"C901",
|
||||
"COM812", # conflicts with ruff format
|
||||
"D",
|
||||
"E501",
|
||||
"EM1",
|
||||
"FIX002",
|
||||
"ISC001", # conflicts with ruff format
|
||||
"PGH003",
|
||||
"PLR0912",
|
||||
"PLR2004",
|
||||
"PLW0603",
|
||||
"S101",
|
||||
"SLF001",
|
||||
"T201",
|
||||
"TD0",
|
||||
"TRY",
|
||||
[tool.ruff.lint]
|
||||
extend-select = ["E402"]
|
||||
isort.known-first-party = ["cista"]
|
||||
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004"]
|
||||
per-file-ignores."scripts/*" = ["T20"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.4.1",
|
||||
"pytest-asyncio>=0.25.0",
|
||||
"pytest-cov>=7.0.0",
|
||||
"ruff>=0.8.0",
|
||||
"mypy>=1.13.0",
|
||||
"pre-commit>=4.0.0",
|
||||
"httpx>=0.28.1",
|
||||
]
|
||||
show-source = true
|
||||
show-fixes = true
|
||||
|
||||
[tool.ruff.isort]
|
||||
known-first-party = ["cista"]
|
||||
[tool.coverage.run]
|
||||
source = ["cista"]
|
||||
branch = true
|
||||
omit = [
|
||||
"*/tests/*",
|
||||
"*/test_*",
|
||||
"*/__pycache__/*",
|
||||
"cista/_version.py",
|
||||
]
|
||||
|
||||
[tool.ruff.per-file-ignores]
|
||||
"tests/*" = ["S", "ANN", "D", "INP"]
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"if self.debug:",
|
||||
"if settings.DEBUG",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if 0:",
|
||||
"if __name__ == .__main__.:",
|
||||
"class .*\\bProtocol\\):",
|
||||
"@(abc\\.)?abstractmethod",
|
||||
]
|
||||
show_missing = true
|
||||
skip_covered = false
|
||||
precision = 2
|
||||
|
||||
Executable
+114
@@ -0,0 +1,114 @@
|
||||
#!/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] [--backend backend] [cista_args...]
|
||||
|
||||
Options:
|
||||
-l, --listen Vite frontend endpoint (default: localhost:8989)
|
||||
--backend Cista backend endpoint (default: from config, or :8999)
|
||||
|
||||
Any additional arguments are passed to the cista command.
|
||||
|
||||
Environment:
|
||||
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Import devutil from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
||||
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
||||
from devutil import ProcessGroup, logger, ready, setup_vite # type: ignore
|
||||
|
||||
from cista import config
|
||||
from cista.serve import parse_listen
|
||||
|
||||
DEFAULT_BACKEND_PORT = 8999
|
||||
|
||||
|
||||
def setup_sanic_backend(
|
||||
listen: str | None, extra_args: list[str]
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Parse backend listen address and build cista dev command.
|
||||
|
||||
Returns (url, cmd).
|
||||
"""
|
||||
config.load_config()
|
||||
listen = listen or config.config.listen or f":{DEFAULT_BACKEND_PORT}"
|
||||
url, opts = parse_listen(listen)
|
||||
port = opts.get("port", DEFAULT_BACKEND_PORT)
|
||||
host = opts.get("host", "localhost") or "localhost"
|
||||
|
||||
cmd = ["cista", "--dev", "-l", listen] + extra_args
|
||||
return f"http://{host}:{port}", cmd
|
||||
|
||||
|
||||
async def run_devserver(
|
||||
frontend: str | None, backend: str | None, extra_args: list[str]
|
||||
) -> None:
|
||||
reporoot = Path(__file__).parent.parent
|
||||
front = reporoot / "frontend"
|
||||
if not (front / "package.json").exists():
|
||||
logger.warning("Frontend source not found at %s", front)
|
||||
raise SystemExit(1)
|
||||
|
||||
frontend_url, npm_install, vite = setup_vite(frontend or "")
|
||||
backend_url, sanic_cmd = setup_sanic_backend(backend, extra_args)
|
||||
|
||||
# Tell vite where to proxy API requests
|
||||
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
|
||||
|
||||
async with ProcessGroup() as pg:
|
||||
install_proc = await pg.spawn(*npm_install, cwd=str(front))
|
||||
await asyncio.sleep(0.2) # reduce message overlap
|
||||
await pg.spawn(*sanic_cmd, cwd=str(reporoot))
|
||||
|
||||
# Wait for both install and backend to be ready
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(pg.wait(install_proc))
|
||||
tg.create_task(ready(backend_url, path="/api/health?from=devserver.py"))
|
||||
|
||||
# Start Vite dev server (ProcessGroup waits for any exit, then terminates others)
|
||||
await pg.spawn(*vite, cwd=str(front))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run Vite and Cista (Sanic) development servers",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=HELP_EPILOG,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--listen",
|
||||
metavar="host:port",
|
||||
help="Vite frontend endpoint (default: localhost:8989)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
metavar="host:port",
|
||||
help="Cista backend endpoint (default: from config, or :8999)",
|
||||
)
|
||||
args, unknown = parser.parse_known_args()
|
||||
with contextlib.suppress(KeyboardInterrupt):
|
||||
asyncio.run(run_devserver(args.listen, args.backend, unknown))
|
||||
|
||||
|
||||
HELP_EPILOG = """
|
||||
scripts/devserver.py # Default ports
|
||||
scripts/devserver.py -l 3000 # Vite on localhost:3000
|
||||
scripts/devserver.py -l :3000 --backend 8080 # Vite on *:3000, backend on :8080
|
||||
|
||||
Additional arguments are passed to the cista backend command.
|
||||
|
||||
JS_RUNTIME environment variable can be used to select the JS runtime
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Hatch build hook for building Vue frontend during package build."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from buildutil import build
|
||||
|
||||
|
||||
class CustomBuildHook(BuildHookInterface):
|
||||
def initialize(self, version, build_data):
|
||||
super().initialize(version, build_data)
|
||||
build("frontend")
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Utilities used at build time and in devserver script. No dependencies."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class _PrefixFormatter(logging.Formatter):
|
||||
"""Formatter that adds prefix based on log level."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
if record.levelno >= logging.WARNING:
|
||||
return f"┃ ⚠️ {record.getMessage()}"
|
||||
return record.getMessage()
|
||||
|
||||
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(_PrefixFormatter())
|
||||
logger = logging.getLogger("fastapi-vue")
|
||||
logger.addHandler(_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def _check_node_version(node_path: str) -> None:
|
||||
"""Check if Node.js version is >= 20.
|
||||
|
||||
Raises RuntimeError if version is too old or cannot be determined.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[node_path, "--version"], capture_output=True, text=True, check=True
|
||||
)
|
||||
version_str = result.stdout.strip()
|
||||
# Parse version like "v20.10.0" or "v18.17.1"
|
||||
match = re.match(r"v(\d+)", version_str)
|
||||
if match:
|
||||
major_version = int(match.group(1))
|
||||
if major_version >= 20:
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"Node.js {version_str} found, but v20+ required (install with nvm)"
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
||||
pass
|
||||
raise RuntimeError("Could not determine Node.js version")
|
||||
|
||||
|
||||
def find_js_runtime() -> tuple[str, str]:
|
||||
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||
|
||||
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||
Raises JSRuntimeError if no suitable runtime is found.
|
||||
"""
|
||||
options = ["npm", "deno", "bun"]
|
||||
node_version_error: RuntimeError | None = None
|
||||
|
||||
# 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:
|
||||
raise RuntimeError(
|
||||
f"JS_RUNTIME={js_runtime_env}: {option} not found"
|
||||
)
|
||||
# Check Node.js version if using npm
|
||||
if option == "npm":
|
||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||
if node_path is None:
|
||||
raise RuntimeError(
|
||||
f"JS_RUNTIME={js_runtime_env}: node not found"
|
||||
)
|
||||
_check_node_version(node_path) # Raises on failure
|
||||
return tool, option
|
||||
raise RuntimeError(f"JS_RUNTIME={js_runtime_env} not recognized")
|
||||
|
||||
# Auto-detect
|
||||
for option in options:
|
||||
if tool := shutil.which(option):
|
||||
# Check Node.js version if using npm
|
||||
if option == "npm":
|
||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||
if node_path is None:
|
||||
continue
|
||||
try:
|
||||
_check_node_version(node_path)
|
||||
except RuntimeError as e:
|
||||
node_version_error = e
|
||||
continue # Try next runtime
|
||||
return tool, option
|
||||
|
||||
# No runtime found - provide helpful error
|
||||
if node_version_error:
|
||||
raise node_version_error
|
||||
raise RuntimeError("Node.js (v20+), Deno or Bun is required but none was found")
|
||||
|
||||
|
||||
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"),
|
||||
}
|
||||
|
||||
tool, name = find_js_runtime()
|
||||
return [tool, *install[name]], [tool, *build[name]]
|
||||
|
||||
|
||||
def find_dev_tool() -> list[str]:
|
||||
"""Find JavaScript runtime and construct dev command.
|
||||
|
||||
Returns dev_cmd (without vite-specific args).
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
dev_args = {
|
||||
"deno": ("run", "-A", "npm:vite"),
|
||||
"npm": ("--silent", "run", "dev", "--"),
|
||||
"bun": ("run", "dev", "--"),
|
||||
}
|
||||
|
||||
tool, name = find_js_runtime()
|
||||
|
||||
if name == "bun":
|
||||
logger.warning(
|
||||
"Bun has a bug in WS proxying (https://github.com/oven-sh/bun/issues/9882). Consider using npm instead."
|
||||
)
|
||||
|
||||
return [tool, *dev_args[name]]
|
||||
|
||||
|
||||
def find_install_tool() -> list[str]:
|
||||
"""Find JavaScript runtime and construct install command.
|
||||
|
||||
Returns install_cmd.
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
install_args = {
|
||||
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
|
||||
"npm": ("install", "--silent"),
|
||||
"bun": ("install", "--silent"),
|
||||
}
|
||||
|
||||
tool, name = find_js_runtime()
|
||||
return [tool, *install_args[name]]
|
||||
|
||||
|
||||
def build(folder: str = "frontend") -> None:
|
||||
"""Build the frontend in the specified folder.
|
||||
|
||||
Raises SystemExit(1) on failure.
|
||||
"""
|
||||
logger.info(">>> Building %s", folder)
|
||||
|
||||
try:
|
||||
install_cmd, build_cmd = find_build_tool()
|
||||
except RuntimeError as e:
|
||||
logger.warning(e)
|
||||
raise SystemExit(1)
|
||||
|
||||
def run(cmd):
|
||||
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
|
||||
logger.info("### %s", " ".join(display_cmd))
|
||||
subprocess.run(cmd, check=True, cwd=folder)
|
||||
|
||||
try:
|
||||
run(install_cmd)
|
||||
logger.info("")
|
||||
run(build_cmd)
|
||||
except subprocess.CalledProcessError:
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from buildutil import find_dev_tool, find_install_tool, logger
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
DEFAULT_VITE_PORT = 8989
|
||||
DEFAULT_BACKEND_PORT = 8999
|
||||
|
||||
|
||||
class ProcessGroup:
|
||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||
|
||||
def __init__(self):
|
||||
self._procs: list[asyncio.subprocess.Process] = []
|
||||
|
||||
async def spawn(
|
||||
self, *cmd: str, cwd: str | None = None
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Spawn a subprocess and track it."""
|
||||
logger.info(">>> %s", " ".join([Path(cmd[0]).name, *cmd[1:]]))
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||
self._procs.append(proc)
|
||||
return proc
|
||||
|
||||
async def wait(self, proc: asyncio.subprocess.Process) -> None:
|
||||
"""Wait for a process to complete, raise SystemExit(1) on failure."""
|
||||
if await proc.wait() != 0:
|
||||
logger.warning("Command failed")
|
||||
raise SystemExit(1)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, *_):
|
||||
"""Wait for one process to exit, terminate others, then wait for all."""
|
||||
cleanup_task = asyncio.create_task(self._cleanup())
|
||||
try:
|
||||
await asyncio.shield(cleanup_task)
|
||||
except asyncio.CancelledError:
|
||||
# Shield was cancelled but cleanup_task continues - wait for it
|
||||
await cleanup_task
|
||||
|
||||
async def _cleanup(self):
|
||||
running = [p for p in self._procs if p.returncode is None]
|
||||
if not running:
|
||||
return
|
||||
|
||||
# Wait for any one process to exit
|
||||
await asyncio.wait(
|
||||
[asyncio.create_task(p.wait()) for p in running],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
# Terminate remaining processes
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
try:
|
||||
p.terminate()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
# Wait for all to finish (with overall timeout)
|
||||
still_running = [p for p in self._procs if p.returncode is None]
|
||||
if still_running:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*[p.wait() for p in still_running]),
|
||||
timeout=10,
|
||||
)
|
||||
except TimeoutError:
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
try:
|
||||
p.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
await p.wait()
|
||||
|
||||
|
||||
async def ready(url: str, path: str = "") -> None:
|
||||
"""Wait for the server to be ready by polling an endpoint.
|
||||
|
||||
Raises SystemExit(1) if server doesn't start in time.
|
||||
"""
|
||||
max_attempts = 50
|
||||
full_url = f"{url}{path}"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
await client.get(full_url, timeout=1.0)
|
||||
logger.info("✓ Backend ready!")
|
||||
return
|
||||
except httpx.RequestError:
|
||||
if attempt == max_attempts - 1:
|
||||
logger.warning("Backend didn't start in time")
|
||||
raise SystemExit(1)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
def setup_vite(endpoint: str) -> tuple[str, list[str], list[str]]:
|
||||
"""Parse frontend endpoint and build commands.
|
||||
|
||||
Returns (url, install_cmd, dev_cmd).
|
||||
Raises SystemExit(1) on invalid config.
|
||||
"""
|
||||
endpoints = parse_endpoint(endpoint, DEFAULT_VITE_PORT)
|
||||
|
||||
if "uds" in endpoints[0]:
|
||||
logger.warning("Unix sockets not supported with vite devserver")
|
||||
raise SystemExit(1)
|
||||
|
||||
port = endpoints[0]["port"]
|
||||
host = endpoints[0]["host"]
|
||||
|
||||
install_cmd = find_install_tool()
|
||||
dev_cmd = find_dev_tool()
|
||||
if host != "localhost":
|
||||
dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}")
|
||||
if port != 5173:
|
||||
dev_cmd.append(f"--port={port}")
|
||||
|
||||
return f"http://{host}:{port}", install_cmd, dev_cmd
|
||||
|
||||
|
||||
def setup_fastapi(
|
||||
endpoint: str, module: str, default_port: int = DEFAULT_BACKEND_PORT
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Parse backend endpoint and build fastapi dev command.
|
||||
|
||||
Returns (url, cmd).
|
||||
Raises SystemExit(1) on invalid config.
|
||||
"""
|
||||
endpoints = parse_endpoint(endpoint, default_port)
|
||||
|
||||
if "uds" in endpoints[0]:
|
||||
logger.warning("Unix sockets not supported with vite devserver")
|
||||
raise SystemExit(1)
|
||||
|
||||
host = endpoints[0]["host"]
|
||||
port = endpoints[0]["port"]
|
||||
|
||||
cmd = [
|
||||
"fastapi",
|
||||
"dev",
|
||||
"--entrypoint",
|
||||
module,
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
str(port),
|
||||
]
|
||||
return f"http://{host}:{port}", cmd
|
||||
@@ -0,0 +1,102 @@
|
||||
import argparse
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
from cista.preview import process_image_with_timing
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate image previews for all files in a folder, one at a time.",
|
||||
)
|
||||
parser.add_argument("folder", type=Path, help="Folder to scan recursively")
|
||||
parser.add_argument(
|
||||
"--px",
|
||||
type=int,
|
||||
default=1024,
|
||||
help="Maximum preview dimension in pixels (default: 1024)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--quality",
|
||||
type=int,
|
||||
default=60,
|
||||
help="AVIF quality passed to preview generation (default: 60)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def is_image_file(path: Path) -> bool:
|
||||
mime_type, _ = mimetypes.guess_type(path.name)
|
||||
return bool(mime_type and mime_type.startswith("image/"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
folder = args.folder.resolve()
|
||||
if not folder.is_dir():
|
||||
raise SystemExit(f"Not a directory: {folder}")
|
||||
|
||||
files = sorted(
|
||||
path for path in folder.rglob("*") if path.is_file() and is_image_file(path)
|
||||
)
|
||||
if not files:
|
||||
print(f"No image files found under {folder}")
|
||||
return 0
|
||||
|
||||
total_files = 0
|
||||
total_bytes = 0
|
||||
total_timing_slots: list[float] = []
|
||||
total_preview_ms: float = 0.0
|
||||
failures = 0
|
||||
|
||||
print(f"Scanning {folder}")
|
||||
print(f"Generating previews for {len(files)} image files")
|
||||
|
||||
for path in files:
|
||||
total_files += 1
|
||||
rel = path.relative_to(folder)
|
||||
try:
|
||||
preview, timing = process_image_with_timing(
|
||||
path,
|
||||
maxsize=args.px,
|
||||
quality=args.quality,
|
||||
)
|
||||
except Exception as exc:
|
||||
failures += 1
|
||||
print(f"FAIL {rel} error={exc}")
|
||||
continue
|
||||
|
||||
total_bytes += len(preview)
|
||||
timings = timing.timings or []
|
||||
if len(total_timing_slots) < len(timings):
|
||||
total_timing_slots.extend([0.0] * (len(timings) - len(total_timing_slots)))
|
||||
for i, value in enumerate(timings):
|
||||
total_timing_slots[i] += value
|
||||
total_ms = sum(timings)
|
||||
total_preview_ms += total_ms
|
||||
|
||||
detail = " / ".join(f"{value:.1f}ms" for value in timings)
|
||||
if detail:
|
||||
detail = f"timings={detail} total={total_ms:.1f}ms"
|
||||
else:
|
||||
detail = f"total={total_ms:.1f}ms"
|
||||
print(f"OK {rel} backend={timing.backend} bytes={len(preview)} {detail}")
|
||||
|
||||
completed = total_files - failures
|
||||
print()
|
||||
print("Summary")
|
||||
print(f" files={total_files}")
|
||||
print(f" completed={completed}")
|
||||
print(f" failed={failures}")
|
||||
print(f" preview_bytes={total_bytes}")
|
||||
if completed:
|
||||
if total_timing_slots:
|
||||
for i, value in enumerate(total_timing_slots, start=1):
|
||||
print(f" timing{i}_total_ms={value:.1f}")
|
||||
print(f" preview_total_ms={total_preview_ms:.1f}")
|
||||
print(f" preview_avg_ms={total_preview_ms / completed:.1f}")
|
||||
return 0 if failures == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+12
-56
@@ -1,10 +1,7 @@
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
|
||||
from cista.protocol import FileEntry, UpdateMessage, UpdDel, UpdIns, UpdKeep
|
||||
from cista.watching import State, format_update
|
||||
from cista.watching import format_update
|
||||
|
||||
|
||||
def decode(data: str):
|
||||
@@ -13,7 +10,9 @@ def decode(data: str):
|
||||
|
||||
# Helper function to create a list of FileEntry objects
|
||||
def f(count, start=0):
|
||||
return [FileEntry(i, str(i), str(i), 0, 0, 0) for i in range(start, start + count)]
|
||||
return [
|
||||
FileEntry(i, str(i), str(i), 0, 0, 0, 0) for i in range(start, start + count)
|
||||
]
|
||||
|
||||
|
||||
def test_identical_lists():
|
||||
@@ -37,6 +36,14 @@ def test_insertions():
|
||||
assert decode(format_update(old_list, new_list)) == expected
|
||||
|
||||
|
||||
def test_insertion_at_end():
|
||||
old_list = [*f(3), FileEntry(1, "xxx", "xxx", 0, 0, 0, 1)]
|
||||
newfile = FileEntry(1, "yyy", "yyy", 0, 0, 0, 1)
|
||||
new_list = [*old_list, newfile]
|
||||
expected = [UpdKeep(4), UpdIns([newfile])]
|
||||
assert decode(format_update(old_list, new_list)) == expected
|
||||
|
||||
|
||||
def test_deletions():
|
||||
old_list = f(3)
|
||||
new_list = [old_list[0], old_list[2]]
|
||||
@@ -83,54 +90,3 @@ def test_longer_lists():
|
||||
def sortkey(name):
|
||||
# Define the sorting key for names here
|
||||
return name.lower()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def state():
|
||||
entries = [
|
||||
FileEntry(0, "", "root", 0, 0, 0),
|
||||
FileEntry(1, "bar", "bar", 0, 0, 0),
|
||||
FileEntry(2, "baz", "bar/baz", 0, 0, 0),
|
||||
FileEntry(1, "foo", "foo", 0, 0, 0),
|
||||
FileEntry(1, "xxx", "xxx", 0, 0, 0),
|
||||
FileEntry(2, "yyy", "xxx/yyy", 0, 0, 1),
|
||||
]
|
||||
s = State()
|
||||
s._listing = entries
|
||||
return s
|
||||
|
||||
|
||||
def test_existing_directory(state):
|
||||
path = PurePosixPath("bar")
|
||||
expected_slice = slice(1, 3) # Includes 'bar' and 'baz'
|
||||
assert state._slice(path) == expected_slice
|
||||
|
||||
|
||||
def test_existing_file(state):
|
||||
path = PurePosixPath("xxx/yyy")
|
||||
expected_slice = slice(5, 6) # Only includes 'yyy'
|
||||
assert state._slice(path) == expected_slice
|
||||
|
||||
|
||||
def test_nonexistent_directory(state):
|
||||
path = PurePosixPath("zzz")
|
||||
expected_slice = slice(6, 6) # 'zzz' would be inserted at end
|
||||
assert state._slice(path) == expected_slice
|
||||
|
||||
|
||||
def test_nonexistent_file(state):
|
||||
path = (PurePosixPath("bar/mmm"), 1)
|
||||
expected_slice = slice(3, 3) # A file would be inserted after 'baz' under 'bar'
|
||||
assert state._slice(path) == expected_slice
|
||||
|
||||
|
||||
def test_root_directory(state):
|
||||
path = PurePosixPath()
|
||||
expected_slice = slice(0, 6) # Entire tree
|
||||
assert state._slice(path) == expected_slice
|
||||
|
||||
|
||||
def test_directory_with_subdirs_and_files(state):
|
||||
path = PurePosixPath("xxx")
|
||||
expected_slice = slice(4, 6) # Includes 'xxx' and 'yyy'
|
||||
assert state._slice(path) == expected_slice
|
||||
|
||||
Reference in New Issue
Block a user