Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,156 +1,175 @@
|
|||||||
# Cista Web Storage
|
# Cista Web Storage
|
||||||
|
|
||||||
<img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.webp" align=left width=250>
|
<img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.webp" align=left width=250>
|
||||||
|
|
||||||
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.
|
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**.
|
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.
|
**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 we used and loved despite its numerous bugs. Cista Storage stands out in handling even the most exotic filenames, ensuring a smooth experience where others falter.
|
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.
|
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...
|
Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and perhaps upload something...
|
||||||
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
### Running the Server
|
### Running the Server
|
||||||
|
|
||||||
We recommend using [UV](https://docs.astral.sh/uv/getting-started/installation/) to directly run Cista:
|
We recommend using [UV](https://docs.astral.sh/uv/getting-started/installation/) to directly run Cista:
|
||||||
|
|
||||||
Create an account: (otherwise the server is public for all)
|
Try it out locally at http://localhost:8000 (serves the current directory):
|
||||||
```fish
|
```fish
|
||||||
uvx cista --user yourname --privileged
|
uvx cista
|
||||||
```
|
```
|
||||||
|
|
||||||
Serve your files at http://localhost:8000:
|
Create an account: (otherwise the server is public for all)
|
||||||
```fish
|
```fish
|
||||||
uvx cista -l :8000 /path/to/files
|
uvx cista --user yourname --privileged
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternatively, you can install with `pip` or `uv pip`. This enables using the `cista` command directly without `uvx` or `uv run`.
|
Serve your files at http://localhost:8000:
|
||||||
|
```fish
|
||||||
```fish
|
uvx cista -l :8000 /path/to/files
|
||||||
pip install cista --break-system-packages
|
```
|
||||||
```
|
|
||||||
|
Alternatively, you can install with `pip` or `uv pip`. This enables using the `cista` command directly without `uvx` or `uv run`.
|
||||||
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
|
|
||||||
|
```fish
|
||||||
## Authentication
|
pip install cista --break-system-packages
|
||||||
|
```
|
||||||
Cista supports three authentication modes:
|
|
||||||
|
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
|
||||||
### Built-in Authentication (default)
|
|
||||||
|
## Authentication
|
||||||
User accounts are managed directly by Cista. Create users with the `--user` flag:
|
|
||||||
|
Cista supports two authenticatioon mode, each of which supporting ordinary and privileged users. Either one can be combined with the public mode.
|
||||||
```fish
|
|
||||||
uvx cista --user admin --privileged # Create admin user
|
### Public Mode
|
||||||
uvx cista --user guest # Create regular user
|
|
||||||
```
|
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.
|
||||||
|
|
||||||
Privileged users can manage other users and change settings via the Admin Settings menu.
|
### Built-in Password Authentication (default)
|
||||||
|
|
||||||
### Public Mode
|
User accounts are managed directly by Cista. Create users with the `--user` flag:
|
||||||
|
|
||||||
In public mode, anyone can read, send and even delete files without without logging in. Privileged users can still log in via the menu to access admin settings, from where the public mode can be toggled on or off.
|
```fish
|
||||||
|
uvx cista --user admin --privileged # Create admin user
|
||||||
### Paskia SSO Authentication
|
uvx cista --user guest # Create regular user
|
||||||
|
```
|
||||||
For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. Set the `PASKIA_BACKEND_URL` environment variable:
|
|
||||||
|
Privileged users can manage other users and change settings via the Admin Settings menu.
|
||||||
```fish
|
|
||||||
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
|
### 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.
|
||||||
In Paskia mode:
|
|
||||||
- All `/auth/*` requests are proxied to the Paskia backend
|
Set the `PASKIA_BACKEND_URL` environment variable:
|
||||||
- Users with `cista:login` permission can access files
|
|
||||||
- Users with `cista:admin` permission get privileged access (Admin Settings)
|
```fish
|
||||||
- Public mode works with Paskia: unauthenticated users can browse, while the menu has option to login
|
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
|
||||||
|
```
|
||||||
### Internet Access
|
|
||||||
|
Run the Paskia backend on the same machine (to use that default URL):
|
||||||
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.
|
```fish
|
||||||
|
uvx paskia
|
||||||
`/etc/caddy/Caddyfile`:
|
```
|
||||||
|
|
||||||
```Caddyfile
|
In Paskia mode:
|
||||||
cista.example.com {
|
- All `/auth/*` requests are proxied to the Paskia backend
|
||||||
reverse_proxy :8000
|
- 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)
|
||||||
|
|
||||||
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`
|
### Internet Access
|
||||||
|
|
||||||
## System Deployment
|
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.
|
||||||
|
|
||||||
This setup allows easy addition of storages, each with its own domain, configuration, and files.
|
`/etc/caddy/Caddyfile`:
|
||||||
|
|
||||||
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 bun/npm.
|
```Caddyfile
|
||||||
|
cista.example.com {
|
||||||
Create `/etc/systemd/system/cista@.service`:
|
reverse_proxy :8000
|
||||||
|
}
|
||||||
```ini
|
```
|
||||||
[Unit]
|
|
||||||
Description=Cista storage %i
|
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`
|
||||||
|
|
||||||
[Service]
|
## System Deployment
|
||||||
User=storage
|
|
||||||
ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
|
This setup allows easy addition of storages, each with its own domain, configuration, and files.
|
||||||
Restart=always
|
|
||||||
|
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.
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
Create (edit) a systemd unit:
|
||||||
```
|
|
||||||
|
```fish
|
||||||
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.
|
sudo systemctl edit --force --full cista@.service
|
||||||
|
```
|
||||||
```fish
|
|
||||||
systemctl daemon-reload
|
Paste the following:
|
||||||
systemctl enable --now cista@foo.example.com
|
|
||||||
systemctl enable --now cista@bar.example.com
|
```ini
|
||||||
```
|
[Unit]
|
||||||
|
Description=Cista storage %i
|
||||||
Public exposure is easiest using the Caddy web server.
|
|
||||||
|
[Service]
|
||||||
`/etc/caddy/Caddyfile`:
|
User=storage
|
||||||
|
ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
|
||||||
```Caddyfile
|
Restart=always
|
||||||
foo.example.com, bar.example.com {
|
#Environment=PASKIA_BACKEND_URL=http://localhost:4401
|
||||||
reverse_proxy unix//srv/cista/{host}/socket
|
|
||||||
}
|
[Install]
|
||||||
```
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
## Development setup
|
|
||||||
|
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.
|
||||||
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.
|
|
||||||
|
```fish
|
||||||
Make sure you have git, uv and bun (or npm) installed.
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now cista@foo.example.com
|
||||||
Backend (Python) – setup and run:
|
systemctl enable --now cista@bar.example.com
|
||||||
|
```
|
||||||
```fish
|
|
||||||
git clone https://git.zi.fi/Vasanko/cista-storage.git
|
Public exposure is easiest using the Caddy web server.
|
||||||
cd cista-storage
|
|
||||||
uv sync --dev
|
`/etc/caddy/Caddyfile`:
|
||||||
uv run cista --dev -l :8000 /path/to/files
|
|
||||||
```
|
```Caddyfile
|
||||||
|
foo.example.com, bar.example.com {
|
||||||
Frontend (Vue/Vite) – run the dev server in another terminal:
|
reverse_proxy unix//srv/cista/{host}/socket
|
||||||
|
}
|
||||||
```fish
|
```
|
||||||
cd frontend
|
|
||||||
bun install
|
## Development setup
|
||||||
bun run dev
|
|
||||||
```
|
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.
|
||||||
|
|
||||||
Building the package for release (frontend + Python wheel/sdist):
|
Make sure you have git, uv and bun (or npm) installed.
|
||||||
|
|
||||||
```fish
|
Backend (Python) – setup and run:
|
||||||
uv build
|
|
||||||
```
|
```fish
|
||||||
|
git clone https://git.zi.fi/Vasanko/cista-storage.git
|
||||||
Vue is used to build files in `cista/wwwroot`, included prebuilt in the Python package. `uv build` runs the project build hooks to bundle the frontend and produce a NodeJS-independent Python package.
|
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
|
||||||
|
bun install
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
+15
-4
@@ -25,14 +25,22 @@ def create_banner():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def create_startup_box(*, folder, url, unix=None, dev=False, paskia_url=None):
|
def create_startup_box(
|
||||||
|
*, folder, url, unix=None, dev=False, paskia_url=None, public=False
|
||||||
|
):
|
||||||
"""Create a framed startup box with server information."""
|
"""Create a framed startup box with server information."""
|
||||||
title = f"Cista {cista.__version__}"
|
title = f"Cista {cista.__version__}"
|
||||||
listen = unix if unix else url
|
listen = unix if unix else url
|
||||||
location = f"{folder} @ {listen}"
|
location = f"{folder} @ {listen}"
|
||||||
lines = [title, location]
|
lines = [title, location]
|
||||||
|
# Auth line: Paskia <url> or Password, with optional Public suffix
|
||||||
if paskia_url:
|
if paskia_url:
|
||||||
lines.append(f"Paskia: {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:
|
if dev:
|
||||||
lines.append("dev mode")
|
lines.append("dev mode")
|
||||||
|
|
||||||
@@ -53,10 +61,12 @@ doc = """\
|
|||||||
Usage:
|
Usage:
|
||||||
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
|
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
|
||||||
cista [-c <confdir>] --user <name> [--privileged] [--password]
|
cista [-c <confdir>] --user <name> [--privileged] [--password]
|
||||||
|
cista --version
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-c CONFDIR Custom config directory
|
-c CONFDIR Custom config directory
|
||||||
-l LISTEN-ADDR Listen on
|
-l, --listen LISTEN-ADDR
|
||||||
|
Listen on
|
||||||
:8989 (localhost port, plain http)
|
:8989 (localhost port, plain http)
|
||||||
<addr>:3000 (bind another address, port)
|
<addr>:3000 (bind another address, port)
|
||||||
/path/to/unix.sock (unix socket)
|
/path/to/unix.sock (unix socket)
|
||||||
@@ -109,7 +119,7 @@ def _main():
|
|||||||
args = docopt(doc)
|
args = docopt(doc)
|
||||||
if args["--user"]:
|
if args["--user"]:
|
||||||
return _user(args)
|
return _user(args)
|
||||||
listen = args["-l"]
|
listen = args["--listen"]
|
||||||
# Validate arguments first
|
# Validate arguments first
|
||||||
if args["<path>"]:
|
if args["<path>"]:
|
||||||
path = Path(args["<path>"]).resolve()
|
path = Path(args["<path>"]).resolve()
|
||||||
@@ -157,6 +167,7 @@ def _main():
|
|||||||
unix=opts.get("unix"),
|
unix=opts.get("unix"),
|
||||||
dev=dev,
|
dev=dev,
|
||||||
paskia_url=PASKIA_BACKEND_URL or None,
|
paskia_url=PASKIA_BACKEND_URL or None,
|
||||||
|
public=config.config.public,
|
||||||
)
|
)
|
||||||
sys.stderr.write(startup_box)
|
sys.stderr.write(startup_box)
|
||||||
# Run the server
|
# Run the server
|
||||||
|
|||||||
@@ -163,6 +163,17 @@ def subscribe(uuid, ws):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
@bp.put("config/public")
|
||||||
async def update_public(request):
|
async def update_public(request):
|
||||||
await auth.verify(request, privileged=True)
|
await auth.verify(request, privileged=True)
|
||||||
@@ -176,3 +187,20 @@ async def update_public(request):
|
|||||||
raise BadRequest(str(e)) from None
|
raise BadRequest(str(e)) from None
|
||||||
config.update_config({"public": public})
|
config.update_config({"public": public})
|
||||||
return json({"message": "Public access setting updated", "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})
|
||||||
|
|||||||
+2
-2
@@ -269,7 +269,7 @@ async def verify(request, *, privileged=False):
|
|||||||
raise Unauthorized(
|
raise Unauthorized(
|
||||||
f"Login required for {request.path}",
|
f"Login required for {request.path}",
|
||||||
"cookie",
|
"cookie",
|
||||||
context={"auth": {"iframe": "/auth/restricted"}},
|
context={"auth": {"iframe": "/auth/restricted/"}},
|
||||||
quiet=True,
|
quiet=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ async def verify(request, *, privileged=False):
|
|||||||
bp = Blueprint("auth", url_prefix="/auth")
|
bp = Blueprint("auth", url_prefix="/auth")
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/restricted")
|
@bp.get("/restricted/")
|
||||||
async def login_page(request):
|
async def login_page(request):
|
||||||
"""Login page that works both standalone and in paskia iframe."""
|
"""Login page that works both standalone and in paskia iframe."""
|
||||||
s = session.get(request)
|
s = session.get(request)
|
||||||
|
|||||||
+70
-12
@@ -2,7 +2,10 @@ import asyncio
|
|||||||
import gc
|
import gc
|
||||||
import io
|
import io
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import threading
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from collections import OrderedDict
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
@@ -25,6 +28,49 @@ pillow_heif.register_heif_opener()
|
|||||||
bp = Blueprint("preview", url_prefix="/preview")
|
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)
|
||||||
|
|
||||||
|
|
||||||
@bp.on_request
|
@bp.on_request
|
||||||
async def verify_preview(request):
|
async def verify_preview(request):
|
||||||
"""Verify access to preview routes."""
|
"""Verify access to preview routes."""
|
||||||
@@ -55,6 +101,29 @@ async def preview(req, path):
|
|||||||
etag = config.derived_secret(
|
etag = config.derived_secret(
|
||||||
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
||||||
).hex()
|
).hex()
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if not filepath.is_file():
|
||||||
|
raise NotFound("File not found")
|
||||||
|
|
||||||
|
# Generate preview
|
||||||
|
img = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
|
||||||
|
)
|
||||||
|
if not img:
|
||||||
|
# Preview generation failed, redirect to the file itself
|
||||||
|
return redirect(f"/files/{path}", status=303)
|
||||||
|
|
||||||
|
# Build headers and cache the full response
|
||||||
savename = PurePosixPath(filepath.name).with_suffix(".avif")
|
savename = PurePosixPath(filepath.name).with_suffix(".avif")
|
||||||
headers = {
|
headers = {
|
||||||
"etag": etag,
|
"etag": etag,
|
||||||
@@ -64,19 +133,8 @@ async def preview(req, path):
|
|||||||
"content-type": "image/avif",
|
"content-type": "image/avif",
|
||||||
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
|
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
|
||||||
}
|
}
|
||||||
if req.headers.if_none_match == etag:
|
_preview_cache.set(etag, CachedPreview(headers=headers, body=img))
|
||||||
# The client has it cached, respond 304 Not Modified
|
|
||||||
return empty(304, headers=headers)
|
|
||||||
|
|
||||||
if not filepath.is_file():
|
|
||||||
raise NotFound("File not found")
|
|
||||||
|
|
||||||
img = await asyncio.get_event_loop().run_in_executor(
|
|
||||||
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
|
|
||||||
)
|
|
||||||
if not img:
|
|
||||||
# Preview generation failed, redirect to the file itself
|
|
||||||
return redirect(f"/files/{path}", status=303)
|
|
||||||
return raw(img, headers=headers)
|
return raw(img, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -146,6 +146,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
|||||||
key: str
|
key: str
|
||||||
mtime: int
|
mtime: int
|
||||||
size: int
|
size: int
|
||||||
|
allocated: int
|
||||||
isfile: int
|
isfile: int
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -177,5 +178,6 @@ class UpdateMessage(msgspec.Struct):
|
|||||||
class Space(msgspec.Struct):
|
class Space(msgspec.Struct):
|
||||||
disk: int
|
disk: int
|
||||||
free: int
|
free: int
|
||||||
usage: int
|
used: int
|
||||||
storage: int
|
storage: int
|
||||||
|
allocated: int
|
||||||
|
|||||||
+1
-6
@@ -62,11 +62,6 @@ def parse_listen(listen):
|
|||||||
return "http://localhost", {"unix": unix.as_posix()}
|
return "http://localhost", {"unix": unix.as_posix()}
|
||||||
|
|
||||||
host, port = ep["host"], ep["port"]
|
host, port = ep["host"], ep["port"]
|
||||||
# When binding all interfaces, use single_listener=False for Sanic
|
|
||||||
if len(endpoints) > 1:
|
if len(endpoints) > 1:
|
||||||
return f"http://localhost:{port}", {
|
return f"http://localhost:{port}", {"host": host, "port": port}
|
||||||
"host": host,
|
|
||||||
"port": port,
|
|
||||||
"single_listener": False,
|
|
||||||
}
|
|
||||||
return f"http://{host}:{port}", {"host": host, "port": port}
|
return f"http://{host}:{port}", {"host": host, "port": port}
|
||||||
|
|||||||
+30
-38
@@ -15,7 +15,8 @@ import re
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import websockets
|
import websockets
|
||||||
from sanic import Blueprint
|
from sanic import Blueprint, json
|
||||||
|
from sanic import raw as raw_response
|
||||||
from sanic.exceptions import Forbidden, SanicException, Unauthorized
|
from sanic.exceptions import Forbidden, SanicException, Unauthorized
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
|
|
||||||
@@ -48,6 +49,8 @@ async def get_client() -> httpx.AsyncClient:
|
|||||||
global _client
|
global _client
|
||||||
if _client is None or _client.is_closed:
|
if _client is None or _client.is_closed:
|
||||||
_client = httpx.AsyncClient(timeout=1.0)
|
_client = httpx.AsyncClient(timeout=1.0)
|
||||||
|
if "user-agent" in _client.headers:
|
||||||
|
del _client.headers["user-agent"] # No httpx UA
|
||||||
return _client
|
return _client
|
||||||
|
|
||||||
|
|
||||||
@@ -171,10 +174,10 @@ async def proxy_auth_request(request):
|
|||||||
"upgrade",
|
"upgrade",
|
||||||
"proxy-authorization",
|
"proxy-authorization",
|
||||||
"proxy-authenticate",
|
"proxy-authenticate",
|
||||||
"forwarded",
|
|
||||||
"x-forwarded-for",
|
"x-forwarded-for",
|
||||||
"x-forwarded-host",
|
"x-forwarded-host",
|
||||||
"x-forwarded-proto",
|
"x-forwarded-proto",
|
||||||
|
"forwarded",
|
||||||
}
|
}
|
||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
@@ -182,9 +185,17 @@ async def proxy_auth_request(request):
|
|||||||
for key, value in request.headers.items()
|
for key, value in request.headers.items()
|
||||||
if key.lower() not in skip_headers
|
if key.lower() not in skip_headers
|
||||||
]
|
]
|
||||||
headers.append(("x-forwarded-for", request.client_ip))
|
|
||||||
|
# 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-host", request.host))
|
||||||
headers.append(("x-forwarded-proto", request.scheme))
|
headers.append(("x-forwarded-proto", request.scheme))
|
||||||
|
headers.append(
|
||||||
|
(
|
||||||
|
"forwarded",
|
||||||
|
f"by=cista;for={request.client_ip};host={request.host};proto={request.scheme}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with client.stream(
|
async with client.stream(
|
||||||
@@ -210,8 +221,6 @@ async def proxy_auth_request(request):
|
|||||||
if key.lower() not in resp_hop_by_hop
|
if key.lower() not in resp_hop_by_hop
|
||||||
]
|
]
|
||||||
|
|
||||||
from sanic import raw as raw_response
|
|
||||||
|
|
||||||
return raw_response(
|
return raw_response(
|
||||||
raw_content,
|
raw_content,
|
||||||
status=response.status_code,
|
status=response.status_code,
|
||||||
@@ -221,35 +230,31 @@ async def proxy_auth_request(request):
|
|||||||
|
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
logger.error(f"Auth proxy request failed: {e}")
|
logger.error(f"Auth proxy request failed: {e}")
|
||||||
from sanic import json
|
|
||||||
|
|
||||||
return json(
|
return json(
|
||||||
{"detail": "Authentication service unavailable", "error": str(e)},
|
{"detail": "Authentication service unavailable"},
|
||||||
status=503,
|
status=503,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def proxy_auth_websocket(request, ws):
|
async def proxy_auth_websocket(request, ws):
|
||||||
"""Proxy a WebSocket connection to the auth backend."""
|
"""Proxy a WebSocket connection to the auth backend."""
|
||||||
path = request.path
|
url = f"ws{PASKIA_BACKEND_URL.removeprefix('http')}{request.path}"
|
||||||
query_string = request.query_string
|
if request.query_string:
|
||||||
ws_backend = PASKIA_BACKEND_URL.replace("http://", "ws://").replace(
|
url = f"{url}?{request.query_string}"
|
||||||
"https://", "wss://"
|
|
||||||
)
|
|
||||||
url = f"{ws_backend}{path}"
|
|
||||||
if query_string:
|
|
||||||
url = f"{url}?{query_string}"
|
|
||||||
|
|
||||||
additional_headers = {}
|
additional_headers = {}
|
||||||
if "cookie" in request.headers:
|
if "cookie" in request.headers:
|
||||||
additional_headers["cookie"] = request.headers["cookie"]
|
additional_headers["cookie"] = request.headers["cookie"]
|
||||||
if "authorization" in request.headers:
|
if "authorization" in request.headers:
|
||||||
additional_headers["authorization"] = request.headers["authorization"]
|
additional_headers["authorization"] = request.headers["authorization"]
|
||||||
|
if "host" in request.headers:
|
||||||
|
additional_headers["host"] = request.headers["host"]
|
||||||
if "origin" in request.headers:
|
if "origin" in request.headers:
|
||||||
additional_headers["origin"] = request.headers["origin"]
|
additional_headers["origin"] = request.headers["origin"]
|
||||||
if "user-agent" in request.headers:
|
if "user-agent" in request.headers:
|
||||||
additional_headers["user-agent"] = request.headers["user-agent"]
|
additional_headers["user-agent"] = request.headers["user-agent"]
|
||||||
additional_headers["x-forwarded-for"] = request.ip
|
additional_headers["x-forwarded-for"] = request.client_ip.strip("[]")
|
||||||
additional_headers["x-forwarded-host"] = request.host
|
additional_headers["x-forwarded-host"] = request.host
|
||||||
additional_headers["x-forwarded-proto"] = request.scheme
|
additional_headers["x-forwarded-proto"] = request.scheme
|
||||||
|
|
||||||
@@ -281,23 +286,20 @@ async def proxy_auth_websocket(request, ws):
|
|||||||
logger.error(f"WebSocket proxy to {url} failed: {e}")
|
logger.error(f"WebSocket proxy to {url} failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _is_websocket_request(request) -> bool:
|
# Blueprint for auth proxy routes (only registered when paskia_enabled())
|
||||||
"""Check if the request is a WebSocket upgrade request."""
|
bp = Blueprint("sso", url_prefix="/auth")
|
||||||
connection = request.headers.get("connection", "").lower()
|
|
||||||
upgrade = request.headers.get("upgrade", "").lower()
|
|
||||||
connection_tokens = [t.strip() for t in connection.split(",")]
|
|
||||||
return "upgrade" in connection_tokens and upgrade == "websocket"
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_websocket_upgrade(request):
|
@bp.websocket("/ws/<path:path>")
|
||||||
"""Handle WebSocket upgrade and proxy the connection."""
|
async def auth_websocket_proxy(request, ws, path=""):
|
||||||
protocol = request.transport.get_protocol()
|
"""Proxy WebSocket connections to the auth backend."""
|
||||||
ws = await protocol.websocket_handshake(request, subprotocols=None)
|
|
||||||
await proxy_auth_websocket(request, ws)
|
await proxy_auth_websocket(request, ws)
|
||||||
|
|
||||||
|
|
||||||
# Blueprint for auth proxy routes (only registered when paskia_enabled())
|
@bp.websocket("/ws/")
|
||||||
bp = Blueprint("sso", url_prefix="/auth")
|
async def auth_websocket_proxy_root(request, ws):
|
||||||
|
"""Proxy root WebSocket connections to the auth backend."""
|
||||||
|
await proxy_auth_websocket(request, ws)
|
||||||
|
|
||||||
|
|
||||||
@bp.route(
|
@bp.route(
|
||||||
@@ -305,20 +307,10 @@ bp = Blueprint("sso", url_prefix="/auth")
|
|||||||
)
|
)
|
||||||
async def auth_proxy(request, path=""):
|
async def auth_proxy(request, path=""):
|
||||||
"""Proxy all auth requests to the auth backend."""
|
"""Proxy all auth requests to the auth backend."""
|
||||||
if _is_websocket_request(request):
|
|
||||||
await _handle_websocket_upgrade(request)
|
|
||||||
from sanic import empty
|
|
||||||
|
|
||||||
return empty()
|
|
||||||
return await proxy_auth_request(request)
|
return await proxy_auth_request(request)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
@bp.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||||
async def auth_proxy_root(request):
|
async def auth_proxy_root(request):
|
||||||
"""Proxy root auth requests to the auth backend."""
|
"""Proxy root auth requests to the auth backend."""
|
||||||
if _is_websocket_request(request):
|
|
||||||
await _handle_websocket_upgrade(request)
|
|
||||||
from sanic import empty
|
|
||||||
|
|
||||||
return empty()
|
|
||||||
return await proxy_auth_request(request)
|
return await proxy_auth_request(request)
|
||||||
|
|||||||
+102
-3
@@ -17,6 +17,33 @@ from cista import config
|
|||||||
from cista.fileio import fuid
|
from cista.fileio import fuid
|
||||||
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep
|
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep
|
||||||
|
|
||||||
|
# Platform-specific allocated size calculation
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
GetCompressedFileSizeW = kernel32.GetCompressedFileSizeW
|
||||||
|
GetCompressedFileSizeW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(wintypes.DWORD)]
|
||||||
|
GetCompressedFileSizeW.restype = wintypes.DWORD
|
||||||
|
INVALID_FILE_SIZE = 0xFFFFFFFF
|
||||||
|
|
||||||
|
def get_allocated_size(path: Path, st: stat_result) -> int:
|
||||||
|
"""Get actual disk allocation on Windows using GetCompressedFileSizeW."""
|
||||||
|
high = wintypes.DWORD()
|
||||||
|
low = GetCompressedFileSizeW(str(path), ctypes.byref(high))
|
||||||
|
if low == INVALID_FILE_SIZE and ctypes.get_last_error() != 0:
|
||||||
|
raise OSError(f"GetCompressedFileSizeW failed for {path}")
|
||||||
|
return (high.value << 32) + low
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
def get_allocated_size(path: Path, st: stat_result) -> int:
|
||||||
|
"""Get actual disk allocation on Unix using st_blocks."""
|
||||||
|
# st_blocks is in 512-byte units
|
||||||
|
return st.st_blocks * 512
|
||||||
|
|
||||||
|
|
||||||
pubsub = {}
|
pubsub = {}
|
||||||
sortkey = natsort_keygen(alg=ns.LOCALE)
|
sortkey = natsort_keygen(alg=ns.LOCALE)
|
||||||
|
|
||||||
@@ -24,7 +51,7 @@ sortkey = natsort_keygen(alg=ns.LOCALE)
|
|||||||
class State:
|
class State:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self._space = Space(0, 0, 0, 0)
|
self._space = Space(0, 0, 0, 0, 0)
|
||||||
self.root: list[FileEntry] = []
|
self.root: list[FileEntry] = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -148,12 +175,18 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
try:
|
try:
|
||||||
st = stat or path.stat()
|
st = stat or path.stat()
|
||||||
isfile = int(not S_ISDIR(st.st_mode))
|
isfile = int(not S_ISDIR(st.st_mode))
|
||||||
|
try:
|
||||||
|
allocated = get_allocated_size(path, st) if isfile else 0
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"get_allocated_size failed for {path}")
|
||||||
|
allocated = st.st_size if isfile else 0
|
||||||
entry = FileEntry(
|
entry = FileEntry(
|
||||||
level=len(rel.parts),
|
level=len(rel.parts),
|
||||||
name=rel.name,
|
name=rel.name,
|
||||||
key=fuid(st),
|
key=fuid(st),
|
||||||
mtime=int(st.st_mtime),
|
mtime=int(st.st_mtime),
|
||||||
size=st.st_size if isfile else 0,
|
size=st.st_size if isfile else 0,
|
||||||
|
allocated=allocated,
|
||||||
isfile=isfile,
|
isfile=isfile,
|
||||||
)
|
)
|
||||||
if isfile:
|
if isfile:
|
||||||
@@ -181,8 +214,9 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
level=entry.level,
|
level=entry.level,
|
||||||
name=entry.name,
|
name=entry.name,
|
||||||
key=entry.key,
|
key=entry.key,
|
||||||
size=entry.size + child.size,
|
|
||||||
mtime=max(entry.mtime, child.mtime),
|
mtime=max(entry.mtime, child.mtime),
|
||||||
|
size=entry.size + child.size,
|
||||||
|
allocated=entry.allocated + child.allocated,
|
||||||
isfile=entry.isfile,
|
isfile=entry.isfile,
|
||||||
)
|
)
|
||||||
ret.extend(sub)
|
ret.extend(sub)
|
||||||
@@ -227,7 +261,14 @@ def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
|
|||||||
def update_space(loop):
|
def update_space(loop):
|
||||||
"""Called periodically to update the disk usage."""
|
"""Called periodically to update the disk usage."""
|
||||||
du = shutil.disk_usage(rootpath)
|
du = shutil.disk_usage(rootpath)
|
||||||
space = Space(*du, storage=state.root[0].size)
|
root = state.root[0]
|
||||||
|
space = Space(
|
||||||
|
disk=du.total,
|
||||||
|
free=du.free,
|
||||||
|
used=du.used,
|
||||||
|
storage=root.size,
|
||||||
|
allocated=root.allocated,
|
||||||
|
)
|
||||||
# Update only on difference above 1 MB
|
# Update only on difference above 1 MB
|
||||||
tol = 10**6
|
tol = 10**6
|
||||||
old = msgspec.structs.astuple(state.space)
|
old = msgspec.structs.astuple(state.space)
|
||||||
@@ -504,8 +545,66 @@ class PathIndex:
|
|||||||
|
|
||||||
self.root = new_root
|
self.root = new_root
|
||||||
self._rebuild()
|
self._rebuild()
|
||||||
|
|
||||||
|
# Recalculate sizes for ancestor folders (including root)
|
||||||
|
self._recalculate_ancestors(path)
|
||||||
|
|
||||||
return new_root
|
return new_root
|
||||||
|
|
||||||
|
def _recalculate_ancestors(self, path: PurePosixPath):
|
||||||
|
"""Recalculate size/allocated for all ancestors of path, including root."""
|
||||||
|
# Build list of ancestors from deepest to root
|
||||||
|
ancestors = []
|
||||||
|
current = path.parent if path.parts else PurePosixPath()
|
||||||
|
while True:
|
||||||
|
ancestors.append(current)
|
||||||
|
if not current.parts:
|
||||||
|
break
|
||||||
|
current = current.parent
|
||||||
|
|
||||||
|
# Process from deepest ancestor to root
|
||||||
|
for ancestor_path in ancestors:
|
||||||
|
if ancestor_path not in self._index:
|
||||||
|
continue
|
||||||
|
start, count = self._index[ancestor_path]
|
||||||
|
if count == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ancestor = self.root[start]
|
||||||
|
if ancestor.isfile:
|
||||||
|
continue # Files don't aggregate
|
||||||
|
|
||||||
|
# Sum size/allocated of direct children
|
||||||
|
total_size = 0
|
||||||
|
total_allocated = 0
|
||||||
|
i = start + 1
|
||||||
|
while i < start + count:
|
||||||
|
child = self.root[i]
|
||||||
|
if child.level == ancestor.level + 1:
|
||||||
|
total_size += child.size
|
||||||
|
total_allocated += child.allocated
|
||||||
|
# Skip child's subtree
|
||||||
|
child_path = ancestor_path / child.name
|
||||||
|
if child_path in self._index:
|
||||||
|
_, child_count = self._index[child_path]
|
||||||
|
i += child_count
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Update ancestor entry if changed
|
||||||
|
if ancestor.size != total_size or ancestor.allocated != total_allocated:
|
||||||
|
self.root[start] = FileEntry(
|
||||||
|
level=ancestor.level,
|
||||||
|
name=ancestor.name,
|
||||||
|
key=ancestor.key,
|
||||||
|
mtime=ancestor.mtime,
|
||||||
|
size=total_size,
|
||||||
|
allocated=total_allocated,
|
||||||
|
isfile=ancestor.isfile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def collapse_paths(paths: set[PurePosixPath]) -> set[PurePosixPath]:
|
def collapse_paths(paths: set[PurePosixPath]) -> set[PurePosixPath]:
|
||||||
"""Remove child paths if parent is in set."""
|
"""Remove child paths if parent is in set."""
|
||||||
|
|||||||
+64
-14
@@ -16,7 +16,7 @@
|
|||||||
<RouterView :path="path.pathList" :query="path.query" />
|
<RouterView :path="path.pathList" :query="path.query" />
|
||||||
</main>
|
</main>
|
||||||
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
|
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
|
||||||
<HeaderSelected :path="path.pathList" />
|
<SelectionToolbar :path="path.pathList" />
|
||||||
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
|
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
|
||||||
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
|
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
|
||||||
</footer>
|
</footer>
|
||||||
@@ -36,6 +36,7 @@ import type { SortOrder } from './utils/docsort'
|
|||||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||||
import UserManagementModal from './components/UserManagementModal.vue'
|
import UserManagementModal from './components/UserManagementModal.vue'
|
||||||
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||||
|
import SelectionToolbar from './components/SelectionToolbar.vue'
|
||||||
|
|
||||||
interface Path {
|
interface Path {
|
||||||
path: string
|
path: string
|
||||||
@@ -62,6 +63,7 @@ onUnmounted(watchDisconnect)
|
|||||||
const headerMain = ref<typeof HeaderMain | null>(null)
|
const headerMain = ref<typeof HeaderMain | null>(null)
|
||||||
let vert = 0
|
let vert = 0
|
||||||
let timer: any = null
|
let timer: any = null
|
||||||
|
|
||||||
const globalShortcutHandler = (event: KeyboardEvent) => {
|
const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||||
if (store.dialog) {
|
if (store.dialog) {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
@@ -75,6 +77,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
const c = fileExplorer.isCursor()
|
const c = fileExplorer.isCursor()
|
||||||
const input = (event.target as HTMLElement).tagName === 'INPUT'
|
const input = (event.target as HTMLElement).tagName === 'INPUT'
|
||||||
const keyup = event.type === 'keyup'
|
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.repeat) {
|
||||||
if (
|
if (
|
||||||
event.key === 'ArrowUp' ||
|
event.key === 'ArrowUp' ||
|
||||||
@@ -90,13 +99,32 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
//console.log("key pressed", event)
|
//console.log("key pressed", event)
|
||||||
/// Long if-else machina for all keys we handle here
|
/// Long if-else machina for all keys we handle here
|
||||||
let arrow = ''
|
let arrow = ''
|
||||||
if (!input && event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase()
|
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
|
// Find: process on keydown so that we can bypass the built-in search hotkey
|
||||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
||||||
headerMain.value!.toggleSearchInput()
|
headerMain.value!.toggleSearchInput()
|
||||||
}
|
}
|
||||||
// Search also on / (UNIX style)
|
// Search also on / (UNIX style) - use code to support any keyboard layout
|
||||||
else if (!input && keyup && event.key === '/') {
|
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()
|
headerMain.value!.toggleSearchInput()
|
||||||
}
|
}
|
||||||
// Globally close search, clear errors on Escape
|
// Globally close search, clear errors on Escape
|
||||||
@@ -142,13 +170,34 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
timer = null
|
timer = null
|
||||||
}
|
}
|
||||||
let f: any
|
let f: any
|
||||||
switch (arrow) {
|
// Arrow navigation - always use fileExplorer for repeatable movement
|
||||||
case 'up': f = () => fileExplorer.up(event); break
|
if (arrow && !keyup) {
|
||||||
case 'down': f = () => fileExplorer.down(event); break
|
const focusSearch = () => (document.querySelector('.headermain input[type="search"]') as HTMLElement)?.focus()
|
||||||
case 'left': f = () => fileExplorer.left(event); break
|
const focusBreadcrumb = () => (document.querySelector('.breadcrumb') as HTMLElement)?.focus()
|
||||||
case 'right': f = () => fileExplorer.right(event); break
|
|
||||||
|
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 && !keyup) {
|
if (f) {
|
||||||
// Initial move, then t0 delay until repeats at tr intervals
|
// Initial move, then t0 delay until repeats at tr intervals
|
||||||
const t0 = 200, tr = event.altKey ? 20 : 100
|
const t0 = 200, tr = event.altKey ? 20 : 100
|
||||||
f()
|
f()
|
||||||
@@ -156,12 +205,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('keydown', globalShortcutHandler)
|
// Use capture phase to handle events before they reach target elements
|
||||||
window.addEventListener('keyup', globalShortcutHandler)
|
window.addEventListener('keydown', globalShortcutHandler, true)
|
||||||
|
window.addEventListener('keyup', globalShortcutHandler, true)
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('keydown', globalShortcutHandler)
|
window.removeEventListener('keydown', globalShortcutHandler, true)
|
||||||
window.removeEventListener('keyup', globalShortcutHandler)
|
window.removeEventListener('keyup', globalShortcutHandler, true)
|
||||||
})
|
})
|
||||||
export type { Path }
|
export type { Path }
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
--transition-time: 0.2s;
|
--transition-time: 0.2s;
|
||||||
/* The following are overridden by responsive layouts */
|
/* The following are overridden by responsive layouts */
|
||||||
--root-font-size: 1rem;
|
--root-font-size: 1rem;
|
||||||
--header-font-size: 1rem;
|
--header-font-size: clamp(0.7rem, 2.5vw, 1rem);
|
||||||
--header-height: 4rem;
|
--header-height: clamp(2.8rem, 10vw, 4rem);
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
@media screen and (min-width: 1000px) {
|
@media screen and (min-width: 1000px) {
|
||||||
:root {
|
:root {
|
||||||
--root-font-size: calc(8px + 8 * 100vw / 1000);
|
--root-font-size: calc(8px + 8 * 100vw / 1000);
|
||||||
|
--header-font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media screen and (min-width: 2000px) {
|
@media screen and (min-width: 2000px) {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
|
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
|
||||||
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
||||||
<p v-else-if="!store.connected">No Connection</p>
|
<p v-else-if="!store.connected">No Connection</p>
|
||||||
<p v-else-if="store.document.length === 0">Waiting for File List</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="store.query">No matches!</p>
|
||||||
<p v-else-if="!exists(props.path)">Folder not found</p>
|
<p v-else-if="!exists(props.path)">Folder not found</p>
|
||||||
<p v-else>Empty folder</p>
|
<p v-else>Empty folder</p>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import FileRenameInput from './FileRenameInput.vue'
|
import FileRenameInput from './FileRenameInput.vue'
|
||||||
@@ -124,6 +124,7 @@ defineExpose({
|
|||||||
dir: true,
|
dir: true,
|
||||||
mtime: now,
|
mtime: now,
|
||||||
size: 0,
|
size: 0,
|
||||||
|
allocated: 0,
|
||||||
})
|
})
|
||||||
store.cursor = editing.value.key
|
store.cursor = editing.value.key
|
||||||
},
|
},
|
||||||
@@ -134,6 +135,17 @@ defineExpose({
|
|||||||
isCursor() {
|
isCursor() {
|
||||||
return store.cursor && 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() {
|
cursorRename() {
|
||||||
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
||||||
},
|
},
|
||||||
@@ -149,7 +161,12 @@ defineExpose({
|
|||||||
},
|
},
|
||||||
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
||||||
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
||||||
left(ev: KeyboardEvent) { router.back() },
|
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) {
|
right(ev: KeyboardEvent) {
|
||||||
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
|
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
|
||||||
if (a) a.click()
|
if (a) a.click()
|
||||||
@@ -189,9 +206,17 @@ defineExpose({
|
|||||||
scrolltimer = null
|
scrolltimer = null
|
||||||
}, 300)
|
}, 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 focusBreadcrumb = () => {
|
||||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
@@ -209,7 +234,7 @@ watchEffect(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (!props.documents.length && store.cursor) {
|
if (!props.documents.length && store.cursor && !store.query) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
focusBreadcrumb()
|
focusBreadcrumb()
|
||||||
}
|
}
|
||||||
@@ -251,8 +276,7 @@ const mkdir = (doc: Doc, name: string) => {
|
|||||||
})
|
})
|
||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
doc.ghost = true
|
store.addGhost(doc)
|
||||||
store.document.push(doc)
|
|
||||||
editing.value = null
|
editing.value = null
|
||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
@@ -351,13 +375,13 @@ const copyImage = async (doc: Doc) => {
|
|||||||
|
|
||||||
const deleteFile = (doc: Doc) => {
|
const deleteFile = (doc: Doc) => {
|
||||||
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
doc.ghost = true
|
store.hideDoc(path)
|
||||||
const control = connect(controlUrl, {
|
const control = connect(controlUrl, {
|
||||||
message(ev: MessageEvent) {
|
message(ev: MessageEvent) {
|
||||||
const res = JSON.parse(ev.data)
|
const res = JSON.parse(ev.data)
|
||||||
if ('error' in res) {
|
if ('error' in res) {
|
||||||
console.error('Delete failed', res.error)
|
console.error('Delete failed', res.error)
|
||||||
doc.ghost = false
|
store.unhideDoc(path)
|
||||||
store.showToast(res.error.message || 'Delete failed')
|
store.showToast(res.error.message || 'Delete failed')
|
||||||
} else if (res.status === 'ack') {
|
} else if (res.status === 'ack') {
|
||||||
store.showToast(`🗑️ Deleted ${doc.name}`)
|
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||||
|
|||||||
@@ -1,22 +1,44 @@
|
|||||||
<template>
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { computed } from 'vue'
|
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 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
|
return +unit ? "bytes" : unit
|
||||||
})
|
})
|
||||||
|
|
||||||
const props = defineProps<{
|
const tooltipText = computed(() => {
|
||||||
doc: Doc
|
const { size, allocated } = props.doc
|
||||||
}>()
|
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.before-size {
|
||||||
|
margin-right: 0.2em;
|
||||||
|
}
|
||||||
.size.empty { color: #555 }
|
.size.empty { color: #555 }
|
||||||
.size.bytes { color: #77a }
|
.size.bytes { color: #77a }
|
||||||
.size.kB { color: #474 }
|
.size.kB { color: #474 }
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { connect, controlUrl } from '@/repositories/WS'
|
import { connect, controlUrl } from '@/repositories/WS'
|
||||||
@@ -67,6 +67,7 @@ defineExpose({
|
|||||||
dir: true,
|
dir: true,
|
||||||
mtime: now,
|
mtime: now,
|
||||||
size: 0,
|
size: 0,
|
||||||
|
allocated: 0,
|
||||||
})
|
})
|
||||||
store.cursor = editing.value.key
|
store.cursor = editing.value.key
|
||||||
},
|
},
|
||||||
@@ -81,6 +82,17 @@ defineExpose({
|
|||||||
isCursor() {
|
isCursor() {
|
||||||
return store.cursor && 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}`) as HTMLAnchorElement | null
|
||||||
|
if (a) a.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
cursorRename() {
|
cursorRename() {
|
||||||
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
||||||
},
|
},
|
||||||
@@ -143,9 +155,17 @@ defineExpose({
|
|||||||
scrolltimer = null
|
scrolltimer = null
|
||||||
}, 300)
|
}, 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 focusBreadcrumb = () => {
|
||||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
@@ -161,7 +181,7 @@ watchEffect(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (!props.documents.length && store.cursor) {
|
if (!props.documents.length && store.cursor && !store.query) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
focusBreadcrumb()
|
focusBreadcrumb()
|
||||||
}
|
}
|
||||||
@@ -205,8 +225,7 @@ const mkdir = (doc: Doc, name: string) => {
|
|||||||
})
|
})
|
||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
doc.ghost = true
|
store.addGhost(doc)
|
||||||
store.document.push(doc)
|
|
||||||
editing.value = null
|
editing.value = null
|
||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
@@ -295,13 +314,13 @@ const copyImage = async (doc: Doc) => {
|
|||||||
|
|
||||||
const deleteFile = (doc: Doc) => {
|
const deleteFile = (doc: Doc) => {
|
||||||
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
doc.ghost = true
|
store.hideDoc(path)
|
||||||
const control = connect(controlUrl, {
|
const control = connect(controlUrl, {
|
||||||
message(ev: MessageEvent) {
|
message(ev: MessageEvent) {
|
||||||
const res = JSON.parse(ev.data)
|
const res = JSON.parse(ev.data)
|
||||||
if ('error' in res) {
|
if ('error' in res) {
|
||||||
console.error('Delete failed', res.error)
|
console.error('Delete failed', res.error)
|
||||||
doc.ghost = false
|
store.unhideDoc(path)
|
||||||
store.showToast(res.error.message || 'Delete failed')
|
store.showToast(res.error.message || 'Delete failed')
|
||||||
} else if (res.status === 'ack') {
|
} else if (res.status === 'ack') {
|
||||||
store.showToast(`🗑️ Deleted ${doc.name}`)
|
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||||
<span>{{ doc.name }}</span>
|
<span>{{ doc.name }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
||||||
<div class=namespacer></div>
|
<div class=namespacer></div>
|
||||||
</template>
|
</template>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
<CursorTooltip ref="tooltip" :text="tooltipText">
|
<CursorTooltip ref="tooltip" :text="tooltipText">
|
||||||
<div class="tooltip-name">{{ doc.name }}</div>
|
<div class="tooltip-name">{{ doc.name }}</div>
|
||||||
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
||||||
|
<div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
|
||||||
</CursorTooltip>
|
</CursorTooltip>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
@@ -34,8 +35,10 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
|
import { formatSize } from '@/utils'
|
||||||
import MediaPreview from '@/components/MediaPreview.vue'
|
import MediaPreview from '@/components/MediaPreview.vue'
|
||||||
import CursorTooltip from './CursorTooltip.vue'
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
import SparseIndicator from './SparseIndicator.vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
type EditingProp = {
|
type EditingProp = {
|
||||||
@@ -52,6 +55,11 @@ const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
|||||||
|
|
||||||
const tooltipText = computed(() => props.doc.key)
|
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) => {
|
const onclick = (ev: Event) => {
|
||||||
if (m.value!.play()) ev.preventDefault()
|
if (m.value!.play()) ev.preventDefault()
|
||||||
store.cursor = props.doc.key
|
store.cursor = props.doc.key
|
||||||
@@ -66,6 +74,13 @@ const onclick = (ev: Event) => {
|
|||||||
.tooltip-details {
|
.tooltip-details {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.tooltip-sparse {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.after-name {
|
||||||
|
margin-left: 0.3em;
|
||||||
|
}
|
||||||
figure {
|
figure {
|
||||||
max-height: 15em;
|
max-height: 15em;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<div class="smallgap"></div>
|
<div class="smallgap"></div>
|
||||||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
||||||
<div class="search-group">
|
<div class="search-group">
|
||||||
<SvgButton name="find" @click="focusSearch" tooltip="Search" />
|
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
|
||||||
<input
|
<input
|
||||||
ref="search"
|
ref="search"
|
||||||
type="search"
|
type="search"
|
||||||
@@ -17,43 +17,10 @@
|
|||||||
@input="updateSearch"
|
@input="updateSearch"
|
||||||
@keydown.escape="clearSearch"
|
@keydown.escape="clearSearch"
|
||||||
/>
|
/>
|
||||||
<span v-if="!query" class="search-hint" @click="focusSearch">/</span>
|
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="spacer smallgap"></div>
|
<div class="spacer smallgap"></div>
|
||||||
<div v-if="store.space.disk" class="disk-space"
|
<DiskSpace v-if="store.space.disk" />
|
||||||
@mouseenter="diskTooltip?.startHover"
|
|
||||||
@mousemove="diskTooltip?.updatePosition"
|
|
||||||
@mouseleave="diskTooltip?.endHover"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 32 32" class="pie-mini">
|
|
||||||
<!-- Base: 'other' usage (light purple - appears on left, before 12 o'clock) -->
|
|
||||||
<circle r="16" cx="16" cy="16" fill="#c8e" />
|
|
||||||
<!-- Middle ring: free space (dynamic color - appears at bottom) -->
|
|
||||||
<circle r="8" cx="16" cy="16" fill="transparent" :stroke="freeColor" stroke-width="16" :stroke-dasharray="pieFree" :stroke-dashoffset="pieFreeOffset" transform="rotate(-90 16 16)" />
|
|
||||||
<!-- Top ring: storage (deep purple - appears on right after 12 o'clock) -->
|
|
||||||
<circle r="8" cx="16" cy="16" fill="transparent" stroke="#82d" stroke-width="16" :stroke-dasharray="pieStorage" transform="rotate(-90 16 16)" />
|
|
||||||
<!-- Subtle inner circle for depth -->
|
|
||||||
<circle r="2" cx="16" cy="16" fill="rgba(255,255,255,0.2)" />
|
|
||||||
</svg>
|
|
||||||
<CursorTooltip ref="diskTooltip" text="Disk space">
|
|
||||||
<div class="disk-tooltip">
|
|
||||||
<svg viewBox="0 0 160 80" width="160" height="80" class="pie-tooltip">
|
|
||||||
<!-- Pie chart centered at 40,40 -->
|
|
||||||
<circle r="32" cx="40" cy="40" fill="#c8e" />
|
|
||||||
<circle r="16" cx="40" cy="40" fill="transparent" :stroke="freeColor" stroke-width="32" :stroke-dasharray="pieFreeLg" :stroke-dashoffset="pieFreeOffsetLg" transform="rotate(-90 40 40)" />
|
|
||||||
<circle r="16" cx="40" cy="40" fill="transparent" stroke="#82d" stroke-width="32" :stroke-dasharray="pieStorageLg" transform="rotate(-90 40 40)" />
|
|
||||||
<circle r="4" cx="40" cy="40" fill="rgba(255,255,255,0.25)" />
|
|
||||||
<!-- Labels on the right -->
|
|
||||||
<rect x="78" y="10" width="10" height="10" fill="#82d" rx="2"/>
|
|
||||||
<text x="92" y="19" class="pie-label">{{ formatSize(store.space.storage) }} stored</text>
|
|
||||||
<rect x="78" y="30" width="10" height="10" fill="#c8e" rx="2"/>
|
|
||||||
<text x="92" y="39" class="pie-label">{{ formatSize(store.space.usage - store.space.storage) }} other</text>
|
|
||||||
<rect x="78" y="50" width="10" height="10" :fill="freeColor" rx="2"/>
|
|
||||||
<text x="92" y="59" class="pie-label">{{ formatSize(store.space.free) }} free</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</CursorTooltip>
|
|
||||||
</div>
|
|
||||||
<SvgButton name="cog" @click="settingsMenu" />
|
<SvgButton name="cog" @click="settingsMenu" />
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
@@ -61,74 +28,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { useSsoAuthStore } from '@/stores/ssoAuth'
|
import { useSsoAuthStore } from '@/stores/ssoAuth'
|
||||||
import { ref, nextTick, watchEffect, computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
import { showAuthIframe } from 'paskia'
|
import { showAuthIframe } from 'paskia'
|
||||||
import { resumeWatching } from '@/repositories/WS'
|
import { resumeWatching } from '@/repositories/WS'
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
import { formatSize } from '@/utils'
|
import DiskSpace from './DiskSpace.vue'
|
||||||
import CursorTooltip from './CursorTooltip.vue'
|
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const ssoStore = useSsoAuthStore()
|
const ssoStore = useSsoAuthStore()
|
||||||
const search = ref<HTMLInputElement | null>()
|
const search = ref<HTMLInputElement | null>()
|
||||||
const diskTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
|
||||||
|
|
||||||
const CIRC = 50.27 // 2π×8
|
const props = defineProps<{
|
||||||
|
|
||||||
// Storage segment (starts at top, -90°)
|
|
||||||
const pieStorage = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return `0 ${CIRC}`
|
|
||||||
const pct = s.storage / s.disk
|
|
||||||
return `${pct * CIRC} ${CIRC}`
|
|
||||||
})
|
|
||||||
|
|
||||||
// Free segment (starts after storage, goes clockwise to bottom area)
|
|
||||||
const pieFree = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return `0 ${CIRC}`
|
|
||||||
const pct = s.free / s.disk
|
|
||||||
return `${pct * CIRC} ${CIRC}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const pieFreeOffset = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return 0
|
|
||||||
// Start after storage segment
|
|
||||||
const storagePct = s.storage / s.disk
|
|
||||||
return -storagePct * CIRC
|
|
||||||
})
|
|
||||||
|
|
||||||
// Free space color: green when plenty, yellow when moderate, red when low
|
|
||||||
const freeColor = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return '#6c6'
|
|
||||||
const freePct = s.free / s.disk
|
|
||||||
if (freePct > 0.25) return '#5b5' // Green: > 25% free
|
|
||||||
if (freePct > 0.10) return '#db3' // Yellow: 10-25% free
|
|
||||||
return '#d44' // Red: < 10% free
|
|
||||||
})
|
|
||||||
|
|
||||||
// Large pie for tooltip (circumference = 2π×16 ≈ 100.53)
|
|
||||||
const CIRC_LG = 100.53
|
|
||||||
const pieStorageLg = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return `0 ${CIRC_LG}`
|
|
||||||
return `${(s.storage / s.disk) * CIRC_LG} ${CIRC_LG}`
|
|
||||||
})
|
|
||||||
const pieFreeLg = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return `0 ${CIRC_LG}`
|
|
||||||
return `${(s.free / s.disk) * CIRC_LG} ${CIRC_LG}`
|
|
||||||
})
|
|
||||||
const pieFreeOffsetLg = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return 0
|
|
||||||
return -(s.storage / s.disk) * CIRC_LG
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
query: string
|
query: string
|
||||||
}>()
|
}>()
|
||||||
@@ -204,7 +115,7 @@ const settingsMenu = (e: Event) => {
|
|||||||
// Show login option only in public mode (non-public modes trigger auth automatically)
|
// Show login option only in public mode (non-public modes trigger auth automatically)
|
||||||
items.push({ label: '🔐 Login', onClick: async () => {
|
items.push({ label: '🔐 Login', onClick: async () => {
|
||||||
try {
|
try {
|
||||||
await showAuthIframe('/auth/restricted#theme=light')
|
await showAuthIframe('/auth/restricted/#theme=light')
|
||||||
resumeWatching()
|
resumeWatching()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Login cancelled')
|
console.log('Login cancelled')
|
||||||
@@ -230,6 +141,7 @@ defineExpose({
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
min-height: 3em;
|
||||||
}
|
}
|
||||||
.search-group {
|
.search-group {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -240,12 +152,24 @@ defineExpose({
|
|||||||
padding: 0 0.3em;
|
padding: 0 0.3em;
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 5.5em;
|
min-width: 2.5em;
|
||||||
max-width: 20em;
|
max-width: 20em;
|
||||||
}
|
}
|
||||||
|
.search-group:hover,
|
||||||
.search-group:focus-within {
|
.search-group:focus-within {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
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 {
|
.search-group:focus-within .search-hint {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -253,6 +177,8 @@ defineExpose({
|
|||||||
.search-group :deep(.action-button) {
|
.search-group :deep(.action-button) {
|
||||||
width: 2.2em;
|
width: 2.2em;
|
||||||
height: 2.2em;
|
height: 2.2em;
|
||||||
|
min-width: 1.5em;
|
||||||
|
min-height: 1.5em;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.search-group input[type='search'] {
|
.search-group input[type='search'] {
|
||||||
@@ -261,9 +187,10 @@ defineExpose({
|
|||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: 0.2em 0.5em 0.2em 0;
|
padding: 0.2em 0.5em 0.2em 0;
|
||||||
font-size: var(--header-font-size);
|
font-size: inherit;
|
||||||
flex: 1 1 3em;
|
flex: 1 1 3em;
|
||||||
min-width: 3em;
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.search-hint {
|
.search-hint {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -279,24 +206,11 @@ defineExpose({
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
.disk-space {
|
@media (hover: hover) and (pointer: fine) {
|
||||||
display: flex;
|
.search-hint {
|
||||||
align-items: center;
|
display: block;
|
||||||
cursor: default;
|
}
|
||||||
}
|
|
||||||
.pie-mini {
|
|
||||||
width: 1.4em;
|
|
||||||
height: 1.4em;
|
|
||||||
}
|
|
||||||
.disk-tooltip {
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.pie-tooltip {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.pie-tooltip .pie-label {
|
|
||||||
fill: #fff;
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="selection-bar" v-if="store.selected.size">
|
|
||||||
<p class="select-text">{{ store.selected.size }} selected</p>
|
|
||||||
<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"
|
|
||||||
>❌<CursorTooltip ref="unselectTooltip" text="Unselect all">Unselect all</CursorTooltip></button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {connect, controlUrl} from '@/repositories/WS'
|
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import CursorTooltip from './CursorTooltip.vue'
|
|
||||||
|
|
||||||
const unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
|
||||||
|
|
||||||
const store = useMainStore()
|
|
||||||
const props = defineProps({
|
|
||||||
path: Array<string>
|
|
||||||
})
|
|
||||||
|
|
||||||
const dst = computed(() => props.path!.join('/'))
|
|
||||||
const op = (opName: string, dst?: string) => {
|
|
||||||
const sel = store.selectedFiles
|
|
||||||
const msg = {
|
|
||||||
op: opName,
|
|
||||||
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
|
|
||||||
if (opName === 'rm' || opName === 'mv')
|
|
||||||
for (const key of sel.keys) sel.docs[key]!.ghost = true
|
|
||||||
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
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
.select-text {
|
|
||||||
color: var(--accent-color);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
margin: 0;
|
|
||||||
padding-right: 0.5em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -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,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,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
class="action-button"
|
class="action-button"
|
||||||
|
:tabindex="tabindex"
|
||||||
@mouseenter="tooltip?.startHover"
|
@mouseenter="tooltip?.startHover"
|
||||||
@mousemove="tooltip?.updatePosition"
|
@mousemove="tooltip?.updatePosition"
|
||||||
@mouseleave="tooltip?.endHover"
|
@mouseleave="tooltip?.endHover"
|
||||||
@@ -19,6 +20,7 @@ import CursorTooltip from './CursorTooltip.vue'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
name: IconName
|
name: IconName
|
||||||
tooltip?: string
|
tooltip?: string
|
||||||
|
tabindex?: string | number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
@@ -32,18 +34,29 @@ const tooltipText = props.tooltip ?? ''
|
|||||||
color: #ccc;
|
color: #ccc;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
padding: 0.2em;
|
margin: 0 0.2em;
|
||||||
width: 3em;
|
padding: 0;
|
||||||
height: 3em;
|
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:hover,
|
||||||
.action-button:focus {
|
.action-button:focus {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
svg {
|
.action-button svg {
|
||||||
fill: #ccc;
|
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:hover svg,
|
||||||
.action-button:focus svg {
|
.action-button:focus svg {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { connect, uploadUrl } from '@/repositories/WS';
|
import { connect, uploadUrl } from '@/repositories/WS';
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { collator } from '@/utils';
|
import { collator } from '@/utils';
|
||||||
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
@@ -98,7 +99,12 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
|||||||
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
|
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
|
||||||
// Optimistic update: ghost folders and files
|
// Optimistic update: ghost folders and files
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
const byPath = new Map(store.document.map(d => [d.loc ? `${d.loc}/${d.name}` : d.name, d]))
|
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>()
|
const added = new Set<string>()
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
const lastSlash = f.cloudName.lastIndexOf('/')
|
const lastSlash = f.cloudName.lastIndexOf('/')
|
||||||
@@ -109,14 +115,13 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
|||||||
for (let i = 0; i < parts.length; i++) {
|
for (let i = 0; i < parts.length; i++) {
|
||||||
const folderPath = parts.slice(0, i + 1).join('/')
|
const folderPath = parts.slice(0, i + 1).join('/')
|
||||||
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
|
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
|
||||||
store.document.push(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, mtime: now, dir: true, ghost: true }))
|
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)
|
added.add(folderPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ghost file or update existing
|
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
|
||||||
const existing = byPath.get(f.cloudName)
|
const existing = byPath.get(f.cloudName)
|
||||||
if (existing) { existing.size = f.file.size; existing.mtime = now; existing.ghost = true }
|
if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, allocated: 0, mtime: now, dir: false }))
|
||||||
else store.document.push(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, mtime: now, dir: false, ghost: true }))
|
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
upqueue = [...upqueue, ...files]
|
upqueue = [...upqueue, ...files]
|
||||||
|
|||||||
@@ -3,6 +3,19 @@
|
|||||||
<div v-if="loading" class="loading">Loading...</div>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<h3>Server Settings</h3>
|
<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">
|
<div class="form-row">
|
||||||
<label for="publicAccess">
|
<label for="publicAccess">
|
||||||
<input
|
<input
|
||||||
@@ -62,7 +75,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, onMounted, watch } from 'vue'
|
import { ref, reactive, onMounted, watch } from 'vue'
|
||||||
import { listUsers, createUser, updateUser, deleteUser, updatePublic } from '@/repositories/User'
|
import { listUsers, createUser, updateUser, deleteUser, updatePublic, updateServerName, getServerConfig } from '@/repositories/User'
|
||||||
import type { ISimpleError } from '@/repositories/Client'
|
import type { ISimpleError } from '@/repositories/Client'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
|
||||||
@@ -78,9 +91,12 @@ const users = ref<User[]>([])
|
|||||||
const success = ref('')
|
const success = ref('')
|
||||||
const copyButtonText = ref('📋')
|
const copyButtonText = ref('📋')
|
||||||
const serverSettings = reactive({
|
const serverSettings = reactive({
|
||||||
public: false
|
public: false,
|
||||||
|
name: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let nameDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
store.dialog = ''
|
store.dialog = ''
|
||||||
success.value = ''
|
success.value = ''
|
||||||
@@ -206,15 +222,48 @@ const updateServerSettings = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(() => {
|
onMounted(() => {
|
||||||
serverSettings.public = store.server.public || false
|
serverSettings.public = store.server.public || false
|
||||||
|
serverSettings.name = ''
|
||||||
loading.value = false
|
loading.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
// Load users when dialog opens (only in built-in auth mode)
|
// Load users and config when dialog opens
|
||||||
watch(() => store.dialog, (newVal) => {
|
watch(() => store.dialog, (newVal) => {
|
||||||
if (newVal === 'usermgmt' && !store.server.paskia) {
|
if (newVal === 'usermgmt') {
|
||||||
loadUsers()
|
loadServerConfig()
|
||||||
|
if (!store.server.paskia) {
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -225,4 +274,13 @@ watch(() => store.server.public, (newVal) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
|
/* 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>
|
</style>
|
||||||
|
|||||||
@@ -7,18 +7,22 @@ export type DocProps = {
|
|||||||
name: string
|
name: string
|
||||||
key: FUID
|
key: FUID
|
||||||
size: number
|
size: number
|
||||||
|
allocated: number
|
||||||
mtime: number
|
mtime: number
|
||||||
dir: boolean
|
dir: boolean
|
||||||
ghost?: boolean
|
ghost?: boolean
|
||||||
|
expires?: number // Unix timestamp for ghost expiry
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Doc {
|
export class Doc {
|
||||||
public loc: string = ""
|
public loc: string = ""
|
||||||
public key: FUID = ""
|
public key: FUID = ""
|
||||||
public size: number = 0
|
public size: number = 0
|
||||||
|
public allocated: number = 0
|
||||||
public mtime: number = 0
|
public mtime: number = 0
|
||||||
public dir: boolean = false
|
public dir: boolean = false
|
||||||
public ghost: 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 */
|
/** @internal Use the name getter/setter instead */
|
||||||
public _name: string = ""
|
public _name: string = ""
|
||||||
|
|
||||||
@@ -33,6 +37,15 @@ export class Doc {
|
|||||||
this._name = name
|
this._name = name
|
||||||
}
|
}
|
||||||
get sizedisp(): string { return formatSize(this.size) }
|
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 modified(): string { return formatUnixDate(this.mtime) }
|
||||||
get url(): string {
|
get url(): string {
|
||||||
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
||||||
@@ -76,9 +89,10 @@ export type FileEntry = [
|
|||||||
number, // level
|
number, // level
|
||||||
string, // name
|
string, // name
|
||||||
FUID,
|
FUID,
|
||||||
number, //mtime
|
number, // mtime
|
||||||
number, // size
|
number, // size
|
||||||
number, // isfile
|
number, // allocated (actual disk usage)
|
||||||
|
number, // isfile
|
||||||
]
|
]
|
||||||
|
|
||||||
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
|
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
|
||||||
|
|||||||
@@ -55,3 +55,13 @@ export async function updatePublic(isPublic: boolean) {
|
|||||||
const data = await Client.put('/api/config/public', { public: isPublic })
|
const data = await Client.put('/api/config/public', { public: isPublic })
|
||||||
return data
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,6 +5,7 @@ import { collator } from '@/utils'
|
|||||||
import { watchConnect, resumeWatching } from '@/repositories/WS'
|
import { watchConnect, resumeWatching } from '@/repositories/WS'
|
||||||
import { sorted, type SortOrder } from '@/utils/docsort'
|
import { sorted, type SortOrder } from '@/utils/docsort'
|
||||||
import SearchWorker from '@/workers/searchWorker?worker'
|
import SearchWorker from '@/workers/searchWorker?worker'
|
||||||
|
import { getDocuments, setDocuments, documentRef } from './documentStore'
|
||||||
|
|
||||||
// Singleton search worker instance
|
// Singleton search worker instance
|
||||||
let searchWorker: Worker | null = null
|
let searchWorker: Worker | null = null
|
||||||
@@ -52,9 +53,20 @@ function getSearchWorker(): Worker {
|
|||||||
return searchWorker
|
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', {
|
export const useMainStore = defineStore('main', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
document: [] as Doc[],
|
// 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>([]),
|
selected: new Set<FUID>([]),
|
||||||
query: '' as string,
|
query: '' as string,
|
||||||
searchResults: [] as Doc[],
|
searchResults: [] as Doc[],
|
||||||
@@ -75,6 +87,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
gallery: false,
|
gallery: false,
|
||||||
sortListing: '' as SortOrder,
|
sortListing: '' as SortOrder,
|
||||||
sortFiltered: '' as SortOrder,
|
sortFiltered: '' as SortOrder,
|
||||||
|
searchHotkey: '/', // Character shown for search hotkey (Slash key)
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
username: '' as string,
|
username: '' as string,
|
||||||
@@ -84,8 +97,9 @@ export const useMainStore = defineStore('main', {
|
|||||||
space: {
|
space: {
|
||||||
disk: 0,
|
disk: 0,
|
||||||
free: 0,
|
free: 0,
|
||||||
usage: 0,
|
used: 0,
|
||||||
storage: 0,
|
storage: 0,
|
||||||
|
allocated: 0,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
persist: {
|
persist: {
|
||||||
@@ -106,22 +120,79 @@ export const useMainStore = defineStore('main', {
|
|||||||
updateRoot(root: FileEntry[]) {
|
updateRoot(root: FileEntry[]) {
|
||||||
const docs = []
|
const docs = []
|
||||||
let loc = [] as string[]
|
let loc = [] as string[]
|
||||||
for (const [level, name, key, mtime, size, isfile] of root) {
|
for (const [level, name, key, mtime, size, allocated, isfile] of root) {
|
||||||
loc = loc.slice(0, level - 1)
|
loc = loc.slice(0, level - 1)
|
||||||
docs.push(new Doc({
|
docs.push(new Doc({
|
||||||
name,
|
name,
|
||||||
loc: level ? loc.join('/') : '/',
|
loc: level ? loc.join('/') : '/',
|
||||||
key,
|
key,
|
||||||
size,
|
size,
|
||||||
|
allocated,
|
||||||
mtime,
|
mtime,
|
||||||
dir: !isfile,
|
dir: !isfile,
|
||||||
}))
|
}))
|
||||||
loc.push(name)
|
loc.push(name)
|
||||||
}
|
}
|
||||||
this.document = docs
|
// 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
|
// Sync documents to search worker
|
||||||
this.syncSearchWorker()
|
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 */
|
/** Show a temporary toast message that auto-dismisses */
|
||||||
showToast(message: string, duration = 3000) {
|
showToast(message: string, duration = 3000) {
|
||||||
if (this.toastTimeout) {
|
if (this.toastTimeout) {
|
||||||
@@ -145,11 +216,13 @@ export const useMainStore = defineStore('main', {
|
|||||||
syncSearchWorker() {
|
syncSearchWorker() {
|
||||||
const worker = getSearchWorker()
|
const worker = getSearchWorker()
|
||||||
// Send plain data to worker (no class instances)
|
// Send plain data to worker (no class instances)
|
||||||
const docData = this.document.map(doc => ({
|
const docs = getDocuments()
|
||||||
|
const docData = docs.map(doc => ({
|
||||||
loc: doc.loc,
|
loc: doc.loc,
|
||||||
name: doc.name,
|
name: doc.name,
|
||||||
key: doc.key,
|
key: doc.key,
|
||||||
size: doc.size,
|
size: doc.size,
|
||||||
|
allocated: doc.allocated,
|
||||||
mtime: doc.mtime,
|
mtime: doc.mtime,
|
||||||
dir: doc.dir,
|
dir: doc.dir,
|
||||||
}))
|
}))
|
||||||
@@ -209,7 +282,11 @@ export const useMainStore = defineStore('main', {
|
|||||||
clearSensitiveData() {
|
clearSensitiveData() {
|
||||||
// Clear all sensitive state on logout or auth failure
|
// Clear all sensitive state on logout or auth failure
|
||||||
localStorage.removeItem('cista-files')
|
localStorage.removeItem('cista-files')
|
||||||
this.document = []
|
setDocuments([])
|
||||||
|
this.ghosts = []
|
||||||
|
this.hiddenPaths.clear()
|
||||||
|
this.stopCleanupTimer()
|
||||||
|
this.docVersion++
|
||||||
this.selected.clear()
|
this.selected.clear()
|
||||||
this.user.username = ''
|
this.user.username = ''
|
||||||
this.user.privileged = false
|
this.user.privileged = false
|
||||||
@@ -268,8 +345,21 @@ export const useMainStore = defineStore('main', {
|
|||||||
getters: {
|
getters: {
|
||||||
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
|
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
|
||||||
isUserLogged(): boolean { return this.user.isLoggedIn },
|
isUserLogged(): boolean { return this.user.isLoggedIn },
|
||||||
recentDocuments(): Doc[] { return sorted(this.document, 'modified') },
|
/** 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 {
|
selectedFiles(): SelectedItems {
|
||||||
|
// Access docVersion to make this reactive
|
||||||
|
void this.docVersion
|
||||||
|
const docs = getDocuments()
|
||||||
const selected = this.selected
|
const selected = this.selected
|
||||||
const found = new Set<FUID>()
|
const found = new Set<FUID>()
|
||||||
const ret: SelectedItems = {
|
const ret: SelectedItems = {
|
||||||
@@ -278,7 +368,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
keys: [],
|
keys: [],
|
||||||
recursive: [],
|
recursive: [],
|
||||||
}
|
}
|
||||||
for (const doc of this.document) {
|
for (const doc of docs) {
|
||||||
if (selected.has(doc.key)) {
|
if (selected.has(doc.key)) {
|
||||||
found.add(doc.key)
|
found.add(doc.key)
|
||||||
ret.keys.push(doc.key)
|
ret.keys.push(doc.key)
|
||||||
@@ -299,7 +389,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
|
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
|
||||||
const nremove = base.loc.length
|
const nremove = base.loc.length
|
||||||
add(base.name, basepath, base)
|
add(base.name, basepath, base)
|
||||||
for (const doc of this.document) {
|
for (const doc of docs) {
|
||||||
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
|
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
|
||||||
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
const rel = full.slice(nremove)
|
const rel = full.slice(nremove)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
|
|
||||||
|
|
||||||
export const exists = (path: string[]) => {
|
export const exists = (path: string[]) => {
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
// Access docVersion to make this reactive
|
||||||
|
void store.docVersion
|
||||||
const p = path.join('/')
|
const p = path.join('/')
|
||||||
return store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
|
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.) */
|
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watchEffect, ref, computed, watch } from 'vue'
|
import { watchEffect, ref, computed, watch } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
import { collator } from '@/utils'
|
import { collator } from '@/utils'
|
||||||
import { sorted, sortedGrouped } from '@/utils/docsort'
|
import { sorted, sortedGrouped } from '@/utils/docsort'
|
||||||
import FileExplorer from '@/components/FileExplorer.vue'
|
import FileExplorer from '@/components/FileExplorer.vue'
|
||||||
@@ -50,13 +51,22 @@ const documents = computed(() => {
|
|||||||
const query = props.query
|
const query = props.query
|
||||||
|
|
||||||
// List the current location (no search)
|
// List the current location (no search)
|
||||||
if (!query) return sorted(
|
if (!query) {
|
||||||
store.document.filter(doc => doc.loc === loc),
|
// Access docVersion to make this reactive to document changes
|
||||||
store.prefs.sortListing,
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// Search results from worker
|
// Search results from worker (also filter hidden)
|
||||||
const docs = store.searchResults
|
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
|
// Custom sort override in effect? Use grouped sorting to keep folders together
|
||||||
const order = store.prefs.sortFiltered
|
const order = store.prefs.sortFiltered
|
||||||
@@ -71,7 +81,7 @@ watchEffect(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Only auto-switch gallery mode when entering a new folder or on initial file list load
|
// Only auto-switch gallery mode when entering a new folder or on initial file list load
|
||||||
watch([() => props.path.join('/'), () => store.document.length], ([path, len], [oldPath, oldLen]) => {
|
watch([() => props.path.join('/'), () => store.documentCount], ([path, len], [oldPath, oldLen]) => {
|
||||||
// React to path change or initial document load (0 → non-zero)
|
// React to path change or initial document load (0 → non-zero)
|
||||||
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
|
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
|
||||||
store.prefs.gallery = documents.value.some(d => d.previewable)
|
store.prefs.gallery = documents.value.some(d => d.previewable)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface DocData {
|
|||||||
name: string
|
name: string
|
||||||
key: string
|
key: string
|
||||||
size: number
|
size: number
|
||||||
|
allocated: number
|
||||||
mtime: number
|
mtime: number
|
||||||
dir: boolean
|
dir: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-5
@@ -114,6 +114,7 @@ filterwarnings = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
|
extend-select = ["E402"]
|
||||||
isort.known-first-party = ["cista"]
|
isort.known-first-party = ["cista"]
|
||||||
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004"]
|
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004"]
|
||||||
per-file-ignores."scripts/*" = ["T20"]
|
per-file-ignores."scripts/*" = ["T20"]
|
||||||
@@ -121,16 +122,13 @@ per-file-ignores."scripts/*" = ["T20"]
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.4.1",
|
"pytest>=8.4.1",
|
||||||
|
"pytest-asyncio>=0.25.0",
|
||||||
|
"pytest-cov>=7.0.0",
|
||||||
"ruff>=0.8.0",
|
"ruff>=0.8.0",
|
||||||
"mypy>=1.13.0",
|
"mypy>=1.13.0",
|
||||||
"pre-commit>=4.0.0",
|
"pre-commit>=4.0.0",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
]
|
]
|
||||||
test = [
|
|
||||||
"pytest>=8.4.1",
|
|
||||||
"pytest-cov>=6.0.0",
|
|
||||||
"pytest-asyncio>=0.25.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["cista"]
|
source = ["cista"]
|
||||||
|
|||||||
+22
-15
@@ -2,11 +2,13 @@
|
|||||||
"""Run Vite development server for frontend and Cista backend with auto-reload.
|
"""Run Vite development server for frontend and Cista backend with auto-reload.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
uv run scripts/devserver.py [frontend] [--backend backend]
|
uv run scripts/devserver.py [-l listen] [--backend backend] [cista_args...]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
frontend Vite frontend endpoint (default: localhost:8989)
|
-l, --listen Vite frontend endpoint (default: localhost:8989)
|
||||||
--backend Cista backend endpoint (default: from config, or :8999)
|
--backend Cista backend endpoint (default: from config, or :8999)
|
||||||
|
|
||||||
|
Any additional arguments are passed to the cista command.
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
|
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
|
||||||
@@ -29,7 +31,9 @@ from cista.serve import parse_listen
|
|||||||
DEFAULT_BACKEND_PORT = 8999
|
DEFAULT_BACKEND_PORT = 8999
|
||||||
|
|
||||||
|
|
||||||
def setup_sanic_backend(listen: str | None) -> tuple[str, list[str]]:
|
def setup_sanic_backend(
|
||||||
|
listen: str | None, extra_args: list[str]
|
||||||
|
) -> tuple[str, list[str]]:
|
||||||
"""Parse backend listen address and build cista dev command.
|
"""Parse backend listen address and build cista dev command.
|
||||||
|
|
||||||
Returns (url, cmd).
|
Returns (url, cmd).
|
||||||
@@ -40,11 +44,13 @@ def setup_sanic_backend(listen: str | None) -> tuple[str, list[str]]:
|
|||||||
port = opts.get("port", DEFAULT_BACKEND_PORT)
|
port = opts.get("port", DEFAULT_BACKEND_PORT)
|
||||||
host = opts.get("host", "localhost") or "localhost"
|
host = opts.get("host", "localhost") or "localhost"
|
||||||
|
|
||||||
cmd = ["cista", "--dev", "-l", listen]
|
cmd = ["cista", "--dev", "-l", listen] + extra_args
|
||||||
return f"http://{host}:{port}", cmd
|
return f"http://{host}:{port}", cmd
|
||||||
|
|
||||||
|
|
||||||
async def run_devserver(frontend: str | None, backend: str | None) -> None:
|
async def run_devserver(
|
||||||
|
frontend: str | None, backend: str | None, extra_args: list[str]
|
||||||
|
) -> None:
|
||||||
reporoot = Path(__file__).parent.parent
|
reporoot = Path(__file__).parent.parent
|
||||||
front = reporoot / "frontend"
|
front = reporoot / "frontend"
|
||||||
if not (front / "package.json").exists():
|
if not (front / "package.json").exists():
|
||||||
@@ -52,7 +58,7 @@ async def run_devserver(frontend: str | None, backend: str | None) -> None:
|
|||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
frontend_url, npm_install, vite = setup_vite(frontend or "")
|
frontend_url, npm_install, vite = setup_vite(frontend or "")
|
||||||
backend_url, sanic_cmd = setup_sanic_backend(backend)
|
backend_url, sanic_cmd = setup_sanic_backend(backend, extra_args)
|
||||||
|
|
||||||
# Tell vite where to proxy API requests
|
# Tell vite where to proxy API requests
|
||||||
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
|
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
|
||||||
@@ -78,26 +84,27 @@ def main():
|
|||||||
epilog=HELP_EPILOG,
|
epilog=HELP_EPILOG,
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"frontend",
|
"-l",
|
||||||
nargs="?",
|
"--listen",
|
||||||
metavar="host:port",
|
metavar="host:port",
|
||||||
help="Vite frontend endpoint (default: localhost:8989)",
|
help="Vite frontend endpoint (default: localhost:8989)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--backend",
|
"--backend",
|
||||||
"-l",
|
|
||||||
metavar="host:port",
|
metavar="host:port",
|
||||||
help="Cista backend endpoint (default: from config, or :8999)",
|
help="Cista backend endpoint (default: from config, or :8999)",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args, unknown = parser.parse_known_args()
|
||||||
with contextlib.suppress(KeyboardInterrupt):
|
with contextlib.suppress(KeyboardInterrupt):
|
||||||
asyncio.run(run_devserver(args.frontend, args.backend))
|
asyncio.run(run_devserver(args.listen, args.backend, unknown))
|
||||||
|
|
||||||
|
|
||||||
HELP_EPILOG = """
|
HELP_EPILOG = """
|
||||||
scripts/devserver.py # Default ports
|
scripts/devserver.py # Default ports
|
||||||
scripts/devserver.py 3000 # Vite on localhost:3000
|
scripts/devserver.py -l 3000 # Vite on localhost:3000
|
||||||
scripts/devserver.py :3000 --backend 8080 # Vite on *:3000, backend on :8080
|
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
|
JS_RUNTIME environment variable can be used to select the JS runtime
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ def find_dev_tool() -> list[str]:
|
|||||||
Raises RuntimeError if no runtime is found.
|
Raises RuntimeError if no runtime is found.
|
||||||
"""
|
"""
|
||||||
dev_args = {
|
dev_args = {
|
||||||
"deno": ("run", "dev", "--"),
|
"deno": ("run", "-A", "npm:vite"),
|
||||||
"npm": ("--silent", "run", "dev", "--"),
|
"npm": ("--silent", "run", "dev", "--"),
|
||||||
"bun": ("run", "dev", "--"),
|
"bun": ("run", "dev", "--"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ def decode(data: str):
|
|||||||
|
|
||||||
# Helper function to create a list of FileEntry objects
|
# Helper function to create a list of FileEntry objects
|
||||||
def f(count, start=0):
|
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():
|
def test_identical_lists():
|
||||||
@@ -35,8 +37,8 @@ def test_insertions():
|
|||||||
|
|
||||||
|
|
||||||
def test_insertion_at_end():
|
def test_insertion_at_end():
|
||||||
old_list = [*f(3), FileEntry(1, "xxx", "xxx", 0, 0, 1)]
|
old_list = [*f(3), FileEntry(1, "xxx", "xxx", 0, 0, 0, 1)]
|
||||||
newfile = FileEntry(1, "yyy", "yyy", 0, 0, 1)
|
newfile = FileEntry(1, "yyy", "yyy", 0, 0, 0, 1)
|
||||||
new_list = [*old_list, newfile]
|
new_list = [*old_list, newfile]
|
||||||
expected = [UpdKeep(4), UpdIns([newfile])]
|
expected = [UpdKeep(4), UpdIns([newfile])]
|
||||||
assert decode(format_update(old_list, new_list)) == expected
|
assert decode(format_update(old_list, new_list)) == expected
|
||||||
|
|||||||
Reference in New Issue
Block a user