Compare commits

..
12 Commits
23 changed files with 716 additions and 304 deletions
+170 -156
View File
@@ -1,156 +1,170 @@
# 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) Create an account: (otherwise the server is public for all)
```fish ```fish
uvx cista --user yourname --privileged uvx cista --user yourname --privileged
``` ```
Serve your files at http://localhost:8000: Serve your files at http://localhost:8000:
```fish ```fish
uvx cista -l :8000 /path/to/files uvx cista -l :8000 /path/to/files
``` ```
Alternatively, you can install with `pip` or `uv pip`. This enables using the `cista` command directly without `uvx` or `uv run`. Alternatively, you can install with `pip` or `uv pip`. This enables using the `cista` command directly without `uvx` or `uv run`.
```fish ```fish
pip install cista --break-system-packages pip install cista --break-system-packages
``` ```
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments. The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
## Authentication ## Authentication
Cista supports three authentication modes: Cista supports two authenticatioon mode, each of which supporting ordinary and privileged users. Either one can be combined with the public mode.
### Built-in 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. 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.
```fish ### Built-in Password Authentication (default)
uvx cista --user admin --privileged # Create admin user
uvx cista --user guest # Create regular user User accounts are managed directly by Cista. Create users with the `--user` flag:
```
```fish
Privileged users can manage other users and change settings via the Admin Settings menu. uvx cista --user admin --privileged # Create admin user
uvx cista --user guest # Create regular user
### Public Mode ```
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. Privileged users can manage other users and change settings via the Admin Settings menu.
### Paskia SSO Authentication ### Passkey Authentication and SSO
For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. Set the `PASKIA_BACKEND_URL` environment variable: 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.
```fish Set the `PASKIA_BACKEND_URL` environment variable:
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
``` ```fish
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
In Paskia mode: ```
- All `/auth/*` requests are proxied to the Paskia backend
- Users with `cista:login` permission can access files Run the Paskia backend on the same machine (to use that default URL):
- 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 uvx paskia
```
### Internet Access
In Paskia mode:
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. - All `/auth/*` requests are proxied to the Paskia backend
- Cista backend verifies access by `/auth/api/validate` endpoint and shows a login dialog if needed
`/etc/caddy/Caddyfile`: - Users with `cista:login` permission can access files
- Users with `cista:admin` permission get privileged access (Admin Settings)
```Caddyfile
cista.example.com { ### Internet Access
reverse_proxy :8000
} Most admins find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains.
```
`/etc/caddy/Caddyfile`:
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`
```Caddyfile
## System Deployment cista.example.com {
reverse_proxy :8000
This setup allows easy addition of storages, each with its own domain, configuration, and files. }
```
Assuming a restricted user account `storage` for serving files and that UV is installed system-wide or on this account. Only UV is required: this does not use git or bun/npm.
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`
Create `/etc/systemd/system/cista@.service`:
## System Deployment
```ini
[Unit] This setup allows easy addition of storages, each with its own domain, configuration, and files.
Description=Cista storage %i
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.
[Service]
User=storage Create (edit) a systemd unit:
ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
Restart=always ```fish
sudo systemctl edit --force --full cista@.service
[Install] ```
WantedBy=multi-user.target
``` Paste the following:
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. ```ini
[Unit]
```fish Description=Cista storage %i
systemctl daemon-reload
systemctl enable --now cista@foo.example.com [Service]
systemctl enable --now cista@bar.example.com User=storage
``` ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
Restart=always
Public exposure is easiest using the Caddy web server. #Environment=PASKIA_BACKEND_URL=http://localhost:4401
`/etc/caddy/Caddyfile`: [Install]
WantedBy=multi-user.target
```Caddyfile ```
foo.example.com, bar.example.com {
reverse_proxy unix//srv/cista/{host}/socket This setup supports multiple storages, each under `/media/storage/<domain>` for files and `/srv/cista/<domain>/` for configuration. UNIX sockets are used instead of numeric ports for convenience.
}
``` ```fish
systemctl daemon-reload
## Development setup systemctl enable --now cista@foo.example.com
systemctl enable --now cista@bar.example.com
For rapid development, we use the Vite development server for the Vue frontend, while running the backend on port 8000 that Vite proxies backend requests to. Each server live reloads whenever its code or configuration are modified. ```
Make sure you have git, uv and bun (or npm) installed. Public exposure is easiest using the Caddy web server.
Backend (Python) setup and run: `/etc/caddy/Caddyfile`:
```fish ```Caddyfile
git clone https://git.zi.fi/Vasanko/cista-storage.git foo.example.com, bar.example.com {
cd cista-storage reverse_proxy unix//srv/cista/{host}/socket
uv sync --dev }
uv run cista --dev -l :8000 /path/to/files ```
```
## Development setup
Frontend (Vue/Vite) run the dev server in another terminal:
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
cd frontend Make sure you have git, uv and bun (or npm) installed.
bun install
bun run dev Backend (Python) setup and run:
```
```fish
Building the package for release (frontend + Python wheel/sdist): git clone https://git.zi.fi/Vasanko/cista-storage.git
cd cista-storage
```fish uv sync --dev
uv build uv run cista --dev -l :8000 /path/to/files
``` ```
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. 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.
+11 -2
View File
@@ -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")
@@ -157,6 +165,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
+28
View File
@@ -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
View File
@@ -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)
+3 -1
View File
@@ -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
+72 -3
View File
@@ -24,7 +24,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 +148,15 @@ 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))
# st_blocks is in 512-byte units
allocated = st.st_blocks * 512 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 +184,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 +231,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 +515,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."""
+2 -1
View File
@@ -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
+39 -14
View File
@@ -36,9 +36,9 @@
</g> </g>
<g ref="labelsRef" class="pie-labels"> <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.storage, sectorInfo.storage.angle) }}</text> <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="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.usage - store.space.storage, sectorInfo.other.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> <defs>
<path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" /> <path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" />
@@ -98,7 +98,31 @@ const truncateLabel = (name: string, maxLen = 10): string => {
return name.slice(0, maxLen - 1) + '…' return name.slice(0, maxLen - 1) + '…'
} }
const storageName = computed(() => truncateLabel(store.server.name || 'stored')) // 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 TAU = 2 * Math.PI
@@ -113,7 +137,7 @@ const CIRC = TAU * midRadius
const pieStorageDash = computed(() => { const pieStorageDash = computed(() => {
const s = store.space const s = store.space
if (!s.disk) return `0 ${CIRC}` if (!s.disk) return `0 ${CIRC}`
return `${(s.storage / s.disk) * CIRC} ${CIRC}` return `${(s.allocated / s.disk) * CIRC} ${CIRC}`
}) })
const pieFreeDash = computed(() => { const pieFreeDash = computed(() => {
@@ -125,7 +149,7 @@ const pieFreeDash = computed(() => {
const pieFreeOffsetVal = computed(() => { const pieFreeOffsetVal = computed(() => {
const s = store.space const s = store.space
if (!s.disk) return 0 if (!s.disk) return 0
return -(s.storage / s.disk) * CIRC return -(s.allocated / s.disk) * CIRC
}) })
const freeColor = computed(() => { const freeColor = computed(() => {
@@ -133,8 +157,8 @@ const freeColor = computed(() => {
if (!s.disk) return '#6c6' if (!s.disk) return '#6c6'
const freePct = s.free / s.disk const freePct = s.free / s.disk
if (freePct > 0.25) return '#5b5' if (freePct > 0.25) return '#5b5'
if (freePct > 0.10) return '#db3' if (freePct > 0.10) return '#ff0'
return '#d44' return '#f00'
}) })
const PIE_RADIUS = 55 const PIE_RADIUS = 55
@@ -153,9 +177,9 @@ const sectorInfo = computed(() => {
other: { angle: 270, pct: 0.25 } other: { angle: 270, pct: 0.25 }
} }
const storagePct = s.storage / s.disk const storagePct = s.allocated / s.disk
const freePct = s.free / s.disk const freePct = s.free / s.disk
const otherPct = (s.usage - s.storage) / s.disk const otherPct = (s.used - s.allocated) / s.disk
const storageAngle = storagePct * 180 // midpoint of storage sector const storageAngle = storagePct * 180 // midpoint of storage sector
const freeStart = storagePct * 360 const freeStart = storagePct * 360
@@ -229,9 +253,10 @@ const adjustedLabelAngles = computed(() => {
}) })
// Arc path for curved text labels (CW for top half, CCW for bottom half) // Arc path for curved text labels (CW for top half, CCW for bottom half)
const createArcPath = (centerAngle: number, id: string) => { const createArcPath = (centerAngle: number, id: string, labelLen: number) => {
const radius = LABEL_RADIUS const radius = LABEL_RADIUS
const arcSpan = 60 // 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 isBottom = centerAngle > 90 && centerAngle <= 270
const startAngle = isBottom ? centerAngle + arcSpan / 2 : centerAngle - arcSpan / 2 const startAngle = isBottom ? centerAngle + arcSpan / 2 : centerAngle - arcSpan / 2
const endAngle = isBottom ? centerAngle - arcSpan / 2 : centerAngle + arcSpan / 2 const endAngle = isBottom ? centerAngle - arcSpan / 2 : centerAngle + arcSpan / 2
@@ -244,9 +269,9 @@ const createArcPath = (centerAngle: number, id: string) => {
} }
} }
const storageLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.storage!, 'storage')) const storageLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.storage!, 'storage', storageName.value.length))
const freeLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.free!, 'free')) const freeLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.free!, 'free', 4))
const otherLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.other!, 'other')) const otherLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.other!, 'other', 5))
const handleClick = () => isExpanded.value ? collapse() : expand() const handleClick = () => isExpanded.value ? collapse() : expand()
+1
View File
@@ -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
}, },
+27 -5
View File
@@ -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 }
+1
View File
@@ -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
}, },
+16 -1
View File
@@ -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;
+2 -1
View File
@@ -115,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')
@@ -141,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;
@@ -1,91 +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 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);
}
.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>
+2 -2
View File
@@ -115,13 +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.addGhost(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, mtime: now, dir: 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 (overwrite case doesn't need ghost, file already visible) // 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) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, mtime: now, dir: false })) if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, allocated: 0, mtime: now, dir: false }))
} }
// @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>
+15 -3
View File
@@ -7,6 +7,7 @@ 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
@@ -17,6 +18,7 @@ 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
@@ -35,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
@@ -78,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>]
+10
View File
@@ -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 }
}
+4 -2
View File
@@ -96,8 +96,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: {
@@ -118,13 +119,14 @@ 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,
})) }))
+15 -12
View File
@@ -2,11 +2,11 @@
"""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] [cista_args...] 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. Any additional arguments are passed to the cista command.
@@ -31,7 +31,9 @@ from cista.serve import parse_listen
DEFAULT_BACKEND_PORT = 8999 DEFAULT_BACKEND_PORT = 8999
def setup_sanic_backend(listen: str | None, extra_args: list[str]) -> 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).
@@ -46,7 +48,9 @@ def setup_sanic_backend(listen: str | None, extra_args: list[str]) -> tuple[str,
return f"http://{host}:{port}", cmd return f"http://{host}:{port}", cmd
async def run_devserver(frontend: str | None, backend: str | None, extra_args: list[str]) -> 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():
@@ -80,26 +84,25 @@ 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, unknown = parser.parse_known_args() args, unknown = parser.parse_known_args()
with contextlib.suppress(KeyboardInterrupt): with contextlib.suppress(KeyboardInterrupt):
asyncio.run(run_devserver(args.frontend, args.backend, unknown)) 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. Additional arguments are passed to the cista backend command.
+5 -3
View File
@@ -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