Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46beda2264 | ||
|
|
a2cfe33cd3 | ||
|
|
f8db2a0f09 | ||
|
|
95230eaa73 | ||
|
|
2cdfcc1bae | ||
|
|
e175708b3e | ||
|
|
a5e4bc0bf3 | ||
|
|
cbc6458c5d | ||
|
|
13b7a9b62f | ||
|
|
7c29d5f54a | ||
|
|
6663a42ab5 | ||
|
|
b34b872140 | ||
|
|
dadc2ba760 | ||
|
|
35cc1c7d43 | ||
|
|
e08c930ef8 | ||
|
|
8318a5f970 | ||
|
|
c7b59fe322 | ||
|
|
c9c59b3d9e | ||
|
|
09c29703f0 | ||
|
|
a8da5e312b | ||
|
|
c8be881e03 | ||
|
|
7405ce77fd | ||
|
|
673ca1d2fb | ||
|
|
4e1e3c50c9 | ||
|
|
f369253c67 | ||
|
|
dd6b334a79 | ||
|
|
a8b3641a90 | ||
|
|
72daa29cd2 | ||
|
|
7e9533965a | ||
|
|
97524b1107 | ||
|
|
e3dbd3e609 | ||
|
|
388e87ec96 | ||
|
|
ff7170f0f7 | ||
|
|
3c414503d6 | ||
|
|
b00c1b875d | ||
|
|
0c03f8b404 | ||
|
|
67022e7c6d | ||
|
|
4ab99768ae | ||
|
|
c1d5c6b46e | ||
|
|
aad337234e | ||
|
|
9bdf4d1a5e | ||
|
|
24e6f7fd34 | ||
|
|
f4eba480d6 | ||
|
|
59672ecb30 | ||
|
|
c781869d09 | ||
|
|
a76c8ed3d9 | ||
|
|
7e353595c0 | ||
|
|
6ca753bca0 | ||
|
|
c9ae367dc0 | ||
|
|
b38468ffe2 | ||
|
|
d2cabd5d29 | ||
|
|
87a07a5560 | ||
|
|
ae3208a9c3 | ||
|
|
04d6ee72bd | ||
|
|
680f2a063b | ||
|
|
bf15e64fe5 | ||
|
|
814ddfcd92 | ||
|
|
aa47cd015a | ||
|
|
372554adbd | ||
|
|
c4be425dcc | ||
|
|
aa447dd05b | ||
|
|
21651216af | ||
|
|
bf3480cfba | ||
|
|
39f6ededfd | ||
|
+2 |
4219230aa1 | ||
|
|
17f8b9fee1 | ||
|
|
6af053b682 | ||
|
|
2befcfc389 | ||
|
|
8f7794e487 | ||
|
|
7ab45ca1ab | ||
|
|
e8b363c10b | ||
|
|
64e94bd97f | ||
|
|
89ffd3e4b1 | ||
|
|
2e396d6cda | ||
|
|
f08e27c6fd | ||
|
|
733dcf9209 | ||
|
|
1262eb664a | ||
|
|
9a93892a91 | ||
|
|
2924850317 | ||
|
|
b369cf049e | ||
|
|
d1ab338b91 | ||
|
|
a2e9e66153 | ||
|
|
5d812e269e | ||
|
|
7d683eb246 | ||
|
|
1b1dbbb78f | ||
|
|
404b03173b | ||
|
|
c33f65243c | ||
|
|
7d4728ae9b | ||
|
|
eced411a51 | ||
|
|
6e1065009c | ||
|
|
c2c6a8dd8f | ||
|
|
0076058280 | ||
|
|
54b67f53ef | ||
|
|
129650237a | ||
|
|
5c5e8b44e1 | ||
|
|
a0987e063e | ||
|
|
fc606ec8fc | ||
|
|
f7da164599 |
No files matched your search
@@ -65,6 +65,13 @@ SPOOLMAN_PORT=7912
|
||||
# Default: SpoolmanDB
|
||||
#EXTERNAL_DB_NAME=SpoolmanDB
|
||||
|
||||
# Serve the previous (React) web client instead of the current one
|
||||
# Both clients ship in the image and talk to the same API, so this is a safe
|
||||
# rollback if the current client misbehaves on your setup. Your data is untouched
|
||||
# either way. Please report the problem so it can be fixed.
|
||||
# Default: FALSE
|
||||
#SPOOLMAN_LEGACY_CLIENT=TRUE
|
||||
|
||||
# Enable debug mode
|
||||
# If enabled, the client will accept requests from any host
|
||||
# This can be useful when developing, but is also a security risk
|
||||
@@ -77,10 +84,26 @@ SPOOLMAN_PORT=7912
|
||||
#PUID=1000
|
||||
#PGID=1000
|
||||
|
||||
# Allows CORS ORIGIN.
|
||||
# Use the https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin semantics
|
||||
# separated by commas
|
||||
# for example to allow request from source1.domain.com on port p1 and source2.domain.com on port p2
|
||||
# SPOOLMAN_CORS_ORIGIN=source1.domain.com:p1, source2.domain.com:p2
|
||||
# to allow from any
|
||||
# SPOOLMAN_CORS_ORIGIN=*
|
||||
# Extra browser origins allowed to talk to Spoolman, separated by commas.
|
||||
# Only needed for a web UI served from a different address than Spoolman, such as a Fluidd or
|
||||
# Mainsail instance on another host. Spoolman's own web UI does not need it, and neither does
|
||||
# Moonraker, OctoPrint, Home Assistant or anything else that is not a browser.
|
||||
# Each entry must include the scheme -- a bare "host:port" is not an origin and will never match.
|
||||
# See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin
|
||||
#SPOOLMAN_CORS_ORIGIN=https://fluidd.local,http://mainsail.local:8080
|
||||
# To allow every origin. WARNING: this turns the origin checks off entirely, so any website you
|
||||
# visit can read and modify your Spoolman data in the background. Only on a trusted network.
|
||||
#SPOOLMAN_CORS_ORIGIN=*
|
||||
|
||||
# Hostnames this Spoolman answers to, separated by commas. Off unless set.
|
||||
# Protects against DNS rebinding, where a domain an attacker owns is pointed at your Spoolman's
|
||||
# local address so that their web page can talk to it. Hostnames, not origins: no scheme, no port.
|
||||
# "*.mydomain.com" covers the domain and its subdomains.
|
||||
# IP addresses, single-word names, .local/.lan/.home/.home.arpa/.internal names and any hostname
|
||||
# already in SPOOLMAN_CORS_ORIGIN keep working without being listed.
|
||||
# If you reach Spoolman through a reverse proxy on a real domain, that domain must be listed here
|
||||
# once this is set, or every request is refused with a 400.
|
||||
#SPOOLMAN_ALLOWED_HOSTS=spoolman.mydomain.com
|
||||
|
||||
# See the Security page in the wiki for how these two fit together:
|
||||
# https://github.com/Donkie/Spoolman/wiki/Security
|
||||
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 162 KiB |
@@ -14,6 +14,13 @@ permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
# A new push to a PR makes the previous run's result irrelevant, so cancel it and
|
||||
# let the new one have the runners. Gated on the event: the push triggers are
|
||||
# master and v* tags, and a cancelled tag run is a half-published release.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
GHCR_IMAGE: donkie/spoolman
|
||||
|
||||
@@ -30,7 +37,7 @@ jobs:
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v4.0.3
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '24'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
@@ -50,6 +57,13 @@ jobs:
|
||||
cd client
|
||||
npm ci
|
||||
|
||||
# lefthook's `ci` hook lints/formats/type-checks client_v2 too, via
|
||||
# hardcoded ./node_modules/.bin/* paths, so its deps must be present.
|
||||
- name: Install client_v2 dependencies
|
||||
run: |
|
||||
cd client_v2
|
||||
npm ci
|
||||
|
||||
- name: Run pre-commit
|
||||
run: uv run lefthook run ci
|
||||
#
|
||||
@@ -65,7 +79,7 @@ jobs:
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v4.0.3
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '24'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
@@ -298,7 +312,7 @@ jobs:
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v4.0.3
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '24'
|
||||
|
||||
- name: Install Playwright
|
||||
run: |
|
||||
@@ -330,12 +344,65 @@ jobs:
|
||||
if: always()
|
||||
run: docker compose -f tests_frontend/docker-compose.yml down -v
|
||||
#
|
||||
# Perform frontend (browser) integration tests for client_v2 against the real image
|
||||
#
|
||||
test-frontend-v2:
|
||||
needs: [build-amd64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download built image
|
||||
uses: actions/download-artifact@v4.1.7
|
||||
with:
|
||||
name: spoolman-image
|
||||
path: /tmp/spoolman-image
|
||||
|
||||
- name: Load built image
|
||||
run: docker load --input /tmp/spoolman-image/spoolman.tar
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v4.0.3
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Install Playwright
|
||||
run: |
|
||||
cd tests_frontend_v2
|
||||
npm ci
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Start Spoolman
|
||||
run: docker compose -f tests_frontend_v2/docker-compose.yml up -d --wait
|
||||
|
||||
- name: Run client_v2 frontend integration tests
|
||||
run: |
|
||||
cd tests_frontend_v2
|
||||
npx playwright test
|
||||
|
||||
- name: Dump Spoolman logs on failure
|
||||
if: failure()
|
||||
run: docker compose -f tests_frontend_v2/docker-compose.yml logs spoolman
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
name: playwright-report-v2
|
||||
path: tests_frontend_v2/playwright-report
|
||||
retention-days: 7
|
||||
|
||||
- name: Stop Spoolman
|
||||
if: always()
|
||||
run: docker compose -f tests_frontend_v2/docker-compose.yml down -v
|
||||
#
|
||||
# Build arm64 image
|
||||
# Don't run this for pull requests
|
||||
# Only push to buildcache and let release job push to registry
|
||||
# Also exported to a tar so test-multiarch-smoke can boot it under QEMU.
|
||||
# On pull requests we can't push to buildcache (forks don't have registry
|
||||
# write access), so cache-to is skipped and we only read from it.
|
||||
#
|
||||
build-arm64:
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
needs: [build-client]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -370,19 +437,39 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and cache Docker image
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: docker/build-push-action@v6.7.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
tags: ${{ env.GHCR_IMAGE }}:test-arm64
|
||||
outputs: type=docker,dest=/tmp/spoolman-arm64.tar
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-arm64
|
||||
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-arm64,mode=max
|
||||
|
||||
# Separate build for pull requests since PRs dont have access to store cache
|
||||
- name: Build and cache Docker image (PR)
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
uses: docker/build-push-action@v6.7.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
tags: ${{ env.GHCR_IMAGE }}:test-arm64
|
||||
outputs: type=docker,dest=/tmp/spoolman-arm64.tar
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-arm64
|
||||
|
||||
- name: Upload arm64 image artifact
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
name: spoolman-image-arm64
|
||||
path: /tmp/spoolman-arm64.tar
|
||||
#
|
||||
# Build arm/v7 image
|
||||
# Don't run this for pull requests
|
||||
# Only push to buildcache and let release job push to registry
|
||||
# Also exported to a tar so test-multiarch-smoke can boot it under QEMU.
|
||||
# On pull requests we can't push to buildcache (forks don't have registry
|
||||
# write access), so cache-to is skipped and we only read from it.
|
||||
#
|
||||
build-armv7:
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
needs: [build-client]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -417,22 +504,117 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and cache Docker image
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: docker/build-push-action@v6.7.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm/v7
|
||||
tags: ${{ env.GHCR_IMAGE }}:test-armv7
|
||||
outputs: type=docker,dest=/tmp/spoolman-armv7.tar
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-armv7
|
||||
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-armv7,mode=max
|
||||
|
||||
# Separate build for pull requests since PRs dont have access to store cache
|
||||
- name: Build and cache Docker image (PR)
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
uses: docker/build-push-action@v6.7.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm/v7
|
||||
tags: ${{ env.GHCR_IMAGE }}:test-armv7
|
||||
outputs: type=docker,dest=/tmp/spoolman-armv7.tar
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-armv7
|
||||
|
||||
- name: Upload armv7 image artifact
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
name: spoolman-image-armv7
|
||||
path: /tmp/spoolman-armv7.tar
|
||||
#
|
||||
# Smoke test the arm64 and armv7 images under QEMU emulation: boot the
|
||||
# container with no config (defaults to SQLite, no external DB needed) and
|
||||
# confirm /api/v1/health comes up. Emulated boot (interpreter start +
|
||||
# alembic migrations) is slow, hence the generous retry budget.
|
||||
#
|
||||
test-multiarch-smoke:
|
||||
needs: [build-arm64, build-armv7]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: arm64
|
||||
platform: linux/arm64
|
||||
artifact: spoolman-image-arm64
|
||||
tarball: spoolman-arm64.tar
|
||||
- arch: armv7
|
||||
platform: linux/arm/v7
|
||||
artifact: spoolman-image-armv7
|
||||
tarball: spoolman-armv7.tar
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.2.0
|
||||
|
||||
- name: Download built image
|
||||
uses: actions/download-artifact@v4.1.7
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
path: /tmp/spoolman-image
|
||||
|
||||
- name: Load built image
|
||||
run: docker load --input /tmp/spoolman-image/${{ matrix.tarball }}
|
||||
|
||||
- name: Start Spoolman (${{ matrix.arch }}, emulated via QEMU)
|
||||
run: |
|
||||
docker run -d --name spoolman-smoke \
|
||||
--platform ${{ matrix.platform }} \
|
||||
-p 8000:8000 \
|
||||
${{ env.GHCR_IMAGE }}:test-${{ matrix.arch }}
|
||||
|
||||
- name: Wait for /health endpoint
|
||||
run: |
|
||||
for i in $(seq 1 90); do
|
||||
if curl -fsS http://localhost:8000/api/v1/health; then
|
||||
echo
|
||||
echo "Spoolman (${{ matrix.arch }}) is healthy"
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "Spoolman (${{ matrix.arch }}) did not become healthy in time" >&2
|
||||
exit 1
|
||||
|
||||
- name: Dump Spoolman logs on failure
|
||||
if: failure()
|
||||
run: docker logs spoolman-smoke
|
||||
|
||||
- name: Stop Spoolman
|
||||
if: always()
|
||||
run: docker rm -f spoolman-smoke
|
||||
#
|
||||
# Release images GHCR and Docker Hub if tests pass
|
||||
# Don't run this for pull requests
|
||||
#
|
||||
publish-images:
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
needs: [test, test-frontend, build-amd64, build-arm64, build-armv7, style, build-client]
|
||||
needs:
|
||||
[
|
||||
test,
|
||||
test-frontend,
|
||||
test-frontend-v2,
|
||||
test-multiarch-smoke,
|
||||
build-amd64,
|
||||
build-arm64,
|
||||
build-armv7,
|
||||
style,
|
||||
build-client,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
# Lets cosign fetch an OIDC token from Github and sign without a private key.
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -498,7 +680,15 @@ jobs:
|
||||
echo "GIT_COMMIT=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
|
||||
echo "BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@v4.1.2
|
||||
with:
|
||||
# Installer v4+ verifies cosign v3 downloads via the Sigstore bundle
|
||||
# format; pin an explicit, current cosign release for reproducibility.
|
||||
cosign-release: v3.1.1
|
||||
|
||||
- name: Build and push Docker images
|
||||
id: build
|
||||
uses: docker/build-push-action@v6.7.0
|
||||
with:
|
||||
context: .
|
||||
@@ -515,6 +705,24 @@ jobs:
|
||||
type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-amd64
|
||||
type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-arm64
|
||||
type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-armv7
|
||||
|
||||
# Sign the manifest by digest rather than by tag: all the tags above point
|
||||
# at the same digest, and the same digest is valid in both registries, so
|
||||
# signing every tag would just repeat the same signature.
|
||||
- name: Sign the published images
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
TAGS: |
|
||||
${{ steps.meta-ghcr.outputs.tags }}
|
||||
${{ steps.meta-dh.outputs.tags }}
|
||||
run: |
|
||||
echo "${TAGS}" | while read -r tag; do
|
||||
[ -n "${tag}" ] || continue
|
||||
echo "${tag%:*}"
|
||||
done | sort --unique | while read -r repository; do
|
||||
echo "Signing ${repository}@${DIGEST}"
|
||||
cosign sign --yes "${repository}@${DIGEST}"
|
||||
done
|
||||
#
|
||||
# Create Github Release if CI was triggered by a tag
|
||||
#
|
||||
|
||||
@@ -2,13 +2,22 @@ name: Guard Translations
|
||||
|
||||
# Translations are managed exclusively through Weblate (https://hosted.weblate.org/projects/spoolman/).
|
||||
# Only the "en" source locale is edited by hand in this repo; every other language is written back
|
||||
# by the Weblate bot. This workflow fails any PR (except Weblate's own) that MODIFIES or DELETES an
|
||||
# existing non-English translation file, so manual edits don't get overwritten and lost on the next
|
||||
# Weblate sync.
|
||||
# by the Weblate bot. This workflow fails any PR (except Weblate's own) that hand-edits a non-English
|
||||
# translation file, so manual edits don't get overwritten and lost on the next Weblate sync.
|
||||
#
|
||||
# ADDING a brand-new language file is allowed: contributors bootstrapping a new language must add the
|
||||
# code entry in client/src/i18n.ts, and they may include the initial client/public/locales/<lang>/
|
||||
# file in the same PR. Only edits to files that ALREADY exist on the base branch are blocked.
|
||||
# Two clients, two different rules:
|
||||
#
|
||||
# client_v2/locales/ — the ACTIVE translation target. Weblate translates this one.
|
||||
# MODIFY/DELETE of an existing non-English file is blocked; ADDING a
|
||||
# brand-new language file is allowed, because bootstrapping a language
|
||||
# also needs two hand-written code entries (see NEW_LANGUAGE_HOWTO
|
||||
# below) that only a human PR can add.
|
||||
#
|
||||
# client/public/locales/ — the LEGACY React client, CLOSED to translation work. Its Weblate
|
||||
# component is locked, so nothing new arrives from translators and
|
||||
# the files here are frozen at whatever shipped. Any change to a
|
||||
# non-English file is blocked, including adding a new language:
|
||||
# effort spent there is effort not spent on the client users get.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -43,57 +52,98 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Files changed in this PR, keyed by change type. --no-renames turns any rename into a
|
||||
# delete + add pair, so a "renamed" (i.e. rewritten) existing file still trips the delete
|
||||
# rule below and can't be smuggled through as a fresh add.
|
||||
# --no-renames turns any rename into a delete + add pair, so a "renamed" (i.e. rewritten)
|
||||
# existing file still trips the delete rule and can't be smuggled through as a fresh add.
|
||||
#
|
||||
# --diff-filter=MD keeps only Modified and Deleted paths: edits to files that ALREADY
|
||||
# exist on the base branch. Added (A) files are intentionally excluded, so a contributor
|
||||
# may add a brand-new client/public/locales/<lang>/ file for a new language.
|
||||
offending="$(git diff --no-renames --diff-filter=MD --name-only "$BASE_SHA" "$HEAD_SHA" \
|
||||
# --diff-filter=MD keeps only Modified and Deleted paths: edits to files that ALREADY exist
|
||||
# on the base branch. Added (A) files are handled separately per client below.
|
||||
# Three dots, not two. `git diff A B` compares the two trees as they stand, so every
|
||||
# commit that landed on the base branch after this PR forked shows up as if the PR had
|
||||
# changed it -- a Weblate sync merging to master made unrelated PRs fail with
|
||||
# "client_v2/locales/sv/common.json" in the offending list. `git diff A...B` diffs from
|
||||
# the MERGE BASE instead, which is exactly "what this PR changed" and stays correct no
|
||||
# matter how far master has moved on.
|
||||
edited="$(git diff --no-renames --diff-filter=MD --name-only "$BASE_SHA...$HEAD_SHA" || true)"
|
||||
added="$(git diff --no-renames --diff-filter=A --name-only "$BASE_SHA...$HEAD_SHA" || true)"
|
||||
|
||||
# Active client: edits to existing non-English files are blocked, new languages are fine.
|
||||
offending_v2="$(printf '%s\n' "$edited" \
|
||||
| grep -E '^client_v2/locales/[^/]+/' \
|
||||
| grep -vE '^client_v2/locales/en/' \
|
||||
|| true)"
|
||||
|
||||
# Legacy client: every non-English change is blocked, adds included.
|
||||
offending_legacy="$(printf '%s\n' "$edited" "$added" \
|
||||
| grep -E '^client/public/locales/[^/]+/' \
|
||||
| grep -vE '^client/public/locales/en/' \
|
||||
|| true)"
|
||||
|
||||
if [ -n "$offending" ]; then
|
||||
echo "::error title=Manual translation edits are not allowed::This PR edits existing non-English translation files. See the job summary for details."
|
||||
|
||||
{
|
||||
echo "## ❌ Manual translation edits detected"
|
||||
echo ""
|
||||
echo "This PR modifies or deletes one or more **existing non-English** translation files:"
|
||||
echo ""
|
||||
echo '```'
|
||||
echo "$offending"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### Why this is blocked"
|
||||
echo ""
|
||||
echo "Every language except the English source (\`client/public/locales/en/\`) is managed"
|
||||
echo "**exclusively through Weblate**. Any manual edit to an existing non-English file here"
|
||||
echo "will be **silently overwritten and lost** the next time Weblate syncs — so we don't"
|
||||
echo "accept them."
|
||||
echo ""
|
||||
echo "### What to do instead"
|
||||
echo ""
|
||||
echo "- **To fix or improve an existing translation:** contribute it through Weblate at"
|
||||
echo " <https://hosted.weblate.org/projects/spoolman/>. It's free, requires no coding,"
|
||||
echo " and your change flows back into the repo automatically."
|
||||
echo "- **To add or change a user-facing string:** edit **only** the English source files under"
|
||||
echo " \`client/public/locales/en/\`. Weblate will pick up the new keys and expose them to"
|
||||
echo " translators. Revert your changes to every other language and push again."
|
||||
echo "- **To add a brand-new language:** that IS allowed here. Add the language entry to"
|
||||
echo " \`client/src/i18n.ts\` and, if you like, an initial \`client/public/locales/<lang>/\`"
|
||||
echo " file — new files are fine, only edits to existing ones are blocked."
|
||||
echo ""
|
||||
echo "> If you believe an existing file genuinely needs a manual change (e.g. a structural"
|
||||
echo "> fix), please explain it in a PR comment so a maintainer can review."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo ""
|
||||
echo "Offending files:"
|
||||
echo "$offending"
|
||||
exit 1
|
||||
if [ -z "$offending_v2" ] && [ -z "$offending_legacy" ]; then
|
||||
echo "No manual edits to non-English translation files detected. ✅"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "No manual edits to existing non-English translation files detected. ✅"
|
||||
echo "::error title=Manual translation edits are not allowed::This PR hand-edits non-English translation files. See the job summary for details."
|
||||
|
||||
{
|
||||
echo "## ❌ Manual translation edits detected"
|
||||
echo ""
|
||||
|
||||
if [ -n "$offending_v2" ]; then
|
||||
echo "### Existing translations edited by hand"
|
||||
echo ""
|
||||
echo "This PR modifies or deletes existing non-English translation files:"
|
||||
echo ""
|
||||
echo '```'
|
||||
echo "$offending_v2"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Every language except the English source (\`client_v2/locales/en/common.json\`) is"
|
||||
echo "managed **exclusively through Weblate**. A manual edit here will be **silently"
|
||||
echo "overwritten and lost** the next time Weblate syncs — so we don't accept them."
|
||||
echo ""
|
||||
echo "**What to do instead:**"
|
||||
echo ""
|
||||
echo "- **To fix or improve a translation:** contribute it through Weblate at"
|
||||
echo " <https://hosted.weblate.org/projects/spoolman/>. It's free, requires no coding,"
|
||||
echo " and your change flows back into the repo automatically."
|
||||
echo "- **To add or change a user-facing string:** edit **only**"
|
||||
echo " \`client_v2/locales/en/common.json\`. Weblate picks up the new keys and exposes"
|
||||
echo " them to translators. Revert your changes to every other language and push again."
|
||||
echo "- **To add a brand-new language:** that IS allowed here — new files aren't blocked,"
|
||||
echo " only edits to existing ones. A new language needs two code entries as well:"
|
||||
echo " the locale code in \`client_v2/project.inlang/settings.json\` (so paraglide compiles"
|
||||
echo " it) and an endonym in \`client_v2/src/lib/i18n/languages.ts\` (so it appears in the"
|
||||
echo " language picker). Without both, the translation file is built but unreachable."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "$offending_legacy" ]; then
|
||||
echo "### Legacy client translations are closed"
|
||||
echo ""
|
||||
echo "This PR changes non-English translation files of the **legacy React client**:"
|
||||
echo ""
|
||||
echo '```'
|
||||
echo "$offending_legacy"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "\`client/public/locales/\` belongs to the old client, which is only kept around for"
|
||||
echo "the \`SPOOLMAN_LEGACY_CLIENT\` fallback. Its Weblate component is **locked** and its"
|
||||
echo "translations are frozen — nothing here reaches the UI that users get by default."
|
||||
echo ""
|
||||
echo "**Translate \`client_v2\` instead:** the same strings, in the client everyone actually"
|
||||
echo "sees, at <https://hosted.weblate.org/projects/spoolman/>."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "> If you believe a file genuinely needs a manual change (e.g. a structural fix),"
|
||||
echo "> please explain it in a PR comment so a maintainer can review."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo ""
|
||||
echo "Offending files:"
|
||||
# Plain `if` rather than `[ -n … ] && echo …`: under `set -e` a false test makes the
|
||||
# whole && list return non-zero and abort the step, which would skip the second echo.
|
||||
if [ -n "$offending_v2" ]; then echo "$offending_v2"; fi
|
||||
if [ -n "$offending_legacy" ]; then echo "$offending_legacy"; fi
|
||||
exit 1
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
issue-manager:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: tiangolo/issue-manager@0.5.1
|
||||
- uses: tiangolo/issue-manager@0.8.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
config: >
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
"message": "It seems the issue was answered, closing this now."
|
||||
},
|
||||
"waiting": {
|
||||
"delay": 60400,
|
||||
"delay": 604800,
|
||||
"message": "Closing after 7 days of waiting for the additional info requested."
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v4.0.3
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '24'
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
|
||||
@@ -2,8 +2,8 @@ name: Auto-merge Weblate PRs
|
||||
|
||||
# Translations are managed exclusively through Weblate (https://hosted.weblate.org/projects/spoolman/),
|
||||
# which opens a pull request for every sync back to this repo (see guard-translations.yml). Those PRs
|
||||
# are machine-generated, always scoped to client/public/locales/, and gated by the full CI suite just
|
||||
# like any other PR. Rather than hand-merging each one, this workflow turns on GitHub's native
|
||||
# are machine-generated, always scoped to a locale tree (client_v2/locales/ today), and gated by the
|
||||
# full CI suite just like any other PR. Rather than hand-merging each one, this workflow turns on GitHub's native
|
||||
# auto-merge for Weblate-authored PRs so they merge themselves once all required status checks pass.
|
||||
#
|
||||
# Why auto-merge instead of giving Weblate direct push access to master:
|
||||
@@ -37,9 +37,14 @@ jobs:
|
||||
steps:
|
||||
# Defence in depth: don't trust the author alone, trust the diff. Even a compromised Weblate
|
||||
# token can only ever get auto-merged if the PR is confined to locale JSON files. Anything
|
||||
# else (a code file, a workflow, a non-.json file, a file outside client/public/locales/)
|
||||
# else (a code file, a workflow, a non-.json file, a file outside the two locale trees)
|
||||
# fails this step, auto-merge is never enabled, and the PR waits for a human. The file list is
|
||||
# read from the API — this step never checks out or runs PR code, so pull_request_target is safe.
|
||||
#
|
||||
# Both locale trees are allowed. client_v2/locales/ is where translations go now; the legacy
|
||||
# client/public/locales/ tree stays on the list only so that a sync opened before its Weblate
|
||||
# component was locked isn't stranded half-merged. Locking the component, not this allowlist,
|
||||
# is what stops new legacy translations (see guard-translations.yml).
|
||||
- name: Verify PR only changes locale JSON files
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -55,15 +60,18 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Every changed path must be a .json file under client/public/locales/<lang>/.
|
||||
offending="$(printf '%s\n' "$files" | grep -vE '^client/public/locales/[^/]+/.+\.json$' || true)"
|
||||
# Every changed path must be a .json file under client_v2/locales/<lang>/ (active) or
|
||||
# client/public/locales/<lang>/ (legacy, being wound down).
|
||||
offending="$(printf '%s\n' "$files" \
|
||||
| grep -vE '^(client_v2/locales|client/public/locales)/[^/]+/.+\.json$' || true)"
|
||||
|
||||
if [ -n "$offending" ]; then
|
||||
echo "::error title=Out-of-scope Weblate PR::PR touches files outside client/public/locales/**/*.json; not auto-merging."
|
||||
echo "::error title=Out-of-scope Weblate PR::PR touches files outside the locale trees; not auto-merging."
|
||||
{
|
||||
echo "## ❌ Auto-merge blocked: out-of-scope changes"
|
||||
echo ""
|
||||
echo "This Weblate PR changes files that are **not** locale JSON under \`client/public/locales/\`:"
|
||||
echo "This Weblate PR changes files that are **not** locale JSON under"
|
||||
echo "\`client_v2/locales/\` or \`client/public/locales/\`:"
|
||||
echo ""
|
||||
echo '```'
|
||||
echo "$offending"
|
||||
|
||||
@@ -33,6 +33,19 @@ COPY alembic.ini README.md uv.lock pyproject.toml /home/app/spoolman/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked
|
||||
|
||||
# greenlet ships no 32-bit ARM wheel, so on armv7 it is compiled from source.
|
||||
# setuptools links the C++ extension with gcc, which leaves libstdc++ out of the
|
||||
# .so's NEEDED list, and it then crashes at import with an undefined libstdc++
|
||||
# typeinfo symbol, taking startup down during the DB migration. Add libstdc++ to
|
||||
# the NEEDED list so the loader pulls it in. Only armv7 is affected; amd64 and
|
||||
# arm64 use a correctly linked prebuilt wheel.
|
||||
RUN if [ "$(uname -m)" = "armv7l" ]; then \
|
||||
apt-get update && apt-get install -y --no-install-recommends patchelf \
|
||||
&& patchelf --add-needed libstdc++.so.6 \
|
||||
"$(find /home/app/spoolman/.venv -name '_greenlet*.so')" \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
FROM python:3.14-slim-bookworm AS python-runner
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/Donkie/Spoolman
|
||||
|
||||
@@ -20,8 +20,9 @@ Spoolman is a self-hosted web service designed to help you efficiently manage yo
|
||||
* **Central Filament Database**: A community-supported database of manufacturers and filaments simplify adding new spools to your inventory. Contribute by heading to [SpoolmanDB](https://github.com/Donkie/SpoolmanDB).
|
||||
* **Web-Based Client**: Spoolman includes a built-in web client that lets you manage data effortlessly:
|
||||
* View, create, edit, and delete filament data.
|
||||
* Search, group and filter your inventory by manufacturer, material, location and more.
|
||||
* Add custom fields to tailor information to your specific needs.
|
||||
* Print labels with QR codes for easy spool identification and tracking.
|
||||
* Design and print labels with QR codes for easy spool identification and tracking.
|
||||
* Contribute to its translation into 18 languages via [Weblate](https://hosted.weblate.org/projects/spoolman/).
|
||||
* **Database Support**: SQLite, PostgreSQL, MySQL, and CockroachDB.
|
||||
* **Multi-Printer Management**: Handles spool updates from several printers simultaneously.
|
||||
@@ -35,7 +36,23 @@ Spoolman is a self-hosted web service designed to help you efficiently manage yo
|
||||
* [MCP Server](https://github.com/Disane87/spoolman-mcp) - Manage your filament inventory through AI assistants like Claude using the Model Context Protocol
|
||||
|
||||
**Web client preview:**
|
||||

|
||||

|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img alt="The Spoolman dashboard, showing spools as draggable cards grouped by storage location" src=".github/media/dashboard-screenshot.png"></td>
|
||||
<td width="50%"><img alt="The Spoolman label designer, showing a 50 by 25 mm spool label with a QR code, filament name and colour swatch" src=".github/media/label-designer-screenshot.png"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><sub>Dashboard — spools as cards, grouped by location or any other field. Drag one to move it.</sub></td>
|
||||
<td align="center"><sub>Label designer — QR labels built from any spool, filament or vendor field.</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Installation
|
||||
Please see the [Installation page on the Wiki](https://github.com/Donkie/Spoolman/wiki/Installation) for details how to install Spoolman.
|
||||
|
||||
If the new client misbehaves on your setup, set `SPOOLMAN_LEGACY_CLIENT=TRUE` to go back to
|
||||
the previous one. Both ship in every release and talk to the same API, so your data is
|
||||
untouched either way — but please [report the problem](https://github.com/Donkie/Spoolman/issues)
|
||||
so it can be fixed.
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "spoolman-ui",
|
||||
"version": "0.24.0",
|
||||
"version": "0.26.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "spoolman-ui",
|
||||
"version": "0.24.0",
|
||||
"version": "0.26.0",
|
||||
"dependencies": {
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@loadable/component": "^5.16.7",
|
||||
@@ -58,7 +58,7 @@
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x"
|
||||
"node": "^20.19 || ^22.12 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@ant-design/colors": {
|
||||
@@ -7575,9 +7575,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
||||
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
|
||||
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "spoolman-ui",
|
||||
"version": "0.24.0",
|
||||
"version": "0.26.0",
|
||||
"engines": {
|
||||
"node": "20.x"
|
||||
"node": "^20.19 || ^22.12 || >=24"
|
||||
},
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -345,7 +345,7 @@
|
||||
"filament": "Марка филамента. У них есть такие свойства, как имя, материал, цвет, диаметр и многое другое.",
|
||||
"vendor": "Компании, производящие филаменты."
|
||||
},
|
||||
"description": "<title>Помощь</title><p>Вот несколько советов, которые помогут вам начать</p><p>Spoolman хранит 3 различных типа данных:</p><itemsHelp/><p>Чтобы добавить новую катушку в базу данных, начните с создания объекта <filamentCreateLink>Филамент</filamentCreateLink>. Как только это будет сделано, приступайте к созданию объекта <spoolCreateLink>Катушка</spoolCreateLink> для этой конкретной катушки. Если позже вы приобретете дополнительные катушки того же филамента, просто создайте дополнительные объекты катушек и повторно используйте тот же объект Филамент.</p><p>При желании вы также можете создать объект <vendorCreateLink>Производитель</vendorCreateLink> для компании, производящий филамент, если хотите отслеживать эту информацию.</p><p>У вас есть возможность связать Spoolman с другими службами 3D-принтеров, например Moonraker, который может автоматически отслеживать использование филамента и обновлять объекты Катушек для вас. Обратитесь к <readmeLink>Spoolman README</readmeLink> для получения инструкций по настройке.</p>",
|
||||
"description": "<title>Помощь</title><p>Вот несколько советов, которые помогут вам начать.</p><p>Spoolman хранит 3 различных типа данных:</p><itemsHelp/><p>Чтобы добавить новую катушку в базу данных, начните с создания объекта <filamentCreateLink>филамент</filamentCreateLink>. Как только это будет сделано, приступайте к созданию объекта <spoolCreateLink>катушка</spoolCreateLink> для этой конкретной катушки. Если позже вы приобретете дополнительные катушки того же филамента, просто создайте дополнительные объекты катушек и повторно используйте тот же объект филамент.</p><p>При желании вы также можете создать объект <vendorCreateLink>производитель</vendorCreateLink> для компании, производящей филамент, если хотите отслеживать эту информацию.</p><p>У вас есть возможность связать Spoolman с другими службами 3D-принтеров, например Moonraker, который может автоматически отслеживать использование филамента и обновлять объекты катушек для вас. Обратитесь к <readmeLink>Spoolman README</readmeLink> для получения инструкций по настройке.</p>",
|
||||
"help": "Помощь"
|
||||
},
|
||||
"kofi": "Поддержите меня на Ko-fi",
|
||||
|
||||
@@ -187,16 +187,20 @@ export function ParsedExtras<T extends { extra?: { [key: string]: string } }>(
|
||||
|
||||
/**
|
||||
* Convert the JSON-parsed value extra key-values of an entity to their string values.
|
||||
*
|
||||
* A field the user left empty has no value, which travels as an explicit null: the API
|
||||
* merges `extra` per key, so a key simply left out of the map would keep whatever it
|
||||
* already held instead of being cleared.
|
||||
* @param obj
|
||||
* @returns
|
||||
*/
|
||||
export function StringifiedExtras<T extends { extra?: { [key: string]: unknown } }>(
|
||||
obj: T,
|
||||
): Omit<T, "extra"> & { extra?: { [key: string]: string } } {
|
||||
): Omit<T, "extra"> & { extra?: { [key: string]: string | null } } {
|
||||
if (obj.extra) {
|
||||
const newExtra: { [key: string]: string } = {};
|
||||
const newExtra: { [key: string]: string | null } = {};
|
||||
Object.entries(obj.extra).forEach(([key, value]) => {
|
||||
newExtra[key] = JSON.stringify(value);
|
||||
newExtra[key] = value === undefined ? null : JSON.stringify(value);
|
||||
});
|
||||
return {
|
||||
...obj,
|
||||
|
||||
@@ -19,6 +19,10 @@ We also need to stringify them again before sending them back to the API, which
|
||||
the form's onFinish method. Form.Item's normalize should do this, but it doesn't seem to work.
|
||||
*/
|
||||
|
||||
// What the form submits: extra values back as JSON strings, with null for a field left
|
||||
// empty — the API merges extra per key, so null is what clears a value.
|
||||
type IFilamentRequest = Omit<IFilament, "extra"> & { extra?: { [key: string]: string | null } };
|
||||
|
||||
export const FilamentEdit = () => {
|
||||
const t = useTranslate();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
@@ -27,7 +31,7 @@ export const FilamentEdit = () => {
|
||||
const currency = useCurrency();
|
||||
const [colorType, setColorType] = useState<"single" | "multi">("single");
|
||||
|
||||
const { formProps, saveButtonProps } = useForm<IFilament, HttpError, IFilament, IFilament>({
|
||||
const { formProps, saveButtonProps } = useForm<IFilament, HttpError, IFilamentRequest, IFilament>({
|
||||
liveMode: "manual",
|
||||
onLiveEvent() {
|
||||
// Warn the user if the filament has been updated since the form was opened
|
||||
|
||||
@@ -15,13 +15,17 @@ We also need to stringify them again before sending them back to the API, which
|
||||
the form's onFinish method. Form.Item's normalize should do this, but it doesn't seem to work.
|
||||
*/
|
||||
|
||||
// What the form submits: extra values back as JSON strings, with null for a field left
|
||||
// empty — the API merges extra per key, so null is what clears a value.
|
||||
type IVendorRequest = Omit<IVendor, "extra"> & { extra?: { [key: string]: string | null } };
|
||||
|
||||
export const VendorEdit = () => {
|
||||
const t = useTranslate();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [hasChanged, setHasChanged] = useState(false);
|
||||
const extraFields = useGetFields(EntityType.vendor);
|
||||
|
||||
const { formProps, saveButtonProps } = useForm<IVendor, HttpError, IVendor, IVendor>({
|
||||
const { formProps, saveButtonProps } = useForm<IVendor, HttpError, IVendorRequest, IVendor>({
|
||||
liveMode: "manual",
|
||||
onLiveEvent() {
|
||||
// Warn the user if the vendor has been updated since the form was opened
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useGetSetting } from "./querySettings";
|
||||
|
||||
export function useCurrency() {
|
||||
const { data: currency } = useGetSetting("currency");
|
||||
return JSON.parse(currency?.value ?? '"EUR"');
|
||||
try {
|
||||
return JSON.parse(currency?.value ?? '"EUR"');
|
||||
} catch {
|
||||
return "EUR";
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrencySymbol(locale: string | undefined, currency: string) {
|
||||
@@ -20,7 +24,12 @@ export function getCurrencySymbol(locale: string | undefined, currency: string)
|
||||
|
||||
export function useCurrencyFormatter() {
|
||||
const currency = useCurrency();
|
||||
const roundPrices = JSON.parse(useGetSetting("round_prices").data?.value ?? "false");
|
||||
let roundPrices = false;
|
||||
try {
|
||||
roundPrices = JSON.parse(useGetSetting("round_prices").data?.value ?? "false");
|
||||
} catch {
|
||||
roundPrices = false;
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
|
||||
@@ -9,3 +9,12 @@ node_modules
|
||||
|
||||
# Generated code (paraglide i18n compiler output — also gitignored)
|
||||
src/lib/paraglide
|
||||
|
||||
# Written into the inlang project by the compiler; everything but settings.json
|
||||
# there is gitignored, so compiling before linting must not fail the check.
|
||||
project.inlang/.meta.json
|
||||
project.inlang/README.md
|
||||
project.inlang/cache
|
||||
|
||||
# Local scratch notes (gitignored, never reaches CI — don't fail the local hook)
|
||||
docs
|
||||
@@ -135,7 +135,7 @@ async function audit(page: Page, testInfo: TestInfo, label: string) {
|
||||
|
||||
const STATIC_ROUTES: { path: string; label: string }[] = [
|
||||
{ path: '/', label: 'library (home)' },
|
||||
{ path: '/locations', label: 'locations' },
|
||||
{ path: '/dashboard', label: 'dashboard' },
|
||||
{ path: '/labels', label: 'labels' },
|
||||
{ path: '/settings', label: 'settings' }
|
||||
];
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SURFACES_DIR, REPORT_PATH, TAP_MIN, type SurfaceResult } from './report
|
||||
// Runs once after the suite (in the main process, so it survives worker
|
||||
// restarts): read every per-surface JSON and render the consolidated report.
|
||||
export default async function globalTeardown() {
|
||||
let files: string[] = [];
|
||||
let files: string[];
|
||||
try {
|
||||
files = (await readdir(SURFACES_DIR)).filter((f) => f.endsWith('.json'));
|
||||
} catch {
|
||||
@@ -22,7 +22,10 @@ export default async function globalTeardown() {
|
||||
}
|
||||
results.sort((a, b) => a.order - b.order);
|
||||
|
||||
const esc = (s: string) => s.replace(/\|/g, '\\|');
|
||||
// Escape backslash and pipe in one pass (escaping them separately would let a
|
||||
// trailing backslash re-escape its own escape and leak the pipe through), and
|
||||
// flatten newlines since they'd break the table row regardless.
|
||||
const esc = (s: string) => s.replace(/[\\|]/g, '\\$&').replace(/\s*[\r\n]+\s*/g, ' ');
|
||||
const lines: string[] = [];
|
||||
lines.push('# Mobile accessibility audit — client_v2');
|
||||
lines.push('');
|
||||
|
||||
@@ -31,13 +31,14 @@ export default ts.config(
|
||||
},
|
||||
rules: {
|
||||
// Typed as `no-undef`; TypeScript already reports undefined identifiers.
|
||||
'no-undef': 'off',
|
||||
// Base-path resolution is centralized in src/lib/library/params.ts helpers
|
||||
// (selectHref/libraryHref/etc. already call resolve() from $app/paths), and
|
||||
// generic components take a pre-resolved href prop. This rule can't see through
|
||||
// those helpers, so it only produces false positives here — and blindly wrapping
|
||||
// call sites in resolve() would double-apply the deploy base path.
|
||||
'svelte/no-navigation-without-resolve': 'off'
|
||||
'no-undef': 'off'
|
||||
// svelte/no-navigation-without-resolve is left ON: it is the only automated guard
|
||||
// against base-path navigation bugs, and those are invisible to anyone developing
|
||||
// at the root path. It can't see through the helpers in src/lib/library/params.ts
|
||||
// (selectHref/libraryHref/... already call resolve() from $app/paths) nor through
|
||||
// components that take an already-resolved href prop, so the files where that is
|
||||
// the case carry a file-level disable naming the reason. Never "fix" one of those
|
||||
// by wrapping the call site in resolve() — that double-applies the base path.
|
||||
// svelte/prefer-svelte-reactivity is left ON to guard against in-place mutation of
|
||||
// reactive Set/Map. The current hits are all transient computation-local collections
|
||||
// and are silenced with per-line eslint-disable comments at each site.
|
||||
|
||||
@@ -1,17 +1,91 @@
|
||||
{
|
||||
"add.addAndNew": "Přidat & nová",
|
||||
"add.addN_few": "Přidat {{count}} cívky",
|
||||
"add.addN_one": "Přidat {{count}} cívku",
|
||||
"add.addN_other": "Přidat {{count}} cívky",
|
||||
"add.adding": "Přidávám...",
|
||||
"add.advanced": "Podrobné technické údaje",
|
||||
"add.advancedNote": "hustota, průměr, teploty (automaticky vyplněno podle materiálu)",
|
||||
"add.change": "změna",
|
||||
"add.count": "Počet",
|
||||
"add.createNew": "Vytvořit nový filament",
|
||||
"add.createNewSub": "není ve vašem katalogu ani v {{name}}",
|
||||
"add.dbUnavailable": "{{name}} není k dispozici",
|
||||
"add.duplicate": "duplikát",
|
||||
"add.duplicateNote": "Technické parametry, výrobce a cena byly zkopírovány. Zadejte nový název a barvu.",
|
||||
"add.duplicateRename": "Stále má stejný název jako původní – přejmenujte jej, abyste je mohli od sebe odlišit.",
|
||||
"add.duplicateTitle": "Nový filament · kopie „{{name}}“",
|
||||
"add.filamentNamePlaceholder": "např. PolyTerra Matte Sage",
|
||||
"add.fill.full": "Celý",
|
||||
"add.fillLevel": "Úroveň naplnění",
|
||||
"add.importNote": "Tento filament se přidá do vašeho katalogu jakmile přidáte cívky.",
|
||||
"add.inCatalog": "v katalogu",
|
||||
"add.locationHint": "Kde cívku fyzicky uchováváte – na polici, v krabici nebo v tiskárně. Pokud je všechny uchováváte na jednom místě, pole nechte prázdné.",
|
||||
"add.locationPlaceholder": "např. police A, sušička 2, tiskárna 1",
|
||||
"add.manufacturerPlaceholder": "např. Polymaker",
|
||||
"add.nameHint": "Uveďte barvu – právě podle ní se dají vaše filamenty od sebe rozeznat.",
|
||||
"add.newFilament": "nový filament",
|
||||
"add.newFilamentTitle": "Nový filament",
|
||||
"add.noCatalog": "Ve vašem katalogu nejsou žádné filamenty",
|
||||
"add.noManufacturer": "Žádný výrobce",
|
||||
"add.pickColor": "Vyberte barvu",
|
||||
"add.searchPlaceholder": "Prohledejte svůj katalog a {{name}}...",
|
||||
"add.searching": "Hledám...",
|
||||
"add.spoolWeightPreset.cardboard": "Karton {{weight}} g",
|
||||
"add.spoolWeightPreset.plastic": "Plast {{weight}} g",
|
||||
"add.spoolWeightPresetsLead": "Začněte s typickou cívkou:",
|
||||
"add.step2": "Krok 2 · podrobnosti o cívce",
|
||||
"add.summary_few": "{{count}} cívky",
|
||||
"add.summary_one": "{{count}} cívka",
|
||||
"add.summary_other": "{{count}} cívek",
|
||||
"add.typeToSearchAll": "Zadejte text a prohledejte tisíce filamentů",
|
||||
"add.typeToSearchMatches": "Zadejte text pro vyhledávání — žádné výsledky",
|
||||
"add.useExisting": "použít stávající",
|
||||
"add.vendorHint.existing": "S využitím stávajícího výrobce “{{name}}”",
|
||||
"add.vendorHint.new": "Nový výrobce “{{name}}” bude vytvořen",
|
||||
"add.vendorHint.optional": "Volitelné — pokud není uveden výrobce, ponechte pole prázdné",
|
||||
"add.yourCatalog": "Váš katalog",
|
||||
"buttons.archive": "Archivovat",
|
||||
"buttons.cancel": "Zrušit",
|
||||
"buttons.clear": "Vymazat",
|
||||
"buttons.close": "Zavřít",
|
||||
"buttons.delete": "Smazat",
|
||||
"buttons.edit": "Upravit",
|
||||
"buttons.filter": "Filtr",
|
||||
"buttons.save": "Uložit",
|
||||
"buttons.showArchived": "Zobrazit archivované",
|
||||
"buttons.unArchive": "Zrušit archivaci",
|
||||
"common.decrement": "Snížení",
|
||||
"common.increment": "Zvýšení",
|
||||
"common.remove": "Odstranit",
|
||||
"common.search": "Hledat",
|
||||
"dashboard.dashboard": "Přehledová obrazovka",
|
||||
"dashboard.deleteGroup": "Odstranit skupinu",
|
||||
"dashboard.errorEmptyName": "Jméno nesmí být prázdné",
|
||||
"dashboard.newGroup": "Nová skupina",
|
||||
"dashboard.newGroupName": "Nová skupina {{n}}",
|
||||
"dashboard.spoolCount_few": "{{count}} cívky",
|
||||
"dashboard.spoolCount_one": "{{count}} cívka",
|
||||
"dashboard.spoolCount_other": "{{count}} cívek",
|
||||
"dashboard.unassigned": "Nepřiřazeno",
|
||||
"datetime.done": "Hotovo",
|
||||
"datetime.hour": "Hodina",
|
||||
"datetime.minute": "Minuta",
|
||||
"datetime.nextMonth": "Příští měsíc",
|
||||
"datetime.now": "Nyní",
|
||||
"datetime.pick": "Vyberte datum a čas",
|
||||
"datetime.prevMonth": "Předchozí měsíc",
|
||||
"datetime.time": "Čas",
|
||||
"documentTitle.dashboard.list": "Ovládací panel | Spoolman",
|
||||
"documentTitle.labels.list": "Štítky | Spoolman",
|
||||
"documentTitle.library.list": "Knihovna | Spoolman",
|
||||
"documentTitle.locations.list": "Umístění | Spoolman",
|
||||
"documentTitle.settings.list": "Nastavení | Spoolman",
|
||||
"filament.fields.addColor": "Přidat barvu",
|
||||
"filament.fields.articleNumber": "Číslo výrobku",
|
||||
"filament.fields.coaxial": "Koextrudovaný",
|
||||
"filament.fields.colorHex": "Barva",
|
||||
"filament.fields.colorType": "Typ barvy",
|
||||
"filament.fields.comment": "Komentář",
|
||||
"filament.fields.density": "Hustota",
|
||||
"filament.fields.diameter": "Průměr",
|
||||
@@ -23,6 +97,7 @@
|
||||
"filament.fields.name": "Název",
|
||||
"filament.fields.price": "Cena",
|
||||
"filament.fields.registered": "Registrováno",
|
||||
"filament.fields.removeColor": "Odstranit barvu",
|
||||
"filament.fields.settingsBedTemp": "Teplota podložky",
|
||||
"filament.fields.settingsExtruderTemp": "Teplota extruderu",
|
||||
"filament.fields.singleColor": "Jediný",
|
||||
@@ -34,7 +109,9 @@
|
||||
"filament.fieldsHelp.spoolWeight": "Hmotnost prázdné cívky. Slouží k určení naměřené hmotnosti cívky.",
|
||||
"filament.fieldsHelp.weight": "Hmotnost plného cívky filamentu (čistá hmotnost). Tato hmotnost by neměla zahrnovat hmotnost samotné cívky, ale pouze filamentu. Obvykle se uvádí na obalu.",
|
||||
"filament.filament": "Filamenty",
|
||||
"footer.documentation": "Dokumentace",
|
||||
"help.help": "Nápověda",
|
||||
"inspector.adjustWeight": "Nastavit váhu",
|
||||
"kofi": "Příspěvek autorovi na Ko-fi",
|
||||
"loading": "Načítání",
|
||||
"locations.locations": "Umístění",
|
||||
@@ -83,10 +160,11 @@
|
||||
"settings.extraFields.booleanFalse": "Ne",
|
||||
"settings.extraFields.booleanTrue": "Ano",
|
||||
"settings.extraFields.deleteConfirm": "Smazat pole {{name}}?",
|
||||
"settings.extraFields.description.intro": "Zde můžete přidat další vlastní pole k subjektům.",
|
||||
"settings.extraFields.description.constraints": "Jakmile je pole přidáno, nemůžete změnit jeho klíč ani typ a u polí typu výběr nemůžete odebrat volby ani změnit stav více voleb. Pokud pole odstraníte, budou odstraněna přidružená data pro všechny subjekty.",
|
||||
"settings.extraFields.description.intro": "Zde můžete přidat další vlastní pole k subjektům.",
|
||||
"settings.extraFields.description.keyUsage": "Klíčové je, jako co ostatní programy čtou/zapisují data, takže pokud má být vaše vlastní pole integrováno s programem třetí strany, ujistěte se, že je správně nastaveno. Výchozí hodnota se použije pouze pro nové položky.",
|
||||
"settings.extraFields.description.tableViews": "Výstupní pole nelze v zobrazeních tabulky řadit ani filtrovat.",
|
||||
"settings.extraFields.errors.nameRequired": "Jméno je povinné.",
|
||||
"settings.extraFields.fieldType.boolean": "Logická hodnota",
|
||||
"settings.extraFields.fieldType.choice": "Volba",
|
||||
"settings.extraFields.fieldType.datetime": "Datumčas",
|
||||
@@ -95,6 +173,10 @@
|
||||
"settings.extraFields.fieldType.integer": "Celé číslo",
|
||||
"settings.extraFields.fieldType.integerRange": "Rozsah celých čísel",
|
||||
"settings.extraFields.fieldType.text": "Text",
|
||||
"settings.extraFields.max": "max",
|
||||
"settings.extraFields.min": "min",
|
||||
"settings.extraFields.multiple": "Více",
|
||||
"settings.extraFields.namePlaceholder": "Zobrazovaný název",
|
||||
"settings.extraFields.nonUniqueKeyError": "Tento klíč musí být jedinečný.",
|
||||
"settings.extraFields.params.choices": "Volby",
|
||||
"settings.extraFields.params.defaultValue": "Výchozí hodnota",
|
||||
@@ -103,14 +185,19 @@
|
||||
"settings.extraFields.params.name": "Název",
|
||||
"settings.extraFields.params.order": "Objednávka",
|
||||
"settings.extraFields.params.unit": "Jednotka",
|
||||
"settings.extraFields.saveField": "Uložit pole",
|
||||
"settings.extraFields.tab": "Další pole",
|
||||
"settings.general.baseUrl.label": "Základní URL",
|
||||
"settings.general.baseUrl.tooltip": "Základní adresa URL, která se má použít při generování funkcí, jako jsou QR kódy.",
|
||||
"settings.general.currency.desc": "Třípísmenný kód ISO (např. EUR, USD) používaný pro ceny",
|
||||
"settings.general.currency.label": "Měna",
|
||||
"settings.general.roundPrices.label": "Zaokrouhlené ceny",
|
||||
"settings.general.roundPrices.tooltip": "Zaokrouhlete ceny na nejbližší celé číslo.",
|
||||
"settings.general.tab": "Obecné",
|
||||
"settings.header": "Nastavení",
|
||||
"settings.language.desc": "Jazyk rozhraní (pouze v tomto prohlížeči)",
|
||||
"settings.language.label": "Jazyk",
|
||||
"settings.library.tab": "Chování knihovny",
|
||||
"spool.fields.archived": "Archivováno",
|
||||
"spool.fields.comment": "Komentář",
|
||||
"spool.fields.filament": "Filament",
|
||||
@@ -135,6 +222,8 @@
|
||||
"spool.form.measurementType.weight": "Hmotnost",
|
||||
"spool.spool": "Cívky",
|
||||
"unknown": "Neznámý",
|
||||
"validation.mustBeNumber": "Musí to být číslo",
|
||||
"validation.required": "Povinné",
|
||||
"vendor.fields.comment": "Komentář",
|
||||
"vendor.fields.emptySpoolWeight": "Hmotnost prázdné cívky",
|
||||
"vendor.fields.externalId": "Externí ID",
|
||||
|
||||
@@ -9,14 +9,20 @@
|
||||
"add.count": "Count",
|
||||
"add.createNew": "Create a new filament",
|
||||
"add.createNewSub": "not in your catalog or {{name}}",
|
||||
"add.duplicate": "duplicate",
|
||||
"add.duplicateTitle": "New filament · copy of “{{name}}”",
|
||||
"add.duplicateNote": "Specs, manufacturer and price were copied over. Give it a new name and color.",
|
||||
"add.duplicateRename": "Still named like the original — rename it to tell them apart.",
|
||||
"add.dbUnavailable": "{{name}} unavailable",
|
||||
"add.manufacturerPlaceholder": "e.g. Polymaker",
|
||||
"add.filamentNamePlaceholder": "e.g. PolyTerra Matte Sage",
|
||||
"add.nameHint": "Include the color — it’s what tells your filaments apart.",
|
||||
"add.fill.full": "Full",
|
||||
"add.fillLevel": "Fill level",
|
||||
"add.importNote": "This filament will be added to your catalog when you add the spools.",
|
||||
"add.inCatalog": "in catalog",
|
||||
"add.locationPlaceholder": "e.g. Shelf A",
|
||||
"add.locationHint": "Where you physically keep the spool — a shelf, a box, or a printer. Leave it blank if you keep them all in one place.",
|
||||
"add.locationPlaceholder": "e.g. Shelf A, Dry box 2, Printer 1",
|
||||
"add.newFilament": "new filament",
|
||||
"add.newFilamentTitle": "New filament",
|
||||
"add.noCatalog": "No filaments in your catalog",
|
||||
@@ -24,6 +30,9 @@
|
||||
"add.pickColor": "Pick color",
|
||||
"add.searchPlaceholder": "Search your catalog and {{name}}...",
|
||||
"add.searching": "Searching...",
|
||||
"add.spoolWeightPreset.cardboard": "Cardboard {{weight}} g",
|
||||
"add.spoolWeightPreset.plastic": "Plastic {{weight}} g",
|
||||
"add.spoolWeightPresetsLead": "Start from a typical spool:",
|
||||
"add.step2": "Step 2 · spool details",
|
||||
"add.summary_one": "{{count}} spool",
|
||||
"add.summary_other": "{{count}} spools",
|
||||
@@ -48,6 +57,22 @@
|
||||
"common.increment": "Increment",
|
||||
"common.remove": "Remove",
|
||||
"common.search": "Search",
|
||||
"dashboard.addFieldHint": "Add a text or choice field to spools in Settings to group by it too",
|
||||
"dashboard.dashboard": "Dashboard",
|
||||
"dashboard.deleteGroup": "Delete group",
|
||||
"dashboard.dragHint": "Drag a spool to another card to change its {{field}}, or within a card to reorder",
|
||||
"dashboard.dropHere": "drop spools here",
|
||||
"dashboard.emptyHelp": "This page groups your spools by a field of your choosing, add some spools to get started!",
|
||||
"dashboard.errorEmptyName": "Name cannot be empty",
|
||||
"dashboard.errorExists": "A group with that name already exists",
|
||||
"dashboard.errorRename": "Failed to rename group",
|
||||
"dashboard.lastUsed": "Last used {{time}} ago",
|
||||
"dashboard.newGroup": "New group",
|
||||
"dashboard.newGroupName": "New group {{n}}",
|
||||
"dashboard.reorderGroup": "Drag to reorder group",
|
||||
"dashboard.spoolCount_one": "{{count}} spool",
|
||||
"dashboard.spoolCount_other": "{{count}} spools",
|
||||
"dashboard.unassigned": "Unassigned",
|
||||
"datetime.done": "Done",
|
||||
"datetime.hour": "Hour",
|
||||
"datetime.minute": "Minute",
|
||||
@@ -56,9 +81,9 @@
|
||||
"datetime.pick": "Pick date and time",
|
||||
"datetime.prevMonth": "Previous month",
|
||||
"datetime.time": "Time",
|
||||
"documentTitle.dashboard.list": "Dashboard | Spoolman",
|
||||
"documentTitle.labels.list": "Labels | Spoolman",
|
||||
"documentTitle.library.list": "Library | Spoolman",
|
||||
"documentTitle.locations.list": "Locations | Spoolman",
|
||||
"documentTitle.settings.list": "Settings | Spoolman",
|
||||
"filament.fields.addColor": "Add color",
|
||||
"filament.fields.articleNumber": "Article Number",
|
||||
@@ -85,7 +110,7 @@
|
||||
"filament.fields.weight": "Weight",
|
||||
"filament.fieldsHelp.articleNumber": "E.g. EAN, UPC, etc.",
|
||||
"filament.fieldsHelp.multiColorDirection": "Filaments can have multiple colors in two ways: either through coextrusion, like dual-color filaments with consistent multi-colors, or through longitudinal color changes, like gradient filaments that shift colors along the spool.",
|
||||
"filament.fieldsHelp.spoolWeight": "The weight of an empty spool. Used to determine measured weight of a spool.",
|
||||
"filament.fieldsHelp.spoolWeight": "The weight of an empty spool. Used to determine measured weight of a spool. Not sure? Cardboard spools usually weigh 130–150 g and plastic ones 180–250 g — for an exact number, put the spool on a scale once it runs out.",
|
||||
"filament.fieldsHelp.weight": "The filament weight of a full spool (net weight). This should not include the weight of the spool itself, only the filament. It is what is usually written on the packaging.",
|
||||
"filament.filament": "Filaments",
|
||||
"footer.documentation": "Documentation",
|
||||
@@ -99,6 +124,30 @@
|
||||
"inspector.applying": "Applying...",
|
||||
"inspector.colorHex": "Color hex",
|
||||
"inspector.consumeAmount": "Consume amount:",
|
||||
"inspector.delete.deleting": "Deleting...",
|
||||
"inspector.delete.errorGone": "It had already been deleted.",
|
||||
"inspector.delete.errorSpoolsExist": "Can't delete this filament — spools still use it.",
|
||||
"inspector.delete.errorUnknown": "Delete failed. See the server logs for details.",
|
||||
"inspector.delete.filament": "Delete filament",
|
||||
"inspector.delete.filamentBlockedHint": "Delete those spools first. Archiving them is not enough.",
|
||||
"inspector.delete.filamentBlockedTitle": "This filament can't be deleted",
|
||||
"inspector.delete.filamentBlocked_one": "{{count}} spool still uses “{{name}}”, including archived ones.",
|
||||
"inspector.delete.filamentBlocked_other": "{{count}} spools still use “{{name}}”, including archived ones.",
|
||||
"inspector.delete.filamentBody": "“{{name}}” will be removed from your catalog. This cannot be undone.",
|
||||
"inspector.delete.filamentDone": "Filament deleted.",
|
||||
"inspector.delete.filamentTitle": "Delete this filament?",
|
||||
"inspector.delete.spool": "Delete spool",
|
||||
"inspector.delete.spoolBody": "Spool #{{id}} of “{{name}}” will be removed, along with its usage history. This cannot be undone.",
|
||||
"inspector.delete.spoolDone": "Spool deleted.",
|
||||
"inspector.delete.spoolRemaining": "It still has {{weight}} of filament left. Archive it instead if you only want it out of the way.",
|
||||
"inspector.delete.spoolTitle": "Delete this spool?",
|
||||
"inspector.delete.vendor": "Delete manufacturer",
|
||||
"inspector.delete.vendorBody": "“{{name}}” will be removed. This cannot be undone.",
|
||||
"inspector.delete.vendorDone": "Manufacturer deleted.",
|
||||
"inspector.delete.vendorOrphans_one": "Its {{count}} filament is kept, but is left without a manufacturer.",
|
||||
"inspector.delete.vendorOrphans_other": "Its {{count}} filaments are kept, but are left without a manufacturer.",
|
||||
"inspector.delete.vendorTitle": "Delete this manufacturer?",
|
||||
"inspector.duplicateFilament": "Duplicate",
|
||||
"inspector.empty": "Select a spool, filament or manufacturer from the list to see its details here.",
|
||||
"inspector.loading": "Loading...",
|
||||
"inspector.notFound": "This item could not be found. It may have been deleted.",
|
||||
@@ -106,10 +155,14 @@
|
||||
"inspector.enterValidWeight": "Enter a valid weight.",
|
||||
"inspector.filamentDefault": "{{price}} · filament default",
|
||||
"inspector.measuredHelp": "Total weight of the spool + remaining filament, as read from a scale.",
|
||||
"inspector.manageExtraFields": "Manage fields",
|
||||
"inspector.multi": "multi ×{{count}}",
|
||||
"inspector.newGross": "New gross weight:",
|
||||
"inspector.noExtraFields": "None yet — add your own fields to track anything Spoolman doesn't.",
|
||||
"inspector.ofRemaining": "of {{weight}} remaining · {{length}} m",
|
||||
"inspector.openFilament": "Open filament",
|
||||
"inspector.openLink": "Open {{url}} in a new tab",
|
||||
"inspector.openManufacturer": "Open manufacturer",
|
||||
"inspector.specs": "Specs — edit in place",
|
||||
"inspector.spoolsCount": "Spools · {{count}}",
|
||||
"inspector.spoolsTotalRemaining": "{{weight}} total",
|
||||
@@ -208,6 +261,7 @@
|
||||
"labels.showingSpool": "Showing spool #{{id}}",
|
||||
"labels.showingTags": "Fields show as tags until you pick a spool above.",
|
||||
"labels.sizeMm": "Size",
|
||||
"labels.skipHint": "Skip Items leaves that many cells blank before the first label, letting you reuse a partly used sheet: count the labels already peeled off, left to right from the top.",
|
||||
"labels.stroke": "Stroke",
|
||||
"labels.strokeMm": "Stroke",
|
||||
"labels.tabDesign": "Design",
|
||||
@@ -223,16 +277,21 @@
|
||||
"labels.xMm": "X",
|
||||
"labels.yMm": "Y",
|
||||
"library.apiError": "Couldn't reach the Spoolman API. Is the backend running?",
|
||||
"library.collapseGroup": "Hide these spools",
|
||||
"library.emptyFiltered": "No {{unit}} match the current filters.",
|
||||
"library.emptyTitle": "No spools yet",
|
||||
"library.emptyBody": "Add your first spool to start tracking your filament.",
|
||||
"library.expandGroup": "Show these spools",
|
||||
"library.filterBy": "Filter by",
|
||||
"library.groupBy": "Group",
|
||||
"library.groupNone": "None (flat)",
|
||||
"library.inUse": "in use",
|
||||
"library.groupSpools_one": "{{count}} spool",
|
||||
"library.groupSpools_other": "{{count}} spools",
|
||||
"library.nUnused": "×{{count}} unused",
|
||||
"library.noLocation": "no location",
|
||||
"library.noValues": "No values",
|
||||
"library.openGroup": "Open {{name}}",
|
||||
"library.openManufacturer": "Open manufacturer {{name}}",
|
||||
"library.section.extra": "Extra fields",
|
||||
"library.section.filament": "Filament",
|
||||
"library.section.spool": "Spool",
|
||||
@@ -248,23 +307,9 @@
|
||||
"library.unusedGroupMixed": "{{count}} unused",
|
||||
"library.unusedSub": "{{weight}} g each · {{location}}",
|
||||
"library.unusedSubMixed": "{{location}}",
|
||||
"library.used": "used",
|
||||
"library.usedAgo": "used {{time}} ago",
|
||||
"loading": "Loading",
|
||||
"locations.dragHint": "Drag spools between locations to reassign them, or within a location to reorder them",
|
||||
"locations.dropHere": "drop spools here",
|
||||
"locations.errorEmpty": "Location name cannot be empty",
|
||||
"locations.errorExists": "A location with that name already exists",
|
||||
"locations.errorRename": "Failed to rename location",
|
||||
"locations.lastUsed": "Last used {{time}} ago",
|
||||
"locations.locations": "Locations",
|
||||
"locations.newLocation": "New Location",
|
||||
"locations.newShelf": "New location {{n}}",
|
||||
"locations.noLocation": "No Location",
|
||||
"locations.deleteLocation": "Delete location",
|
||||
"locations.noLocationsHelp": "This page lets you organize your spools in locations, add some spools to get started!",
|
||||
"locations.reorderLocation": "Drag to reorder location",
|
||||
"locations.spoolCount_one": "{{count}} spool",
|
||||
"locations.spoolCount_other": "{{count}} spools",
|
||||
"nav.labels": "Labels",
|
||||
"nav.library": "Library",
|
||||
"no": "No",
|
||||
@@ -319,6 +364,7 @@
|
||||
"scanner.starting": "Starting camera...",
|
||||
"scanner.title": "QR Code Scanner",
|
||||
"search.colorHint": "Color names must be entered in English.",
|
||||
"search.moreSpools": "+{{count}} more",
|
||||
"search.noResults": "No matches found",
|
||||
"search.searching": "Searching...",
|
||||
"search.section.filaments": "Filaments",
|
||||
@@ -401,7 +447,7 @@
|
||||
"spool.fields.registered": "Registered",
|
||||
"spool.fields.remainingWeight": "Remaining Weight",
|
||||
"spool.fields.usedWeight": "Used Weight",
|
||||
"spool.fieldsHelp.location": "Where the spool is located if you have multiple locations where you store your spools.",
|
||||
"spool.fieldsHelp.location": "Where you physically keep this spool — a shelf, a drawer, a dry box, or the printer it is loaded in. Spools are grouped by location in your library, so it is how you find one again.",
|
||||
"spool.fieldsHelp.lotNr": "Manufacturer's lot number. Can be used to ensure a print has consistent color if multiple spools are used.",
|
||||
"spool.fieldsHelp.measuredWeight": "How much the filament and spool weigh.",
|
||||
"spool.fieldsHelp.price": "Price of a full spool. If not set, the price of the filament will be assumed instead.",
|
||||
|
||||
@@ -1,4 +1,43 @@
|
||||
{
|
||||
"add.addAndNew": "Lägg till och nytt",
|
||||
"add.addN_one": "Lägg till {{count}} spole",
|
||||
"add.addN_other": "Lägg till {{count}} spolar",
|
||||
"add.adding": "Lägger till...",
|
||||
"add.advanced": "Avancerade specifikationer",
|
||||
"add.advancedNote": "densitet, diameter, temperaturer (auto-fylld av materialet)",
|
||||
"add.change": "ändra",
|
||||
"add.count": "Antal",
|
||||
"add.createNew": "Skapa ett nytt filament",
|
||||
"add.createNewSub": "inte i din katalog eller {{name}}",
|
||||
"add.dbUnavailable": "{{name}} otillgänglig",
|
||||
"add.duplicate": "duplicera",
|
||||
"add.duplicateNote": "Parametrar, tillverkare och pris kopierades över. Ge det ett nytt namn och färg.",
|
||||
"add.duplicateRename": "Fortfarande samma namn som originalet - byt namn för att hålla dem isär.",
|
||||
"add.duplicateTitle": "Nytt filament · kopia av \"{{name}}\"",
|
||||
"add.filamentNamePlaceholder": "till exempel PolyTerra Matte Sage",
|
||||
"add.fill.full": "Full",
|
||||
"add.fillLevel": "Fyllnadsnivå",
|
||||
"add.importNote": "Detta filament kommer att läggas till i din katalog när du lägger till spolarna.",
|
||||
"add.inCatalog": "i katalog",
|
||||
"add.locationHint": "Där spolen fysiskt finns - en hylla, en låda eller en skrivare. Lämna det tomt om du håller dem alla på ett ställe.",
|
||||
"add.locationPlaceholder": "till exempel Hylla A, Låda 2, Skrivare 1",
|
||||
"add.manufacturerPlaceholder": "till exempel Polymaker",
|
||||
"add.nameHint": "Inkludera färgnamnet - det är vad som skiljer dina filament ifrån varandra.",
|
||||
"add.newFilament": "nytt filament",
|
||||
"add.newFilamentTitle": "Nytt filament",
|
||||
"add.noCatalog": "Inga filament i din katalog",
|
||||
"add.noManufacturer": "Ingen tillverkare",
|
||||
"add.pickColor": "Välj färg",
|
||||
"add.searchPlaceholder": "Sök i din katalog och {{name}}...",
|
||||
"add.searching": "Söker...",
|
||||
"add.spoolWeightPreset.cardboard": "Kartong {{weight}} g",
|
||||
"add.spoolWeightPreset.plastic": "Plast {{weight}} g",
|
||||
"add.spoolWeightPresetsLead": "Börja med en typisk spole:",
|
||||
"add.step2": "Steg 2 · Spoldetaljer",
|
||||
"add.summary_one": "{{count}} spole",
|
||||
"add.summary_other": "{{count}} spolar",
|
||||
"add.typeToSearchAll": "Skriv för att söka bland tusentals filament",
|
||||
"add.typeToSearchMatches": "Skriv för att söka - inga resultat",
|
||||
"buttons.archive": "Arkivera",
|
||||
"buttons.cancel": "Avbryt",
|
||||
"buttons.clear": "Rensa",
|
||||
@@ -74,8 +113,8 @@
|
||||
"settings.extraFields.booleanFalse": "Nej",
|
||||
"settings.extraFields.booleanTrue": "Ja",
|
||||
"settings.extraFields.deleteConfirm": "Ta bort fältet {{name}}?",
|
||||
"settings.extraFields.description.intro": "Här kan du lägga till extra anpassade fält för dina objekt.",
|
||||
"settings.extraFields.description.constraints": "När ett fält har lagts till kan du inte ändra dess nyckel eller typ, och för fält av typen \"val\" kan du inte ta bort val eller ändra flervalsläget. Om du tar bort ett fält kommer den associerade datan för alla enheter att raderas.",
|
||||
"settings.extraFields.description.intro": "Här kan du lägga till extra anpassade fält för dina objekt.",
|
||||
"settings.extraFields.description.keyUsage": "Nyckeln är vad andra program läser/skriver datan som, så om ditt anpassade fält ska integreras med ett tredjepartsprogram, se till att ställa in det korrekt. Standardvärdet tillämpas endast på nya objekt.",
|
||||
"settings.extraFields.description.tableViews": "Extra fält kan inte sorteras eller filtreras i tabellvyn.",
|
||||
"settings.extraFields.fieldType.boolean": "Booleansk",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "spoolman-client-v2",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.26.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"strip-empty-locales": "node scripts/strip-empty-locales.mjs",
|
||||
@@ -10,8 +10,11 @@
|
||||
"prebuild": "node scripts/strip-empty-locales.mjs",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"paraglide": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide --strategy localStorage preferredLanguage baseLocale",
|
||||
"check": "npm run paraglide && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "npm run paraglide && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"lint:eslint": "eslint .",
|
||||
@@ -20,35 +23,33 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.12.1",
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@inlang/paraglide-js": "^2.18.2",
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.8.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@inlang/paraglide-js": "^2.23.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.70.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.22.0",
|
||||
"globals": "^16.5.0",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-svelte": "^3.2.7",
|
||||
"svelte": "^5.1.9",
|
||||
"svelte-check": "^4.0.5",
|
||||
"typescript": "^5.6.3",
|
||||
"globals": "^17.8.0",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
"svelte": "^5.56.8",
|
||||
"svelte-check": "^4.7.4",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "^5.4.10"
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.25.0",
|
||||
"@lucide/svelte": "^1.28.0",
|
||||
"fflate": "^0.8.3",
|
||||
"konva": "^10.3.0",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"svelte-dnd-action": "^0.9.74",
|
||||
"svelte-dnd-action": "^0.9.76",
|
||||
"svelte-konva": "^1.0.1"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.21.5": true
|
||||
}
|
||||
}
|
||||
@@ -24,19 +24,37 @@
|
||||
--border-input: #383838;
|
||||
--hairline: rgba(255, 255, 255, 0.05);
|
||||
|
||||
/* Text — muted/dim/faint are tuned to clear WCAG AA (4.5:1) on the dark
|
||||
* surfaces they sit on (down to the lightest, surface-raised #2e2e2e) while
|
||||
* staying monotonic (muted brightest, faint dimmest). See app.css light
|
||||
* theme for the mirrored values. */
|
||||
/* Text — muted/dim/faint clear WCAG AA (4.5:1) on the dark surfaces they sit on,
|
||||
* down to the lightest (surface-raised #2e2e2e). #959595 is the darkest gray that
|
||||
* still clears 4.5:1 there, so faint sits exactly on that floor and the other two
|
||||
* step up from it in even ~7-point L* increments — the same perceptual spacing the
|
||||
* pre-AA ramp had, so the muted -> dim -> faint hierarchy stays legible instead of
|
||||
* collapsing into three near-identical grays. Contrast on #2e2e2e: 7.0 / 5.6 / 4.5.
|
||||
* Don't darken faint to widen the ramp further; that breaks AA. See the light
|
||||
* theme below for the mirrored values. */
|
||||
--text: #ececec;
|
||||
--text-2: #c9c9c9;
|
||||
--text-muted: #a0a0a0;
|
||||
--text-dim: #979797;
|
||||
--text-faint: #909090;
|
||||
--text-muted: #bababa;
|
||||
--text-dim: #a7a7a7;
|
||||
--text-faint: #959595;
|
||||
|
||||
/* Accent (warm orange) */
|
||||
--accent: #be682f;
|
||||
--accent-hover: #d47a3b;
|
||||
/* Solid fill behind white text — primary buttons, the active segmented tab,
|
||||
* the selected calendar day. White on --accent is only 4.03:1, which fails
|
||||
* WCAG AA, and no foreground fixes it: on #be682f the best any color manages
|
||||
* is 5.21:1 (pure black), and every readable one is worse than white. So the
|
||||
* fill gets its own token, darkened just enough to clear 4.5:1 (4.53) at the
|
||||
* same hue and saturation — 3.3 L* below --accent, which is imperceptible
|
||||
* beside it. --accent itself is unchanged and still drives links, borders,
|
||||
* washes and focus rings, where it sits on a dark surface and passes easily.
|
||||
* Note neither is the Spoolman brand orange #dc7734 (see static/spoolman.svg);
|
||||
* both themes already ship a darkened adaptation of it, and white on the brand
|
||||
* orange itself would be 3.12:1. Unlike --accent, the fill DARKENS on hover —
|
||||
* lightening it would drop back below AA. The light theme already did this. */
|
||||
--accent-fill: #b2612c;
|
||||
--accent-fill-hover: #9b5526;
|
||||
--accent-link: #d0854a;
|
||||
--accent-link-hover: #e09a60;
|
||||
--accent-soft: #e0996a;
|
||||
@@ -49,6 +67,7 @@
|
||||
/* Status */
|
||||
--danger: #c4503a;
|
||||
--danger-soft: #d98a7a;
|
||||
--danger-wash: rgba(196, 80, 58, 0.14);
|
||||
--success: #6bb26b;
|
||||
--success-border: #3f6b3f;
|
||||
--unused-bg: #33302c;
|
||||
@@ -97,18 +116,26 @@
|
||||
--border-input: #ceccc6;
|
||||
--hairline: rgba(0, 0, 0, 0.06);
|
||||
|
||||
/* Text — muted/dim/faint darkened to clear WCAG AA (4.5:1) on the light
|
||||
* surfaces they sit on (down to the darkest, surface-sunken #e8e6e2) while
|
||||
* staying monotonic (muted darkest, faint lightest). */
|
||||
/* Text — mirror of the dark ramp: muted/dim/faint clear WCAG AA (4.5:1) on the
|
||||
* light surfaces they sit on, down to the darkest (surface-sunken #e8e6e2).
|
||||
* #676767 is the lightest gray that still clears 4.5:1 there, so faint sits on
|
||||
* that ceiling and the other two step down from it. The steps are tighter than
|
||||
* dark (~4.5 L*) because #444444 text-2 caps the range, but they stay clearly
|
||||
* distinguishable. Contrast on #e8e6e2: 6.3 / 5.4 / 4.5. */
|
||||
--text: #232323;
|
||||
--text-2: #444444;
|
||||
--text-muted: #5f5f5f;
|
||||
--text-dim: #666666;
|
||||
--text-faint: #6d6d6d;
|
||||
--text-muted: #525252;
|
||||
--text-dim: #5c5c5c;
|
||||
--text-faint: #676767;
|
||||
|
||||
/* Accent (warm orange, darkened for contrast on light) */
|
||||
--accent: #b25c26;
|
||||
--accent-hover: #9d4f1e;
|
||||
/* Light's accent was already dark enough for white text (4.72:1 / 5.88:1 on
|
||||
* hover), so the fill tokens just mirror the pair above. They exist here so
|
||||
* both themes resolve the same variables, not because light needed a change. */
|
||||
--accent-fill: #b25c26;
|
||||
--accent-fill-hover: #9d4f1e;
|
||||
--accent-link: #a9551f;
|
||||
--accent-link-hover: #8f4718;
|
||||
--accent-soft: #95501f;
|
||||
@@ -121,6 +148,7 @@
|
||||
/* Status */
|
||||
--danger: #b7402c;
|
||||
--danger-soft: #a8462f;
|
||||
--danger-wash: rgba(183, 64, 44, 0.1);
|
||||
--success: #3d7a3d;
|
||||
--success-border: #a9c9a9;
|
||||
--unused-bg: #f1e9df;
|
||||
|
||||
@@ -4,10 +4,21 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon-180x180.png" />
|
||||
|
||||
<!-- Installability. The manifest sits at the deploy root and states start_url,
|
||||
scope and its icon sources relative to itself, so an operator running under
|
||||
SPOOLMAN_BASE_PATH gets a correctly scoped app without a build-time rebuild.
|
||||
%sveltekit.assets% is relative on the prerendered documents; on the SPA
|
||||
fallback (200.html) it is absolute and gets rewritten server-side — see
|
||||
load_and_rewrite_fallback_base_path in spoolman/client.py. -->
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
|
||||
<meta name="theme-color" content="#1f1f1f" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<title>Spoolman</title>
|
||||
|
||||
<!-- Resolve the saved color theme before first paint to avoid a flash of the
|
||||
|
||||
@@ -102,6 +102,10 @@ class WebSocketLive implements LiveConnection {
|
||||
return;
|
||||
}
|
||||
sock.ws = ws;
|
||||
// This socket's own keepalive handle. Kept local (not just on `sock`) so a
|
||||
// superseded socket can clean up after itself without touching the shared
|
||||
// `sock` state — see the identity guards below.
|
||||
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let msg: Record<string, unknown>;
|
||||
@@ -127,16 +131,30 @@ class WebSocketLive implements LiveConnection {
|
||||
}
|
||||
};
|
||||
|
||||
// A socket can be superseded before its own events land: reconnectAllNow()
|
||||
// opens a replacement while this one is still CLOSING, and the late
|
||||
// onopen/onclose would then clobber the replacement's state (killing its
|
||||
// keepalive, or nulling sock.ws under it). Every handler that touches the
|
||||
// shared `sock` first checks it is still the current socket.
|
||||
ws.onopen = () => {
|
||||
if (sock.ws !== ws) {
|
||||
ws.close(); // superseded while connecting — drop it
|
||||
return;
|
||||
}
|
||||
sock.attempts = 0; // connected — reset backoff
|
||||
// Light keepalive; the server replies {status:"healthy"} which we ignore.
|
||||
sock.pingTimer = setInterval(() => {
|
||||
pingTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send('ping');
|
||||
}, 25000);
|
||||
sock.pingTimer = pingTimer;
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (sock.pingTimer) clearInterval(sock.pingTimer);
|
||||
// Always stop our own keepalive, current socket or not, so a superseded
|
||||
// socket doesn't leak its interval.
|
||||
if (pingTimer) clearInterval(pingTimer);
|
||||
pingTimer = null;
|
||||
if (sock.ws !== ws) return; // a replacement is live — leave it alone
|
||||
sock.pingTimer = null;
|
||||
sock.ws = null;
|
||||
if (!sock.closed && sock.subs.size > 0) this.scheduleReconnect(resource, sock);
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { Filament, MultiColorDirection, Spool, Vendor } from '$lib/types';
|
||||
import type {
|
||||
Filament,
|
||||
FilamentPatch,
|
||||
MultiColorDirection,
|
||||
Spool,
|
||||
SpoolPatch,
|
||||
Vendor,
|
||||
VendorPatch
|
||||
} from '$lib/types';
|
||||
import type { GroupSummary } from './types';
|
||||
import { formatDurationShort, formatShortDate } from '$lib/utils/datetime';
|
||||
|
||||
@@ -56,14 +64,26 @@ export function mapFilament(f: Json): Filament {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Below this much used, a spool counts as untouched. `used_weight === 0` was too literal a
|
||||
* test: registering a spool by its measured weight computes `net + spool − measured`, and in
|
||||
* floating point a full 1 kg spool on a 128.11 g core lands on 2.3e-13 instead of 0. Such a
|
||||
* spool claimed to be in use the moment it was added (#986). A hundredth of a gram is under
|
||||
* any scale's resolution and under the display's, so nothing real is rounded away here — and
|
||||
* a negative used_weight (filament added back) falls on the unused side too, as it should.
|
||||
*/
|
||||
const USED_EPSILON_GRAMS = 0.01;
|
||||
|
||||
export function mapSpool(s: Json): Spool {
|
||||
const f: Json = s.filament ?? {};
|
||||
return {
|
||||
id: s.id,
|
||||
filamentId: String(f.id ?? ''),
|
||||
unused: (s.used_weight ?? 0) === 0,
|
||||
unused: (s.used_weight ?? 0) < USED_EPSILON_GRAMS,
|
||||
remaining: s.remaining_weight ?? 0,
|
||||
initial: s.initial_weight ?? f.weight ?? 0,
|
||||
initialOverride: s.initial_weight ?? undefined,
|
||||
usedWeight: s.used_weight ?? 0,
|
||||
location: s.location ?? '',
|
||||
lot: s.lot_nr ?? '',
|
||||
price: s.price ?? undefined,
|
||||
@@ -81,16 +101,24 @@ export function mapSpool(s: Json): Spool {
|
||||
|
||||
export function mapGroup(g: Json): GroupSummary {
|
||||
const field = g.group_by as GroupSummary['field'];
|
||||
let title = '';
|
||||
let subtitle = '';
|
||||
let title: string;
|
||||
let subtitle: string;
|
||||
let badge = '';
|
||||
let colors: string[] = [];
|
||||
let direction: MultiColorDirection | undefined;
|
||||
// Filament groups carry their manufacturer separately from the subtitle text
|
||||
// so the header can render it as a link to the vendor (see GroupHeader).
|
||||
let vendorId: string | undefined;
|
||||
let vendorName: string | undefined;
|
||||
|
||||
if (field === 'filament' && g.filament) {
|
||||
const f: Json = g.filament;
|
||||
title = f.name ?? '(unnamed filament)';
|
||||
subtitle = `${f.vendor?.name ?? 'No manufacturer'} · ${f.diameter} mm`;
|
||||
subtitle = `${f.diameter} mm`;
|
||||
if (f.vendor) {
|
||||
vendorId = String(f.vendor.id);
|
||||
vendorName = f.vendor.name ?? '(unnamed manufacturer)';
|
||||
}
|
||||
badge = f.material ?? '';
|
||||
colors = colorsFromApi(f);
|
||||
direction = f.multi_color_direction ?? undefined;
|
||||
@@ -104,7 +132,10 @@ export function mapGroup(g: Json): GroupSummary {
|
||||
title = g.key ?? 'No location';
|
||||
subtitle = `${g.in_use_count} in use`;
|
||||
} else {
|
||||
// extra.<key>: the key IS the value, and there is no entity to name the group.
|
||||
// The dashboard, which is what groups on these, titles them itself.
|
||||
title = g.key ?? '';
|
||||
subtitle = `${g.spool_count} spool${g.spool_count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -113,6 +144,8 @@ export function mapGroup(g: Json): GroupSummary {
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
vendorId,
|
||||
vendorName,
|
||||
colors,
|
||||
direction,
|
||||
spoolCount: g.spool_count ?? 0,
|
||||
@@ -148,7 +181,7 @@ export function colorFieldsToApi(
|
||||
return { color_hex: hexes[0] ?? null, multi_color_hexes: null, multi_color_direction: null };
|
||||
}
|
||||
|
||||
export function spoolPatchToApi(patch: Partial<Spool>): Json {
|
||||
export function spoolPatchToApi(patch: SpoolPatch): Json {
|
||||
const out: Json = {};
|
||||
if ('location' in patch) out.location = patch.location ?? '';
|
||||
if ('lot' in patch) out.lot_nr = patch.lot ?? '';
|
||||
@@ -162,7 +195,7 @@ export function spoolPatchToApi(patch: Partial<Spool>): Json {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function filamentPatchToApi(patch: Partial<Filament>): Json {
|
||||
export function filamentPatchToApi(patch: FilamentPatch): Json {
|
||||
const out: Json = {};
|
||||
if ('name' in patch) out.name = patch.name;
|
||||
if ('material' in patch) out.material = patch.material;
|
||||
@@ -183,7 +216,7 @@ export function filamentPatchToApi(patch: Partial<Filament>): Json {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function vendorPatchToApi(patch: Partial<Vendor>): Json {
|
||||
export function vendorPatchToApi(patch: VendorPatch): Json {
|
||||
const out: Json = {};
|
||||
if ('name' in patch) out.name = patch.name;
|
||||
if ('emptyWeight' in patch) out.empty_spool_weight = patch.emptyWeight;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SearchMatch, SearchResults } from './types';
|
||||
import type { Filament, Spool, Vendor } from '$lib/types';
|
||||
import type { FilamentSearchMatch, FilamentSpoolHit, SearchMatch, SearchResults } from './types';
|
||||
import type { Spool, Vendor } from '$lib/types';
|
||||
import { API_BASE } from './config';
|
||||
import { mapFilament, mapSpool, mapVendor } from './map';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
@@ -8,6 +8,11 @@ import { inventory } from '$lib/stores/inventory.svelte';
|
||||
// entity it fetches is upserted into the reactive cache so that clicking a result
|
||||
// (which opens its inspector) has the data ready immediately.
|
||||
|
||||
// Spools to request per matching filament, shown as shortcut pills under the
|
||||
// filament (issue #993: searching a filament name should lead to a spool). Kept
|
||||
// small: the panel only has room for a few, and every keystroke re-queries.
|
||||
const SPOOLS_PER_FILAMENT = 5;
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type Json = Record<string, any>;
|
||||
|
||||
@@ -24,12 +29,29 @@ function mapSpoolHit(m: Json): SearchMatch<Spool> {
|
||||
return { entity: spool, matchField: m.match_field };
|
||||
}
|
||||
|
||||
function mapFilamentHit(m: Json): SearchMatch<Filament> {
|
||||
function mapFilamentSpool(s: Json): FilamentSpoolHit {
|
||||
return {
|
||||
id: s.id,
|
||||
remainingWeight: s.remaining_weight ?? undefined,
|
||||
location: s.location ?? undefined,
|
||||
archived: !!s.archived
|
||||
};
|
||||
}
|
||||
|
||||
function mapFilamentHit(m: Json): FilamentSearchMatch {
|
||||
const raw: Json = m.filament ?? {};
|
||||
if (raw.vendor) inventory.upsertVendor(mapVendor(raw.vendor));
|
||||
const filament = mapFilament(raw);
|
||||
inventory.upsertFilament(filament);
|
||||
return { entity: filament, matchField: m.match_field };
|
||||
// These are too partial to upsert into the inventory cache; opening one fetches
|
||||
// the full spool (see Inspector.svelte), which is what a plain deep link does too.
|
||||
const spools = (m.spools ?? []).map(mapFilamentSpool);
|
||||
return {
|
||||
entity: filament,
|
||||
matchField: m.match_field,
|
||||
spools,
|
||||
spoolCount: m.spool_count ?? spools.length
|
||||
};
|
||||
}
|
||||
|
||||
function mapVendorHit(m: Json): SearchMatch<Vendor> {
|
||||
@@ -50,7 +72,11 @@ export async function searchAll(
|
||||
const q = query.trim();
|
||||
if (!q) return EMPTY;
|
||||
|
||||
const params = new URLSearchParams({ q, color_similarity_threshold: String(threshold) });
|
||||
const params = new URLSearchParams({
|
||||
q,
|
||||
color_similarity_threshold: String(threshold),
|
||||
spools_per_filament: String(SPOOLS_PER_FILAMENT)
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/search?${params.toString()}`, { signal });
|
||||
if (!res.ok) throw new Error(`GET /search → ${res.status}`);
|
||||
const data = (await res.json()) as Json;
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Filament, MultiColorDirection, Spool, Vendor } from '$lib/types';
|
||||
import type { GroupQuery, GroupSummary, Page, SpoolQuery } from './types';
|
||||
import { getList, getJson, patchJson, postJson, putJson, HttpError } from './http';
|
||||
import type {
|
||||
Extra,
|
||||
Filament,
|
||||
FilamentPatch,
|
||||
MultiColorDirection,
|
||||
Spool,
|
||||
SpoolPatch,
|
||||
Vendor,
|
||||
VendorPatch
|
||||
} from '$lib/types';
|
||||
import type { GroupField, GroupQuery, GroupSummary, Page, SpoolQuery } from './types';
|
||||
import { getList, getJson, patchJson, postJson, putJson, deleteResource, HttpError } from './http';
|
||||
import type { QueryParams } from './http';
|
||||
import {
|
||||
mapFilament,
|
||||
@@ -41,6 +50,8 @@ export interface NewFilamentDraft {
|
||||
price?: number;
|
||||
articleNumber?: string;
|
||||
comment?: string;
|
||||
/** Custom-field values to carry over (used when duplicating a filament). */
|
||||
extra?: Extra;
|
||||
}
|
||||
|
||||
// Map a filter chip prop → API query param. Values are quoted for exact match.
|
||||
@@ -87,6 +98,10 @@ function sortParam(sort: { field: string; dir: string }[]): string | undefined {
|
||||
|
||||
function scopeParams(params: QueryParams, scope: SpoolQuery['groupScope']) {
|
||||
if (!scope) return;
|
||||
// An empty key is the group of spools whose value is unset. Every string-valued
|
||||
// field spells that as an empty filter, which the backend reads as "null or empty"
|
||||
// (and, for an extra field, "no row for this key either").
|
||||
const exact = (v: string) => (v === '' ? '' : quote(v));
|
||||
switch (scope.field) {
|
||||
case 'filament':
|
||||
params['filament.id'] = scope.key;
|
||||
@@ -95,10 +110,14 @@ function scopeParams(params: QueryParams, scope: SpoolQuery['groupScope']) {
|
||||
params['filament.vendor.id'] = scope.key === '' ? '-1' : scope.key;
|
||||
break;
|
||||
case 'material':
|
||||
params['filament.material'] = scope.key === '' ? '' : quote(scope.key);
|
||||
params['filament.material'] = exact(scope.key);
|
||||
break;
|
||||
case 'location':
|
||||
params['location'] = scope.key === '' ? '' : quote(scope.key);
|
||||
params['location'] = exact(scope.key);
|
||||
break;
|
||||
default:
|
||||
// `extra.<key>`, which is already the query param the backend expects.
|
||||
params[scope.field] = exact(scope.key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -288,6 +307,7 @@ class HttpSpoolSource {
|
||||
price: draft.price || undefined,
|
||||
article_number: draft.articleNumber || undefined,
|
||||
comment: draft.comment || undefined,
|
||||
extra: draft.extra && Object.keys(draft.extra).length ? draft.extra : undefined,
|
||||
...colorFieldsToApi(draft.colors, draft.multiColorDirection)
|
||||
};
|
||||
const created = await postJson<Json>('/filament', body);
|
||||
@@ -335,7 +355,7 @@ class HttpSpoolSource {
|
||||
|
||||
// --- writes -------------------------------------------------------------
|
||||
|
||||
async saveSpool(id: number, patch: Partial<Spool>): Promise<void> {
|
||||
async saveSpool(id: number, patch: SpoolPatch): Promise<void> {
|
||||
const updated = await patchJson<Json>(`/spool/${id}`, spoolPatchToApi(patch));
|
||||
inventory.upsertSpool(mapSpool(updated));
|
||||
}
|
||||
@@ -363,11 +383,11 @@ class HttpSpoolSource {
|
||||
inventory.upsertSpool(spool);
|
||||
return spool;
|
||||
}
|
||||
async saveFilament(id: string, patch: Partial<Filament>): Promise<void> {
|
||||
async saveFilament(id: string, patch: FilamentPatch): Promise<void> {
|
||||
const updated = await patchJson<Json>(`/filament/${id}`, filamentPatchToApi(patch));
|
||||
inventory.upsertFilament(mapFilament(updated));
|
||||
}
|
||||
async saveVendor(id: string, patch: Partial<Vendor>): Promise<void> {
|
||||
async saveVendor(id: string, patch: VendorPatch): Promise<void> {
|
||||
const updated = await patchJson<Json>(`/vendor/${id}`, vendorPatchToApi(patch));
|
||||
inventory.upsertVendor(mapVendor(updated));
|
||||
}
|
||||
@@ -376,6 +396,50 @@ class HttpSpoolSource {
|
||||
return cacheSpools([created])[0];
|
||||
}
|
||||
|
||||
// --- deletes ------------------------------------------------------------
|
||||
// Each drops the entity from the cache itself rather than waiting for the
|
||||
// websocket to echo it back, so the list and inspector update at once (and
|
||||
// still do when live updates are unavailable).
|
||||
|
||||
/**
|
||||
* How many spools reference this filament, archived ones included — the number
|
||||
* that decides whether the API will let it be deleted. Asks for one row and
|
||||
* reads X-Total-Count rather than paging the spools in.
|
||||
*/
|
||||
async countSpoolsOfFilament(id: string, signal?: AbortSignal): Promise<number> {
|
||||
const { total } = await getList(
|
||||
'/spool',
|
||||
{ 'filament.id': id, allow_archived: 'true', limit: 1 },
|
||||
signal
|
||||
);
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a spool. Nothing references a spool, so this always succeeds — it is
|
||||
* also the only way to free a filament for deletion, since archiving is just a
|
||||
* flag and the foreign key still holds.
|
||||
*/
|
||||
async deleteSpool(id: number): Promise<void> {
|
||||
await deleteResource(`/spool/${id}`);
|
||||
inventory.removeSpool(id);
|
||||
}
|
||||
|
||||
/** Delete a filament. Rejects with a 403 HttpError if it still has spools. */
|
||||
async deleteFilament(id: string): Promise<void> {
|
||||
await deleteResource(`/filament/${id}`);
|
||||
inventory.removeFilament(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a manufacturer. Its filaments are NOT deleted — the server clears
|
||||
* their vendor and leaves them in the catalog, which `removeVendor` mirrors.
|
||||
*/
|
||||
async deleteVendor(id: string): Promise<void> {
|
||||
await deleteResource(`/vendor/${id}`);
|
||||
inventory.removeVendor(id);
|
||||
}
|
||||
|
||||
// --- filter option lists -----------------------------------------------
|
||||
|
||||
async materials(): Promise<string[]> {
|
||||
@@ -405,9 +469,18 @@ class HttpSpoolSource {
|
||||
async locations(): Promise<string[]> {
|
||||
return getJson<string[]>('/location');
|
||||
}
|
||||
/** Rename a location, moving every spool currently in it to the new name. */
|
||||
async renameLocation(current: string, next: string): Promise<void> {
|
||||
await patchJson<string>(`/location/${encodeURIComponent(current)}`, { name: next });
|
||||
/**
|
||||
* Replace one value of one spool field wherever it occurs, in a single request. Works for
|
||||
* any field the spool owns, so the dashboard can rename a whole group without paging in its
|
||||
* members — and without leaving out the ones it never loaded. Returns how many spools held
|
||||
* the old value.
|
||||
*/
|
||||
async renameFieldValue(field: GroupField, value: string, newValue: string): Promise<number> {
|
||||
const result = await patchJson<{ spools_updated: number }>(`/spool/field/${encodeURIComponent(field)}`, {
|
||||
value,
|
||||
new_value: newValue
|
||||
});
|
||||
return result.spools_updated;
|
||||
}
|
||||
async lotNumbers(): Promise<string[]> {
|
||||
return getJson<string[]>('/lot-number');
|
||||
|
||||
@@ -15,8 +15,11 @@ export interface SortField {
|
||||
dir: SortDir;
|
||||
}
|
||||
|
||||
/** The entity/axis a group is keyed on. */
|
||||
export type GroupField = 'filament' | 'vendor' | 'material' | 'location';
|
||||
/**
|
||||
* The entity/axis a group is keyed on. These are the API's own `group_by` values, so
|
||||
* they travel to the server unchanged — `extra.<key>` groups on a spool custom field.
|
||||
*/
|
||||
export type GroupField = 'filament' | 'vendor' | 'material' | 'location' | `extra.${string}`;
|
||||
|
||||
/** One group returned by listGroups — the header + server aggregates. */
|
||||
export interface GroupSummary {
|
||||
@@ -26,6 +29,10 @@ export interface GroupSummary {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
/** Filament groups only: the manufacturer, so the header can link to it.
|
||||
* Both absent when the filament has no manufacturer set. */
|
||||
vendorId?: string;
|
||||
vendorName?: string;
|
||||
colors: string[];
|
||||
/** Multi-color layout, when grouping by a single filament; undefined otherwise. */
|
||||
direction?: MultiColorDirection;
|
||||
@@ -93,10 +100,25 @@ export interface SearchMatch<T> {
|
||||
matchField: string;
|
||||
}
|
||||
|
||||
/** One of a matched filament's spools, in the fields a shortcut pill shows. */
|
||||
export interface FilamentSpoolHit {
|
||||
id: number;
|
||||
remainingWeight?: number;
|
||||
location?: string;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
/** A filament search hit, plus the spools offered as shortcuts beneath it. */
|
||||
export interface FilamentSearchMatch extends SearchMatch<Filament> {
|
||||
spools: FilamentSpoolHit[];
|
||||
/** Total spools of this filament, of which `spools` holds at most the first few. */
|
||||
spoolCount: number;
|
||||
}
|
||||
|
||||
/** Categorized results of a cross-entity search. */
|
||||
export interface SearchResults {
|
||||
spools: SearchMatch<Spool>[];
|
||||
filaments: SearchMatch<Filament>[];
|
||||
filaments: FilamentSearchMatch[];
|
||||
vendors: SearchMatch<Vendor>[];
|
||||
/** True when the query was recognized as a color (a threshold slider is relevant). */
|
||||
isColorQuery: boolean;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import Swatch from './Swatch.svelte';
|
||||
import ColorEditor from './ColorEditor.svelte';
|
||||
import Button from './Button.svelte';
|
||||
@@ -16,7 +17,9 @@
|
||||
import { serverInfo } from '$lib/stores/serverInfo.svelte';
|
||||
import { spoolSource, type NewFilamentDraft } from '$lib/api/spoolSource';
|
||||
import { fields } from '$lib/stores/fields.svelte';
|
||||
import type { EntityType } from '$lib/api/fields';
|
||||
import { externalColors, externalDirection, type ExternalFilament } from '$lib/api/external';
|
||||
import { roundGrams, weightAuto } from '$lib/utils/format';
|
||||
import { loadMaterials, type MaterialSpec } from '$lib/data/materials';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
@@ -24,9 +27,11 @@
|
||||
open: boolean;
|
||||
/** When set, open straight to step 2 with this local filament chosen. */
|
||||
presetFilamentId?: string | null;
|
||||
/** When set, open straight to step 2 on a new filament copied from this one. */
|
||||
duplicateFilamentId?: string | null;
|
||||
onclose?: () => void;
|
||||
}
|
||||
let { open, presetFilamentId = null, onclose }: Props = $props();
|
||||
let { open, presetFilamentId = null, duplicateFilamentId = null, onclose }: Props = $props();
|
||||
|
||||
// A chosen filament is one from the local catalog, a SpoolmanDB entry, or —
|
||||
// when `creating` — a brand-new filament described by the `nf` form.
|
||||
@@ -43,6 +48,9 @@
|
||||
let creating = $state(false);
|
||||
let submitting = $state(false);
|
||||
let locations = $state<string[]>([]);
|
||||
// The filament the current new-filament form was copied from, if any. Drives
|
||||
// the "duplicate of X" heading, the rename nudge, and the extra-field carry-over.
|
||||
let cloneSource = $state<Filament | null>(null);
|
||||
|
||||
// New-filament fields + lookups for the combobox / auto-fill.
|
||||
let nf = $state({
|
||||
@@ -58,7 +66,15 @@
|
||||
articleNumber: '',
|
||||
comment: ''
|
||||
});
|
||||
// Custom-field values for the filament being created. Separate from the spool's
|
||||
// `extraValues` below: the two entities have their own field definitions.
|
||||
let filamentExtra = $state<Extra>({});
|
||||
let showAdvanced = $state(false);
|
||||
let nameInput = $state<HTMLInputElement | undefined>();
|
||||
// Display-only: an untouched empty name shows the naming guidance rather than
|
||||
// shouting "Required" at a form the user just opened. Submission is unaffected
|
||||
// — `errors`/`canSubmit` still treat the empty name as invalid throughout.
|
||||
let nameTouched = $state(false);
|
||||
let vendorNames = $state<string[]>([]);
|
||||
let materialNames = $state<string[]>([]);
|
||||
let materialSpecs = $state<Record<string, MaterialSpec>>({});
|
||||
@@ -124,6 +140,7 @@
|
||||
initialized = true;
|
||||
runSearch();
|
||||
fields.ensure('spool');
|
||||
fields.ensure('filament');
|
||||
spoolSource
|
||||
.locations()
|
||||
.then((l) => (locations = l))
|
||||
@@ -139,6 +156,9 @@
|
||||
if (presetFilamentId) {
|
||||
const f = inventory.filamentById(presetFilamentId);
|
||||
if (f) choose({ source: 'catalog', filament: f });
|
||||
} else if (duplicateFilamentId) {
|
||||
const f = inventory.filamentById(duplicateFilamentId);
|
||||
if (f) startDuplicate(f);
|
||||
}
|
||||
} else if (!open) {
|
||||
initialized = false;
|
||||
@@ -152,6 +172,16 @@
|
||||
if (open && step === 1 && searchInput) searchInput.focus();
|
||||
});
|
||||
|
||||
// When duplicating, the name is the one field that must change, so put the
|
||||
// caret in it (at the end — the colour word is usually a suffix, and the rest
|
||||
// of the name is worth keeping rather than retyping).
|
||||
$effect(() => {
|
||||
if (open && cloneSource && nameInput) {
|
||||
nameInput.focus();
|
||||
nameInput.setSelectionRange(nameInput.value.length, nameInput.value.length);
|
||||
}
|
||||
});
|
||||
|
||||
// --- spool form ---------------------------------------------------------
|
||||
type FillMode = 'full' | 'used' | 'remaining' | 'measured';
|
||||
let count = $state('1');
|
||||
@@ -178,6 +208,14 @@
|
||||
{ key: 'remaining', labelKey: m['spool.fields.remainingWeight'] },
|
||||
{ key: 'measured', labelKey: m['spool.fields.measuredWeight'] }
|
||||
];
|
||||
// Ballpark empty-spool weights, offered in the Spool Weight help for people who
|
||||
// have no idea what to put there. Deliberately round: they're a starting point
|
||||
// to be corrected by weighing the spool, not a claim about any specific brand.
|
||||
const SPOOL_WEIGHT_PRESETS = [
|
||||
{ weight: 140, label: () => m['add.spoolWeightPreset.cardboard']({ weight: 140 }) },
|
||||
{ weight: 200, label: () => m['add.spoolWeightPreset.plastic']({ weight: 200 }) }
|
||||
];
|
||||
|
||||
let fillHelp = $derived(
|
||||
fillMode === 'used'
|
||||
? m['spool.fieldsHelp.usedWeight']()
|
||||
@@ -188,16 +226,37 @@
|
||||
: ''
|
||||
);
|
||||
|
||||
function seedExtraDefaults(): Extra {
|
||||
const out: Extra = {};
|
||||
for (const f of fields.get('spool')) if (f.default_value != null) out[f.key] = f.default_value;
|
||||
/** `current` with a default filled in for every field it doesn't already carry. */
|
||||
function withDefaults(entity: EntityType, current: Extra): Extra {
|
||||
const out = { ...current };
|
||||
for (const f of fields.get(entity))
|
||||
if (f.default_value != null && !(f.key in out)) out[f.key] = f.default_value;
|
||||
return out;
|
||||
}
|
||||
function setExtra(key: string, json: string | undefined) {
|
||||
const next = { ...extraValues };
|
||||
// Definitions are fetched on open, so they can land after the form is already on
|
||||
// screen — opening straight into a preset or a duplicate leaves no time for the
|
||||
// request. Top up the defaults when they arrive; anything already there, seeded
|
||||
// or typed, is left alone. The writes are untracked so this doesn't re-run itself.
|
||||
$effect(() => {
|
||||
fields.get('spool');
|
||||
fields.get('filament');
|
||||
untrack(() => {
|
||||
extraValues = withDefaults('spool', extraValues);
|
||||
filamentExtra = withDefaults('filament', filamentExtra);
|
||||
});
|
||||
});
|
||||
|
||||
function setExtraOn(current: Extra, key: string, json: string | undefined): Extra {
|
||||
const next = { ...current };
|
||||
if (json === undefined) delete next[key];
|
||||
else next[key] = json;
|
||||
extraValues = next;
|
||||
return next;
|
||||
}
|
||||
function setExtra(key: string, json: string | undefined) {
|
||||
extraValues = setExtraOn(extraValues, key, json);
|
||||
}
|
||||
function setFilamentExtra(key: string, json: string | undefined) {
|
||||
filamentExtra = setExtraOn(filamentExtra, key, json);
|
||||
}
|
||||
|
||||
function resetSpoolForm() {
|
||||
@@ -209,11 +268,12 @@
|
||||
fillWeight = '';
|
||||
firstUsed = undefined;
|
||||
lastUsed = undefined;
|
||||
extraValues = seedExtraDefaults();
|
||||
extraValues = withDefaults('spool', {});
|
||||
}
|
||||
|
||||
function choose(c: Choice) {
|
||||
creating = false;
|
||||
cloneSource = null;
|
||||
chosen = c;
|
||||
netWeight = String(cWeight(c) || 1000);
|
||||
const sw = cSpoolWeight(c);
|
||||
@@ -226,6 +286,8 @@
|
||||
|
||||
function startCreate() {
|
||||
creating = true;
|
||||
cloneSource = null;
|
||||
nameTouched = false;
|
||||
chosen = null;
|
||||
showAdvanced = false;
|
||||
nf = {
|
||||
@@ -241,6 +303,7 @@
|
||||
articleNumber: '',
|
||||
comment: ''
|
||||
};
|
||||
filamentExtra = withDefaults('filament', {});
|
||||
netWeight = '1000';
|
||||
spoolWeight = '';
|
||||
price = '';
|
||||
@@ -248,6 +311,45 @@
|
||||
step = 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new filament copied from an existing one — the "I bought the same
|
||||
* filament in another colour" case. Everything that describes the *product*
|
||||
* carries over (manufacturer, material, specs, weights, price, custom fields — all
|
||||
* of it still editable in the form); everything that identifies the *variant* is
|
||||
* left for the user: the colour is cleared and the article number (a per-colour
|
||||
* SKU) is dropped. The name is kept as a starting point since it's usually one
|
||||
* word away from the new one,
|
||||
* with a nudge below the field until it's changed.
|
||||
*/
|
||||
function startDuplicate(f: Filament) {
|
||||
creating = true;
|
||||
cloneSource = f;
|
||||
nameTouched = false;
|
||||
chosen = null;
|
||||
// Specs came from a real filament rather than a material guess, so open the
|
||||
// advanced block: it's what makes the copy visibly a copy.
|
||||
showAdvanced = true;
|
||||
nf = {
|
||||
vendorName: inventory.vendorById(f.vendorId)?.name ?? '',
|
||||
name: f.name,
|
||||
material: f.material,
|
||||
colors: [],
|
||||
multiColorDirection: undefined,
|
||||
density: String(f.density),
|
||||
diameter: String(f.diameter),
|
||||
nozzleTemp: f.nozzleTemp ? String(f.nozzleTemp) : '',
|
||||
bedTemp: f.bedTemp ? String(f.bedTemp) : '',
|
||||
articleNumber: '',
|
||||
comment: f.comment
|
||||
};
|
||||
filamentExtra = withDefaults('filament', { ...f.extra });
|
||||
netWeight = String(f.weight || 1000);
|
||||
spoolWeight = f.spoolWeight ? String(f.spoolWeight) : '';
|
||||
price = f.price ? String(f.price) : '';
|
||||
resetSpoolForm();
|
||||
step = 2;
|
||||
}
|
||||
|
||||
// Vendor combobox: reuse an existing vendor if the name matches, else create.
|
||||
let vendorTrimmed = $derived(nf.vendorName.trim());
|
||||
let vendorMatch = $derived(vendorNames.find((v) => v.toLowerCase() === vendorTrimmed.toLowerCase()));
|
||||
@@ -258,6 +360,10 @@
|
||||
? m['add.vendorHint.existing']({ name: vendorMatch })
|
||||
: m['add.vendorHint.new']({ name: vendorTrimmed })
|
||||
);
|
||||
// Nudge, not an error: Spoolman allows same-named filaments, but keeping the
|
||||
// original's name on a duplicate is almost always an oversight.
|
||||
let nameStillSource = $derived(!!cloneSource && nf.name.trim() === cloneSource.name.trim());
|
||||
|
||||
function onMaterial(v: string) {
|
||||
nf.material = v;
|
||||
const spec = materialSpecs[v.trim().toLowerCase()];
|
||||
@@ -277,6 +383,8 @@
|
||||
externalResults = [];
|
||||
chosen = null;
|
||||
creating = false;
|
||||
cloneSource = null;
|
||||
nameTouched = false;
|
||||
submitting = false;
|
||||
}
|
||||
function close() {
|
||||
@@ -289,6 +397,9 @@
|
||||
submitting = true;
|
||||
try {
|
||||
let filamentId: number;
|
||||
// Set when this submit created a filament, so "Add & new" can offer the
|
||||
// next colour of it without going back through search.
|
||||
let created: Filament | null = null;
|
||||
if (creating) {
|
||||
const draft: NewFilamentDraft = {
|
||||
name: nf.name.trim(),
|
||||
@@ -304,9 +415,11 @@
|
||||
bedTemp: nf.bedTemp ? Number(nf.bedTemp) : undefined,
|
||||
price: parseFloat(price) || undefined,
|
||||
articleNumber: nf.articleNumber.trim() || undefined,
|
||||
comment: nf.comment.trim() || undefined
|
||||
comment: nf.comment.trim() || undefined,
|
||||
extra: filamentExtra
|
||||
};
|
||||
const f = await spoolSource.createFilament(draft);
|
||||
created = f;
|
||||
filamentId = Number(f.id);
|
||||
} else if (chosen!.source === 'external') {
|
||||
const imported = await spoolSource.importExternalFilament(chosen!.ext);
|
||||
@@ -330,13 +443,22 @@
|
||||
if (fillMode === 'used') body.used_weight = Number(fillWeight) || 0;
|
||||
else if (fillMode === 'remaining') body.remaining_weight = Number(fillWeight) || 0;
|
||||
else if (fillMode === 'measured')
|
||||
body.used_weight = Math.max(0, net + spool - (Number(fillWeight) || 0));
|
||||
// Rounded, because this subtraction is where float dust gets born: weighing a full
|
||||
// 1000 g spool on a 128.11 g core gives 1000 + 128.11 − 1128.11 = 2.3e-13, and a
|
||||
// spool created with that much used is not an unused spool any more (#986).
|
||||
body.used_weight = roundGrams(Math.max(0, net + spool - (Number(fillWeight) || 0)));
|
||||
if (firstUsed) body.first_used = firstUsed;
|
||||
if (lastUsed) body.last_used = lastUsed;
|
||||
if (Object.keys(extraValues).length) body.extra = extraValues;
|
||||
|
||||
for (let i = 0; i < n; i++) await spoolSource.createSpool(body);
|
||||
if (andAnother) {
|
||||
if (andAnother && created) {
|
||||
// Just added a brand-new filament: the overwhelmingly likely next entry
|
||||
// is a sibling of it (the multi-colour shopping trip this flow exists
|
||||
// for), so hand back the same form pre-copied instead of an empty search.
|
||||
reset();
|
||||
startDuplicate(created);
|
||||
} else if (andAnother) {
|
||||
reset();
|
||||
runSearch();
|
||||
} else {
|
||||
@@ -456,6 +578,12 @@
|
||||
<span class="rn">{f.name}</span>
|
||||
<span class="rs">{vendorName(f)} · {f.material}</span>
|
||||
</div>
|
||||
<!-- See the note on the external rows below: weight disambiguates two
|
||||
otherwise identical entries, so it sits outside the truncating
|
||||
.res-name. A catalog filament may have no weight recorded (0). -->
|
||||
{#if f.weight}
|
||||
<span class="res-weight">{weightAuto(f.weight)}</span>
|
||||
{/if}
|
||||
<span class="tag in-catalog">{m['add.inCatalog']()}</span>
|
||||
</button>
|
||||
{/each}
|
||||
@@ -483,6 +611,13 @@
|
||||
<span class="rn">{ext.name}</span>
|
||||
<span class="rs">{ext.manufacturer} · {ext.material}</span>
|
||||
</div>
|
||||
<!-- Weight is part of the identity here: vendors list the same filament in
|
||||
several sizes, so the rows are otherwise indistinguishable. It sits
|
||||
outside .res-name so the ellipsis can never eat the one field that
|
||||
tells two matching results apart. -->
|
||||
{#if ext.weight}
|
||||
<span class="res-weight">{weightAuto(ext.weight)}</span>
|
||||
{/if}
|
||||
<span class="tag external">{serverInfo.externalDbName}</span>
|
||||
</button>
|
||||
{/each}
|
||||
@@ -501,9 +636,16 @@
|
||||
{#if creating}
|
||||
<div class="fil-section">
|
||||
<div class="fs-head">
|
||||
<span class="fs-title">{m['add.newFilamentTitle']()}</span>
|
||||
<span class="fs-title"
|
||||
>{cloneSource
|
||||
? m['add.duplicateTitle']({ name: cloneSource.name })
|
||||
: m['add.newFilamentTitle']()}</span
|
||||
>
|
||||
<button class="fs-back" onclick={() => (step = 1)}>{m['add.useExisting']()}</button>
|
||||
</div>
|
||||
{#if cloneSource}
|
||||
<div class="dup-note">{m['add.duplicateNote']()}</div>
|
||||
{/if}
|
||||
<div class="form">
|
||||
<label class="wide">
|
||||
{m['filament.fields.vendor']()}
|
||||
@@ -523,11 +665,19 @@
|
||||
<label class="wide">
|
||||
{m['filament.fields.name']()} <span class="req">*</span>
|
||||
<input
|
||||
bind:this={nameInput}
|
||||
bind:value={nf.name}
|
||||
placeholder={m['add.filamentNamePlaceholder']()}
|
||||
class:invalid={errors.name}
|
||||
class:invalid={errors.name && nameTouched}
|
||||
oninput={() => (nameTouched = true)}
|
||||
onblur={() => (nameTouched = true)}
|
||||
/>
|
||||
{#if errors.name}<span class="err">{errors.name}</span>{/if}
|
||||
<!-- The rename nudge only outranks the naming hint while the copy
|
||||
still carries the original's name; after that both cases get the
|
||||
same advice, since color is what makes a filament name useful. -->
|
||||
{#if errors.name && nameTouched}<span class="err">{errors.name}</span>
|
||||
{:else if nameStillSource}<span class="hint accent">{m['add.duplicateRename']()}</span>
|
||||
{:else}<span class="hint">{m['add.nameHint']()}</span>{/if}
|
||||
</label>
|
||||
<label>
|
||||
{m['filament.fields.material']()}
|
||||
@@ -587,7 +737,7 @@
|
||||
{#if errors.diameter}<span class="err">{errors.diameter}</span>{/if}
|
||||
</label>
|
||||
<label
|
||||
>{m['filament.fields.settingsBedTemp']()}
|
||||
>{m['filament.fields.settingsExtruderTemp']()}
|
||||
<NumberInput
|
||||
bind:value={nf.nozzleTemp}
|
||||
min={0}
|
||||
@@ -600,7 +750,7 @@
|
||||
{#if errors.nozzleTemp}<span class="err">{errors.nozzleTemp}</span>{/if}
|
||||
</label>
|
||||
<label
|
||||
>{m['filament.fields.settingsExtruderTemp']()}
|
||||
>{m['filament.fields.settingsBedTemp']()}
|
||||
<NumberInput
|
||||
bind:value={nf.bedTemp}
|
||||
min={0}
|
||||
@@ -622,6 +772,10 @@
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Outside the advanced block: a custom field only exists because
|
||||
someone defined it, so it isn't an advanced detail to them. The
|
||||
section renders nothing when no filament fields are defined. -->
|
||||
<ExtraFieldsSection entity="filament" extra={filamentExtra} onchange={setFilamentExtra} />
|
||||
</div>
|
||||
<div class="sec-divider"></div>
|
||||
{:else if chosen}
|
||||
@@ -634,8 +788,21 @@
|
||||
>{serverInfo.externalDbName}</span
|
||||
>{/if}
|
||||
</div>
|
||||
<div class="cs">{cVendor(chosen)} · {cMaterial(chosen)}</div>
|
||||
<!-- Weight closes the loop on the search rows: it confirms which of several
|
||||
same-named sizes was picked. This is the filament's full-spool weight, so
|
||||
it stays put even if the Weight field below is edited for this spool. -->
|
||||
<div class="cs">
|
||||
{cVendor(chosen)} · {cMaterial(chosen)}{cWeight(chosen)
|
||||
? ' · ' + weightAuto(cWeight(chosen))
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Found the right product but the wrong colour? Branch off it here
|
||||
rather than backing out and filling a blank form. -->
|
||||
{#if chosen.source === 'catalog'}
|
||||
{@const src = chosen.filament}
|
||||
<button class="change" onclick={() => startDuplicate(src)}>{m['add.duplicate']()}</button>
|
||||
{/if}
|
||||
<button class="change" onclick={() => (step = 1)}>{m['add.change']()}</button>
|
||||
</div>
|
||||
{#if chosen.source === 'external'}
|
||||
@@ -695,9 +862,25 @@
|
||||
invalid={!!errors.spoolWeight}
|
||||
/>
|
||||
{#if openHelp === 'spoolWeight'}
|
||||
<span class="help-popup" id="spoolWeight-help" role="note"
|
||||
>{m['filament.fieldsHelp.spoolWeight']()}</span
|
||||
>
|
||||
<span class="help-popup" id="spoolWeight-help" role="note">
|
||||
{m['filament.fieldsHelp.spoolWeight']()}
|
||||
<!-- Buttons nested in the <label>: a click on interactive content is
|
||||
not forwarded to the labelled input, so picking a preset doesn't
|
||||
also yank focus into the weight field. -->
|
||||
<span class="presets">
|
||||
<span class="presets-lead">{m['add.spoolWeightPresetsLead']()}</span>
|
||||
{#each SPOOL_WEIGHT_PRESETS as preset (preset.weight)}
|
||||
<button
|
||||
type="button"
|
||||
class="preset"
|
||||
onclick={() => {
|
||||
spoolWeight = String(preset.weight);
|
||||
openHelp = null;
|
||||
}}>{preset.label()}</button
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if errors.spoolWeight}<span class="err">{errors.spoolWeight}</span>{/if}
|
||||
</label>
|
||||
@@ -707,14 +890,19 @@
|
||||
{#if errors.price}<span class="err">{errors.price}</span>{/if}
|
||||
</label>
|
||||
<label>{m['spool.fields.lotNr']()}<input class="mono" bind:value={lot} placeholder="—" /></label>
|
||||
<label class="wide"
|
||||
>{m['spool.fields.location']()}<Combobox
|
||||
<label class="wide">
|
||||
{m['spool.fields.location']()}
|
||||
<Combobox
|
||||
value={location}
|
||||
options={locations}
|
||||
placeholder={m['add.locationPlaceholder']()}
|
||||
oninput={(v) => (location = v)}
|
||||
/></label
|
||||
>
|
||||
/>
|
||||
<!-- Always-on rather than behind the ⓘ used elsewhere: "Location" reads as
|
||||
metadata until you're told it means the physical shelf, and testers
|
||||
didn't open a popup to find that out. -->
|
||||
<span class="hint">{m['add.locationHint']()}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="fill">
|
||||
@@ -929,6 +1117,13 @@
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.res-weight {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text);
|
||||
}
|
||||
.tag {
|
||||
font-size: 10.5px;
|
||||
padding: 1px 7px;
|
||||
@@ -1036,6 +1231,11 @@
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dup-note {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-faint);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.sec-divider {
|
||||
height: 1px;
|
||||
background: var(--border-soft);
|
||||
@@ -1153,6 +1353,30 @@
|
||||
.hint.accent {
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
.presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.presets-lead {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.preset {
|
||||
border: 1px solid var(--border-strong);
|
||||
background: none;
|
||||
border-radius: 999px;
|
||||
padding: 3px 10px;
|
||||
color: var(--accent-link);
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.preset:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-wash-soft);
|
||||
}
|
||||
.form textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-strong);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
Generic link component: `href` arrives already resolved against the deploy base
|
||||
path (the params.ts helpers that produce it call resolve()), so resolving it
|
||||
again here would double-apply the base path. */
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
// Inspector breadcrumb trail. The last item is the current entity (rendered
|
||||
// static); earlier items with an `href` are navigable links. A `muted` crumb
|
||||
@@ -47,8 +51,13 @@
|
||||
font: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
/* Underlined on hover, not just recoloured: a crumb is a short piece of dim
|
||||
text in a corner, and colour alone left people reading it as a caption rather
|
||||
than as the way to the manufacturer (#989). */
|
||||
.crumb:hover {
|
||||
color: var(--accent-link-hover);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.current {
|
||||
color: var(--text-2);
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
Generic link component: `href` arrives already resolved against the deploy base
|
||||
path (the params.ts helpers that produce it call resolve()), so resolving it
|
||||
again here would double-apply the base path. */
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'outline' | 'ghost';
|
||||
variant?: 'primary' | 'outline' | 'ghost' | 'danger' | 'danger-ghost';
|
||||
type?: 'button' | 'submit';
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
/** When set the button navigates: it renders as a real `<a href>` link
|
||||
* (middle-click / copy-link work) instead of a `<button>`. */
|
||||
href?: string;
|
||||
title?: string;
|
||||
/** Accessible name. Required when the content is an icon with no text. */
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
children: Snippet;
|
||||
}
|
||||
@@ -19,17 +25,18 @@
|
||||
onclick,
|
||||
href,
|
||||
title,
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
children
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a class="btn {variant}" class:disabled {href} {title} {onclick}>
|
||||
<a class="btn {variant}" class:disabled {href} {title} aria-label={ariaLabel} {onclick}>
|
||||
{@render children()}
|
||||
</a>
|
||||
{:else}
|
||||
<button class="btn {variant}" {type} {onclick} {title} {disabled}>
|
||||
<button class="btn {variant}" {type} {onclick} {title} aria-label={ariaLabel} {disabled}>
|
||||
{@render children()}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -53,12 +60,12 @@
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--accent);
|
||||
background: var(--accent-fill);
|
||||
color: #fff;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
background: var(--accent-fill-hover);
|
||||
}
|
||||
|
||||
.outline {
|
||||
@@ -73,14 +80,37 @@
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
/* Occasional actions that sit apart from the one button a panel is really for
|
||||
(duplicate, delete). They recede until you reach for them, and only take on
|
||||
weight — or colour — under the cursor. */
|
||||
.ghost,
|
||||
.danger-ghost {
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
padding: 6px 10px;
|
||||
color: var(--text-dim);
|
||||
padding: 7px 9px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ghost:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.danger-ghost:hover {
|
||||
color: var(--danger);
|
||||
background: var(--danger-wash);
|
||||
}
|
||||
|
||||
/* Confirming a delete. Filled rather than outlined so the irreversible action
|
||||
never looks like the quiet way out of a dialog. */
|
||||
.danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.danger:hover {
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
.danger:disabled:hover {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.btn:disabled,
|
||||
@@ -89,7 +119,7 @@
|
||||
opacity: 0.45;
|
||||
}
|
||||
.primary:disabled:hover {
|
||||
background: var(--accent);
|
||||
background: var(--accent-fill);
|
||||
}
|
||||
.outline:disabled:hover {
|
||||
border-color: var(--border-strong);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { portal } from '$lib/actions/portal';
|
||||
import { getFieldLabelId } from './fieldLabel';
|
||||
|
||||
interface Props {
|
||||
value: string | number;
|
||||
@@ -13,6 +14,8 @@
|
||||
underline?: boolean;
|
||||
/** Fired on every change — typing or picking an option. */
|
||||
oninput?: (value: string) => void;
|
||||
/** Accessible name, for use outside a <Field> or <label> that supplies one. */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -23,9 +26,12 @@
|
||||
invalid = false,
|
||||
disabled = false,
|
||||
underline = false,
|
||||
oninput
|
||||
oninput,
|
||||
ariaLabel
|
||||
}: Props = $props();
|
||||
|
||||
// Named by the enclosing <Field>'s label cell when there is one; see fieldLabel.ts.
|
||||
const fieldLabelId = getFieldLabelId();
|
||||
const listId = $props.id();
|
||||
let open = $state(false);
|
||||
let highlight = $state(-1);
|
||||
@@ -113,7 +119,11 @@
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Escape') {
|
||||
// Swallow it: an enclosing dialog (AddSpoolModal) closes on any Escape
|
||||
// reaching the window, so without this, dismissing the dropdown would
|
||||
// also discard the whole form.
|
||||
close();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +158,8 @@
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : fieldLabelId}
|
||||
aria-autocomplete="list"
|
||||
autocomplete="off"
|
||||
onfocus={openList}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
// A small modal for destructive actions. Its job is the body text: the caller
|
||||
// spells out what is about to happen (what survives, what doesn't) instead of
|
||||
// asking a bare "are you sure?".
|
||||
//
|
||||
// Leaving `onconfirm` unset turns it into an explain-only dialog — used when the
|
||||
// action isn't possible at all and the user deserves the reason rather than an
|
||||
// error toast after the fact.
|
||||
import Button from './Button.svelte';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
/** The consequence, spelled out — one paragraph per entry. */
|
||||
lines: string[];
|
||||
/** Label for the destructive button; omit it (with `onconfirm`) to explain only. */
|
||||
confirmLabel?: string;
|
||||
onconfirm?: () => void;
|
||||
onclose: () => void;
|
||||
/** Disables the buttons while the request is in flight. */
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
let { open, title, lines, confirmLabel, onconfirm, onclose, busy = false }: Props = $props();
|
||||
|
||||
let dialog = $state<HTMLDivElement | null>(null);
|
||||
// Captured before the dialog takes focus and restored when it closes, so
|
||||
// cancelling puts the user back on the button they pressed. After a successful
|
||||
// delete that button is gone and the focus() is a harmless no-op.
|
||||
let opener: HTMLElement | null = null;
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
opener ??= document.activeElement as HTMLElement | null;
|
||||
dialog?.focus();
|
||||
} else if (opener) {
|
||||
opener.focus();
|
||||
opener = null;
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
if (!busy) onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(e) => {
|
||||
if (open && e.key === 'Escape') close();
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if open}
|
||||
<div class="overlay">
|
||||
<!-- Click-outside catcher: a sibling of the dialog (not a parent) so it doesn't
|
||||
nest interactive controls inside an interactive element. Keyboard close is
|
||||
handled by the window Escape listener above. -->
|
||||
<button class="backdrop" tabindex="-1" aria-hidden="true" onclick={close}></button>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
tabindex="-1"
|
||||
bind:this={dialog}
|
||||
>
|
||||
<div class="head">
|
||||
<span class="title" id="confirm-dialog-title">{title}</span>
|
||||
<button class="x" onclick={close} aria-label={m['buttons.close']()}><X size={16} /></button>
|
||||
</div>
|
||||
<div class="body">
|
||||
{#each lines as line (line)}
|
||||
<p>{line}</p>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="foot">
|
||||
{#if onconfirm}
|
||||
<Button variant="outline" disabled={busy} onclick={close}>{m['buttons.cancel']()}</Button>
|
||||
<Button variant="danger" disabled={busy} onclick={onconfirm}>
|
||||
{confirmLabel ?? m['buttons.delete']()}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="outline" onclick={close}>{m['buttons.close']()}</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 12vh 16px 16px;
|
||||
}
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
.dialog {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 420px;
|
||||
max-width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 20px 0;
|
||||
}
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
.x {
|
||||
margin-left: auto;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
}
|
||||
.x:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.body {
|
||||
padding: 12px 20px 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.body p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.body p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 20px 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -248,7 +248,12 @@
|
||||
if (root && !root.contains(t) && pop && !pop.contains(t)) close();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close();
|
||||
if (e.key !== 'Escape') return;
|
||||
// Same reason as Combobox: this listener sits on document, so stopping
|
||||
// propagation here keeps an enclosing dialog's window-level Escape
|
||||
// handler from closing the whole form along with the popover.
|
||||
close();
|
||||
e.stopPropagation();
|
||||
};
|
||||
const onScroll = () => close();
|
||||
document.addEventListener('pointerdown', onDocPointer, true);
|
||||
@@ -466,7 +471,7 @@
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
.day.selected {
|
||||
background: var(--accent);
|
||||
background: var(--accent-fill);
|
||||
color: #fff;
|
||||
}
|
||||
.time-row {
|
||||
@@ -516,7 +521,7 @@
|
||||
}
|
||||
.done {
|
||||
margin-left: auto;
|
||||
background: var(--accent);
|
||||
background: var(--accent-fill);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
@@ -527,6 +532,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.done:hover {
|
||||
background: var(--accent-hover);
|
||||
background: var(--accent-fill-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +1,64 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
The href below is an external absolute URL out of the user's own field value;
|
||||
there is no deploy base path to resolve it against. */
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||
import { getFieldLabelId } from './fieldLabel';
|
||||
import { extractUrls } from '$lib/utils/links';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Props {
|
||||
value: string | number;
|
||||
placeholder?: string;
|
||||
mono?: boolean;
|
||||
oninput?: (value: string) => void;
|
||||
/** Accessible name, for use outside a <Field> that supplies one. */
|
||||
ariaLabel?: string;
|
||||
/**
|
||||
* Offer any URLs in the value as links beside the input (#992). The value
|
||||
* stays editable text — an inline anchor can't also be a caret target — so
|
||||
* the links sit next to the field rather than replacing it.
|
||||
*/
|
||||
linkify?: boolean;
|
||||
}
|
||||
|
||||
let { value, placeholder = '—', mono = false, oninput }: Props = $props();
|
||||
let { value, placeholder = '—', mono = false, oninput, ariaLabel, linkify = false }: Props = $props();
|
||||
|
||||
// Named by the enclosing <Field>'s label cell when there is one; see fieldLabel.ts.
|
||||
const labelId = getFieldLabelId();
|
||||
|
||||
let urls = $derived(linkify ? extractUrls(String(value ?? '')) : []);
|
||||
</script>
|
||||
|
||||
<input class="edit" class:mono {value} {placeholder} oninput={(e) => oninput?.(e.currentTarget.value)} />
|
||||
<span class="row">
|
||||
<input
|
||||
class="edit"
|
||||
class:mono
|
||||
{value}
|
||||
{placeholder}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : labelId}
|
||||
oninput={(e) => oninput?.(e.currentTarget.value)}
|
||||
/>
|
||||
{#each urls as url (url)}
|
||||
<a
|
||||
class="open"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="nofollow noopener"
|
||||
title={url}
|
||||
aria-label={m['inspector.openLink']({ url })}><ExternalLink size={13} /></a
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
.edit {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -20,8 +67,33 @@
|
||||
font-size: 12.5px;
|
||||
padding: 2px 0;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.edit:focus {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.open {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
color: var(--accent-link);
|
||||
}
|
||||
.open::before {
|
||||
/* Roomy tap target on touch, laid over the icon so it doesn't grow the row
|
||||
— the same trick the ⓘ help toggle in Field.svelte uses. Kept narrower
|
||||
than it is tall so that when a value holds several URLs, neighbouring
|
||||
targets never reach across another icon's glyph. */
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 24px;
|
||||
height: 32px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.open:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,8 @@
|
||||
import { FieldType, NUMERIC_FIELD_TYPES, type FieldDef } from '$lib/api/fields';
|
||||
import Toggle from './Toggle.svelte';
|
||||
import DateTimeField from './DateTimeField.svelte';
|
||||
import EditableField from './EditableField.svelte';
|
||||
import LinkedText from './LinkedText.svelte';
|
||||
import { formatDateTime } from '$lib/utils/datetime';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
@@ -76,13 +78,15 @@
|
||||
<span>{parsed === true ? m.yes() : m.no()}</span>
|
||||
{:else if field.field_type === FieldType.choice && field.multi_choice}
|
||||
<span>{selected.length ? selected.join(', ') : '—'}</span>
|
||||
{:else if field.field_type === FieldType.text}
|
||||
<LinkedText text={parsed !== undefined && parsed !== '' ? withUnit(String(parsed)) : ''} />
|
||||
{:else}
|
||||
<span class:mono={NUMERIC_FIELD_TYPES.has(field.field_type)}
|
||||
>{parsed !== undefined && parsed !== '' ? withUnit(String(parsed)) : '—'}</span
|
||||
>
|
||||
{/if}
|
||||
{:else if field.field_type === FieldType.text}
|
||||
<input class="edit" value={parsed ?? ''} oninput={(e) => onText(e.currentTarget.value)} />
|
||||
<EditableField value={parsed ?? ''} placeholder="" ariaLabel={field.name} linkify oninput={onText} />
|
||||
{:else if field.field_type === FieldType.integer}
|
||||
<span class="numrow">
|
||||
<input
|
||||
@@ -90,6 +94,7 @@
|
||||
type="number"
|
||||
step="1"
|
||||
value={parsed ?? ''}
|
||||
aria-label={field.name}
|
||||
oninput={(e) => onInt(e.currentTarget.value)}
|
||||
/>
|
||||
{#if field.unit}<span class="unit">{field.unit}</span>{/if}
|
||||
@@ -101,6 +106,7 @@
|
||||
type="number"
|
||||
step="any"
|
||||
value={parsed ?? ''}
|
||||
aria-label={field.name}
|
||||
oninput={(e) => onFloat(e.currentTarget.value)}
|
||||
/>
|
||||
{#if field.unit}<span class="unit">{field.unit}</span>{/if}
|
||||
@@ -113,6 +119,7 @@
|
||||
type="number"
|
||||
step={isInt ? '1' : 'any'}
|
||||
value={lo}
|
||||
aria-label={`${field.name} ${m['settings.extraFields.min']()}`}
|
||||
oninput={(e) => onRange(0, e.currentTarget.value, isInt)}
|
||||
placeholder={m['settings.extraFields.min']()}
|
||||
/>
|
||||
@@ -122,6 +129,7 @@
|
||||
type="number"
|
||||
step={isInt ? '1' : 'any'}
|
||||
value={hi}
|
||||
aria-label={`${field.name} ${m['settings.extraFields.max']()}`}
|
||||
oninput={(e) => onRange(1, e.currentTarget.value, isInt)}
|
||||
placeholder={m['settings.extraFields.max']()}
|
||||
/>
|
||||
@@ -130,13 +138,14 @@
|
||||
{:else if field.field_type === FieldType.datetime}
|
||||
<DateTimeField value={dtValue} oninput={onDateTime} />
|
||||
{:else if field.field_type === FieldType.boolean}
|
||||
<Toggle
|
||||
checked={parsed === true}
|
||||
onchange={(v) => emit(v)}
|
||||
ariaLabel={m['settings.extraFields.fieldType.boolean']()}
|
||||
/>
|
||||
<Toggle checked={parsed === true} onchange={(v) => emit(v)} ariaLabel={field.name} />
|
||||
{:else if field.field_type === FieldType.choice && !field.multi_choice}
|
||||
<select class="sel" value={parsed ?? ''} onchange={(e) => emit(e.currentTarget.value || undefined)}>
|
||||
<select
|
||||
class="sel"
|
||||
value={parsed ?? ''}
|
||||
aria-label={field.name}
|
||||
onchange={(e) => emit(e.currentTarget.value || undefined)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each field.choices ?? [] as choice (choice)}
|
||||
<option value={choice}>{choice}</option>
|
||||
|
||||
@@ -5,35 +5,87 @@
|
||||
import SectionLabel from './SectionLabel.svelte';
|
||||
import FieldGrid from './FieldGrid.svelte';
|
||||
import Field from './Field.svelte';
|
||||
import { extraFieldsHref } from '$lib/settings/params';
|
||||
import type { EntityType } from '$lib/api/fields';
|
||||
import type { Extra } from '$lib/types';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
interface Props {
|
||||
entity: EntityType;
|
||||
extra: Extra;
|
||||
onchange: (key: string, json: string | undefined) => void;
|
||||
readonly?: boolean;
|
||||
/**
|
||||
* Show the section even when this entity has no extra fields, with a link to
|
||||
* Settings for defining some. On for the detail views, which are where the
|
||||
* feature is worth discovering; off in forms and in read-only mirrors of
|
||||
* another entity, where an empty section is just noise.
|
||||
*/
|
||||
manage?: boolean;
|
||||
/**
|
||||
* Emit only the field rows — no section header, no grid of its own — so the
|
||||
* caller can drop them straight into its own <FieldGrid>. Used by the
|
||||
* read-only mirrors of another entity, where a second "Extra fields" heading
|
||||
* in the same panel reads as a peer section rather than as more of the
|
||||
* entity above it (#992).
|
||||
*/
|
||||
headless?: boolean;
|
||||
}
|
||||
let { entity, extra, onchange, readonly = false }: Props = $props();
|
||||
let { entity, extra, onchange, readonly = false, manage = false, headless = false }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
fields.ensure(entity);
|
||||
});
|
||||
let defs = $derived(fields.get(entity));
|
||||
// An empty list is only worth announcing once we know it IS the answer, rather
|
||||
// than the definitions not having arrived yet.
|
||||
let show = $derived(defs.length > 0 || (manage && fields.isLoaded(entity)));
|
||||
</script>
|
||||
|
||||
{#if defs.length}
|
||||
<SectionLabel>{m['settings.extraFields.tab']()}</SectionLabel>
|
||||
<FieldGrid>
|
||||
{#each defs as f (f.key)}
|
||||
<Field label={f.name}>
|
||||
<ExtraFieldInput
|
||||
field={f}
|
||||
value={extra[f.key]}
|
||||
onchange={(json) => onchange(f.key, json)}
|
||||
{readonly}
|
||||
/>
|
||||
</Field>
|
||||
{/each}
|
||||
</FieldGrid>
|
||||
{#snippet manageLink()}
|
||||
<!-- extraFieldsHref() already resolves against the deploy base path; resolving
|
||||
it again here would double-apply the base. -->
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a class="manage" href={extraFieldsHref(entity)}
|
||||
>{m['inspector.manageExtraFields']()} <ArrowRight size={13} /></a
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
{#snippet rows()}
|
||||
{#each defs as f (f.key)}
|
||||
<Field label={f.name}>
|
||||
<ExtraFieldInput field={f} value={extra[f.key]} onchange={(json) => onchange(f.key, json)} {readonly} />
|
||||
</Field>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
{#if headless}
|
||||
{@render rows()}
|
||||
{:else if show}
|
||||
<SectionLabel right={manage ? manageLink : undefined}>{m['settings.extraFields.tab']()}</SectionLabel>
|
||||
{#if defs.length}
|
||||
<FieldGrid>
|
||||
{@render rows()}
|
||||
</FieldGrid>
|
||||
{:else}
|
||||
<div class="none">{m['inspector.noExtraFields']()}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.manage {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--accent-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
.manage:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.none {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { setFieldLabelId } from './fieldLabel';
|
||||
|
||||
// One label/value row inside a <FieldGrid>. Svelte components render without a
|
||||
// wrapper element, so the label and value below become direct grid cells.
|
||||
// `mono` renders the value in the monospace font. `help` adds an ⓘ toggle next
|
||||
// to the label that reveals the explanation under the value, matching the help
|
||||
// toggles in the add-spool form.
|
||||
//
|
||||
// The label cell and the value cell are siblings, so a control in the value cell
|
||||
// gets no implicit label from being nested. Publishing the label's id lets those
|
||||
// controls name themselves — see fieldLabel.ts.
|
||||
let {
|
||||
label,
|
||||
mono = false,
|
||||
@@ -15,11 +20,18 @@
|
||||
}: { label: string; mono?: boolean; help?: string; children: Snippet } = $props();
|
||||
|
||||
let helpOpen = $state(false);
|
||||
const helpId = $props.id();
|
||||
// One $props.id() per component is all Svelte allows, so suffix it for the two
|
||||
// elements that need to be referenced.
|
||||
const uid = $props.id();
|
||||
const helpId = `${uid}-help`;
|
||||
const labelId = `${uid}-label`;
|
||||
setFieldLabelId(labelId);
|
||||
</script>
|
||||
|
||||
<!-- The id sits on an inner span holding only the label text: the ⓘ toggle is a
|
||||
sibling, so referencing it would fold "Help" into every control's name. -->
|
||||
<span class="k" class:top={helpOpen}
|
||||
>{label}{#if help}<button
|
||||
><span id={labelId}>{label}</span>{#if help}<button
|
||||
type="button"
|
||||
class="help-toggle"
|
||||
aria-label={m['help.help']()}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
The hrefs below are external absolute URLs out of the user's own field values;
|
||||
there is no deploy base path to resolve them against. */
|
||||
import { splitLinks } from '$lib/utils/links';
|
||||
|
||||
// Read-only text value with any URLs in it rendered as real links (#992).
|
||||
// Use this wherever a free-text field is *shown* rather than edited; the
|
||||
// editable inputs get the same links as an affordance beside them instead.
|
||||
let { text, placeholder = '—' }: { text: string; placeholder?: string } = $props();
|
||||
|
||||
let segments = $derived(splitLinks(text ?? ''));
|
||||
</script>
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
{#if !segments.length}<span class="empty">{placeholder}</span>{:else}{#each segments as seg, i (i)}{#if seg.href}<a class="link" href={seg.href} target="_blank" rel="nofollow noopener">{seg.text}</a>{:else}{seg.text}{/if}{/each}{/if}
|
||||
|
||||
<style>
|
||||
.empty {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.link {
|
||||
color: var(--accent-link);
|
||||
text-decoration: none;
|
||||
/* Long order URLs must not push the field grid wider than its column. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
const tabs = [
|
||||
{ href: '/', label: m['nav.library'] },
|
||||
{ href: '/locations', label: m['locations.locations'] },
|
||||
{ href: '/dashboard', label: m['dashboard.dashboard'] },
|
||||
{ href: '/labels', label: m['nav.labels'] },
|
||||
{ href: '/settings', label: m['settings.header'] }
|
||||
] satisfies { href: Pathname; label: () => string }[];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { getFieldLabelId } from './fieldLabel';
|
||||
// A themed numeric input. Native spinners are hidden app-wide (see app.css);
|
||||
// this component supplies its own up/down steppers styled to match the dark UI.
|
||||
//
|
||||
@@ -31,6 +32,8 @@
|
||||
/** Commit-mode only: called instead of `onchange` when the field is left empty,
|
||||
for fields where blank means "unset" (e.g. a price that falls back to a default). */
|
||||
onclear?: () => void;
|
||||
/** Accessible name, for use outside a <Field> or <label> that supplies one. */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
let {
|
||||
value = $bindable(),
|
||||
@@ -45,9 +48,13 @@
|
||||
dense = false,
|
||||
disabled = false,
|
||||
onchange,
|
||||
onclear
|
||||
onclear,
|
||||
ariaLabel
|
||||
}: Props = $props();
|
||||
|
||||
// Named by the enclosing <Field>'s label cell when there is one; see fieldLabel.ts.
|
||||
const labelId = getFieldLabelId();
|
||||
|
||||
// Commit mode keeps a local draft so keystrokes don't fire onchange; the controlled
|
||||
// `value` re-seeds it whenever the parent changes it (e.g. a new element is selected).
|
||||
let draft = $state('');
|
||||
@@ -98,6 +105,8 @@
|
||||
{step}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : labelId}
|
||||
inputmode="decimal"
|
||||
/>
|
||||
{#if unit}<span class="unit">{unit}</span>{/if}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
Generic link component: `href` arrives already resolved against the deploy base
|
||||
path (the params.ts helpers that produce it call resolve()), so resolving it
|
||||
again here would double-apply the base path. */
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
height: 44px;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
background: var(--accent-fill);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
|
||||
// Ties a <Field>'s label cell to the control(s) rendered in its value cell.
|
||||
//
|
||||
// FieldGrid is a two-column CSS grid: <Field> emits the label as one cell and the
|
||||
// control as a sibling cell, so the two are never nested and the control gets no
|
||||
// implicit label the way AddSpoolModal's <label>-wrapped inputs do. Without help
|
||||
// a screen reader announces every inspector field as an unnamed edit box.
|
||||
//
|
||||
// <Field> publishes the id of its label text here; the generic controls that can
|
||||
// sit in a value cell (EditableField, NumberInput, Combobox) read it and point
|
||||
// aria-labelledby at it. Outside a <Field> the context is undefined and they fall
|
||||
// back to whatever labelling the call site provides, so nothing changes for the
|
||||
// controls used in AddSpoolModal.
|
||||
//
|
||||
// aria-labelledby rather than <label for>: a value cell may hold several controls
|
||||
// (a range's min/max pair) or none that are labelable at all (ColorEditor), and a
|
||||
// reference can be shared or simply left unused, where a `for` would have to pick
|
||||
// exactly one control and would dangle when there isn't one.
|
||||
const FIELD_LABEL_ID = Symbol('field-label-id');
|
||||
|
||||
export function setFieldLabelId(id: string): void {
|
||||
setContext(FIELD_LABEL_ID, id);
|
||||
}
|
||||
|
||||
/** The enclosing <Field>'s label id, or undefined when rendered outside one. */
|
||||
export function getFieldLabelId(): string | undefined {
|
||||
return getContext<string | undefined>(FIELD_LABEL_ID);
|
||||
}
|
||||
@@ -77,11 +77,17 @@
|
||||
return { spool: s, filament, vendor };
|
||||
});
|
||||
|
||||
// Fit the label into the canvas viewport (with sensible min/max zoom).
|
||||
// Fit the label into the canvas viewport (with sensible min/max zoom). The viewport is
|
||||
// at most VIEW_W wide, but on a narrow screen it is only as wide as the stage actually
|
||||
// is — measured, so the preview scales down to fit instead of being clipped by the
|
||||
// stage's edges (there is no zoom control to recover from that). The 2px/mm floor keeps
|
||||
// an absurdly large label legible; `.stage-area` scrolls in that case.
|
||||
const VIEW_W = 500;
|
||||
const VIEW_H = 340;
|
||||
let stageW = $state(0);
|
||||
const viewW = $derived(stageW > 0 ? Math.min(VIEW_W, stageW) : VIEW_W);
|
||||
const pxPerMm = $derived(
|
||||
Math.max(2, Math.min(14, Math.min(VIEW_W / design.label.w, VIEW_H / design.label.h)))
|
||||
Math.max(2, Math.min(14, Math.min(viewW / design.label.w, VIEW_H / design.label.h)))
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
@@ -218,15 +224,19 @@
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
<LabelCanvas
|
||||
{design}
|
||||
binding={previewBinding}
|
||||
baseUrl={settings.baseUrl}
|
||||
{pxPerMm}
|
||||
interactive
|
||||
bind:selectedId
|
||||
onchange={updateElement}
|
||||
/>
|
||||
<!-- Full-width measuring box: its width comes from the layout (never from the canvas
|
||||
inside it), so feeding it back into `pxPerMm` can't loop. -->
|
||||
<div class="stage" bind:clientWidth={stageW}>
|
||||
<LabelCanvas
|
||||
{design}
|
||||
binding={previewBinding}
|
||||
baseUrl={settings.baseUrl}
|
||||
{pxPerMm}
|
||||
interactive
|
||||
bind:selectedId
|
||||
onchange={updateElement}
|
||||
/>
|
||||
</div>
|
||||
<div class="hint">
|
||||
{m['labels.canvasHint']()}
|
||||
{previewBinding ? m['labels.showingReal']() : m['labels.showingTags']()}
|
||||
@@ -323,10 +333,21 @@
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.stage {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.preview-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
/* Shrinks with the stage instead of pushing it wide (which would scroll the label
|
||||
out of view on a phone). */
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
@@ -338,7 +359,8 @@
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
max-width: 260px;
|
||||
flex: 0 1 260px;
|
||||
min-width: 0;
|
||||
}
|
||||
.preview-bar select:focus {
|
||||
outline: none;
|
||||
@@ -354,4 +376,11 @@
|
||||
padding-left: 18px;
|
||||
min-height: 420px;
|
||||
}
|
||||
/* On a phone the stage gives up most of its padding so the label itself gets the width.
|
||||
Must come after the base `.stage-area` rule: a media query adds no specificity. */
|
||||
@media (max-width: 560px) {
|
||||
.stage-area {
|
||||
padding: 14px 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -293,6 +293,11 @@
|
||||
|
||||
const grid = $derived(sheetGrid(layout, design.label));
|
||||
|
||||
// Preview width: PREVIEW_W at most, less if the column is narrower than that.
|
||||
const PREVIEW_W = 260;
|
||||
let previewW = $state(0);
|
||||
const previewFitW = $derived(previewW > 0 ? Math.min(PREVIEW_W, previewW) : PREVIEW_W);
|
||||
|
||||
async function doPrint() {
|
||||
if (bindings.length === 0) return;
|
||||
printing = true;
|
||||
@@ -593,6 +598,7 @@
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
<p class="help">{m['labels.skipHint']()}</p>
|
||||
<label class="chk"
|
||||
><input
|
||||
type="checkbox"
|
||||
@@ -633,12 +639,17 @@
|
||||
<div class="col-head"><span>{m['labels.preview']()}</span></div>
|
||||
{#if bindings.length > 0}
|
||||
<div class="preview-wrap">
|
||||
<LabelCanvas
|
||||
{design}
|
||||
binding={bindings[0]}
|
||||
baseUrl={settings.baseUrl}
|
||||
pxPerMm={Math.min(6, 260 / design.label.w)}
|
||||
/>
|
||||
<!-- Full-width measuring box, as in the designer: the preview is capped at
|
||||
PREVIEW_W but shrinks with the column on a narrow screen rather than
|
||||
spilling out of it. -->
|
||||
<div class="preview-fit" bind:clientWidth={previewW}>
|
||||
<LabelCanvas
|
||||
{design}
|
||||
binding={bindings[0]}
|
||||
baseUrl={settings.baseUrl}
|
||||
pxPerMm={Math.min(6, previewFitW / design.label.w)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted small">
|
||||
{kind === 'filament'
|
||||
@@ -880,6 +891,12 @@
|
||||
background: var(--surface-2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.preview-fit {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
Every href below comes from a src/lib/library/params.ts helper, which already
|
||||
resolves against the deploy base path; resolving again would double-apply it. */
|
||||
import Swatch from '../Swatch.svelte';
|
||||
import ColorEditor from '../ColorEditor.svelte';
|
||||
import Button from '../Button.svelte';
|
||||
import ConfirmDialog from '../ConfirmDialog.svelte';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Copy from '@lucide/svelte/icons/copy';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import Square from '@lucide/svelte/icons/square';
|
||||
import SquareCheck from '@lucide/svelte/icons/square-check';
|
||||
import EditableField from '../EditableField.svelte';
|
||||
@@ -14,6 +20,7 @@
|
||||
import Breadcrumbs from '../Breadcrumbs.svelte';
|
||||
import FieldGrid from '../FieldGrid.svelte';
|
||||
import Field from '../Field.svelte';
|
||||
import VendorSection from './VendorSection.svelte';
|
||||
import type { Filament, Spool } from '$lib/types';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
@@ -22,7 +29,11 @@
|
||||
import { page } from '$app/state';
|
||||
import * as params from '$lib/library/params';
|
||||
import { pct, weightAuto } from '$lib/utils/format';
|
||||
import { usageLabel } from '$lib/utils/library';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { isAbortError } from '$lib/api/http';
|
||||
import { classifyDeleteFailure, planFilamentDelete } from '$lib/library/deletion';
|
||||
import { toasts } from '$lib/stores/toasts.svelte';
|
||||
import { loadMaterials } from '$lib/data/materials';
|
||||
import { live } from '$lib/api/live';
|
||||
import { makeSaver, makeExtraSaver } from '$lib/utils/saver';
|
||||
@@ -97,11 +108,77 @@
|
||||
}
|
||||
|
||||
const extraSaver = makeExtraSaver(
|
||||
(e) => inventory.patchFilament(filament.id, { extra: e }),
|
||||
(p) => trackSave(spoolSource.saveFilament(filament.id, { extra: p })),
|
||||
() => filament.id,
|
||||
(id, e) => inventory.patchFilament(id, { extra: e }),
|
||||
(id, p) => trackSave(spoolSource.saveFilament(id, { extra: p })),
|
||||
() => filament.extra
|
||||
);
|
||||
$effect(() => () => extraSaver.flush());
|
||||
|
||||
// --- delete -------------------------------------------------------------
|
||||
|
||||
// The API refuses to delete a filament that still has spools, and archiving is
|
||||
// no help — it is a flag, not a deletion, so an archived spool holds the
|
||||
// filament down just as hard. The list above hides archived spools by default,
|
||||
// so ask the server for the real total rather than counting what's on screen.
|
||||
// `null` means "not counted yet": the button stays disabled until we know.
|
||||
let spoolCount = $state<number | null>(null);
|
||||
$effect(() => {
|
||||
const id = filamentId;
|
||||
void revision; // recount when a spool is added or removed
|
||||
const ctrl = new AbortController();
|
||||
spoolSource
|
||||
.countSpoolsOfFilament(id, ctrl.signal)
|
||||
.then((n) => (spoolCount = n))
|
||||
.catch((e) => {
|
||||
if (!isAbortError(e, ctrl.signal)) console.error('Failed to count filament spools', e);
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
});
|
||||
let plan = $derived(planFilamentDelete(spoolCount ?? 0));
|
||||
|
||||
let confirmOpen = $state(false);
|
||||
let deleting = $state(false);
|
||||
|
||||
let confirmLines = $derived(
|
||||
plan.allowed
|
||||
? [m['inspector.delete.filamentBody']({ name: filament.name })]
|
||||
: [
|
||||
m['inspector.delete.filamentBlocked']({ count: plan.spools, name: filament.name }),
|
||||
m['inspector.delete.filamentBlockedHint']()
|
||||
]
|
||||
);
|
||||
|
||||
async function remove() {
|
||||
deleting = true;
|
||||
// The edit that is still in flight belongs to a filament that is about to
|
||||
// stop existing — dropping it avoids a PATCH that can only 404.
|
||||
saver.cancel();
|
||||
extraSaver.cancel();
|
||||
try {
|
||||
await spoolSource.deleteFilament(filament.id);
|
||||
toasts.success(m['inspector.delete.filamentDone']());
|
||||
params.clearSelection();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete filament', e);
|
||||
const failure = classifyDeleteFailure(e);
|
||||
if (failure === 'in-use') {
|
||||
// A spool was added between our count and the click.
|
||||
toasts.error(m['inspector.delete.errorSpoolsExist']());
|
||||
spoolCount = null;
|
||||
revision++; // recount, so the dialog tells the truth next time
|
||||
} else if (failure === 'gone') {
|
||||
toasts.error(m['inspector.delete.errorGone']());
|
||||
inventory.removeFilament(filament.id);
|
||||
params.clearSelection();
|
||||
} else {
|
||||
toasts.error(m['inspector.delete.errorUnknown']());
|
||||
}
|
||||
} finally {
|
||||
deleting = false;
|
||||
confirmOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="insp">
|
||||
@@ -133,9 +210,41 @@
|
||||
<Button onclick={() => ui.openAddModal(filament.id)}
|
||||
><Plus size={15} /> {m['inspector.addSpoolsOfThis']()}</Button
|
||||
>
|
||||
<!-- Duplicating and deleting are occasional, so they sit apart as quiet icons
|
||||
and leave the end of the row to the one action this panel is really for.
|
||||
Buying the same filament in another colour is common enough to stay a
|
||||
shortcut here rather than hide behind a menu. Delete stays enabled when
|
||||
spools block it: the dialog then explains why, which a greyed-out button
|
||||
never could. -->
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
title={m['inspector.duplicateFilament']()}
|
||||
ariaLabel={m['inspector.duplicateFilament']()}
|
||||
onclick={() => ui.openDuplicateModal(filament.id)}><Copy size={15} /></Button
|
||||
>
|
||||
<Button
|
||||
variant="danger-ghost"
|
||||
title={m['inspector.delete.filament']()}
|
||||
ariaLabel={m['inspector.delete.filament']()}
|
||||
disabled={spoolCount === null || deleting}
|
||||
onclick={() => (confirmOpen = true)}><Trash2 size={15} /></Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
busy={deleting}
|
||||
title={plan.allowed
|
||||
? m['inspector.delete.filamentTitle']()
|
||||
: m['inspector.delete.filamentBlockedTitle']()}
|
||||
lines={confirmLines}
|
||||
confirmLabel={deleting ? m['inspector.delete.deleting']() : m['buttons.delete']()}
|
||||
onconfirm={plan.allowed ? remove : undefined}
|
||||
onclose={() => (confirmOpen = false)}
|
||||
/>
|
||||
|
||||
<SectionLabel>
|
||||
<span style="padding-left:20px"
|
||||
>{m['inspector.spoolsCount']({ count: spools.length })}{#if spools.length}<span class="total"
|
||||
@@ -164,13 +273,7 @@
|
||||
data-sveltekit-noscroll
|
||||
>
|
||||
<span class="id mono">#{s.id}</span>
|
||||
<span class="state"
|
||||
>{s.archived
|
||||
? m['spool.fields.archived']()
|
||||
: s.unused
|
||||
? m['library.unused']()
|
||||
: m['library.inUse']()}</span
|
||||
>
|
||||
<span class="state">{s.archived ? m['spool.fields.archived']() : usageLabel(s)}</span>
|
||||
<span class="barwrap">
|
||||
<ProgressBar
|
||||
value={pct(s.remaining, s.initial)}
|
||||
@@ -294,13 +397,23 @@
|
||||
oninput={(v) => set({ articleNumber: v })}
|
||||
/>
|
||||
</Field>
|
||||
{#if filament.externalId}
|
||||
<Field label={m['filament.fields.externalId']()} mono>{filament.externalId}</Field>
|
||||
{/if}
|
||||
<Field label={m['filament.fields.registered']()}>{filament.registeredLabel}</Field>
|
||||
<Field label={m['filament.fields.comment']()}>
|
||||
<EditableField value={filament.comment} oninput={(v) => set({ comment: v })} />
|
||||
<EditableField value={filament.comment} linkify oninput={(v) => set({ comment: v })} />
|
||||
</Field>
|
||||
</FieldGrid>
|
||||
|
||||
<ExtraFieldsSection entity="filament" extra={filament.extra} onchange={extraSaver.change} />
|
||||
<ExtraFieldsSection entity="filament" extra={filament.extra} onchange={extraSaver.change} manage />
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<VendorSection
|
||||
{vendor}
|
||||
href={vendor ? params.selectHref(page.url.searchParams, 'vendor', vendor.id) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -338,9 +451,18 @@
|
||||
margin-top: 2px;
|
||||
}
|
||||
.add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
}
|
||||
.sep {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
margin: 4px 0 4px 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
.spools {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -412,12 +534,21 @@
|
||||
color: var(--text-dim);
|
||||
flex: none;
|
||||
}
|
||||
/* Specs on the left, the manufacturer's own fields on the right — same split as
|
||||
the manufacturer view, which puts its filament list in the second column. */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 32px;
|
||||
padding: 4px 20px 24px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
/* Keep the "add spools of this" action reachable on narrow panels by
|
||||
wrapping it onto its own full-width row instead of hiding it. */
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
/* Keep the header actions reachable on narrow panels by wrapping them onto
|
||||
their own full-width row instead of hiding them. */
|
||||
.head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -427,6 +558,7 @@
|
||||
.add {
|
||||
margin-left: 0;
|
||||
flex-basis: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -78,15 +78,35 @@
|
||||
// Events are COALESCED: one revision bump refetches the group page AND every
|
||||
// visible GroupRow (~1 + pageSize requests). A burst — bulk import, a printer
|
||||
// streaming `use` updates, the initial sync — would otherwise fire that whole
|
||||
// fan-out once per event. Debouncing collapses each burst into a single refetch
|
||||
// cycle a beat after it settles.
|
||||
let bumpTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
// fan-out once per event.
|
||||
//
|
||||
// This is a LEADING-edge throttle, not a trailing debounce: the common case is a
|
||||
// single edit by the user in another tab or a printer reporting one spool, and
|
||||
// that must land immediately — waiting out a quiet period first would make every
|
||||
// isolated change feel laggy. So the first event refetches at once and opens a
|
||||
// cooldown; anything arriving during it is folded into one catch-up refetch when
|
||||
// the cooldown ends. A burst therefore costs one fan-out per window instead of
|
||||
// one per event.
|
||||
//
|
||||
// The window is long enough to swallow the sub-second event storms the fan-out is
|
||||
// there to protect against, and short enough that the catch-up still reads as
|
||||
// "live" rather than as a delayed update.
|
||||
const COALESCE_MS = 300;
|
||||
let cooldown: ReturnType<typeof setTimeout> | undefined;
|
||||
let coalesced = false;
|
||||
function bumpRevision() {
|
||||
if (bumpTimer) return; // a bump is already pending; fold this event into it
|
||||
bumpTimer = setTimeout(() => {
|
||||
bumpTimer = undefined;
|
||||
revision++;
|
||||
}, 300);
|
||||
if (cooldown) {
|
||||
coalesced = true; // fold into the refetch that fires when the cooldown ends
|
||||
return;
|
||||
}
|
||||
revision++;
|
||||
cooldown = setTimeout(() => {
|
||||
cooldown = undefined;
|
||||
if (coalesced) {
|
||||
coalesced = false;
|
||||
bumpRevision();
|
||||
}
|
||||
}, COALESCE_MS);
|
||||
}
|
||||
$effect(() => {
|
||||
const offs = (['spool', 'filament', 'vendor'] as const).map((resource) =>
|
||||
@@ -94,7 +114,9 @@
|
||||
);
|
||||
return () => {
|
||||
offs.forEach((off) => off());
|
||||
if (bumpTimer) clearTimeout(bumpTimer);
|
||||
if (cooldown) clearTimeout(cooldown);
|
||||
cooldown = undefined;
|
||||
coalesced = false;
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
Both hrefs come from a src/lib/library/params.ts helper, which already resolves
|
||||
against the deploy base path; resolving again would double-apply it. */
|
||||
import Swatch from '../Swatch.svelte';
|
||||
import MaterialBadge from '../MaterialBadge.svelte';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import Factory from '@lucide/svelte/icons/factory';
|
||||
import type { GroupHeaderInfo } from '$lib/utils/library';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Props {
|
||||
group: GroupHeaderInfo;
|
||||
@@ -9,55 +15,118 @@
|
||||
* its inspector, so the header is a real `<a>`. Absent for groupings with
|
||||
* no entity to open (material/location) — then it's inert. */
|
||||
href?: string;
|
||||
/** Filament groups: link to the manufacturer's inspector. Absent when the
|
||||
* filament has no manufacturer (the name then renders as a muted note). */
|
||||
vendorHref?: string;
|
||||
sticky?: boolean;
|
||||
/** Whether the group's spools are folded away behind this header. */
|
||||
collapsed?: boolean;
|
||||
ontoggle?: () => void;
|
||||
}
|
||||
|
||||
let { group, href, sticky = false }: Props = $props();
|
||||
let { group, href, vendorHref, sticky = false, collapsed = false, ontoggle }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={href ? 'a' : 'div'}
|
||||
class="header"
|
||||
class:sticky
|
||||
class:link={href}
|
||||
{href}
|
||||
data-sveltekit-keepfocus={href ? '' : undefined}
|
||||
data-sveltekit-noscroll={href ? '' : undefined}
|
||||
>
|
||||
<!-- The row is three controls, not one: the body opens the group's entity (where it
|
||||
has one), the manufacturer opens ITS entity, and the disclosure at the trailing
|
||||
edge folds the spools away. They are siblings rather than nested — anchors can't
|
||||
nest, and all three should be real, keyboard-reachable controls.
|
||||
Living in the header — which is sticky — is what keeps the fold reachable while
|
||||
you are scrolled deep inside a long group, which is exactly when you want it.
|
||||
|
||||
The entity link is only the title, but its stretched ::after (see the styles)
|
||||
extends its hit area over the whole row, so the row-wide click target survives
|
||||
the two links being siblings. That is also what makes `.main:hover` mean
|
||||
"anywhere on the row except the other two controls", which is the precision the
|
||||
hover affordance below needs. -->
|
||||
<div class="header" class:sticky class:link={href}>
|
||||
{#if href}
|
||||
<!-- The entity link is an overlay covering the whole row, not a box around the
|
||||
title: it has to be a sibling of the manufacturer link (anchors can't
|
||||
nest), and covering the row keeps the generous click target the header
|
||||
always had. It comes first so `~` can reach the title it labels. -->
|
||||
<a
|
||||
class="main"
|
||||
{href}
|
||||
aria-label={m['library.openGroup']({ name: group.title })}
|
||||
data-sveltekit-keepfocus
|
||||
data-sveltekit-noscroll
|
||||
></a>
|
||||
{/if}
|
||||
{#if group.colors.length}
|
||||
<Swatch colors={group.colors} direction={group.direction} size={24} radius={6} />
|
||||
{/if}
|
||||
<div class="body">
|
||||
<div class="line">
|
||||
<!-- A group header reads as a heading, and headings don't normally lead
|
||||
anywhere — so the link has to say so itself, by taking the underline
|
||||
and colour every link gets on hover. -->
|
||||
<span class="title">{group.title}</span>
|
||||
{#if group.badge}<MaterialBadge label={group.badge} />{/if}
|
||||
</div>
|
||||
{#if group.subtitle}<div class="sub">{group.subtitle}</div>{/if}
|
||||
{#if group.vendorName || group.subtitle}
|
||||
<div class="sub">
|
||||
<!-- The manufacturer is already printed on every filament group, so this
|
||||
is the shortest possible route to one: no menu, no search, no detour
|
||||
through a filament. Its own pill-shaped hover is what separates it
|
||||
from the row-wide link it sits inside. -->
|
||||
{#if group.vendorName && vendorHref}
|
||||
<a
|
||||
class="vendor"
|
||||
href={vendorHref}
|
||||
title={m['library.openManufacturer']({ name: group.vendorName })}
|
||||
data-sveltekit-keepfocus
|
||||
data-sveltekit-noscroll
|
||||
>
|
||||
<Factory size={11} />
|
||||
<span class="vname">{group.vendorName}</span>
|
||||
</a>
|
||||
{:else if group.vendorName}
|
||||
<span class="vendor-none">{group.vendorName}</span>
|
||||
{/if}
|
||||
{#if group.vendorName && group.subtitle}<span class="dot" aria-hidden="true">·</span>{/if}
|
||||
{#if group.subtitle}<span class="stext">{group.subtitle}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="meta">{group.meta}</div>
|
||||
</svelte:element>
|
||||
<!-- The group's two aggregates, stacked to mirror the title/subtitle opposite
|
||||
them: what it holds, and how much of it. The count is also what says the
|
||||
rows below are ALL of this group's spools, not the first few. -->
|
||||
<div class="meta">
|
||||
<span class="weight">{group.meta}</span>
|
||||
<span class="count">{group.count}</span>
|
||||
</div>
|
||||
<button
|
||||
class="disc"
|
||||
type="button"
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={collapsed ? m['library.expandGroup']() : m['library.collapseGroup']()}
|
||||
onclick={ontoggle}
|
||||
>
|
||||
<!-- The glyph turns, not the button: rotating the control itself would swing
|
||||
its box (and hit area) out past the edge of the row. -->
|
||||
<span class="chev" class:open={!collapsed}><ChevronRight size={15} /></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
/* Positioned so the entity link's stretched ::after resolves against the row.
|
||||
Nothing in here may clip overflow, or that hit area would be cut down to
|
||||
whichever box does the clipping (hence the per-span truncation below). */
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: none;
|
||||
padding: 9px 0 9px 14px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
border-left: 2px solid transparent;
|
||||
background: none;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* Only entity-backed headers (filament/vendor) are links; the rest are inert. */
|
||||
.header.link {
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Only entity-backed headers (filament/vendor) lead anywhere, so only they take
|
||||
the row-wide hover; the other two controls keep their own on top of it. */
|
||||
.header.link:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
@@ -75,25 +144,129 @@
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
/* The row-sized hit area itself: transparent and unpainted, it only takes clicks
|
||||
and hovers. Sized by the row rather than by the title so assistive tooling and
|
||||
tap-target audits measure the target people actually press. The manufacturer
|
||||
link and the disclosure lift themselves above it. */
|
||||
.main {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* The swatch is positioned (it layers its colour bands), so it would otherwise
|
||||
paint above the overlay and swallow clicks on that corner of the row. It is
|
||||
decorative here — the row's link is what it belongs to. */
|
||||
.header :global(.swatch) {
|
||||
pointer-events: none;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* Driven from the overlay, so the title reacts to the row being hovered anywhere
|
||||
— but NOT to the two controls stacked above it, which lead somewhere else. */
|
||||
.main:hover ~ .body .title {
|
||||
color: var(--accent-link);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.vname,
|
||||
.stext {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.dot {
|
||||
flex: none;
|
||||
}
|
||||
/* Above the stretched link, or it would never be clickable. The padding makes it
|
||||
a ~28px tap target (over the 24px WCAG 2.2 floor) while the matching negative
|
||||
margin keeps it laid out as if it had none — so the header stays as tight as
|
||||
it was and the name still lines up under the title. */
|
||||
.vendor {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 7px;
|
||||
margin: -7px;
|
||||
border-radius: var(--radius-sm);
|
||||
/* --accent-link is tuned for the lighter surfaces links normally sit on; on
|
||||
the sunken header it only reaches 4.2:1 in the light theme. --accent-soft
|
||||
is the same hue and clears AA on this surface in both themes (4.9 / 7.3). */
|
||||
color: var(--accent-soft);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vendor:hover {
|
||||
background: var(--surface-raised);
|
||||
color: var(--accent-link-hover);
|
||||
}
|
||||
.vendor-none {
|
||||
font-style: italic;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.weight {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.count {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
/* Full-height so the hit target is the whole right edge of the header — far
|
||||
easier to hit on a phone than the glyph, without drawing a heavier control.
|
||||
Above the stretched link for the same reason the manufacturer is. */
|
||||
.disc {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
text-align: right;
|
||||
width: 34px;
|
||||
margin: -9px 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Its own hover, distinct from the row's, so the fold reads as a separate
|
||||
control rather than as part of the link it sits next to. */
|
||||
.disc:hover {
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.chev {
|
||||
display: flex;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.chev.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { tick, untrack } from 'svelte';
|
||||
import GroupHeader from './GroupHeader.svelte';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import SpoolRow from './SpoolRow.svelte';
|
||||
@@ -14,6 +14,7 @@
|
||||
import { isAbortError } from '$lib/api/http';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import { collapsedGroups, groupId } from '$lib/stores/collapsedGroups.svelte';
|
||||
import { weightAuto } from '$lib/utils/format';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
@@ -32,6 +33,25 @@
|
||||
let loading = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
|
||||
// The spool list can be folded away behind its header, per group and remembered
|
||||
// across visits (see stores/collapsedGroups).
|
||||
let id = $derived(groupId(group.field, group.key));
|
||||
let collapsed = $derived(collapsedGroups.has(id));
|
||||
|
||||
let el: HTMLDivElement | undefined = $state();
|
||||
|
||||
async function toggle() {
|
||||
const collapsing = !collapsed;
|
||||
collapsedGroups.toggle(id);
|
||||
if (!collapsing) return;
|
||||
// Folding a long group away removes everything you were looking at, and the
|
||||
// browser then clamps the scroller to the shorter content — leaving you
|
||||
// somewhere else in the library entirely. Pull the group you just collapsed
|
||||
// back into view; `nearest` makes this a no-op when it never left.
|
||||
await tick();
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
// The parent's keyed {#each} recreates this component when the group identity
|
||||
// changes, so the loaded window naturally resets; persisting groups keep it.
|
||||
|
||||
@@ -46,8 +66,15 @@
|
||||
// list has moved on is what makes rapid paging (or navigating away and back)
|
||||
// pile up queries on the server. The effect cleanup cancels this row's request
|
||||
// whenever it is superseded or the row goes away.
|
||||
//
|
||||
// A collapsed group doesn't fetch at all — that is most of the point of
|
||||
// collapsing one on a big library, since the group page's cost is one request
|
||||
// per visible group. Collapsing mid-flight aborts through the cleanup below;
|
||||
// expanding re-runs this effect and the rows arrive then. What was already
|
||||
// loaded is kept, so re-expanding paints immediately and refreshes behind it.
|
||||
let reqId = 0;
|
||||
$effect(() => {
|
||||
if (collapsed) return;
|
||||
const ctrl = new AbortController();
|
||||
void revision; // refetch on live events
|
||||
const want = untrack(() => spools.length) || PAGE;
|
||||
@@ -105,9 +132,13 @@
|
||||
title: group.title,
|
||||
subtitle: group.subtitle,
|
||||
badge: group.badge,
|
||||
// Filament groups always name their manufacturer — as a link where there is
|
||||
// one to link to, and otherwise as a note that there isn't.
|
||||
vendorName: group.field === 'filament' ? (group.vendorName ?? m['add.noManufacturer']()) : undefined,
|
||||
colors: group.colors,
|
||||
direction: group.direction,
|
||||
meta: weightAuto(group.totalRemaining)
|
||||
meta: weightAuto(group.totalRemaining),
|
||||
count: m['library.groupSpools']({ count: group.spoolCount })
|
||||
});
|
||||
|
||||
// A group header links to its entity's inspector only where the group *is* an
|
||||
@@ -119,24 +150,35 @@
|
||||
? params.selectHrefFromState(libraryState, 'vendor', group.key)
|
||||
: undefined
|
||||
);
|
||||
|
||||
// The second destination in a filament header: its manufacturer. This is the
|
||||
// only route to a vendor that doesn't go through a menu, the search box, or an
|
||||
// inspector you had to know to open first (#989).
|
||||
let vendorHref = $derived(
|
||||
group.field === 'filament' && group.vendorId
|
||||
? params.selectHrefFromState(libraryState, 'vendor', group.vendorId)
|
||||
: undefined
|
||||
);
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<GroupHeader group={header} sticky href={headerHref} />
|
||||
{#each inUse as vm (vm.spool.id)}
|
||||
<SpoolRow {vm} {showSwatch} indent={26} context={group.field} />
|
||||
{/each}
|
||||
{#if unused.length === 1}
|
||||
<!-- A lone unused spool gains nothing from a collapsing header — it just
|
||||
adds a click and a row — so render it inline like the used spools. -->
|
||||
<SpoolRow vm={unused[0]} {showSwatch} indent={26} context={group.field} />
|
||||
{:else if unused.length > 1}
|
||||
<UnusedRow {unused} {showSwatch} indent={26} context={group.field} />
|
||||
{/if}
|
||||
{#if moreCount > 0}
|
||||
<button class="more" onclick={loadMore} disabled={loading || loadingMore}
|
||||
><Plus size={13} /> {m['library.showMore']({ count: moreCount })}</button
|
||||
>
|
||||
<div bind:this={el}>
|
||||
<GroupHeader group={header} sticky href={headerHref} {vendorHref} {collapsed} ontoggle={toggle} />
|
||||
{#if !collapsed}
|
||||
{#each inUse as vm (vm.spool.id)}
|
||||
<SpoolRow {vm} {showSwatch} indent={26} context={group.field} />
|
||||
{/each}
|
||||
{#if unused.length === 1}
|
||||
<!-- A lone unused spool gains nothing from a collapsing header — it just
|
||||
adds a click and a row — so render it inline like the used spools. -->
|
||||
<SpoolRow vm={unused[0]} {showSwatch} indent={26} context={group.field} />
|
||||
{:else if unused.length > 1}
|
||||
<UnusedRow {unused} {showSwatch} indent={26} context={group.field} />
|
||||
{/if}
|
||||
{#if moreCount > 0}
|
||||
<button class="more" onclick={loadMore} disabled={loading || loadingMore}
|
||||
><Plus size={13} /> {m['library.showMore']({ count: moreCount })}</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
Every href below comes from a src/lib/library/params.ts helper, which already
|
||||
resolves against the deploy base path; resolving again would double-apply it. */
|
||||
import SearchInput from '../SearchInput.svelte';
|
||||
import Swatch from '../Swatch.svelte';
|
||||
import MaterialBadge from '../MaterialBadge.svelte';
|
||||
@@ -9,6 +12,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { fields } from '$lib/stores/fields.svelte';
|
||||
import { weightAuto } from '$lib/utils/format';
|
||||
import type { EntityKind } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
@@ -97,14 +101,34 @@
|
||||
interface FlatItem {
|
||||
kind: EntityKind;
|
||||
id: string;
|
||||
/** Unique per row. A spool can be both its own result and a pill under a
|
||||
* filament, so kind+id alone would highlight two rows at once. */
|
||||
key: string;
|
||||
}
|
||||
|
||||
let flat = $derived<FlatItem[]>(
|
||||
results
|
||||
? [
|
||||
...results.spools.map((m) => ({ kind: 'spool' as const, id: String(m.entity.id) })),
|
||||
...results.filaments.map((m) => ({ kind: 'filament' as const, id: m.entity.id })),
|
||||
...results.vendors.map((m) => ({ kind: 'vendor' as const, id: m.entity.id }))
|
||||
...results.spools.map((m) => ({
|
||||
kind: 'spool' as const,
|
||||
id: String(m.entity.id),
|
||||
key: `spool:${m.entity.id}`
|
||||
})),
|
||||
// Each filament is followed by its spool pills, so arrowing down walks
|
||||
// from the filament straight into its spools.
|
||||
...results.filaments.flatMap((m) => [
|
||||
{ kind: 'filament' as const, id: m.entity.id, key: `filament:${m.entity.id}` },
|
||||
...m.spools.map((s) => ({
|
||||
kind: 'spool' as const,
|
||||
id: String(s.id),
|
||||
key: `filament:${m.entity.id}/spool:${s.id}`
|
||||
}))
|
||||
]),
|
||||
...results.vendors.map((m) => ({
|
||||
kind: 'vendor' as const,
|
||||
id: m.entity.id,
|
||||
key: `vendor:${m.entity.id}`
|
||||
}))
|
||||
]
|
||||
: []
|
||||
);
|
||||
@@ -191,9 +215,9 @@
|
||||
return matchField in MATCH_LABEL[kind] ? MATCH_LABEL[kind][matchField]() : matchField;
|
||||
}
|
||||
|
||||
// The flat index of an item, so hover/selection highlight lines up with keyboard nav.
|
||||
function indexOf(kind: EntityKind, id: string): number {
|
||||
return flat.findIndex((f) => f.kind === kind && f.id === id);
|
||||
// The flat index of a row, so hover/selection highlight lines up with keyboard nav.
|
||||
function indexOf(key: string): number {
|
||||
return flat.findIndex((f) => f.key === key);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -236,7 +260,7 @@
|
||||
{@const filament = inventory.filamentById(spool.entity.filamentId)}
|
||||
<a
|
||||
class="result"
|
||||
class:active={activeIndex === indexOf('spool', String(spool.entity.id))}
|
||||
class:active={activeIndex === indexOf(`spool:${spool.entity.id}`)}
|
||||
href={searchResultHref(
|
||||
page.url.searchParams,
|
||||
page.url.pathname,
|
||||
@@ -263,7 +287,7 @@
|
||||
{@const vendor = inventory.vendorById(filament.entity.vendorId)}
|
||||
<a
|
||||
class="result"
|
||||
class:active={activeIndex === indexOf('filament', filament.entity.id)}
|
||||
class:active={activeIndex === indexOf(`filament:${filament.entity.id}`)}
|
||||
href={searchResultHref(
|
||||
page.url.searchParams,
|
||||
page.url.pathname,
|
||||
@@ -285,6 +309,44 @@
|
||||
{#if filament.entity.material}<MaterialBadge label={filament.entity.material} />{/if}
|
||||
<span class="match">{matchLabel('filament', filament.matchField)}</span>
|
||||
</a>
|
||||
<!-- Shortcut straight to a spool of this filament (#993). Sibling of the
|
||||
result anchor rather than inside it: an <a> may not nest. -->
|
||||
{#if filament.spools.length}
|
||||
<div class="pills">
|
||||
{#each filament.spools as s (s.id)}
|
||||
<a
|
||||
class="pill"
|
||||
class:active={activeIndex === indexOf(`filament:${filament.entity.id}/spool:${s.id}`)}
|
||||
class:archived={s.archived}
|
||||
href={searchResultHref(page.url.searchParams, page.url.pathname, 'spool', String(s.id))}
|
||||
title={s.location ?? undefined}
|
||||
data-sveltekit-noscroll
|
||||
onclick={onResultClick}
|
||||
>
|
||||
<span class="mono">#{s.id}</span>
|
||||
{#if s.remainingWeight !== undefined}<span class="pill-weight"
|
||||
>{weightAuto(s.remainingWeight)}</span
|
||||
>{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{#if filament.spoolCount > filament.spools.length}
|
||||
<!-- The filament page lists them all, so send the overflow there. -->
|
||||
<a
|
||||
class="pill more"
|
||||
href={searchResultHref(
|
||||
page.url.searchParams,
|
||||
page.url.pathname,
|
||||
'filament',
|
||||
filament.entity.id
|
||||
)}
|
||||
data-sveltekit-noscroll
|
||||
onclick={onResultClick}
|
||||
>
|
||||
{m['search.moreSpools']({ count: filament.spoolCount - filament.spools.length })}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@@ -293,7 +355,7 @@
|
||||
{#each results.vendors as vendor (vendor.entity.id)}
|
||||
<a
|
||||
class="result"
|
||||
class:active={activeIndex === indexOf('vendor', vendor.entity.id)}
|
||||
class:active={activeIndex === indexOf(`vendor:${vendor.entity.id}`)}
|
||||
href={searchResultHref(page.url.searchParams, page.url.pathname, 'vendor', vendor.entity.id)}
|
||||
data-sveltekit-noscroll
|
||||
onclick={onResultClick}
|
||||
@@ -437,4 +499,43 @@
|
||||
text-align: center;
|
||||
flex: none;
|
||||
}
|
||||
/* Indented to line up with the filament's title, so the pills read as belonging
|
||||
to the result above rather than as results of their own. */
|
||||
.pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 0 12px 7px 41px;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill:hover,
|
||||
.pill.active {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.pill .mono {
|
||||
font-size: 11px;
|
||||
}
|
||||
.pill-weight {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.pill.archived {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.pill.more {
|
||||
border-style: dashed;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
Every href below comes from a src/lib/library/params.ts helper, which already
|
||||
resolves against the deploy base path; resolving again would double-apply it. */
|
||||
import Swatch from '../Swatch.svelte';
|
||||
import Button from '../Button.svelte';
|
||||
import ConfirmDialog from '../ConfirmDialog.svelte';
|
||||
import NumberInput from '../NumberInput.svelte';
|
||||
import EditableField from '../EditableField.svelte';
|
||||
import Combobox from '../Combobox.svelte';
|
||||
@@ -9,18 +13,24 @@
|
||||
import Archive from '@lucide/svelte/icons/archive';
|
||||
import ArchiveRestore from '@lucide/svelte/icons/archive-restore';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import DateTimeField from '../DateTimeField.svelte';
|
||||
import SectionLabel from '../SectionLabel.svelte';
|
||||
import ExtraFieldsSection from '../ExtraFieldsSection.svelte';
|
||||
import Breadcrumbs from '../Breadcrumbs.svelte';
|
||||
import FieldGrid from '../FieldGrid.svelte';
|
||||
import Field from '../Field.svelte';
|
||||
import LinkedText from '../LinkedText.svelte';
|
||||
import VendorSection from './VendorSection.svelte';
|
||||
import type { Filament, Spool } from '$lib/types';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import * as params from '$lib/library/params';
|
||||
import { lengthMeters, pct, weightAuto } from '$lib/utils/format';
|
||||
import { usageLabel } from '$lib/utils/library';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { classifyDeleteFailure } from '$lib/library/deletion';
|
||||
import { toasts } from '$lib/stores/toasts.svelte';
|
||||
import { makeSaver, makeExtraSaver } from '$lib/utils/saver';
|
||||
import { trackSave } from '$lib/utils/autosave';
|
||||
import { page } from '$app/state';
|
||||
@@ -174,11 +184,54 @@
|
||||
}
|
||||
|
||||
const extraSaver = makeExtraSaver(
|
||||
(e) => inventory.patchSpool(spool.id, { extra: e }),
|
||||
(p) => trackSave(spoolSource.saveSpool(spool.id, { extra: p })),
|
||||
() => spool.id,
|
||||
(id, e) => inventory.patchSpool(id, { extra: e }),
|
||||
(id, p) => trackSave(spoolSource.saveSpool(id, { extra: p })),
|
||||
() => spool.extra
|
||||
);
|
||||
$effect(() => () => extraSaver.flush());
|
||||
|
||||
// --- delete -------------------------------------------------------------
|
||||
|
||||
// Nothing references a spool, so unlike a filament this can never be refused.
|
||||
// It is also what unblocks deleting a filament, since archiving leaves the
|
||||
// foreign key in place. Warn when there is filament left on it, because at that
|
||||
// point archiving is almost certainly what was meant.
|
||||
let confirmOpen = $state(false);
|
||||
let deleting = $state(false);
|
||||
|
||||
let confirmLines = $derived(
|
||||
spool.remaining > 0
|
||||
? [
|
||||
m['inspector.delete.spoolBody']({ id: spool.id, name: filament.name }),
|
||||
m['inspector.delete.spoolRemaining']({ weight: weightAuto(spool.remaining) })
|
||||
]
|
||||
: [m['inspector.delete.spoolBody']({ id: spool.id, name: filament.name })]
|
||||
);
|
||||
|
||||
async function remove() {
|
||||
deleting = true;
|
||||
// The pending edit belongs to a spool that is about to stop existing.
|
||||
saver.cancel();
|
||||
extraSaver.cancel();
|
||||
try {
|
||||
await spoolSource.deleteSpool(spool.id);
|
||||
toasts.success(m['inspector.delete.spoolDone']());
|
||||
params.clearSelection();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete spool', e);
|
||||
if (classifyDeleteFailure(e) === 'gone') {
|
||||
toasts.error(m['inspector.delete.errorGone']());
|
||||
inventory.removeSpool(spool.id);
|
||||
params.clearSelection();
|
||||
} else {
|
||||
toasts.error(m['inspector.delete.errorUnknown']());
|
||||
}
|
||||
} finally {
|
||||
deleting = false;
|
||||
confirmOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="insp">
|
||||
@@ -205,9 +258,7 @@
|
||||
{#if spool.archived}<span class="arch-badge">{m['spool.fields.archived']()}</span>{/if}
|
||||
</div>
|
||||
<div class="subtitle">
|
||||
{filament.material} · {filament.diameter} mm · {spool.unused
|
||||
? m['library.unused']()
|
||||
: m['library.inUse']()}
|
||||
{filament.material} · {filament.diameter} mm · {usageLabel(spool)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
@@ -229,9 +280,28 @@
|
||||
<span class="btn-label">{m['buttons.unArchive']()}</span>
|
||||
{:else}<Archive size={15} /> <span class="btn-label">{m['buttons.archive']()}</span>{/if}
|
||||
</Button>
|
||||
<!-- Set apart from the things you do to a spool you are keeping. -->
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
<Button
|
||||
variant="danger-ghost"
|
||||
title={m['inspector.delete.spool']()}
|
||||
ariaLabel={m['inspector.delete.spool']()}
|
||||
disabled={deleting}
|
||||
onclick={() => (confirmOpen = true)}><Trash2 size={15} /></Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
busy={deleting}
|
||||
title={m['inspector.delete.spoolTitle']()}
|
||||
lines={confirmLines}
|
||||
confirmLabel={deleting ? m['inspector.delete.deleting']() : m['buttons.delete']()}
|
||||
onconfirm={remove}
|
||||
onclose={() => (confirmOpen = false)}
|
||||
/>
|
||||
|
||||
<div class="gauge">
|
||||
<div class="gauge-line">
|
||||
<span class="big mono">{weightAuto(spool.remaining)}</span>
|
||||
@@ -319,11 +389,11 @@
|
||||
<DateTimeField value={spool.lastUsed} oninput={(iso) => set({ lastUsed: iso })} />
|
||||
</Field>
|
||||
<Field label={m['spool.fields.comment']()}>
|
||||
<EditableField value={spool.comment} oninput={(v) => set({ comment: v })} />
|
||||
<EditableField value={spool.comment} linkify oninput={(v) => set({ comment: v })} />
|
||||
</Field>
|
||||
</FieldGrid>
|
||||
|
||||
<ExtraFieldsSection entity="spool" extra={spool.extra} onchange={extraSaver.change} />
|
||||
<ExtraFieldsSection entity="spool" extra={spool.extra} onchange={extraSaver.change} manage />
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
@@ -357,14 +427,30 @@
|
||||
<Field label={m['filament.fields.weight']()} help={m['filament.fieldsHelp.weight']()} mono
|
||||
>{filament.weight} g</Field
|
||||
>
|
||||
{#if filament.spoolWeight}
|
||||
<Field label={m['filament.fields.spoolWeight']()} help={m['filament.fieldsHelp.spoolWeight']()} mono
|
||||
>{filament.spoolWeight} g</Field
|
||||
>
|
||||
<Field label={m['filament.fields.spoolWeight']()} help={m['filament.fieldsHelp.spoolWeight']()} mono
|
||||
>{filament.spoolWeight != null ? `${filament.spoolWeight} g` : '—'}</Field
|
||||
>
|
||||
<Field label={m['filament.fields.price']()} mono>{settings.formatPrice(filament.price)}</Field>
|
||||
<Field
|
||||
label={m['filament.fields.articleNumber']()}
|
||||
help={m['filament.fieldsHelp.articleNumber']()}
|
||||
mono>{filament.articleNumber || '—'}</Field
|
||||
>
|
||||
{#if filament.externalId}
|
||||
<Field label={m['filament.fields.externalId']()} mono>{filament.externalId}</Field>
|
||||
{/if}
|
||||
<Field label={m['filament.fields.registered']()}>{filament.registeredLabel}</Field>
|
||||
<Field label={m['filament.fields.comment']()}><LinkedText text={filament.comment} /></Field>
|
||||
<!-- The filament's own custom fields, as further rows of this grid: a
|
||||
second "Extra fields" heading here would read as a peer of FILAMENT
|
||||
and MANUFACTURER rather than as more of the filament. -->
|
||||
<ExtraFieldsSection entity="filament" extra={filament.extra} onchange={() => {}} readonly headless />
|
||||
</FieldGrid>
|
||||
|
||||
<ExtraFieldsSection entity="filament" extra={filament.extra} onchange={() => {}} readonly />
|
||||
<VendorSection
|
||||
{vendor}
|
||||
href={vendor ? params.selectHref(page.url.searchParams, 'vendor', vendor.id) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -419,6 +505,12 @@
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
}
|
||||
.sep {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
margin: 4px 0 4px 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.gauge {
|
||||
padding: 18px 20px;
|
||||
@@ -486,8 +578,8 @@
|
||||
color: var(--text);
|
||||
}
|
||||
.mode-btn.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-fill);
|
||||
border-color: var(--accent-fill);
|
||||
color: #fff;
|
||||
}
|
||||
.adjust-row {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
Every href below comes from a src/lib/library/params.ts helper, which already
|
||||
resolves against the deploy base path; resolving again would double-apply it. */
|
||||
import Swatch from '../Swatch.svelte';
|
||||
import ProgressBar from '../ProgressBar.svelte';
|
||||
import { rowIdentity, type RowContext, type SpoolVM } from '$lib/utils/library';
|
||||
|
||||
@@ -34,8 +34,12 @@
|
||||
let first = $derived(unused[0]);
|
||||
// "Y g each" only makes sense when every collapsed spool shares that weight.
|
||||
// When they differ (rare) we drop the weight rather than quote a misleading
|
||||
// one taken from whichever spool happens to sort first.
|
||||
let sameWeight = $derived(unused.every((vm) => vm.spool.initial === first.spool.initial));
|
||||
// one taken from whichever spool happens to sort first. A weight of 0 means
|
||||
// unknown, not empty — mapSpool falls back to 0 when neither the spool nor its
|
||||
// filament carries one — so that reads as "differ" too rather than "0 g each".
|
||||
let sameWeight = $derived(
|
||||
first.spool.initial > 0 && unused.every((vm) => vm.spool.initial === first.spool.initial)
|
||||
);
|
||||
let location = $derived(first.spool.location || m['library.unassigned']());
|
||||
let sub = $derived(
|
||||
sameWeight
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
Every href below comes from a src/lib/library/params.ts helper, which already
|
||||
resolves against the deploy base path; resolving again would double-apply it. */
|
||||
import Swatch from '../Swatch.svelte';
|
||||
import Button from '../Button.svelte';
|
||||
import ConfirmDialog from '../ConfirmDialog.svelte';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import EditableField from '../EditableField.svelte';
|
||||
import NumberInput from '../NumberInput.svelte';
|
||||
import SectionLabel from '../SectionLabel.svelte';
|
||||
@@ -11,6 +17,9 @@
|
||||
import { page } from '$app/state';
|
||||
import * as params from '$lib/library/params';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { live } from '$lib/api/live';
|
||||
import { classifyDeleteFailure, planVendorDelete } from '$lib/library/deletion';
|
||||
import { toasts } from '$lib/stores/toasts.svelte';
|
||||
import { makeSaver, makeExtraSaver } from '$lib/utils/saver';
|
||||
import { trackSave } from '$lib/utils/autosave';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
@@ -22,8 +31,15 @@
|
||||
// re-trigger this effect in an infinite loop.
|
||||
let vendorId = $derived(vendor.id);
|
||||
let filaments = $state<Filament[]>([]);
|
||||
// Bumped by live filament events. The list below is fetched server-side rather
|
||||
// than read from the cache, so it needs an explicit nudge — and the delete
|
||||
// dialog counts these filaments, so a stale count would misstate what a delete
|
||||
// is about to orphan.
|
||||
let revision = $state(0);
|
||||
$effect(() => live.subscribe('filament', {}, () => revision++));
|
||||
$effect(() => {
|
||||
const id = vendorId;
|
||||
void revision;
|
||||
let cancelled = false;
|
||||
spoolSource
|
||||
.listFilamentsByVendor(id)
|
||||
@@ -56,11 +72,56 @@
|
||||
}
|
||||
|
||||
const extraSaver = makeExtraSaver(
|
||||
(e) => inventory.patchVendor(vendor.id, { extra: e }),
|
||||
(p) => trackSave(spoolSource.saveVendor(vendor.id, { extra: p })),
|
||||
() => vendor.id,
|
||||
(id, e) => inventory.patchVendor(id, { extra: e }),
|
||||
(id, p) => trackSave(spoolSource.saveVendor(id, { extra: p })),
|
||||
() => vendor.extra
|
||||
);
|
||||
$effect(() => () => extraSaver.flush());
|
||||
|
||||
// --- delete -------------------------------------------------------------
|
||||
|
||||
// Unlike a filament, a manufacturer can always be deleted — its filaments are
|
||||
// not deleted with it, they are left without a manufacturer. That is easy to
|
||||
// mistake for "delete everything from this brand", so the dialog says it
|
||||
// outright rather than asking a bare "are you sure?".
|
||||
let plan = $derived(planVendorDelete(filaments.length));
|
||||
|
||||
let confirmOpen = $state(false);
|
||||
let deleting = $state(false);
|
||||
|
||||
let confirmLines = $derived(
|
||||
plan.orphaned > 0
|
||||
? [
|
||||
m['inspector.delete.vendorBody']({ name: vendor.name }),
|
||||
m['inspector.delete.vendorOrphans']({ count: plan.orphaned })
|
||||
]
|
||||
: [m['inspector.delete.vendorBody']({ name: vendor.name })]
|
||||
);
|
||||
|
||||
async function remove() {
|
||||
deleting = true;
|
||||
// The pending edit belongs to a vendor that is about to stop existing.
|
||||
saver.cancel();
|
||||
extraSaver.cancel();
|
||||
try {
|
||||
await spoolSource.deleteVendor(vendor.id);
|
||||
toasts.success(m['inspector.delete.vendorDone']());
|
||||
params.clearSelection();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete manufacturer', e);
|
||||
if (classifyDeleteFailure(e) === 'gone') {
|
||||
toasts.error(m['inspector.delete.errorGone']());
|
||||
inventory.removeVendor(vendor.id);
|
||||
params.clearSelection();
|
||||
} else {
|
||||
toasts.error(m['inspector.delete.errorUnknown']());
|
||||
}
|
||||
} finally {
|
||||
deleting = false;
|
||||
confirmOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="insp">
|
||||
@@ -74,8 +135,27 @@
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<Button
|
||||
variant="danger-ghost"
|
||||
title={m['inspector.delete.vendor']()}
|
||||
ariaLabel={m['inspector.delete.vendor']()}
|
||||
disabled={deleting}
|
||||
onclick={() => (confirmOpen = true)}><Trash2 size={15} /></Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
busy={deleting}
|
||||
title={m['inspector.delete.vendorTitle']()}
|
||||
lines={confirmLines}
|
||||
confirmLabel={deleting ? m['inspector.delete.deleting']() : m['buttons.delete']()}
|
||||
onconfirm={remove}
|
||||
onclose={() => (confirmOpen = false)}
|
||||
/>
|
||||
|
||||
<div class="grid">
|
||||
<div class="col">
|
||||
<SectionLabel>{m['filament.fields.vendor']()}</SectionLabel>
|
||||
@@ -94,13 +174,16 @@
|
||||
onchange={(v) => set({ emptyWeight: Math.round(v) })}
|
||||
/>
|
||||
</Field>
|
||||
{#if vendor.externalId}
|
||||
<Field label={m['vendor.fields.externalId']()} mono>{vendor.externalId}</Field>
|
||||
{/if}
|
||||
<Field label={m['vendor.fields.registered']()}>{vendor.registeredLabel}</Field>
|
||||
<Field label={m['vendor.fields.comment']()}>
|
||||
<EditableField value={vendor.comment} oninput={(v) => set({ comment: v })} />
|
||||
<EditableField value={vendor.comment} linkify oninput={(v) => set({ comment: v })} />
|
||||
</Field>
|
||||
</FieldGrid>
|
||||
|
||||
<ExtraFieldsSection entity="vendor" extra={vendor.extra} onchange={extraSaver.change} />
|
||||
<ExtraFieldsSection entity="vendor" extra={vendor.extra} onchange={extraSaver.change} manage />
|
||||
</div>
|
||||
<div class="col">
|
||||
<SectionLabel>{m['filament.filament']()}</SectionLabel>
|
||||
@@ -151,6 +234,10 @@
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.actions {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
`href` comes from a src/lib/library/params.ts helper, which already resolves
|
||||
against the deploy base path; resolving again would double-apply it. */
|
||||
import SectionLabel from '../SectionLabel.svelte';
|
||||
import FieldGrid from '../FieldGrid.svelte';
|
||||
import Field from '../Field.svelte';
|
||||
import LinkedText from '../LinkedText.svelte';
|
||||
import ExtraFieldsSection from '../ExtraFieldsSection.svelte';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import type { Vendor } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// Read-only mirror of a manufacturer's fields, shown inside the filament and
|
||||
// spool detail views. Before #992 those views named the manufacturer and
|
||||
// stopped there, so its empty-spool weight, comment and extra fields were only
|
||||
// reachable by navigating away to the manufacturer itself.
|
||||
interface Props {
|
||||
/** Undefined when the filament has no manufacturer, or it isn't cached yet. */
|
||||
vendor: Vendor | undefined;
|
||||
/** Link to the manufacturer's own detail view. */
|
||||
href?: string;
|
||||
}
|
||||
let { vendor, href }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#snippet open()}
|
||||
<a class="link" {href} data-sveltekit-keepfocus data-sveltekit-noscroll
|
||||
>{m['inspector.openManufacturer']()} <ArrowRight size={13} /></a
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
<SectionLabel right={vendor && href ? open : undefined}>
|
||||
{m['library.section.vendor']()}
|
||||
</SectionLabel>
|
||||
|
||||
{#if vendor}
|
||||
<!-- 140px, as in the manufacturer's own view: "Empty Spool Weight" plus its ⓘ
|
||||
wraps to two lines in the default 120px label column. -->
|
||||
<FieldGrid labelWidth="140px">
|
||||
<Field label={m['vendor.fields.name']()}>{vendor.name}</Field>
|
||||
<Field label={m['vendor.fields.emptySpoolWeight']()} help={m['vendor.fieldsHelp.emptySpoolWeight']()} mono
|
||||
>{vendor.emptyWeight} g</Field
|
||||
>
|
||||
{#if vendor.externalId}
|
||||
<Field label={m['vendor.fields.externalId']()} mono>{vendor.externalId}</Field>
|
||||
{/if}
|
||||
<Field label={m['vendor.fields.registered']()}>{vendor.registeredLabel}</Field>
|
||||
<Field label={m['vendor.fields.comment']()}><LinkedText text={vendor.comment} /></Field>
|
||||
<!-- Rows of this same grid rather than their own headed section — see the
|
||||
`headless` prop's note in ExtraFieldsSection. -->
|
||||
<ExtraFieldsSection entity="vendor" extra={vendor.extra} onchange={() => {}} readonly headless />
|
||||
</FieldGrid>
|
||||
{:else}
|
||||
<div class="none">{m['add.noManufacturer']()}</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11.5px;
|
||||
color: var(--accent-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
.none {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -30,7 +30,13 @@
|
||||
[FieldType.integer_range]: m['settings.extraFields.fieldType.integerRange']
|
||||
};
|
||||
|
||||
let entity = $state<EntityType>('spool');
|
||||
interface Props {
|
||||
/** Which entity's fields to manage — owned by the page, which keeps it in the URL. */
|
||||
entity: EntityType;
|
||||
onentity: (entity: EntityType) => void;
|
||||
}
|
||||
let { entity, onentity }: Props = $props();
|
||||
|
||||
// Human label for the currently-selected entity, for messages/headings.
|
||||
const entityLabel = $derived(
|
||||
ENTITIES.find((e) => e.key === entity)?.label() ?? m['library.section.spool']()
|
||||
@@ -40,6 +46,16 @@
|
||||
});
|
||||
let defs = $derived(fields.get(entity));
|
||||
|
||||
// The editor edits ONE entity's field; switching tabs (or arriving on a
|
||||
// different tab through the URL) leaves it pointing at fields that are no
|
||||
// longer on screen, so put it away rather than let a save land on the wrong
|
||||
// entity.
|
||||
$effect(() => {
|
||||
void entity;
|
||||
editing = false;
|
||||
error = '';
|
||||
});
|
||||
|
||||
// Editor state ----------------------------------------------------------
|
||||
let editing = $state(false);
|
||||
let isNew = $state(false);
|
||||
@@ -199,7 +215,7 @@
|
||||
|
||||
<div class="tabs">
|
||||
{#each ENTITIES as e (e.key)}
|
||||
<button class="tab" class:active={entity === e.key} onclick={() => (entity = e.key)}>{e.label()}</button>
|
||||
<button class="tab" class:active={entity === e.key} onclick={() => onentity(e.key)}>{e.label()}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -546,11 +562,11 @@
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
background: var(--accent-fill);
|
||||
color: #fff;
|
||||
}
|
||||
.btn.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
background: var(--accent-fill-hover);
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.form {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Spool } from '$lib/types';
|
||||
import type { GroupField } from '$lib/api/types';
|
||||
import { FieldType, type FieldDef } from '$lib/api/fields';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// The dashboard groups spools into cards by ONE field, and dragging a spool between
|
||||
// cards writes that field. This module is the list of fields that can play that role,
|
||||
// and everything the board needs to read and write one without special-casing it.
|
||||
//
|
||||
// A field qualifies on two counts. It has to be OWNED BY THE SPOOL, because a drag moves
|
||||
// exactly one spool and must mean exactly one change — a filament-owned field like material
|
||||
// would rewrite every other spool of that filament as a side effect of dragging one. And its
|
||||
// value has to be a short, repeatable string a spool can be *assigned* to, which rules out
|
||||
// numbers, dates, booleans and multi-choice; the backend rejects grouping on those too (see
|
||||
// GROUPABLE_EXTRA_FIELD_TYPES).
|
||||
//
|
||||
// That leaves the built-in location plus any single-value text or choice field the user has
|
||||
// added to spools. Lot number is the near miss: spool-owned and short, but stamped on by the
|
||||
// manufacturer, so dragging a spool into another lot would record something untrue. Both it
|
||||
// and material are better served by a library filter than by a board.
|
||||
|
||||
/** The value a spool has for a field; the empty string is the "unassigned" group. */
|
||||
export type GroupKey = string;
|
||||
|
||||
export interface DashboardField {
|
||||
/** Identity: the API's `group_by` value, and the key this field's layout is saved under. */
|
||||
key: GroupField;
|
||||
label: string;
|
||||
/** This spool's current value, '' when unset. */
|
||||
valueOf(spool: Spool): GroupKey;
|
||||
/** The same spool with `value` applied, for the board's own copies. Pure — no request. */
|
||||
withValue(spool: Spool, value: GroupKey): Spool;
|
||||
/** Write `value` to the spool, updating the cache first. */
|
||||
assign(spool: Spool, value: GroupKey): Promise<void>;
|
||||
/**
|
||||
* Give every spool in `from` the value `to`, in one request. Uniform across fields, so a
|
||||
* group can be renamed whether or not the board has paged its spools in.
|
||||
*/
|
||||
rename(from: GroupKey, to: GroupKey): Promise<void>;
|
||||
/** Fixed set of permitted values (single-choice fields); undefined means free text. */
|
||||
choices?: string[];
|
||||
}
|
||||
|
||||
const LOCATION: DashboardField = {
|
||||
key: 'location',
|
||||
get label() {
|
||||
return m['spool.fields.location']();
|
||||
},
|
||||
valueOf: (s) => s.location,
|
||||
withValue: (s, value) => ({ ...s, location: value }),
|
||||
async assign(spool, value) {
|
||||
inventory.patchSpool(spool.id, { location: value });
|
||||
await spoolSource.saveSpool(spool.id, { location: value });
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
await spoolSource.renameFieldValue('location', from, to);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra-field values are stored JSON-encoded; the group key is the decoded string.
|
||||
* Clearing writes JSON null rather than dropping the key: the API patches the extra map
|
||||
* key by key, so an omitted key would leave the old value in place.
|
||||
*/
|
||||
function extraField(def: FieldDef): DashboardField {
|
||||
const key = `extra.${def.key}` as GroupField;
|
||||
const encode = (value: GroupKey) => (value === '' ? 'null' : JSON.stringify(value));
|
||||
return {
|
||||
key,
|
||||
label: def.name,
|
||||
valueOf(spool) {
|
||||
const raw = spool.extra[def.key];
|
||||
if (raw === undefined) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return typeof parsed === 'string' ? parsed : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
withValue: (spool, value) => ({ ...spool, extra: { ...spool.extra, [def.key]: encode(value) } }),
|
||||
async assign(spool, value) {
|
||||
const extra = { [def.key]: encode(value) };
|
||||
inventory.patchSpool(spool.id, { extra: { ...spool.extra, ...extra } });
|
||||
await spoolSource.saveSpool(spool.id, { extra });
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
await spoolSource.renameFieldValue(key, from, to);
|
||||
},
|
||||
choices: def.field_type === FieldType.choice ? (def.choices ?? []) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/** Spool extra fields that hold one plain string, and so can be grouped and dragged into. */
|
||||
function isGroupable(def: FieldDef): boolean {
|
||||
if (def.field_type === FieldType.text) return true;
|
||||
return def.field_type === FieldType.choice && !def.multi_choice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every field the dashboard can group by, in menu order: location (the default view) first,
|
||||
* then the user's own spool fields. A site with no custom spool fields therefore has exactly
|
||||
* one view — the location board — until it defines one.
|
||||
*/
|
||||
export function dashboardFields(spoolFieldDefs: FieldDef[]): DashboardField[] {
|
||||
return [LOCATION, ...spoolFieldDefs.filter(isGroupable).map(extraField)];
|
||||
}
|
||||
|
||||
export const DEFAULT_FIELD_KEY: GroupField = 'location';
|
||||
@@ -0,0 +1,48 @@
|
||||
import { getSettings, setSetting, parseSetting } from '$lib/api/settings';
|
||||
|
||||
// The dashboard's saved layout: which cards a field's board shows, in what order, and
|
||||
// the order of the spools inside each card. Both are stored per grouped-by field, so
|
||||
// switching the view mode brings back that view's own arrangement.
|
||||
//
|
||||
// A group key is the field's value; the empty string is the "unassigned" card.
|
||||
|
||||
export interface DashboardLayout {
|
||||
/** field key → ordered group keys. */
|
||||
groups: Record<string, string[]>;
|
||||
/** field key → group key → ordered spool ids. */
|
||||
spoolOrders: Record<string, Record<string, number[]>>;
|
||||
}
|
||||
|
||||
/** The field key whose layout the pre-dashboard location board's settings describe. */
|
||||
const SEEDED_FIELD = 'location';
|
||||
|
||||
export async function loadLayout(signal?: AbortSignal): Promise<DashboardLayout> {
|
||||
const s = await getSettings(signal);
|
||||
const groups = parseSetting<Record<string, string[]>>(s.dashboard_groups, {});
|
||||
const spoolOrders = parseSetting<Record<string, Record<string, number[]>>>(s.dashboard_spoolorders, {});
|
||||
|
||||
// Before the dashboard there was a location-only board, whose layout lived in
|
||||
// `locations` / `locations_spoolorders`. Those keys still belong to the old client, so
|
||||
// they are read as a one-time seed for the location view and never written back — an
|
||||
// upgrade keeps the shelves it had, and the old client keeps working unchanged.
|
||||
if (!groups[SEEDED_FIELD]) {
|
||||
const seed = parseSetting<string[]>(s.locations, []).filter((l) => l != null);
|
||||
if (seed.length) groups[SEEDED_FIELD] = seed;
|
||||
}
|
||||
if (!spoolOrders[SEEDED_FIELD]) {
|
||||
const seed = parseSetting<Record<string, number[]>>(s.locations_spoolorders, {});
|
||||
if (Object.keys(seed).length) spoolOrders[SEEDED_FIELD] = seed;
|
||||
}
|
||||
|
||||
return { groups, spoolOrders };
|
||||
}
|
||||
|
||||
export function saveGroups(groups: DashboardLayout['groups']): void {
|
||||
setSetting('dashboard_groups', groups).catch((e) => console.error('Failed to save dashboard groups', e));
|
||||
}
|
||||
|
||||
export function saveSpoolOrders(spoolOrders: DashboardLayout['spoolOrders']): void {
|
||||
setSetting('dashboard_spoolorders', spoolOrders).catch((e) =>
|
||||
console.error('Failed to save dashboard spool order', e)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// The dashboard's view mode — which field its cards group by — lives in the URL, so a
|
||||
// particular board can be bookmarked and shared. It is also mirrored to localStorage, so
|
||||
// arriving at a bare /dashboard brings back the view you last looked at.
|
||||
//
|
||||
// The URL always wins when it says something: a bookmark has to mean what it says, even
|
||||
// when this browser was last left on a different view. The parameter is written even for
|
||||
// the default field rather than being omitted, so "no parameter" unambiguously means
|
||||
// "nothing asked for" and the remembered view can take over.
|
||||
|
||||
export const FIELD_PARAM = 'by';
|
||||
|
||||
const LAST_VIEW_KEY = 'spoolman-v2-dashboard-field';
|
||||
|
||||
/** The field key the URL asks for, or null when it doesn't say. */
|
||||
export function fieldKeyFromUrl(params: URLSearchParams): string | null {
|
||||
return params.get(FIELD_PARAM);
|
||||
}
|
||||
|
||||
/** The view this browser was last left on, if any. */
|
||||
export function rememberedFieldKey(): string | null {
|
||||
if (!browser) return null;
|
||||
try {
|
||||
return localStorage.getItem(LAST_VIEW_KEY);
|
||||
} catch {
|
||||
// Private-mode / storage-disabled browsers: the URL and the default still work.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberFieldKey(key: string): void {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(LAST_VIEW_KEY, key);
|
||||
} catch {
|
||||
/* nothing to do — remembering the view is a convenience, not a requirement */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the address bar at `key`'s board.
|
||||
*
|
||||
* `replace` swaps the current history entry, used when canonicalising a URL that didn't
|
||||
* name a view; picking a view from the menu pushes, so Back returns to the previous board.
|
||||
*/
|
||||
export function gotoField(key: string, replace = false): void {
|
||||
// A bare `?query` resolves against the current URL, so this is base-path-independent.
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto(`?${FIELD_PARAM}=${encodeURIComponent(key)}`, {
|
||||
replaceState: replace,
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { migrateTemplate, presetToDesign } from './migrateV1';
|
||||
|
||||
// migrateV1 rewrites label templates and print presets that users authored in the
|
||||
// v1 client. It runs unattended at first load and its output is what they see in
|
||||
// the designer afterwards, so a silent mistranslation here quietly damages work
|
||||
// they can't get back. These tests pin the parts that are easy to break: the token
|
||||
// rewriting table, and the guards that keep a hand-edited preset from producing a
|
||||
// nonsensical design.
|
||||
|
||||
describe('migrateTemplate', () => {
|
||||
it('rewrites bare spool paths', () => {
|
||||
expect(migrateTemplate('{id}')).toBe('{spool.id}');
|
||||
expect(migrateTemplate('{lot_nr}')).toBe('{spool.lot}');
|
||||
expect(migrateTemplate('{remaining_weight}')).toBe('{spool.remaining}');
|
||||
});
|
||||
|
||||
it('lifts vendor out from under filament', () => {
|
||||
expect(migrateTemplate('{filament.vendor.name}')).toBe('{vendor.name}');
|
||||
expect(migrateTemplate('{filament.vendor.empty_spool_weight}')).toBe('{vendor.emptyWeight}');
|
||||
});
|
||||
|
||||
it('rewrites snake_case filament fields to camelCase', () => {
|
||||
expect(migrateTemplate('{filament.settings_extruder_temp}')).toBe('{filament.nozzleTemp}');
|
||||
expect(migrateTemplate('{filament.spool_weight}')).toBe('{filament.spoolWeight}');
|
||||
});
|
||||
|
||||
it('leaves paths that are already identical in both versions', () => {
|
||||
for (const t of ['{filament.name}', '{filament.material}', '{filament.density}']) {
|
||||
expect(migrateTemplate(t)).toBe(t);
|
||||
}
|
||||
});
|
||||
|
||||
it('prefixes extra fields, keeping vendor extras distinct from spool extras', () => {
|
||||
expect(migrateTemplate('{extra.foo}')).toBe('{spool.extra.foo}');
|
||||
expect(migrateTemplate('{filament.vendor.extra.bar}')).toBe('{vendor.extra.bar}');
|
||||
});
|
||||
|
||||
it('rewrites paths inside the wrapped {prefix{path}suffix} form', () => {
|
||||
expect(migrateTemplate('{Lot Nr: {lot_nr}}')).toBe('{Lot Nr: {spool.lot}}');
|
||||
expect(migrateTemplate('{ET: {filament.settings_extruder_temp} °C}')).toBe(
|
||||
'{ET: {filament.nozzleTemp} °C}'
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites every occurrence, not just the first', () => {
|
||||
expect(migrateTemplate('{id} and {id}')).toBe('{spool.id} and {spool.id}');
|
||||
});
|
||||
|
||||
it('preserves surrounding literal text and markup', () => {
|
||||
expect(migrateTemplate('**{filament.vendor.name} - {filament.name}\n#{id}**')).toBe(
|
||||
'**{vendor.name} - {filament.name}\n#{spool.id}**'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a path with no v2 equivalent alone rather than mangling it', () => {
|
||||
// These resolve to "?" at render time; the important thing is that migration
|
||||
// doesn't turn them into some other valid field.
|
||||
expect(migrateTemplate('{remaining_length}')).toBe('{remaining_length}');
|
||||
expect(migrateTemplate('{archived}')).toBe('{archived}');
|
||||
});
|
||||
|
||||
it('handles an empty template', () => {
|
||||
expect(migrateTemplate('')).toBe('');
|
||||
});
|
||||
|
||||
it('does not rewrite a v1 path that is only a prefix of a token', () => {
|
||||
// `{id}` must not match inside `{identifier}`.
|
||||
expect(migrateTemplate('{identifier}')).toBe('{identifier}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('presetToDesign', () => {
|
||||
it('falls back to the v1 default template when the preset stored none', () => {
|
||||
const d = presetToDesign({});
|
||||
const text = d.elements.find((e) => e.type === 'text');
|
||||
// The default template must arrive already migrated, not in v1 form.
|
||||
expect(text?.type === 'text' && text.template).toContain('{vendor.name}');
|
||||
expect(text?.type === 'text' && text.template).not.toContain('{filament.vendor.name}');
|
||||
});
|
||||
|
||||
it('maps layout fields across essentially 1-1', () => {
|
||||
const { layout } = presetToDesign({
|
||||
labelSettings: {
|
||||
printSettings: {
|
||||
margin: { top: 1, bottom: 2, left: 3, right: 4 },
|
||||
printerMargin: { top: 5, bottom: 6, left: 7, right: 8 },
|
||||
spacing: { horizontal: 9, vertical: 10 },
|
||||
columns: 4,
|
||||
skipItems: 2,
|
||||
itemCopies: 3
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(layout.margin).toEqual({ t: 1, b: 2, l: 3, r: 4 });
|
||||
expect(layout.safe).toEqual({ t: 5, b: 6, l: 7, r: 8 });
|
||||
expect(layout.spacing).toEqual({ h: 9, v: 10 });
|
||||
expect(layout.columns).toBe(4);
|
||||
expect(layout.skip).toBe(2);
|
||||
expect(layout.copies).toBe(3);
|
||||
});
|
||||
|
||||
it('carries Tabloid over as a custom sheet since v2 dropped it', () => {
|
||||
const { layout } = presetToDesign({
|
||||
labelSettings: { printSettings: { paperSize: 'Tabloid' } }
|
||||
});
|
||||
expect(layout.paper).toBe('custom');
|
||||
expect(layout.custom).toEqual({ w: 279, h: 432 });
|
||||
});
|
||||
|
||||
it('keeps a known paper size and falls back to A4 for an unknown one', () => {
|
||||
expect(presetToDesign({ labelSettings: { printSettings: { paperSize: 'A5' } } }).layout.paper).toBe('A5');
|
||||
expect(presetToDesign({ labelSettings: { printSettings: { paperSize: 'B7' } } }).layout.paper).toBe('A4');
|
||||
});
|
||||
|
||||
it('folds v1 grid borders into a plain border, and keeps none as none', () => {
|
||||
const border = (m: 'none' | 'border' | 'grid') =>
|
||||
presetToDesign({ labelSettings: { printSettings: { borderShowMode: m } } }).layout.border;
|
||||
expect(border('grid')).toBe('border');
|
||||
expect(border('border')).toBe('border');
|
||||
expect(border('none')).toBe('none');
|
||||
});
|
||||
|
||||
it('clamps a degenerate preset instead of producing a zero or negative canvas', () => {
|
||||
// A hand-edited preset with absurd values must still yield a usable label.
|
||||
const d = presetToDesign({
|
||||
labelSettings: {
|
||||
printSettings: { columns: 0, rows: 0, margin: { top: 500, bottom: 500, left: 500, right: 500 } }
|
||||
}
|
||||
});
|
||||
expect(d.label.w).toBeGreaterThan(0);
|
||||
expect(d.label.h).toBeGreaterThan(0);
|
||||
expect(d.layout.columns).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('omits the QR element when v1 had the QR turned off', () => {
|
||||
const d = presetToDesign({ labelSettings: { showQRCodeMode: 'no' } });
|
||||
expect(d.elements.some((e) => e.type === 'qr')).toBe(false);
|
||||
expect(d.elements.some((e) => e.type === 'text')).toBe(true);
|
||||
});
|
||||
|
||||
it('omits the text element when v1 had content turned off', () => {
|
||||
const d = presetToDesign({ labelSettings: { showContent: false } });
|
||||
expect(d.elements.some((e) => e.type === 'text')).toBe(false);
|
||||
expect(d.elements.some((e) => e.type === 'qr')).toBe(true);
|
||||
});
|
||||
|
||||
it('carries the QR logo flag only for the withIcon mode', () => {
|
||||
const logo = (mode: 'simple' | 'withIcon') => {
|
||||
const qr = presetToDesign({ labelSettings: { showQRCodeMode: mode } }).elements.find(
|
||||
(e) => e.type === 'qr'
|
||||
);
|
||||
return qr?.type === 'qr' && qr.logo;
|
||||
};
|
||||
expect(logo('withIcon')).toBe(true);
|
||||
expect(logo('simple')).toBe(false);
|
||||
});
|
||||
|
||||
it('places the text to the right of the QR when both are shown', () => {
|
||||
const d = presetToDesign({ labelSettings: { showQRCodeMode: 'simple' } });
|
||||
const qr = d.elements.find((e) => e.type === 'qr');
|
||||
const text = d.elements.find((e) => e.type === 'text');
|
||||
if (qr?.type !== 'qr' || text?.type !== 'text') throw new Error('expected both elements');
|
||||
expect(text.x).toBeGreaterThan(qr.x + qr.size - 1);
|
||||
expect(text.w).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('gives each design a distinct id shared by its elements', () => {
|
||||
const a = presetToDesign({});
|
||||
const b = presetToDesign({});
|
||||
expect(a.id).not.toBe(b.id);
|
||||
for (const e of a.elements) expect(e.id.startsWith(a.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('names the design from the preset, falling back when the name is blank', () => {
|
||||
expect(presetToDesign({ labelSettings: { printSettings: { name: 'Shelf' } } }).name).toBe('Shelf');
|
||||
expect(presetToDesign({ labelSettings: { printSettings: { name: '' } } }).name).toBe('Imported label');
|
||||
});
|
||||
|
||||
it('always produces a spool design, since v1 only printed spool labels', () => {
|
||||
expect(presetToDesign({}).kind).toBe('spool');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
newDesign,
|
||||
setDesignKind,
|
||||
DEFAULT_SPOOL_TEXT,
|
||||
DEFAULT_FILAMENT_TEXT,
|
||||
type LabelDesign,
|
||||
type TextElement
|
||||
} from './types';
|
||||
|
||||
function templates(d: LabelDesign): string[] {
|
||||
return d.elements.filter((e): e is TextElement => e.type === 'text').map((e) => e.template);
|
||||
}
|
||||
|
||||
describe('setDesignKind', () => {
|
||||
it('retargets the default id line of a fresh design', () => {
|
||||
const d = newDesign('x');
|
||||
setDesignKind(d, 'filament');
|
||||
expect(d.kind).toBe('filament');
|
||||
expect(templates(d)).toContain(DEFAULT_FILAMENT_TEXT);
|
||||
expect(templates(d)).not.toContain(DEFAULT_SPOOL_TEXT);
|
||||
|
||||
setDesignKind(d, 'spool');
|
||||
expect(templates(d)).toContain(DEFAULT_SPOOL_TEXT);
|
||||
});
|
||||
|
||||
it('retargets the pre-redesign default too', () => {
|
||||
const legacy = '**{filament.name}**\n{filament.material}\n#{spool.id}';
|
||||
const d = newDesign('x');
|
||||
d.elements = [{ ...(d.elements[1] as TextElement), template: legacy }];
|
||||
setDesignKind(d, 'filament');
|
||||
expect(templates(d)).toEqual(['**{filament.name}**\n{filament.material}\n#{filament.id}']);
|
||||
});
|
||||
|
||||
it('leaves user-edited templates alone', () => {
|
||||
const d = newDesign('x');
|
||||
d.elements = [{ ...(d.elements[1] as TextElement), template: 'my own #{spool.id}' }];
|
||||
setDesignKind(d, 'filament');
|
||||
expect(templates(d)).toEqual(['my own #{spool.id}']);
|
||||
});
|
||||
});
|
||||
@@ -178,11 +178,29 @@ export const DEFAULT_LAYOUT: PrintLayout = {
|
||||
border: 'none'
|
||||
};
|
||||
|
||||
/** Default text-block template for a fresh spool label (ends in the spool id). */
|
||||
export const DEFAULT_SPOOL_TEXT = '**{filament.name}**\n{filament.material}\n#{spool.id}';
|
||||
/** Default text-block template for a fresh filament label (a spool id makes no
|
||||
* sense here, so it ends in the filament id instead). */
|
||||
export const DEFAULT_FILAMENT_TEXT = '**{filament.name}**\n{filament.material}\n#{filament.id}';
|
||||
/**
|
||||
* Default "meta" line of a fresh spool label: the material followed by the spool
|
||||
* id. The material sits in a conditional block so the separator disappears along
|
||||
* with it when the filament has no material set.
|
||||
*/
|
||||
export const DEFAULT_SPOOL_TEXT = '{{filament.material} · }#{spool.id}';
|
||||
/** Same line for a fresh filament label — a spool id makes no sense here, so it
|
||||
* ends in the filament id instead. */
|
||||
export const DEFAULT_FILAMENT_TEXT = '{{filament.material} · }#{filament.id}';
|
||||
|
||||
/**
|
||||
* Spool/filament template pairs that count as "still the default", newest first.
|
||||
* The trailing entries are the defaults shipped by earlier versions, kept so that
|
||||
* designs created before the default was redesigned still get their id line
|
||||
* retargeted when their kind is switched.
|
||||
*/
|
||||
const DEFAULT_TEXT_PAIRS: Record<LabelKind, string>[] = [
|
||||
{ spool: DEFAULT_SPOOL_TEXT, filament: DEFAULT_FILAMENT_TEXT },
|
||||
{
|
||||
spool: '**{filament.name}**\n{filament.material}\n#{spool.id}',
|
||||
filament: '**{filament.name}**\n{filament.material}\n#{filament.id}'
|
||||
}
|
||||
];
|
||||
|
||||
/** The default text-block template for a given label kind. */
|
||||
export function defaultTextTemplate(kind: LabelKind): string {
|
||||
@@ -196,17 +214,23 @@ export function defaultTextTemplate(kind: LabelKind): string {
|
||||
* and vice versa. Templates the user has edited are left untouched.
|
||||
*/
|
||||
export function setDesignKind(design: LabelDesign, kind: LabelKind): void {
|
||||
const from = defaultTextTemplate(labelKind(design));
|
||||
const to = defaultTextTemplate(kind);
|
||||
const from = labelKind(design);
|
||||
design.kind = kind;
|
||||
if (from !== to) {
|
||||
design.elements = design.elements.map((el) =>
|
||||
el.type === 'text' && el.template === from ? { ...el, template: to } : el
|
||||
);
|
||||
}
|
||||
if (from === kind) return;
|
||||
design.elements = design.elements.map((el) => {
|
||||
if (el.type !== 'text') return el;
|
||||
const pair = DEFAULT_TEXT_PAIRS.find((p) => p[from] === el.template);
|
||||
return pair ? { ...el, template: pair[kind] } : el;
|
||||
});
|
||||
}
|
||||
|
||||
/** A fresh empty design with a sensible default size and one QR element. */
|
||||
/**
|
||||
* A fresh design at a sensible default size, laid out as: QR on the left, and on
|
||||
* the right a manufacturer line, the filament name, a material · id line, and a
|
||||
* colour swatch bar bottom-aligned with the QR. Every text block is a separate
|
||||
* element because styling (size, weight, colour) is per-element — that's what
|
||||
* lets the name read as the headline and the manufacturer as a subtitle.
|
||||
*/
|
||||
export function newDesign(id: string): LabelDesign {
|
||||
return {
|
||||
id,
|
||||
@@ -217,18 +241,51 @@ export function newDesign(id: string): LabelDesign {
|
||||
elements: [
|
||||
{ id: `${id}-qr`, type: 'qr', x: 2, y: 2, size: 21, ec: 'H', encoding: 'scheme', logo: true },
|
||||
{
|
||||
id: `${id}-t`,
|
||||
// Conditional block: a filament with no vendor prints nothing here
|
||||
// rather than a stray "?".
|
||||
id: `${id}-vendor`,
|
||||
type: 'text',
|
||||
x: 25,
|
||||
y: 3,
|
||||
y: 2.2,
|
||||
w: 23,
|
||||
fontSize: 3,
|
||||
fontSize: 2.5,
|
||||
bold: false,
|
||||
align: 'left',
|
||||
color: '#555555',
|
||||
wrap: false,
|
||||
template: '{{vendor.name}}'
|
||||
},
|
||||
{
|
||||
// The headline. The only wrapping block, so a long name grows into the
|
||||
// gap above the meta line instead of being clipped.
|
||||
id: `${id}-name`,
|
||||
type: 'text',
|
||||
x: 25,
|
||||
y: 5.4,
|
||||
w: 23,
|
||||
fontSize: 3.2,
|
||||
bold: true,
|
||||
align: 'left',
|
||||
color: '#000000',
|
||||
wrap: true,
|
||||
template: '{filament.name}'
|
||||
},
|
||||
{
|
||||
id: `${id}-meta`,
|
||||
type: 'text',
|
||||
x: 25,
|
||||
y: 15.2,
|
||||
w: 23,
|
||||
fontSize: 2.5,
|
||||
bold: false,
|
||||
align: 'left',
|
||||
color: '#333333',
|
||||
wrap: false,
|
||||
template: DEFAULT_SPOOL_TEXT
|
||||
}
|
||||
},
|
||||
// Bottom-aligned with the QR (both end at y=23) so the label reads as two
|
||||
// balanced columns.
|
||||
{ id: `${id}-swatch`, type: 'swatch', x: 25, y: 19, w: 23, h: 4, radius: 1.2 }
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { classifyDeleteFailure, planFilamentDelete, planVendorDelete } from './deletion';
|
||||
import { HttpError } from '$lib/api/http';
|
||||
|
||||
// These cover the two asymmetries behind issue #991: a filament with spools cannot
|
||||
// be deleted at all, while a manufacturer with filaments can — and silently orphans
|
||||
// them. The inspectors phrase both from these results, so getting them wrong is the
|
||||
// difference between an honest dialog and a generic error toast.
|
||||
|
||||
describe('planFilamentDelete', () => {
|
||||
it('refuses while spools reference the filament, and says how many', () => {
|
||||
expect(planFilamentDelete(3)).toEqual({ allowed: false, spools: 3 });
|
||||
});
|
||||
|
||||
it('allows the delete once no spools are left', () => {
|
||||
expect(planFilamentDelete(0)).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it('counts a single spool as blocking, not as a rounding error', () => {
|
||||
expect(planFilamentDelete(1)).toEqual({ allowed: false, spools: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('planVendorDelete', () => {
|
||||
it('reports the filaments that will lose their manufacturer', () => {
|
||||
expect(planVendorDelete(2)).toEqual({ orphaned: 2 });
|
||||
});
|
||||
|
||||
it('reports nothing to orphan for an unused manufacturer', () => {
|
||||
expect(planVendorDelete(0)).toEqual({ orphaned: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyDeleteFailure', () => {
|
||||
it('reads a 403 as "something still references this"', () => {
|
||||
expect(classifyDeleteFailure(new HttpError('DELETE /filament/1 → 403', 403))).toBe('in-use');
|
||||
});
|
||||
|
||||
it('reads a 404 as "already gone"', () => {
|
||||
expect(classifyDeleteFailure(new HttpError('DELETE /vendor/1 → 404', 404))).toBe('gone');
|
||||
});
|
||||
|
||||
it('falls back to unknown for a server error', () => {
|
||||
expect(classifyDeleteFailure(new HttpError('DELETE /filament/1 → 500', 500))).toBe('unknown');
|
||||
});
|
||||
|
||||
it('falls back to unknown for a network failure, which is not an HttpError', () => {
|
||||
expect(classifyDeleteFailure(new TypeError('Failed to fetch'))).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { HttpError } from '$lib/api/http';
|
||||
|
||||
// Deleting a filament or a manufacturer is the one destructive action in the
|
||||
// Library, and the two entities behave differently: the API refuses to delete a
|
||||
// filament that still has spools (403, because the spool→filament FK is not
|
||||
// nullable), but happily deletes a manufacturer that still has filaments — the
|
||||
// filaments survive with their vendor cleared, since filament.vendor_id is.
|
||||
//
|
||||
// Both rules live here rather than in the inspectors so they're stated once and
|
||||
// can be tested without a DOM.
|
||||
|
||||
/** The API would answer 403. `spools` is why — say so instead of offering a delete. */
|
||||
export interface FilamentDeleteBlocked {
|
||||
allowed: false;
|
||||
spools: number;
|
||||
}
|
||||
|
||||
export type FilamentDeletePlan = FilamentDeleteBlocked | { allowed: true };
|
||||
|
||||
/**
|
||||
* Decide what deleting a filament with `spools` spools would do. Count archived
|
||||
* spools too: archiving is a client-side flag, and the foreign key does not care.
|
||||
*/
|
||||
export function planFilamentDelete(spools: number): FilamentDeletePlan {
|
||||
return spools > 0 ? { allowed: false, spools } : { allowed: true };
|
||||
}
|
||||
|
||||
export interface VendorDeletePlan {
|
||||
/** Filaments that survive the delete with their manufacturer cleared. */
|
||||
orphaned: number;
|
||||
}
|
||||
|
||||
/** Decide what deleting a manufacturer holding `filaments` filaments would do. */
|
||||
export function planVendorDelete(filaments: number): VendorDeletePlan {
|
||||
return { orphaned: Math.max(filaments, 0) };
|
||||
}
|
||||
|
||||
/** Why a DELETE failed, in the terms the user needs it explained. */
|
||||
export type DeleteFailure = 'in-use' | 'gone' | 'unknown';
|
||||
|
||||
/**
|
||||
* Classify a rejected delete. The API's 403 body is a generic "see server logs"
|
||||
* string, so the status is all we have to go on — but for these two endpoints a
|
||||
* 403 only ever means the row is still referenced.
|
||||
*/
|
||||
export function classifyDeleteFailure(e: unknown): DeleteFailure {
|
||||
if (e instanceof HttpError) {
|
||||
if (e.status === 403 || e.status === 409) return 'in-use';
|
||||
if (e.status === 404) return 'gone';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -129,6 +129,9 @@ function currentState(): LibraryState {
|
||||
*/
|
||||
function navigate(next: LibraryState, replace = false): void {
|
||||
const qs = serializeState(next);
|
||||
// Both targets are base-path-independent: a bare `?query` resolves against the
|
||||
// current URL, and window.location.pathname already includes the base path.
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto(qs ? `?${qs}` : window.location.pathname, {
|
||||
replaceState: replace,
|
||||
keepFocus: true,
|
||||
@@ -270,5 +273,7 @@ export function openSearchResult(kind: EntityKind, id: string): void {
|
||||
}
|
||||
const p = new URLSearchParams();
|
||||
p.set('sel', `${kind}:${id}`);
|
||||
// libraryPath is `resolve('/')`, so the base path is already applied.
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto(`${libraryPath}?${p.toString()}`, { noScroll: true });
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { EntityType } from '$lib/api/fields';
|
||||
|
||||
// Which entity's extra fields the Settings page is showing lives in the URL, so
|
||||
// the manager can be linked to directly — every inspector's extra-fields section
|
||||
// points at exactly its own tab ("this is where these come from"), and the link
|
||||
// still lands somewhere sensible when pasted to someone else.
|
||||
|
||||
export const ENTITY_PARAM = 'fields';
|
||||
|
||||
/** Anchor on the Settings page's extra-fields section, so links scroll to it. */
|
||||
export const EXTRA_FIELDS_ANCHOR = 'extra-fields';
|
||||
|
||||
const ENTITIES: EntityType[] = ['spool', 'filament', 'vendor'];
|
||||
|
||||
/** The entity tab the URL asks for, or null when it doesn't say (or says nonsense). */
|
||||
export function entityFromUrl(params: URLSearchParams): EntityType | null {
|
||||
const v = params.get(ENTITY_PARAM);
|
||||
return ENTITIES.find((e) => e === v) ?? null;
|
||||
}
|
||||
|
||||
/** Deep link to `entity`'s extra fields in Settings, scrolled to the manager. */
|
||||
export function extraFieldsHref(entity: EntityType): string {
|
||||
return `${resolve('/settings')}?${ENTITY_PARAM}=${entity}#${EXTRA_FIELDS_ANCHOR}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the address bar at `entity`'s tab.
|
||||
*
|
||||
* A bare `?query` resolves against the current URL, so this is base-path-independent;
|
||||
* it also drops any `#extra-fields` we arrived on, which is what we want — switching
|
||||
* tabs shouldn't scroll the page back to the section you are already reading.
|
||||
*/
|
||||
export function gotoEntity(entity: EntityType): void {
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
void goto(`?${ENTITY_PARAM}=${entity}`, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Which Library groups the user has collapsed, remembered per browser.
|
||||
//
|
||||
// This is a reading preference, not part of what a Library URL means: a link is
|
||||
// worth sharing because of its search/filters/sort, and stapling "…and these
|
||||
// nine groups are shut" onto it would say nothing about the data. So it lives in
|
||||
// localStorage next to the other per-browser preferences (theme, low-stock
|
||||
// threshold) rather than in the address bar.
|
||||
//
|
||||
// Only the COLLAPSED groups are stored, so expanded stays the default: a library
|
||||
// that grows a new filament shows it, and a key that no longer matches anything
|
||||
// (a renamed location, a deleted filament) is simply never asked about again.
|
||||
|
||||
const KEY = 'spoolman-v2-collapsed-groups';
|
||||
|
||||
/** Storage identity of a group row — the same pair the list keys its rows on. */
|
||||
export function groupId(field: string, key: string): string {
|
||||
return `${field}:${key}`;
|
||||
}
|
||||
|
||||
class CollapsedGroups {
|
||||
// Reassigned, never mutated in place, so Svelte sees each change. A handful of
|
||||
// keys at most, so a plain array's membership test is cheaper than a Set's
|
||||
// reactivity wrapper.
|
||||
#ids = $state<string[]>([]);
|
||||
|
||||
constructor() {
|
||||
this.#ids = read();
|
||||
}
|
||||
|
||||
has(id: string): boolean {
|
||||
return this.#ids.includes(id);
|
||||
}
|
||||
|
||||
toggle(id: string) {
|
||||
this.#ids = this.has(id) ? this.#ids.filter((x) => x !== id) : [...this.#ids, id];
|
||||
write(this.#ids);
|
||||
}
|
||||
}
|
||||
|
||||
function read(): string[] {
|
||||
if (typeof localStorage === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
|
||||
} catch {
|
||||
// Corrupt entry or storage disabled: everything just starts expanded.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(ids: string[]) {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(ids));
|
||||
} catch {
|
||||
/* nothing to do — remembering the collapse is a convenience, not a requirement */
|
||||
}
|
||||
}
|
||||
|
||||
export const collapsedGroups = new CollapsedGroups();
|
||||
@@ -18,22 +18,29 @@ function byOrder(list: FieldDef[]): FieldDef[] {
|
||||
|
||||
class Fields {
|
||||
private defs = $state<Record<EntityType, FieldDef[]>>({ spool: [], filament: [], vendor: [] });
|
||||
private loaded = new Set<EntityType>();
|
||||
// Reactive so views can wait for the definitions rather than render against an
|
||||
// empty list and then rearrange themselves once they arrive.
|
||||
private loaded = $state<EntityType[]>([]);
|
||||
|
||||
get(entity: EntityType): FieldDef[] {
|
||||
return this.defs[entity] ?? [];
|
||||
}
|
||||
|
||||
/** Whether this entity's definitions are in hand — an empty list is a real answer. */
|
||||
isLoaded(entity: EntityType): boolean {
|
||||
return this.loaded.includes(entity);
|
||||
}
|
||||
|
||||
/** Load once (idempotent). */
|
||||
ensure(entity: EntityType) {
|
||||
if (!this.loaded.has(entity)) void this.load(entity);
|
||||
if (!this.loaded.includes(entity)) void this.load(entity);
|
||||
}
|
||||
|
||||
async load(entity: EntityType) {
|
||||
try {
|
||||
const list = await getFields(entity);
|
||||
this.defs = { ...this.defs, [entity]: byOrder(list) };
|
||||
this.loaded.add(entity);
|
||||
if (!this.loaded.includes(entity)) this.loaded = [...this.loaded, entity];
|
||||
} catch (e) {
|
||||
console.error(`Failed to load ${entity} fields`, e);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,32 @@ class Inventory {
|
||||
for (const v of list) this.upsertVendor(v);
|
||||
}
|
||||
|
||||
// --- removals -----------------------------------------------------------
|
||||
// Used both by live `deleted` events and by our own deletes, so a delete made
|
||||
// here drops out of every view immediately instead of waiting for the round
|
||||
// trip through the websocket.
|
||||
|
||||
removeSpool(id: number) {
|
||||
this.spools = this.spools.filter((s) => s.id !== id);
|
||||
}
|
||||
removeFilament(id: string) {
|
||||
this.filaments = this.filaments.filter((f) => f.id !== id);
|
||||
}
|
||||
/**
|
||||
* Drop a vendor and clear it off every cached filament that referenced it.
|
||||
*
|
||||
* The server does the same on its side — filament.vendor_id is nullable, so a
|
||||
* filament outlives its manufacturer with the field set to null — but it does
|
||||
* not broadcast those filaments (see spoolman/database/vendor.py `delete`).
|
||||
* This is the cache's only chance to stop pointing at a vendor that is gone.
|
||||
*/
|
||||
removeVendor(id: string) {
|
||||
this.vendors = this.vendors.filter((v) => v.id !== id);
|
||||
if (this.filaments.some((f) => f.vendorId === id)) {
|
||||
this.filaments = this.filaments.map((f) => (f.vendorId === id ? { ...f, vendorId: '' } : f));
|
||||
}
|
||||
}
|
||||
|
||||
/** Patch a cached spool in place (optimistic local edit). */
|
||||
patchSpool(id: number, patch: Partial<Spool>) {
|
||||
const s = this.spoolById(id);
|
||||
@@ -83,13 +109,42 @@ class Inventory {
|
||||
if (v) this.upsertVendor({ ...v, ...patch });
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the weights of cached spools that inherit them from `f`.
|
||||
*
|
||||
* The server does not re-broadcast a filament's spools when the filament changes
|
||||
* (that fan-out is unbounded — see spoolman/database/filament.py `update`), so a
|
||||
* filament event is our only signal.
|
||||
*
|
||||
* Only the *stored* weights need this. Remaining length — the value the fan-out
|
||||
* was originally added for — is not cached at all: SpoolInspector computes it as
|
||||
* `lengthMeters(spool.remaining, filament)` where `filament` is $derived from this
|
||||
* cache, so upserting the filament above already refreshes it with the new density
|
||||
* and diameter. The same goes for every other filament value a spool row shows.
|
||||
*
|
||||
* What has nowhere else to come from is a spool with no `initial_weight` of its
|
||||
* own, whose initial/remaining weights the server derived from `filament.weight`.
|
||||
* Mirror `Spool.from_db`'s formula — including its clamp at 0 — so an over-used
|
||||
* spool doesn't go negative.
|
||||
*/
|
||||
private reweighSpoolsOfFilament(f: Filament) {
|
||||
let changed = false;
|
||||
const next = this.spools.map((s) => {
|
||||
if (s.filamentId !== f.id || s.initialOverride !== undefined) return s;
|
||||
if (s.initial === f.weight) return s;
|
||||
changed = true;
|
||||
return { ...s, initial: f.weight, remaining: Math.max(f.weight - s.usedWeight, 0) };
|
||||
});
|
||||
if (changed) this.spools = next;
|
||||
}
|
||||
|
||||
// --- live events (raw API payload → cache) ------------------------------
|
||||
|
||||
ingest(event: LiveEvent) {
|
||||
const payload = event.payload;
|
||||
if (event.resource === 'spool') {
|
||||
const id = Number(event.id);
|
||||
if (event.type === 'deleted') this.spools = this.spools.filter((s) => s.id !== id);
|
||||
if (event.type === 'deleted') this.removeSpool(id);
|
||||
else if (payload) {
|
||||
this.upsertSpool(mapSpool(payload));
|
||||
// Spool payloads embed their filament (+ vendor); keep those fresh too.
|
||||
@@ -102,15 +157,17 @@ class Inventory {
|
||||
}
|
||||
} else if (event.resource === 'filament') {
|
||||
const id = String(event.id);
|
||||
if (event.type === 'deleted') this.filaments = this.filaments.filter((f) => f.id !== id);
|
||||
if (event.type === 'deleted') this.removeFilament(id);
|
||||
else if (payload) {
|
||||
this.upsertFilament(mapFilament(payload));
|
||||
const f = mapFilament(payload);
|
||||
this.upsertFilament(f);
|
||||
this.reweighSpoolsOfFilament(f);
|
||||
const v = payload.vendor as Record<string, unknown> | undefined;
|
||||
if (v) this.upsertVendor(mapVendor(v));
|
||||
}
|
||||
} else if (event.resource === 'vendor') {
|
||||
const id = String(event.id);
|
||||
if (event.type === 'deleted') this.vendors = this.vendors.filter((v) => v.id !== id);
|
||||
if (event.type === 'deleted') this.removeVendor(id);
|
||||
else if (payload) this.upsertVendor(mapVendor(payload));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ class UiState {
|
||||
/** "Add spools" modal. `addModalFilamentId` pre-seeds it with a filament. */
|
||||
addModalOpen = $state(false);
|
||||
addModalFilamentId = $state<string | null>(null);
|
||||
/** When set, the modal opens on a new-filament form copied from this filament. */
|
||||
addModalDuplicateId = $state<string | null>(null);
|
||||
|
||||
/** QR-code scanner modal (camera). */
|
||||
scannerOpen = $state(false);
|
||||
@@ -15,11 +17,23 @@ class UiState {
|
||||
/** Open the Add-spools modal, optionally pre-seeded with a filament. */
|
||||
openAddModal(filamentId?: string) {
|
||||
this.addModalFilamentId = filamentId ?? null;
|
||||
this.addModalDuplicateId = null;
|
||||
this.addModalOpen = true;
|
||||
}
|
||||
/**
|
||||
* Open the Add-spools modal on a new filament copied from `filamentId` — the
|
||||
* "same filament, different colour" case, where everything but the name and
|
||||
* colour carries over.
|
||||
*/
|
||||
openDuplicateModal(filamentId: string) {
|
||||
this.addModalFilamentId = null;
|
||||
this.addModalDuplicateId = filamentId;
|
||||
this.addModalOpen = true;
|
||||
}
|
||||
closeAddModal() {
|
||||
this.addModalOpen = false;
|
||||
this.addModalFilamentId = null;
|
||||
this.addModalDuplicateId = null;
|
||||
}
|
||||
|
||||
openScanner() {
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
/** Custom-field values: field key → JSON-encoded string value. */
|
||||
export type Extra = Record<string, string>;
|
||||
|
||||
/**
|
||||
* Custom-field values on the way to the server: a JSON-encoded string to store, or null
|
||||
* to store no value — which is the only way to clear one that has been set.
|
||||
*/
|
||||
export type ExtraPatch = Record<string, string | null>;
|
||||
|
||||
/** Layout of a multi-color filament's colors. */
|
||||
export type MultiColorDirection = 'coaxial' | 'longitudinal';
|
||||
|
||||
@@ -56,8 +62,13 @@ export interface Spool {
|
||||
unused: boolean;
|
||||
/** Remaining filament weight in grams. */
|
||||
remaining: number;
|
||||
/** Initial (net) weight in grams. */
|
||||
/** Initial (net) weight in grams; falls back to the filament's weight. */
|
||||
initial: number;
|
||||
/** Per-spool initial-weight override; undefined means `initial` came from the
|
||||
* filament and must be recomputed when the filament's weight changes. */
|
||||
initialOverride?: number;
|
||||
/** Filament drawn from this spool so far, in grams. */
|
||||
usedWeight: number;
|
||||
location: string;
|
||||
lot: string;
|
||||
/** Per-spool price override; undefined falls back to the filament's price. */
|
||||
@@ -79,6 +90,11 @@ export interface Spool {
|
||||
extra: Extra;
|
||||
}
|
||||
|
||||
// Write shapes: a partial entity, except that `extra` may carry nulls to clear values.
|
||||
export type VendorPatch = Partial<Omit<Vendor, 'extra'>> & { extra?: ExtraPatch };
|
||||
export type FilamentPatch = Partial<Omit<Filament, 'extra'>> & { extra?: ExtraPatch };
|
||||
export type SpoolPatch = Partial<Omit<Spool, 'extra'>> & { extra?: ExtraPatch };
|
||||
|
||||
export type EntityKind = 'spool' | 'filament' | 'vendor';
|
||||
|
||||
export interface Selection {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { currency, grams, lengthMeters, pct, roundGrams, weightAuto } from './format';
|
||||
import type { Filament } from '$lib/types';
|
||||
|
||||
// These format every weight, length and percentage the user sees. The rounding
|
||||
// rules are deliberate (see the comments in format.ts) rather than incidental, so
|
||||
// they're pinned here.
|
||||
|
||||
const filament = (over: Partial<Filament> = {}): Filament =>
|
||||
({
|
||||
id: '1',
|
||||
vendorId: '1',
|
||||
name: 'Test',
|
||||
material: 'PLA',
|
||||
colors: ['#000000'],
|
||||
diameter: 1.75,
|
||||
density: 1.24,
|
||||
nozzleTemp: 210,
|
||||
bedTemp: 60,
|
||||
weight: 1000,
|
||||
price: 20,
|
||||
comment: '',
|
||||
registeredLabel: '',
|
||||
extra: {},
|
||||
...over
|
||||
}) as Filament;
|
||||
|
||||
describe('grams', () => {
|
||||
it('keeps whole grams whole', () => {
|
||||
expect(grams(0)).toBe('0');
|
||||
expect(grams(864)).toBe('864');
|
||||
});
|
||||
|
||||
it('shows one decimal otherwise', () => {
|
||||
expect(grams(864.5)).toBe('864.5');
|
||||
expect(grams(0.1)).toBe('0.1');
|
||||
});
|
||||
|
||||
it('rounds to one decimal rather than truncating', () => {
|
||||
expect(grams(1.26)).toBe('1.3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('weightAuto', () => {
|
||||
it('uses grams below the 1000 g switch point', () => {
|
||||
expect(weightAuto(0)).toBe('0 g');
|
||||
expect(weightAuto(864)).toBe('864 g');
|
||||
expect(weightAuto(999)).toBe('999 g');
|
||||
expect(weightAuto(999.5)).toBe('999.5 g');
|
||||
});
|
||||
|
||||
it('switches to kg at exactly 1000 g', () => {
|
||||
expect(weightAuto(1000)).toBe('1 kg');
|
||||
});
|
||||
|
||||
it('floors kg so the figure never overstates what is on the spool', () => {
|
||||
// The whole point of flooring: 1199 g must not read as "1.2 kg".
|
||||
expect(weightAuto(1199)).toBe('1.1 kg');
|
||||
expect(weightAuto(1250)).toBe('1.2 kg');
|
||||
expect(weightAuto(1999)).toBe('1.9 kg');
|
||||
expect(weightAuto(2000)).toBe('2 kg');
|
||||
});
|
||||
|
||||
it('does not let float dust drop a full spool below the kg switch point', () => {
|
||||
// What registering a spool by its measured weight leaves behind (#986): the spool is
|
||||
// full, and reading "1000.0 g" beside an identical spool's "1 kg" looks broken.
|
||||
expect(weightAuto(1000 - (1000 + 128.11 - 1128.11))).toBe('1 kg');
|
||||
expect(weightAuto(999.9999999999998)).toBe('1 kg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundGrams', () => {
|
||||
it('snaps float dust to zero', () => {
|
||||
// 1000 + 128.11 - 1128.11 is 2.3e-13, not 0 — the arithmetic behind #986.
|
||||
expect(1000 + 128.11 - 1128.11).not.toBe(0);
|
||||
expect(roundGrams(1000 + 128.11 - 1128.11)).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps weights a scale could actually report', () => {
|
||||
expect(roundGrams(0.5)).toBe(0.5);
|
||||
expect(roundGrams(123.45)).toBe(123.45);
|
||||
expect(roundGrams(1000)).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lengthMeters', () => {
|
||||
it('matches the known length of a 1 kg PLA spool at 1.75 mm', () => {
|
||||
// ~335 m is the widely published figure for 1 kg of 1.75 mm PLA.
|
||||
expect(lengthMeters(1000, filament())).toBeCloseTo(335.3, 0);
|
||||
});
|
||||
|
||||
it('scales linearly with weight', () => {
|
||||
const f = filament();
|
||||
expect(lengthMeters(500, f)).toBeCloseTo(lengthMeters(1000, f) / 2, 6);
|
||||
});
|
||||
|
||||
it('gives a shorter run for a thicker filament of the same weight', () => {
|
||||
// 2.85 mm is ~2.65x the cross-section of 1.75 mm, so the same mass is shorter.
|
||||
const thin = lengthMeters(1000, filament({ diameter: 1.75 }));
|
||||
const thick = lengthMeters(1000, filament({ diameter: 2.85 }));
|
||||
expect(thick).toBeLessThan(thin);
|
||||
expect(thin / thick).toBeCloseTo((2.85 / 1.75) ** 2, 6);
|
||||
});
|
||||
|
||||
it('gives a shorter run for a denser material of the same weight', () => {
|
||||
const pla = lengthMeters(1000, filament({ density: 1.24 }));
|
||||
const petg = lengthMeters(1000, filament({ density: 1.27 }));
|
||||
expect(petg).toBeLessThan(pla);
|
||||
});
|
||||
|
||||
it('is zero for zero weight', () => {
|
||||
expect(lengthMeters(0, filament())).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pct', () => {
|
||||
it('returns a rounded percentage of the initial weight', () => {
|
||||
expect(pct(500, 1000)).toBe(50);
|
||||
expect(pct(1000, 1000)).toBe(100);
|
||||
expect(pct(0, 1000)).toBe(0);
|
||||
expect(pct(333, 1000)).toBe(33);
|
||||
});
|
||||
|
||||
it('returns 0 rather than dividing by zero for an unknown initial weight', () => {
|
||||
expect(pct(500, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('can exceed 100 for an over-used spool', () => {
|
||||
// Not clamped here; callers that render a bar clamp at their own layer.
|
||||
expect(pct(1100, 1000)).toBe(110);
|
||||
});
|
||||
});
|
||||
|
||||
describe('currency', () => {
|
||||
it('always shows two decimals', () => {
|
||||
expect(currency(20)).toBe('€20.00');
|
||||
expect(currency(19.9)).toBe('€19.90');
|
||||
expect(currency(0)).toBe('€0.00');
|
||||
});
|
||||
|
||||
it('accepts a different symbol', () => {
|
||||
expect(currency(20, '$')).toBe('$20.00');
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,26 @@ export function grams(g: number): string {
|
||||
return Number.isInteger(g) ? String(g) : g.toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a gram value to the finest precision this UI deals in. Weights reach us through float
|
||||
* arithmetic — registering a spool by its measured weight subtracts `net + spool − measured`,
|
||||
* and a full 1 kg spool on a 128.11 g core gives 1000 + 128.11 − 1128.11 = 2.3e-13 rather
|
||||
* than 0 — while a hundredth of a gram is already below what any scale reads or any row shows.
|
||||
*/
|
||||
export function roundGrams(g: number): number {
|
||||
return Math.round(g * 100) / 100;
|
||||
}
|
||||
|
||||
/** Grams → "864 g" or "1.2 kg", switching units at 1000 g like the old client. */
|
||||
export function weightAuto(weightInGrams: number): string {
|
||||
if (weightInGrams < 1000) return grams(weightInGrams) + ' g';
|
||||
// Round to the displayed precision before picking the unit: a full spool left at
|
||||
// 999.99999999999995 g by the arithmetic above is 1 kg to every eye that sees it, and
|
||||
// showing it as "1000.0 g" next to its identical neighbour's "1 kg" only reads as a bug.
|
||||
const w = Math.round(weightInGrams * 10) / 10;
|
||||
if (w < 1000) return grams(w) + ' g';
|
||||
// Floor to one decimal so the kg shown never rounds up past what's on the spool —
|
||||
// a displayed "1.2 kg" always means you have at least 1.2 kg, never as little as 1.15.
|
||||
return grams(Math.floor(weightInGrams / 100) / 10) + ' kg';
|
||||
return grams(Math.floor(w / 100) / 10) + ' kg';
|
||||
}
|
||||
|
||||
/** Approximate remaining length in meters for a weight of filament. */
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { usageLabel } from './library';
|
||||
import { mapSpool } from '$lib/api/map';
|
||||
|
||||
// The usage status is the one thing every spool row claims, and #986 was it claiming the
|
||||
// wrong thing: a spool still in its vacuum pack reported "in use". Both halves of that bug
|
||||
// are pinned here — what counts as used, and what the label says when it is.
|
||||
|
||||
describe('mapSpool unused', () => {
|
||||
it('treats a spool with no usage as unused', () => {
|
||||
expect(mapSpool({ id: 1, used_weight: 0 }).unused).toBe(true);
|
||||
});
|
||||
|
||||
it('treats the float dust of a measured registration as unused', () => {
|
||||
// A full 1 kg spool weighed on a 128.11 g core: the API stores 2.3e-13 used, not 0.
|
||||
const dust = 1000 + 128.11 - 1128.11;
|
||||
expect(dust).toBeGreaterThan(0);
|
||||
expect(mapSpool({ id: 1, used_weight: dust }).unused).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a spool that has had filament added back as unused', () => {
|
||||
expect(mapSpool({ id: 1, used_weight: -5 }).unused).toBe(true);
|
||||
});
|
||||
|
||||
it('still counts a real print as used', () => {
|
||||
expect(mapSpool({ id: 1, used_weight: 0.5 }).unused).toBe(false);
|
||||
expect(mapSpool({ id: 1, used_weight: 120 }).unused).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usageLabel', () => {
|
||||
const spool = (over: Record<string, unknown>) => ({ unused: false, lastUsedLabel: '', ...over }) as never;
|
||||
|
||||
it('says unused for an untouched spool', () => {
|
||||
expect(usageLabel(spool({ unused: true }))).toBe('unused');
|
||||
});
|
||||
|
||||
it('says how long ago a dated spool was used', () => {
|
||||
expect(usageLabel(spool({ lastUsedLabel: '3 days' }))).toBe('used 3 days ago');
|
||||
});
|
||||
|
||||
it('says plainly that an undated spool has been used, never that it is in use', () => {
|
||||
// The old fallback here was "in use", which reads as "printing right now" — something
|
||||
// Spoolman has no way of knowing (#986).
|
||||
expect(usageLabel(spool({}))).toBe('used');
|
||||
});
|
||||
});
|
||||
@@ -31,9 +31,30 @@ export interface GroupHeaderInfo {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
/** Filament groups: the manufacturer's name, rendered as a link to it when the
|
||||
* header also gets a `vendorHref` (and as a muted note when it can't be). */
|
||||
vendorName?: string;
|
||||
colors: string[];
|
||||
direction?: MultiColorDirection;
|
||||
/** Aggregate remaining weight across the whole group, formatted. */
|
||||
meta: string;
|
||||
/** How many spools the group holds, formatted ("12 spools"). */
|
||||
count: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a row or header says about a spool's usage: "unused", "used 3 days ago", or a bare
|
||||
* "used" for a spool that has been drawn on but carries no date — one registered part-used,
|
||||
* or adjusted by weight rather than by a print.
|
||||
*
|
||||
* That last case used to read "in use", which claims something Spoolman does not know. There
|
||||
* is no live link to a printer here and no notion of a spool being mounted, so "in use" was
|
||||
* only ever shorthand for "not full"; readers understandably took it to mean "printing right
|
||||
* now" and asked why an unopened spool was busy (#986).
|
||||
*/
|
||||
export function usageLabel(s: Spool): string {
|
||||
if (s.unused) return m['library.unused']();
|
||||
return s.lastUsedLabel ? m['library.usedAgo']({ time: s.lastUsedLabel }) : m['library.used']();
|
||||
}
|
||||
|
||||
export function spoolToVM(s: Spool, repo: Repo, lowThreshold: number): SpoolVM {
|
||||
@@ -51,11 +72,7 @@ export function spoolToVM(s: Spool, repo: Repo, lowThreshold: number): SpoolVM {
|
||||
// A spool without a location leaves the column blank rather than showing a
|
||||
// placeholder — the empty box reads more cleanly in a dense list.
|
||||
location: s.location ?? '',
|
||||
rightLabel: s.unused
|
||||
? m['library.unused']()
|
||||
: s.lastUsedLabel
|
||||
? m['library.usedAgo']({ time: s.lastUsedLabel })
|
||||
: m['library.inUse']()
|
||||
rightLabel: usageLabel(s)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,11 +118,16 @@ export function rowIdentity(vm: SpoolVM, ctx: RowContext): RowIdentity {
|
||||
/**
|
||||
* Distinguish spools of one and the same filament. They're identical except for
|
||||
* how they've been used — and #id, fill, weight and location already sit in their
|
||||
* own columns — so there's nothing to make a bold title out of. Lead with the
|
||||
* usage status as a quiet line; only a comment is worth promoting when present.
|
||||
* own columns — so there's nothing to make a bold title out of. The usage status
|
||||
* is all the row claims; the inspector has the rest.
|
||||
*
|
||||
* The comment deliberately stays out of it. It reads like a label on the handful
|
||||
* of spools whose owner treats it as one, but it's a free-form textarea: people
|
||||
* keep order URLs, multi-line notes and pasted receipts in there. Promoting that
|
||||
* to the row's bold title hands the widest, loudest slot to arbitrary text and
|
||||
* truncates the usage status — the one thing every row can say — to make room.
|
||||
*/
|
||||
function spoolIdentity(vm: SpoolVM): RowIdentity {
|
||||
if (vm.spool.comment) return { title: vm.spool.comment, sub: vm.rightLabel };
|
||||
return { title: '', sub: vm.rightLabel };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractUrls, splitLinks } from './links';
|
||||
|
||||
// These run over user-authored comments and text extra fields, whose contents are
|
||||
// rendered as anchors. Both halves matter: finding the link the user meant, and
|
||||
// never producing an href from something that isn't a web URL.
|
||||
|
||||
describe('splitLinks', () => {
|
||||
it('returns nothing for empty text', () => {
|
||||
expect(splitLinks('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves link-free text as one plain segment', () => {
|
||||
expect(splitLinks('Bought at the local shop')).toEqual([{ text: 'Bought at the local shop' }]);
|
||||
});
|
||||
|
||||
it('links a bare url', () => {
|
||||
expect(splitLinks('https://example.com/p?id=4')).toEqual([
|
||||
{ text: 'https://example.com/p?id=4', href: 'https://example.com/p?id=4' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the text around a url', () => {
|
||||
expect(splitLinks('order https://shop.example/x thanks')).toEqual([
|
||||
{ text: 'order ' },
|
||||
{ text: 'https://shop.example/x', href: 'https://shop.example/x' },
|
||||
{ text: ' thanks' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('links several urls in one value', () => {
|
||||
expect(extractUrls('a http://one.example b https://two.example/c')).toEqual([
|
||||
'http://one.example',
|
||||
'https://two.example/c'
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives a bare www. host a https scheme', () => {
|
||||
expect(splitLinks('www.example.com/x')).toEqual([
|
||||
{ text: 'www.example.com/x', href: 'https://www.example.com/x' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves sentence punctuation out of the url', () => {
|
||||
expect(extractUrls('see https://example.com/p.')).toEqual(['https://example.com/p']);
|
||||
expect(extractUrls('https://example.com/a, https://example.com/b!')).toEqual([
|
||||
'https://example.com/a',
|
||||
'https://example.com/b'
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops a wrapping paren but keeps a balanced one', () => {
|
||||
expect(extractUrls('(https://example.com/p)')).toEqual(['https://example.com/p']);
|
||||
expect(extractUrls('https://example.com/Foo_(bar)')).toEqual(['https://example.com/Foo_(bar)']);
|
||||
});
|
||||
|
||||
it('accepts a schemed host without a dot, for LAN links', () => {
|
||||
expect(extractUrls('http://octopi/spool')).toEqual(['http://octopi/spool']);
|
||||
});
|
||||
|
||||
it('does not link prose that merely looks host-like', () => {
|
||||
expect(extractUrls('www.something')).toEqual([]);
|
||||
expect(extractUrls('Sold out, see notes.txt')).toEqual([]);
|
||||
});
|
||||
|
||||
it('never links a non-web scheme', () => {
|
||||
// The renderer trusts these hrefs, so anything that could execute must not
|
||||
// come back as a link at all.
|
||||
expect(extractUrls('javascript:alert(1)')).toEqual([]);
|
||||
expect(extractUrls('data:text/html,<script>')).toEqual([]);
|
||||
expect(extractUrls('file:///etc/passwd')).toEqual([]);
|
||||
});
|
||||
|
||||
it('reassembles to the original text', () => {
|
||||
const text = 'buy (https://example.com/a) or www.example.org/b. done';
|
||||
expect(
|
||||
splitLinks(text)
|
||||
.map((s) => s.text)
|
||||
.join('')
|
||||
).toBe(text);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
// Find URLs inside free-text field values (comments, text extra fields) so they
|
||||
// can be rendered as real links instead of dead text — a comment holding an
|
||||
// order link or a product page is a common way to use these fields (#992).
|
||||
//
|
||||
// Only http(s) and bare www. forms are recognised, which is deliberate: the
|
||||
// values come from users but are rendered as anchors, and never producing a
|
||||
// `javascript:`/`data:` href is the simplest way to keep that safe.
|
||||
|
||||
export interface TextSegment {
|
||||
/** The text to render. */
|
||||
text: string;
|
||||
/** Set when this segment is a URL: the href to link it to. */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/** Matches a candidate URL; the trailing run is trimmed down by `trimUrl`. */
|
||||
const URL_PATTERN = /(?:https?:\/\/|www\.)[^\s<>"'`]+/gi;
|
||||
|
||||
/** Sentence punctuation that follows a URL far more often than it belongs to one. */
|
||||
const TRAILING_PUNCT = /[.,;:!?'"]+$/;
|
||||
|
||||
const BRACKETS: [string, string][] = [
|
||||
['(', ')'],
|
||||
['[', ']'],
|
||||
['{', '}']
|
||||
];
|
||||
|
||||
function count(s: string, ch: string): number {
|
||||
let n = 0;
|
||||
for (const c of s) if (c === ch) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim what the regex over-matched: "see https://example.com/p." ends a sentence,
|
||||
* and "(https://example.com/p)" wraps the URL in parens. A *balanced* bracket is
|
||||
* kept, since paths legitimately contain them.
|
||||
*/
|
||||
function trimUrl(raw: string): string {
|
||||
let url = raw.replace(TRAILING_PUNCT, '');
|
||||
let trimmed = true;
|
||||
while (trimmed) {
|
||||
trimmed = false;
|
||||
for (const [open, close] of BRACKETS) {
|
||||
if (url.endsWith(close) && count(url, close) > count(url, open)) {
|
||||
url = url.slice(0, -1).replace(TRAILING_PUNCT, '');
|
||||
trimmed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject matches that can't be a real destination. An explicit scheme only needs
|
||||
* a host — LAN links like `http://octopi/` are perfectly valid — but the bare
|
||||
* `www.` form needs a dotted name, so prose like "www.something" isn't linked.
|
||||
*/
|
||||
function isUsable(url: string): boolean {
|
||||
const scheme = /^https?:\/\//i.exec(url);
|
||||
const host = url.slice(scheme ? scheme[0].length : 0).split(/[/?#]/)[0];
|
||||
if (!host || host.startsWith('.') || host.endsWith('.')) return false;
|
||||
return scheme ? true : /^www\.[^.\s]+\.[^.\s]/i.test(host);
|
||||
}
|
||||
|
||||
function toHref(url: string): string {
|
||||
return /^https?:\/\//i.test(url) ? url : 'https://' + url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split free text into plain and linked segments, in order. Text with no URLs
|
||||
* comes back as a single plain segment (or none, when empty).
|
||||
*/
|
||||
export function splitLinks(text: string): TextSegment[] {
|
||||
const out: TextSegment[] = [];
|
||||
if (!text) return out;
|
||||
const re = new RegExp(URL_PATTERN.source, 'gi');
|
||||
let last = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const url = trimUrl(match[0]);
|
||||
if (!url || !isUsable(url)) continue;
|
||||
if (match.index > last) out.push({ text: text.slice(last, match.index) });
|
||||
out.push({ text: url, href: toHref(url) });
|
||||
last = match.index + url.length;
|
||||
}
|
||||
if (last < text.length) out.push({ text: text.slice(last) });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The hrefs of every URL in `text`, in order. Empty when there are none. */
|
||||
export function extractUrls(text: string): string[] {
|
||||
return splitLinks(text)
|
||||
.map((s) => s.href)
|
||||
.filter((h): h is string => !!h);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { makeExtraSaver, makeSaver } from './saver';
|
||||
import type { Extra, ExtraPatch } from '$lib/types';
|
||||
|
||||
// The extra saver is what turns an inspector edit into a PATCH, so these cover the two
|
||||
// things that are easy to get wrong: clearing a value (which needs an explicit null —
|
||||
// an absent key means "leave it alone") and a pending patch landing on the entity it
|
||||
// was made on after the inspector has switched to another one.
|
||||
function harness(initial: Extra, id = 1) {
|
||||
let current: Extra = { ...initial };
|
||||
let currentId = id;
|
||||
const persisted: { id: number; patch: ExtraPatch }[] = [];
|
||||
const saver = makeExtraSaver<number>(
|
||||
() => currentId,
|
||||
(_id, extra) => {
|
||||
current = extra;
|
||||
},
|
||||
(id, patch) => persisted.push({ id, patch }),
|
||||
() => current,
|
||||
10
|
||||
);
|
||||
return {
|
||||
saver,
|
||||
persisted,
|
||||
get current() {
|
||||
return current;
|
||||
},
|
||||
select(next: number, extra: Extra) {
|
||||
currentId = next;
|
||||
current = { ...extra };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('makeExtraSaver', () => {
|
||||
it('sends only the changed key, since the API merges per key', () => {
|
||||
const h = harness({ shelf: '"A"', dry: 'true' });
|
||||
h.saver.change('shelf', '"B"');
|
||||
h.saver.flush();
|
||||
expect(h.persisted).toEqual([{ id: 1, patch: { shelf: '"B"' } }]);
|
||||
});
|
||||
|
||||
it('clears an emptied field with an explicit null', () => {
|
||||
const h = harness({ shelf: '"A"', dry: 'true' });
|
||||
h.saver.change('shelf', undefined);
|
||||
h.saver.flush();
|
||||
expect(h.persisted).toEqual([{ id: 1, patch: { shelf: null } }]);
|
||||
// …and the cache drops the key immediately, so the input renders as unset.
|
||||
expect(h.current).toEqual({ dry: 'true' });
|
||||
});
|
||||
|
||||
it('coalesces edits to several fields into one patch', () => {
|
||||
const h = harness({ shelf: '"A"', dry: 'true' });
|
||||
h.saver.change('shelf', '"B"');
|
||||
h.saver.change('dry', undefined);
|
||||
h.saver.flush();
|
||||
expect(h.persisted).toEqual([{ id: 1, patch: { shelf: '"B"', dry: null } }]);
|
||||
});
|
||||
|
||||
it('re-setting a cleared field drops the null again', () => {
|
||||
const h = harness({ shelf: '"A"' });
|
||||
h.saver.change('shelf', undefined);
|
||||
h.saver.change('shelf', '"C"');
|
||||
h.saver.flush();
|
||||
expect(h.persisted).toEqual([{ id: 1, patch: { shelf: '"C"' } }]);
|
||||
});
|
||||
|
||||
it('lands a pending patch on the entity it was made on', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const h = harness({ shelf: '"A"' });
|
||||
h.saver.change('shelf', '"B"');
|
||||
// The inspector is reused for another spool before the debounce elapses.
|
||||
h.select(2, { shelf: '"Z"' });
|
||||
vi.advanceTimersByTime(20);
|
||||
expect(h.persisted).toEqual([{ id: 1, patch: { shelf: '"B"' } }]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('flushes the previous entity before starting on the next one', () => {
|
||||
const h = harness({ shelf: '"A"' });
|
||||
h.saver.change('shelf', '"B"');
|
||||
h.select(2, { shelf: '"Z"' });
|
||||
h.saver.change('shelf', undefined);
|
||||
h.saver.flush();
|
||||
expect(h.persisted).toEqual([
|
||||
{ id: 1, patch: { shelf: '"B"' } },
|
||||
{ id: 2, patch: { shelf: null } }
|
||||
]);
|
||||
});
|
||||
|
||||
it('does nothing when there is nothing pending', () => {
|
||||
const h = harness({ shelf: '"A"' });
|
||||
h.saver.flush();
|
||||
expect(h.persisted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// Deleting the entity being edited is the one case where a pending patch must NOT
|
||||
// be sent: the row is on its way out, so the PATCH could only come back 404 and
|
||||
// toast an error the user can do nothing about.
|
||||
describe('cancel', () => {
|
||||
it('drops a pending extra-field patch, and the unmount flush finds nothing', () => {
|
||||
const h = harness({ shelf: '"A"' });
|
||||
h.saver.change('shelf', '"B"');
|
||||
h.saver.cancel();
|
||||
h.saver.flush();
|
||||
expect(h.persisted).toEqual([]);
|
||||
});
|
||||
|
||||
it('stops the extra-field debounce that was already scheduled', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const h = harness({ shelf: '"A"' });
|
||||
h.saver.change('shelf', '"B"');
|
||||
h.saver.cancel();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(h.persisted).toEqual([]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('drops a pending field patch and stops its debounce', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const saved: { id: number; patch: { name?: string } }[] = [];
|
||||
const saver = makeSaver<number, { name: string }>((id, patch) => saved.push({ id, patch }), 10);
|
||||
saver.push(1, { name: 'renamed' });
|
||||
saver.cancel();
|
||||
vi.advanceTimersByTime(100);
|
||||
saver.flush();
|
||||
expect(saved).toEqual([]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the saver usable afterwards', () => {
|
||||
const saved: { id: number; patch: { name?: string } }[] = [];
|
||||
const saver = makeSaver<number, { name: string }>((id, patch) => saved.push({ id, patch }), 10);
|
||||
saver.push(1, { name: 'dropped' });
|
||||
saver.cancel();
|
||||
saver.push(2, { name: 'kept' });
|
||||
saver.flush();
|
||||
expect(saved).toEqual([{ id: 2, patch: { name: 'kept' } }]);
|
||||
});
|
||||
});
|
||||
@@ -2,44 +2,75 @@
|
||||
// (across fields) coalesce into one PATCH; switching entity flushes the pending
|
||||
// patch first so it lands on the right one.
|
||||
|
||||
import type { Extra, ExtraPatch } from '$lib/types';
|
||||
|
||||
export interface Saver<Id, Patch> {
|
||||
push(id: Id, patch: Patch): void;
|
||||
flush(): void;
|
||||
/**
|
||||
* Drop the pending patch without sending it. For when the entity being edited
|
||||
* is about to be deleted: flushing would PATCH a row that is on its way out,
|
||||
* and the unmount flush would then fail with a 404 the user can do nothing about.
|
||||
*/
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
// Extra-field saver: optimistically applies each keystroke to the cache (so the
|
||||
// input reflects immediately) and debounces a merged PATCH of the changed keys.
|
||||
// The API merges extra per-key, so sending only the changed keys is safe.
|
||||
export function makeExtraSaver(
|
||||
apply: (extra: Record<string, string>) => void,
|
||||
persist: (patch: Record<string, string>) => void,
|
||||
getCurrent: () => Record<string, string>,
|
||||
//
|
||||
// The API merges extra per key, so sending only the changed keys is safe — and is what
|
||||
// keeps two people editing different fields of the same entity from overwriting each
|
||||
// other. An emptied input (`json === undefined`) travels as an explicit null: that is
|
||||
// what clears a stored value, whereas a key merely left out means "leave it as it is".
|
||||
//
|
||||
// The entity id is captured when the edit is made, so a pending patch always lands on
|
||||
// the entity that was being edited even if the inspector has since switched to another
|
||||
// one (the inspector components are reused across selections, not remounted).
|
||||
export function makeExtraSaver<Id>(
|
||||
getId: () => Id,
|
||||
apply: (id: Id, extra: Extra) => void,
|
||||
persist: (id: Id, patch: ExtraPatch) => void,
|
||||
getCurrent: () => Extra,
|
||||
delay = 500
|
||||
): { change: (key: string, json: string | undefined) => void; flush: () => void } {
|
||||
let pending: Record<string, string> = {};
|
||||
): { change: (key: string, json: string | undefined) => void; flush: () => void; cancel: () => void } {
|
||||
let pending: { id: Id; patch: ExtraPatch } | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function flush() {
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (Object.keys(pending).length) {
|
||||
const patch = pending;
|
||||
pending = {};
|
||||
persist(patch);
|
||||
}
|
||||
}
|
||||
|
||||
function flush() {
|
||||
stop();
|
||||
if (!pending) return;
|
||||
const { id, patch } = pending;
|
||||
pending = null;
|
||||
persist(id, patch);
|
||||
}
|
||||
|
||||
/** See {@link Saver.cancel}. */
|
||||
function cancel() {
|
||||
stop();
|
||||
pending = null;
|
||||
}
|
||||
|
||||
function change(key: string, json: string | undefined) {
|
||||
if (json === undefined) return; // empty input → nothing to persist
|
||||
apply({ ...getCurrent(), [key]: json });
|
||||
pending[key] = json;
|
||||
const id = getId();
|
||||
// A different entity than the pending edits belong to: those go out on their own.
|
||||
if (pending && pending.id !== id) flush();
|
||||
const extra = { ...getCurrent() };
|
||||
if (json === undefined) delete extra[key];
|
||||
else extra[key] = json;
|
||||
apply(id, extra);
|
||||
pending = { id, patch: { ...pending?.patch, [key]: json ?? null } };
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(flush, delay);
|
||||
}
|
||||
|
||||
return { change, flush };
|
||||
return { change, flush, cancel };
|
||||
}
|
||||
|
||||
export function makeSaver<Id, Patch extends object>(
|
||||
@@ -50,11 +81,15 @@ export function makeSaver<Id, Patch extends object>(
|
||||
let pending: Partial<Patch> = {};
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function flush() {
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function flush() {
|
||||
stop();
|
||||
if (currentId !== null && Object.keys(pending).length > 0) {
|
||||
const patch = pending as Patch;
|
||||
pending = {};
|
||||
@@ -62,6 +97,12 @@ export function makeSaver<Id, Patch extends object>(
|
||||
}
|
||||
}
|
||||
|
||||
/** See {@link Saver.cancel}. */
|
||||
function cancel() {
|
||||
stop();
|
||||
pending = {};
|
||||
}
|
||||
|
||||
function push(id: Id, patch: Patch) {
|
||||
if (currentId !== null && currentId !== id) flush();
|
||||
currentId = id;
|
||||
@@ -70,5 +111,5 @@ export function makeSaver<Id, Patch extends object>(
|
||||
timer = setTimeout(flush, delay);
|
||||
}
|
||||
|
||||
return { push, flush };
|
||||
return { push, flush, cancel };
|
||||
}
|
||||
@@ -51,6 +51,7 @@
|
||||
<AddSpoolModal
|
||||
open={ui.addModalOpen}
|
||||
presetFilamentId={ui.addModalFilamentId}
|
||||
duplicateFilamentId={ui.addModalDuplicateId}
|
||||
onclose={() => ui.closeAddModal()}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,957 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Swatch from '$components/Swatch.svelte';
|
||||
import type { Spool } from '$lib/types';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { live, type LiveEvent } from '$lib/api/live';
|
||||
import { isAbortError } from '$lib/api/http';
|
||||
import { mapSpool } from '$lib/api/map';
|
||||
import { libraryHref } from '$lib/library/params';
|
||||
import { weightAuto } from '$lib/utils/format';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { getSettings, setSetting, parseSetting } from '$lib/api/settings';
|
||||
import { dndzone, type DndEvent } from 'svelte-dnd-action';
|
||||
import { flushSync } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import GripVertical from '@lucide/svelte/icons/grip-vertical';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
const NO_LOCATION = 'No location';
|
||||
const FLIP = 160;
|
||||
/** Spools fetched per request when filling (or extending) a location card. */
|
||||
const PAGE = 30;
|
||||
/** Distance from the bottom of a card's list at which the next page is fetched. */
|
||||
const SCROLL_MARGIN = 120;
|
||||
|
||||
// A card's body reserves its final height up front, derived from the location's
|
||||
// spool count (known from the aggregate query before any spool row is fetched),
|
||||
// so the card does not grow — and shove every card below it down — when its first
|
||||
// page arrives. Without this each card jumps from empty (~110px) to full as it
|
||||
// loads, reflowing the whole grid in waves: the load-time "flicker". The result
|
||||
// is clamped to the same cap as the body's `max-height`, so a big location fills
|
||||
// exactly to the cap with no jump. These constants must track the `.chip` /
|
||||
// `.shelf-body` CSS below (chip border-box height, row gap, vertical padding).
|
||||
const CHIP_H = 44;
|
||||
const CHIP_GAP = 6;
|
||||
const BODY_PAD = 20; // 10px top + 10px bottom
|
||||
const BODY_MIN = 42; // floor; matches .shelf-body min-height
|
||||
function bodyReserve(total: number): number {
|
||||
if (total <= 0) return BODY_MIN;
|
||||
return Math.max(BODY_MIN, BODY_PAD + total * CHIP_H + (total - 1) * CHIP_GAP);
|
||||
}
|
||||
|
||||
// A location card. `id` is what svelte-dnd-action keys on; it equals `name`,
|
||||
// the location's display name (the NO_LOCATION sentinel for the unlocated
|
||||
// bucket). `spools` is the card's own drop zone, in display order — only the
|
||||
// pages loaded so far, which is why `total` (the server's count for the whole
|
||||
// location) is what the header reports.
|
||||
type Shelf = { id: string; name: string; spools: Spool[]; total: number; loading: boolean };
|
||||
|
||||
let editingLocation = $state<string | null>(null);
|
||||
let editValue = $state('');
|
||||
let renameError = $state('');
|
||||
|
||||
// The page never loads the whole spool collection. One cheap aggregate query
|
||||
// (`/spool/group?group_by=location`) yields every location and its spool count;
|
||||
// each card then fetches its own spools a page at a time, starting when the card
|
||||
// scrolls into view and continuing as its list is scrolled. So the cost scales
|
||||
// with what is on screen, not with the size of the collection.
|
||||
//
|
||||
// The `locations` setting is an ordered JSON array of names that is the source
|
||||
// of truth for both the display order and any location added before it has
|
||||
// spools. An empty string marks the position of the "No location" bucket, so it
|
||||
// can be reordered like any other card. See the equivalent `useLocations` in the
|
||||
// old client (which pins it first instead).
|
||||
const EMPTY = '';
|
||||
let settingOrder = $state<string[]>([]);
|
||||
let settingsLoaded = $state(false);
|
||||
let locationsLoaded = $state(false);
|
||||
// Per-location custom spool order, keyed by the stored location name (EMPTY for
|
||||
// "No location"), each an array of spool ids. Source of truth for the order of
|
||||
// spools within a card. Shared with the old client's `locations_spoolorders`.
|
||||
let spoolOrders = $state<Record<string, number[]>>({});
|
||||
|
||||
// What has been loaded for one location, keyed by the STORED location name
|
||||
// (EMPTY for "No location"). `total` is authoritative and comes from the group
|
||||
// aggregate; `spools` holds only the pages fetched so far. `started` means the
|
||||
// card has come into view at least once, `done` that every page is in hand.
|
||||
type Bucket = { spools: Spool[]; total: number; loading: boolean; done: boolean; started: boolean };
|
||||
let buckets = $state<Record<string, Bucket>>({});
|
||||
|
||||
// Always read the entry back out of the state proxy after creating it — the
|
||||
// object literal itself isn't reactive, so mutating it wouldn't update the UI.
|
||||
function bucket(key: string): Bucket {
|
||||
if (!buckets[key]) buckets[key] = { spools: [], total: 0, loading: false, done: false, started: false };
|
||||
return buckets[key];
|
||||
}
|
||||
|
||||
// The live drag state. `shelves` is the structure the drag zones bind to and
|
||||
// mutate directly; it is rebuilt from the data above whenever nothing is being
|
||||
// dragged. `dragging` pauses that rebuild so an in-flight drag isn't clobbered.
|
||||
// Shelf dragging is gated to the grip handle by keeping the grid zone disabled
|
||||
// until a grip press enables it (svelte-dnd-action's drag-handle pattern), so
|
||||
// it doesn't hijack pointer presses on the spool zones nested inside each card.
|
||||
let shelves = $state<Shelf[]>([]);
|
||||
let dragging = $state(false);
|
||||
let shelvesDragDisabled = $state(true);
|
||||
let displayNames = $derived(shelves.map((sh) => sh.name));
|
||||
// Nothing at all to show: no saved locations and no spools. Only decided once
|
||||
// both loads have settled, so the help text doesn't flash on the way in.
|
||||
let isEmpty = $derived(settingsLoaded && locationsLoaded && shelves.length === 0);
|
||||
|
||||
// Map between the display sentinel and the stored empty-string marker.
|
||||
const toStored = (names: string[]) => names.map((n) => (n === NO_LOCATION ? EMPTY : n));
|
||||
const storedKey = (name: string) => (name === NO_LOCATION ? EMPTY : name);
|
||||
|
||||
// The one query that runs up front: every location that currently holds spools,
|
||||
// with its count, aggregated in the database. No spool rows cross the wire.
|
||||
// Everything this page reads is tied to one controller, aborted when the page
|
||||
// goes away. Without it, leaving mid-load leaves a screenful of card queries
|
||||
// running for a server that no longer has anyone to answer — and bouncing in
|
||||
// and out of the page stacks a fresh set on top of each abandoned one.
|
||||
let pageAbort = new AbortController();
|
||||
|
||||
async function loadLocations() {
|
||||
// Captured up front: by the time this settles, `pageAbort` may already be a
|
||||
// fresh controller for a later mount.
|
||||
const signal = pageAbort.signal;
|
||||
try {
|
||||
const page = await spoolSource.listGroups({
|
||||
field: 'location',
|
||||
filters: {},
|
||||
sort: [{ field: 'group.title', dir: 'asc' }],
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
lowThreshold: settings.lowThreshold,
|
||||
signal
|
||||
});
|
||||
// A NULL location and an empty-string one are distinct rows to the database
|
||||
// but the same "No location" card here, so counts are summed by key.
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local, not reactive state
|
||||
const totals = new Map<string, number>();
|
||||
for (const g of page.items) totals.set(g.key, (totals.get(g.key) ?? 0) + g.spoolCount);
|
||||
|
||||
for (const [key, total] of totals) {
|
||||
const b = bucket(key);
|
||||
b.total = total;
|
||||
// Set both ways: spools added elsewhere reopen a card that had everything.
|
||||
b.done = b.spools.length >= total;
|
||||
}
|
||||
// A location that no longer has any spools keeps its card (if it is in the
|
||||
// setting) but drops whatever it had loaded.
|
||||
for (const [key, b] of Object.entries(buckets)) {
|
||||
if (totals.has(key)) continue;
|
||||
b.total = 0;
|
||||
b.spools = [];
|
||||
b.done = true;
|
||||
}
|
||||
} catch (e) {
|
||||
if (isAbortError(e, signal)) return;
|
||||
console.error('Failed to load locations', e);
|
||||
} finally {
|
||||
locationsLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// A screenful can be a dozen cards, and firing that many requests at once both
|
||||
// queues behind the browser's per-host connection limit (which the live-update
|
||||
// sockets already eat into) and hammers the server. Cards are filled a few at a
|
||||
// time instead; the rest wait their turn.
|
||||
const MAX_INFLIGHT = 3;
|
||||
let inflight = 0;
|
||||
const waiting: (() => void)[] = [];
|
||||
|
||||
/**
|
||||
* Wait for a slot. Returns false if the page was abandoned while queued — the
|
||||
* queue is where most of the waste sits when someone navigates away from a
|
||||
* screenful of cards, so those turns must be dropped rather than sent.
|
||||
*/
|
||||
async function acquire(signal: AbortSignal): Promise<boolean> {
|
||||
if (inflight >= MAX_INFLIGHT) await new Promise<void>((resolve) => waiting.push(resolve));
|
||||
if (signal.aborted) {
|
||||
// Hand the slot straight to the next in line instead of holding it.
|
||||
waiting.shift()?.();
|
||||
return false;
|
||||
}
|
||||
inflight++;
|
||||
return true;
|
||||
}
|
||||
function release() {
|
||||
inflight--;
|
||||
waiting.shift()?.();
|
||||
}
|
||||
|
||||
// Fetch one more page of a location's spools. Ordering is by id so paging is
|
||||
// stable; the card's custom order is applied to what has been loaded (see
|
||||
// `orderSpools`). Callers may fire this freely — it is a no-op while a request
|
||||
// is in flight or once everything is loaded.
|
||||
async function loadPage(name: string) {
|
||||
const key = storedKey(name);
|
||||
const b = bucket(key);
|
||||
if (b.loading || b.done) return;
|
||||
const signal = pageAbort.signal;
|
||||
b.started = true;
|
||||
b.loading = true;
|
||||
if (!(await acquire(signal))) {
|
||||
b.loading = false;
|
||||
b.started = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const page = await spoolSource.listSpools({
|
||||
filters: {},
|
||||
sort: [{ field: 'id', dir: 'asc' }],
|
||||
groupScope: { field: 'location', key },
|
||||
limit: PAGE,
|
||||
offset: b.spools.length,
|
||||
lowThreshold: settings.lowThreshold,
|
||||
signal
|
||||
});
|
||||
// A spool moved in by a drag or a live event may already be held here.
|
||||
const seen = new Set(b.spools.map((s) => s.id));
|
||||
const fresh = page.items.filter((s) => !seen.has(s.id));
|
||||
b.spools = [...b.spools, ...fresh];
|
||||
b.total = page.total;
|
||||
b.done = page.items.length === 0 || b.spools.length >= page.total;
|
||||
} catch (e) {
|
||||
// Leave the card unstarted so scrolling it back into view retries, rather
|
||||
// than stranding it empty for the rest of the session. A cancelled request
|
||||
// is the same story without the noise.
|
||||
if (!isAbortError(e, signal)) console.error('Failed to load spools for location', name, e);
|
||||
b.started = false;
|
||||
} finally {
|
||||
release();
|
||||
b.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** First page for a card that just scrolled into view. */
|
||||
function ensureLoaded(name: string) {
|
||||
if (!bucket(storedKey(name)).started) loadPage(name);
|
||||
}
|
||||
|
||||
function onBodyScroll(name: string, e: Event) {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_MARGIN) loadPage(name);
|
||||
}
|
||||
|
||||
// Load a card's first page once it is (nearly) on screen, so a collection with
|
||||
// hundreds of locations only queries the ones actually being looked at.
|
||||
function inView(node: HTMLElement, onEnter: () => void) {
|
||||
let enter = onEnter;
|
||||
const io = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && enter(), {
|
||||
rootMargin: '250px'
|
||||
});
|
||||
io.observe(node);
|
||||
return {
|
||||
update(next: () => void) {
|
||||
enter = next;
|
||||
},
|
||||
destroy() {
|
||||
io.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadLocationsSetting() {
|
||||
const signal = pageAbort.signal;
|
||||
try {
|
||||
const s = await getSettings(signal);
|
||||
settingOrder = parseSetting<string[]>(s.locations, []).filter((l) => l != null);
|
||||
spoolOrders = parseSetting<Record<string, number[]>>(s.locations_spoolorders, {});
|
||||
} catch (e) {
|
||||
if (isAbortError(e, signal)) return;
|
||||
console.error('Failed to load locations setting', e);
|
||||
} finally {
|
||||
settingsLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
function saveOrder(order: string[]) {
|
||||
settingOrder = order;
|
||||
setSetting('locations', order).catch((e) => console.error('Failed to save location order', e));
|
||||
}
|
||||
|
||||
// A remote spool change is applied to the loaded cards directly — reloading them
|
||||
// all would undo the point of paging. Counts (and any location that just came
|
||||
// into or out of existence) come from a single debounced aggregate refresh,
|
||||
// which is also what repairs the count of a card whose spools aren't loaded and
|
||||
// whose previous location we therefore can't know.
|
||||
let countsTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function scheduleCountRefresh() {
|
||||
if (countsTimer) clearTimeout(countsTimer);
|
||||
countsTimer = setTimeout(() => {
|
||||
countsTimer = null;
|
||||
loadLocations();
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function applyLiveEvent(event: LiveEvent) {
|
||||
// Keep the shared cache fresh first: the chips read their filament from it.
|
||||
inventory.ingest(event);
|
||||
|
||||
const id = Number(event.id);
|
||||
const spool = event.type !== 'deleted' && event.payload ? mapSpool(event.payload) : null;
|
||||
// Archived spools are not shown here, so they leave their card like a deletion.
|
||||
const newKey = spool && !spool.archived ? spool.location || EMPTY : null;
|
||||
|
||||
for (const [key, b] of Object.entries(buckets)) {
|
||||
const i = b.spools.findIndex((s) => s.id === id);
|
||||
if (i === -1) continue;
|
||||
if (key === newKey) b.spools[i] = spool!;
|
||||
else b.spools = b.spools.filter((s) => s.id !== id);
|
||||
}
|
||||
if (newKey !== null) {
|
||||
const b = buckets[newKey];
|
||||
// Pages are fetched in id order, so a card holds every spool up to the
|
||||
// highest id it has loaded: anything at or below that watermark (and
|
||||
// everything at all, once the card is complete) belongs on screen now and
|
||||
// is spliced into its id position. A higher id sits in a page that hasn't
|
||||
// been fetched, and arrives with it.
|
||||
const watermark = b?.spools.length ? b.spools[b.spools.length - 1].id : -1;
|
||||
if (b && (b.done || id <= watermark) && !b.spools.some((s) => s.id === id)) {
|
||||
const at = b.spools.findIndex((s) => s.id > id);
|
||||
b.spools = at === -1 ? [...b.spools, spool!] : b.spools.toSpliced(at, 0, spool!);
|
||||
}
|
||||
}
|
||||
scheduleCountRefresh();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
pageAbort = new AbortController();
|
||||
loadLocations();
|
||||
loadLocationsSetting();
|
||||
const off = live.subscribe('spool', {}, applyLiveEvent);
|
||||
return () => {
|
||||
off();
|
||||
if (countsTimer) clearTimeout(countsTimer);
|
||||
// Drops both the requests already on the wire and the ones still queued
|
||||
// behind MAX_INFLIGHT.
|
||||
pageAbort.abort();
|
||||
};
|
||||
});
|
||||
|
||||
// The full display order: the saved setting order (empty string → the
|
||||
// "No location" bucket), then any location discovered from spools that isn't
|
||||
// in the setting yet. The "No location" bucket only shows when unlocated
|
||||
// spools exist, and defaults to the front when the setting doesn't place it.
|
||||
function computeOrderedNames(present: Set<string>): string[] {
|
||||
const all: string[] = [];
|
||||
for (const l of settingOrder) {
|
||||
const name = l === EMPTY ? NO_LOCATION : l;
|
||||
if (!all.includes(name)) all.push(name);
|
||||
}
|
||||
if (present.has(NO_LOCATION) && !all.includes(NO_LOCATION)) all.unshift(NO_LOCATION);
|
||||
for (const name of present) if (name !== NO_LOCATION && !all.includes(name)) all.push(name);
|
||||
return all.filter((n) => n !== NO_LOCATION || present.has(NO_LOCATION));
|
||||
}
|
||||
|
||||
// Sort a location's LOADED spools by its saved custom order; any spool not in the
|
||||
// order keeps its incoming (id-sorted) position at the end. Array.sort is
|
||||
// stable, so ties preserve that order.
|
||||
function orderSpools(name: string, list: Spool[]): Spool[] {
|
||||
const order = spoolOrders[storedKey(name)];
|
||||
if (!order?.length) return list;
|
||||
const rank = new Map(order.map((id, i) => [id, i]));
|
||||
const at = (id: number) => rank.get(id) ?? Number.MAX_SAFE_INTEGER;
|
||||
return [...list].sort((a, b) => at(a.id) - at(b.id));
|
||||
}
|
||||
|
||||
// Which locations currently hold spools, by display name.
|
||||
function presentNames(): Set<string> {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local, not reactive state
|
||||
const names = new Set<string>();
|
||||
for (const [key, b] of Object.entries(buckets)) {
|
||||
if (b.total > 0) names.add(key === EMPTY ? NO_LOCATION : key);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function buildShelves(): Shelf[] {
|
||||
return computeOrderedNames(presentNames()).map((name) => {
|
||||
const b = buckets[storedKey(name)];
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
spools: orderSpools(name, b?.spools ?? []),
|
||||
total: b?.total ?? 0,
|
||||
loading: b?.loading ?? false
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Rebuild the displayed cards from the data whenever it changes, except while a
|
||||
// drag is in progress (the drag handlers own `shelves` then). Also keeps the
|
||||
// `locations` setting reflecting the displayed order so locations discovered
|
||||
// from spools are persisted; it settles once the setting matches.
|
||||
$effect(() => {
|
||||
// Both loads have to have settled: rebuilding before the locations are known
|
||||
// would drop the "No location" marker out of the order and then save that.
|
||||
if (!settingsLoaded || !locationsLoaded || dragging) return;
|
||||
const desired = buildShelves();
|
||||
shelves = desired;
|
||||
const stored = toStored(desired.map((sh) => sh.name));
|
||||
if (JSON.stringify(stored) !== JSON.stringify(settingOrder)) saveOrder(stored);
|
||||
});
|
||||
|
||||
// Arming the grid's drag zone from the grip has to happen BEFORE the press
|
||||
// reaches the card: svelte-dnd-action only attaches its mousedown/touchstart
|
||||
// listeners to an item while the zone's `dragDisabled` is false, and it does so
|
||||
// when the action updates. Svelte 5 flushes state changes asynchronously, so
|
||||
// setting the flag in a plain handler arrives too late and the press is simply
|
||||
// never seen — flushSync applies it (and re-runs the action) synchronously.
|
||||
// `pointerdown` is the hook because it precedes both mousedown and touchstart,
|
||||
// so mouse and touch are armed the same way.
|
||||
function enableShelfDrag() {
|
||||
flushSync(() => (shelvesDragDisabled = false));
|
||||
}
|
||||
|
||||
function shelfConsider(e: CustomEvent<DndEvent<Shelf>>) {
|
||||
dragging = true;
|
||||
shelves = e.detail.items;
|
||||
}
|
||||
|
||||
function shelfFinalize(e: CustomEvent<DndEvent<Shelf>>) {
|
||||
shelves = e.detail.items;
|
||||
dragging = false;
|
||||
shelvesDragDisabled = true;
|
||||
saveOrder(toStored(shelves.map((sh) => sh.name)));
|
||||
}
|
||||
|
||||
function spoolConsider(idx: number, e: CustomEvent<DndEvent<Spool>>) {
|
||||
dragging = true;
|
||||
shelves[idx].spools = e.detail.items;
|
||||
}
|
||||
|
||||
function spoolFinalize(idx: number, e: CustomEvent<DndEvent<Spool>>) {
|
||||
shelves[idx].spools = e.detail.items;
|
||||
dragging = false;
|
||||
commitSpoolLayout();
|
||||
}
|
||||
|
||||
// Persist the outcome of a spool drag from the current card layout: move any
|
||||
// spool that now sits in a different card to that location, then save every
|
||||
// card's spool order. Diff-based, so the duplicate finalize fired on the other
|
||||
// zone of a cross-card move is a harmless no-op.
|
||||
function commitSpoolLayout() {
|
||||
const changed: { id: number; location: string }[] = [];
|
||||
for (const sh of shelves) {
|
||||
const key = storedKey(sh.name);
|
||||
const b = bucket(key);
|
||||
const before = b.spools.length;
|
||||
b.spools = sh.spools.map((sp) => {
|
||||
if ((sp.location || '') === key) return sp;
|
||||
changed.push({ id: sp.id, location: key });
|
||||
return { ...sp, location: key };
|
||||
});
|
||||
// Cards hold a page, not the location, so the count has to be adjusted by
|
||||
// what moved rather than recomputed from the list.
|
||||
b.total += b.spools.length - before;
|
||||
}
|
||||
for (const c of changed) {
|
||||
inventory.patchSpool(c.id, { location: c.location });
|
||||
spoolSource.saveSpool(c.id, { location: c.location }).catch((e) => console.error('Move failed', e));
|
||||
}
|
||||
|
||||
// A card's saved order can only speak for the spools it has loaded. Ids from
|
||||
// the previous order that aren't loaded (and haven't moved to another card)
|
||||
// are kept, after the loaded ones, so a drag in a partially loaded card
|
||||
// doesn't discard the order of the pages below it.
|
||||
const moved = new Set(changed.map((c) => c.id));
|
||||
const nextOrders: Record<string, number[]> = { ...spoolOrders };
|
||||
for (const sh of shelves) {
|
||||
const key = storedKey(sh.name);
|
||||
const loaded = sh.spools.map((s) => s.id);
|
||||
const inCard = new Set(loaded);
|
||||
const carried = (spoolOrders[key] ?? []).filter((id) => !inCard.has(id) && !moved.has(id));
|
||||
nextOrders[key] = [...loaded, ...carried];
|
||||
}
|
||||
if (JSON.stringify(nextOrders) !== JSON.stringify(spoolOrders)) {
|
||||
spoolOrders = nextOrders;
|
||||
setSetting('locations_spoolorders', nextOrders).catch((e) =>
|
||||
console.error('Failed to save spool order', e)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function addLocation() {
|
||||
const existing = new Set(displayNames);
|
||||
let n = 1;
|
||||
let name = m['locations.newShelf']({ n });
|
||||
while (existing.has(name)) name = m['locations.newShelf']({ n: ++n });
|
||||
saveOrder([...settingOrder, name]);
|
||||
}
|
||||
|
||||
function focusAndSelect(el: HTMLInputElement) {
|
||||
el.focus();
|
||||
el.select();
|
||||
}
|
||||
|
||||
function startEdit(loc: string) {
|
||||
if (loc === NO_LOCATION) return;
|
||||
editingLocation = loc;
|
||||
editValue = loc;
|
||||
renameError = '';
|
||||
}
|
||||
|
||||
// Only empty, non-default locations can be removed (matches the old client).
|
||||
function deleteLocation(name: string) {
|
||||
saveOrder(settingOrder.filter((l) => l !== name));
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingLocation = null;
|
||||
renameError = '';
|
||||
}
|
||||
|
||||
async function commitEdit() {
|
||||
if (editingLocation === null) return;
|
||||
const oldName = editingLocation;
|
||||
const newName = editValue.trim();
|
||||
|
||||
if (newName === oldName) {
|
||||
editingLocation = null;
|
||||
renameError = '';
|
||||
return;
|
||||
}
|
||||
if (!newName) {
|
||||
renameError = m['locations.errorEmpty']();
|
||||
return;
|
||||
}
|
||||
if (displayNames.includes(newName)) {
|
||||
renameError = m['locations.errorExists']();
|
||||
return;
|
||||
}
|
||||
|
||||
const old = buckets[oldName];
|
||||
try {
|
||||
if (old && old.total > 0) {
|
||||
await spoolSource.renameLocation(oldName, newName);
|
||||
// Carry the loaded page over so the card doesn't have to refetch.
|
||||
buckets[newName] = {
|
||||
...old,
|
||||
spools: old.spools.map((s) => ({ ...s, location: newName }))
|
||||
};
|
||||
delete buckets[oldName];
|
||||
}
|
||||
// Carry the card's custom spool order over to the new name.
|
||||
if (spoolOrders[oldName]) {
|
||||
const { [oldName]: moved, ...rest } = spoolOrders;
|
||||
spoolOrders = { ...rest, [newName]: moved };
|
||||
setSetting('locations_spoolorders', spoolOrders).catch((e) =>
|
||||
console.error('Failed to save spool order', e)
|
||||
);
|
||||
}
|
||||
// Rename in place in the setting so the card keeps its position.
|
||||
saveOrder(settingOrder.map((l) => (l === oldName ? newName : l)));
|
||||
editingLocation = null;
|
||||
renameError = '';
|
||||
} catch (e) {
|
||||
renameError = e instanceof Error ? e.message : m['locations.errorRename']();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onpointerup={() => {
|
||||
if (!dragging) shelvesDragDisabled = true;
|
||||
}}
|
||||
/>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m['documentTitle.locations.list']()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page scroll-y">
|
||||
<div class="head">
|
||||
<span class="title">{m['locations.locations']()}</span>
|
||||
{#if !isEmpty}<span class="hint">{m['locations.dragHint']()}</span>{/if}
|
||||
<button class="add" onclick={addLocation}><Plus size={14} /> {m['locations.newLocation']()}</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid"
|
||||
use:dndzone={{
|
||||
items: shelves,
|
||||
type: 'shelf',
|
||||
flipDurationMs: FLIP,
|
||||
dragDisabled: shelvesDragDisabled,
|
||||
dropTargetStyle: {}
|
||||
}}
|
||||
onconsider={(e) => shelfConsider(e as CustomEvent<DndEvent<Shelf>>)}
|
||||
onfinalize={(e) => shelfFinalize(e as CustomEvent<DndEvent<Shelf>>)}
|
||||
>
|
||||
{#each shelves as shelf, idx (shelf.id)}
|
||||
<div
|
||||
class="shelf"
|
||||
role="list"
|
||||
animate:flip={{ duration: FLIP }}
|
||||
use:inView={() => ensureLoaded(shelf.name)}
|
||||
>
|
||||
<div class="shelf-head">
|
||||
<span
|
||||
class="grip"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label={m['locations.reorderLocation']()}
|
||||
onpointerdown={enableShelfDrag}><GripVertical size={16} /></span
|
||||
>
|
||||
{#if editingLocation === shelf.name}
|
||||
<input
|
||||
class="shelf-name-input"
|
||||
value={editValue}
|
||||
use:focusAndSelect
|
||||
oninput={(e) => (editValue = (e.target as HTMLInputElement).value)}
|
||||
onblur={commitEdit}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="shelf-name"
|
||||
class:editable={shelf.name !== NO_LOCATION}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => startEdit(shelf.name)}
|
||||
onkeydown={(e) => e.key === 'Enter' && startEdit(shelf.name)}
|
||||
>
|
||||
{shelf.name === NO_LOCATION ? m['locations.noLocation']() : shelf.name}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="shelf-meta">{m['locations.spoolCount']({ count: shelf.total })}</span>
|
||||
{#if shelf.name !== NO_LOCATION && shelf.total === 0 && editingLocation !== shelf.name}
|
||||
<button
|
||||
class="shelf-delete"
|
||||
aria-label={m['locations.deleteLocation']()}
|
||||
title={m['locations.deleteLocation']()}
|
||||
onclick={() => deleteLocation(shelf.name)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if editingLocation === shelf.name && renameError}
|
||||
<div class="rename-error">{renameError}</div>
|
||||
{/if}
|
||||
<div class="shelf-body-wrap">
|
||||
<div
|
||||
class="shelf-body"
|
||||
style="min-height: min({bodyReserve(shelf.total)}px, var(--shelf-body-cap))"
|
||||
onscroll={(e) => onBodyScroll(shelf.name, e)}
|
||||
use:dndzone={{ items: shelf.spools, type: 'spool', flipDurationMs: FLIP, dropTargetStyle: {} }}
|
||||
onconsider={(e) => spoolConsider(idx, e as CustomEvent<DndEvent<Spool>>)}
|
||||
onfinalize={(e) => spoolFinalize(idx, e as CustomEvent<DndEvent<Spool>>)}
|
||||
>
|
||||
{#each shelf.spools as s (s.id)}
|
||||
{@const f = inventory.filamentById(s.filamentId)!}
|
||||
{@const v = inventory.vendorOf(f)}
|
||||
<!-- A real link to the spool's inspector (open-in-new-tab, copy-link),
|
||||
but also a svelte-dnd-action drag item. `draggable=false` keeps the
|
||||
browser's native link drag from fighting the pointer-based dnd; a
|
||||
genuine drag never fires a click, so it won't also navigate. -->
|
||||
<a
|
||||
class="chip"
|
||||
href={libraryHref('spool', String(s.id))}
|
||||
draggable="false"
|
||||
animate:flip={{ duration: FLIP }}
|
||||
>
|
||||
<Swatch colors={f.colors} direction={f.multiColorDirection} size={22} radius={5} />
|
||||
<div class="chip-info">
|
||||
<div class="chip-title">
|
||||
<span class="chip-id mono">#{s.id}</span>
|
||||
{#if v.name !== '?'}{v.name} -
|
||||
{/if}{f.name}
|
||||
</div>
|
||||
<div class="chip-subtitle">
|
||||
{f.material} -
|
||||
<span class:low={settings.isLow(s.remaining, s.unused)}>{weightAuto(s.remaining)}</span>
|
||||
/ {weightAuto(f.weight)}{#if s.lastUsedLabel}
|
||||
- {m['locations.lastUsed']({
|
||||
time: s.lastUsedLabel
|
||||
})}{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{#if shelf.spools.length === 0 && !shelf.loading}
|
||||
<span class="empty">{m['locations.dropHere']()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if shelf.loading}
|
||||
<div class="shelf-loading" aria-hidden="true"></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if isEmpty}
|
||||
<p class="no-locations">{m['locations.noLocationsHelp']()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 20px 22px 40px;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.add {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px dashed var(--accent-border);
|
||||
color: var(--accent-link);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 12px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.add:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
/* Centered when there are only a few shelves; wraps and fills the full width
|
||||
* once enough shelves are present to need it. */
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.shelf {
|
||||
flex: 1 1 300px;
|
||||
max-width: 380px;
|
||||
/* Without this a flex item's min-width is `auto` (its min-content width), which
|
||||
* grows once the card's spool chips load. That can push the card past the point
|
||||
* where N of them still fit on a row, so the grid drops a column and re-wraps —
|
||||
* cards jumping sideways as each location's query lands (the width-wise
|
||||
* "flicker"). Pinning min-width to 0 makes the column count depend only on the
|
||||
* flex-basis and available width, so it stays put as cards fill. */
|
||||
min-width: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 110px;
|
||||
}
|
||||
.shelf-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.grip {
|
||||
color: var(--text-faint);
|
||||
font-size: 13px;
|
||||
cursor: grab;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
touch-action: none;
|
||||
}
|
||||
.grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.shelf-name {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.shelf-name.editable {
|
||||
cursor: text;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.shelf-name.editable:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
.shelf-name-input {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.shelf-name-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.rename-error {
|
||||
font-size: 11px;
|
||||
color: var(--danger-soft);
|
||||
padding: 0 12px 8px;
|
||||
}
|
||||
.shelf-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.shelf-delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.shelf-delete:hover {
|
||||
color: var(--danger-soft);
|
||||
background: var(--bg);
|
||||
}
|
||||
/* Wraps the drop zone so the "drop here" hint can overlay an empty card
|
||||
* without being a child of the zone (which maps its children 1:1 to items). */
|
||||
.shelf-body-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
/* Capped so a location with hundreds of spools stays a card rather than an
|
||||
* endless column; scrolling it to the bottom fetches the next page. */
|
||||
.shelf-body {
|
||||
/* Single source of truth for the height cap: the reserved min-height (set
|
||||
* inline from the spool count) is clamped to this too, so it never exceeds
|
||||
* max-height. */
|
||||
--shelf-body-cap: min(52vh, 420px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
flex: 1;
|
||||
min-height: 42px;
|
||||
max-height: var(--shelf-body-cap);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
/* Indeterminate bar along the bottom of a card while a page is in flight. */
|
||||
.shelf-loading {
|
||||
height: 2px;
|
||||
margin: 0 12px 6px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
background: var(--border-soft);
|
||||
}
|
||||
.shelf-loading::after {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 35%;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
animation: shelf-load 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes shelf-load {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(320%);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shelf-loading::after {
|
||||
animation: none;
|
||||
width: 100%;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: 7px;
|
||||
padding: 6px 9px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.chip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.chip-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
.chip-title {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chip-id {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-right: 2px;
|
||||
}
|
||||
.chip-subtitle {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chip-subtitle .low {
|
||||
color: var(--danger-soft);
|
||||
}
|
||||
.no-locations {
|
||||
margin: 18vh auto 0;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-faint);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,13 @@
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { languages } from '$lib/i18n/languages';
|
||||
import { trackSave } from '$lib/utils/autosave';
|
||||
import { page } from '$app/state';
|
||||
import { entityFromUrl, gotoEntity, EXTRA_FIELDS_ANCHOR } from '$lib/settings/params';
|
||||
|
||||
// Which entity's extra fields to show comes from the URL (`?fields=…`), so the
|
||||
// inspectors can link straight at the tab they're about; a URL that says nothing
|
||||
// falls back to spools.
|
||||
let fieldsEntity = $derived(entityFromUrl(page.url.searchParams) ?? 'spool');
|
||||
|
||||
const themeOptions: { value: ThemePref; label: () => string }[] = [
|
||||
{ value: 'system', label: m['settings.appearance.theme.system'] },
|
||||
@@ -125,14 +132,14 @@
|
||||
</SettingRow>
|
||||
</Card>
|
||||
|
||||
<div class="sec-label">{m['settings.extraFields.tab']()}</div>
|
||||
<div class="sec-label" id={EXTRA_FIELDS_ANCHOR}>{m['settings.extraFields.tab']()}</div>
|
||||
<div class="subtitle sub2">
|
||||
<p>{m['settings.extraFields.description.intro']()}</p>
|
||||
<p>{m['settings.extraFields.description.constraints']()}</p>
|
||||
<p>{m['settings.extraFields.description.keyUsage']()}</p>
|
||||
<p>{m['settings.extraFields.description.tableViews']()}</p>
|
||||
</div>
|
||||
<ExtraFieldsManager />
|
||||
<ExtraFieldsManager entity={fieldsEntity} onentity={gotoEntity} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -165,6 +172,9 @@
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dim);
|
||||
padding: 22px 0 8px;
|
||||
/* Deep links (#extra-fields) scroll a section to the top of the page's own
|
||||
scroller; leave it some air rather than jamming it against the edge. */
|
||||
scroll-margin-top: 16px;
|
||||
}
|
||||
.code,
|
||||
.url,
|
||||
@@ -219,7 +229,7 @@
|
||||
color: var(--text-2);
|
||||
}
|
||||
.seg-btn.active {
|
||||
background: var(--accent);
|
||||
background: var(--accent-fill);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Spoolman",
|
||||
"short_name": "Spoolman",
|
||||
"description": "Keep track of your inventory of 3D-printer filament spools.",
|
||||
"lang": "en",
|
||||
"display": "standalone",
|
||||
"background_color": "#1f1f1f",
|
||||
"theme_color": "#dc7734",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"icons": [
|
||||
{ "src": "pwa-64x64.png", "sizes": "64x64", "type": "image/png" },
|
||||
{ "src": "pwa-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "pwa-512x512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "maskable-icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 710 B |
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Self-destructing service worker — the upgrade path off the legacy React client.
|
||||
*
|
||||
* The legacy client (`client/`) registered a vite-plugin-pwa worker at `./sw.js`
|
||||
* with scope `./`, injected into its index.html. That means *every* visitor who
|
||||
* ever opened Spoolman has an active worker at the deploy root — not just people
|
||||
* who installed it to a home screen — and its precache holds a complete copy of
|
||||
* the old app shell, served for every navigation.
|
||||
*
|
||||
* client_v2 has no worker of its own. If nothing lived at this path the update
|
||||
* check would 404, and per the service worker spec a failed update leaves the
|
||||
* existing registration intact: the browser would keep serving the old React UI
|
||||
* from cache against the new backend, with nothing to indicate the upgrade ever
|
||||
* happened. So we must *answer* that update check — with a worker that takes
|
||||
* over, clears everything the old one left behind, and then removes itself.
|
||||
*
|
||||
* Keep this file for as long as anyone might still be upgrading from a version
|
||||
* that shipped the legacy PWA. It is deliberately dependency-free and lives in
|
||||
* `static/`, which adapter-static copies to the build root, so it is reachable at
|
||||
* `sw.js` relative to the deploy base path — the exact URL the old registration
|
||||
* polls, base path or not.
|
||||
*
|
||||
* Covered by tests_frontend_v2/tests/legacy-sw.spec.ts.
|
||||
*/
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
// Don't queue up behind the outgoing worker in "waiting" — that state only
|
||||
// clears when every tab is closed, which is precisely the user who is stuck.
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// Drop the old app shell. Nothing on this origin caches anything anymore,
|
||||
// so clearing the lot is both correct and the only way to be sure the
|
||||
// precache can't resurface.
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
||||
|
||||
await self.registration.unregister();
|
||||
|
||||
// unregister() doesn't touch pages that are already open — they stay
|
||||
// controlled, still showing whatever the old worker handed them. Reload
|
||||
// them so they come back uncontrolled, straight from the server.
|
||||
const windows = await self.clients.matchAll({ type: 'window' });
|
||||
await Promise.all(
|
||||
windows.map((client) =>
|
||||
// navigate() rejects for clients this worker doesn't control; that's a
|
||||
// page we can't help anyway, and the registration is already gone.
|
||||
Promise.resolve(client.navigate(client.url)).catch(() => {})
|
||||
)
|
||||
);
|
||||
})()
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { paraglideVitePlugin } from '@inlang/paraglide-js';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
@@ -11,5 +11,11 @@ export default defineConfig({
|
||||
strategy: ['localStorage', 'preferredLanguage', 'baseLocale']
|
||||
})
|
||||
],
|
||||
server: { port: 5174 }
|
||||
server: { port: 5174 },
|
||||
test: {
|
||||
// Unit tests only. The Playwright a11y audit lives in e2e/ and is run by
|
||||
// `npm run audit:a11y`; including it here would make vitest try to execute it.
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'node'
|
||||
}
|
||||
});
|
||||
@@ -10,6 +10,30 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Kubernetes sets a Docker-style "service link" variable for every Service that
|
||||
# exists in the namespace when the pod starts, so a Service named "spoolman"
|
||||
# makes SPOOLMAN_PORT=tcp://<clusterIP>:8000 here. That is the Service address,
|
||||
# not a port, and uvicorn refuses to start on it. Ignore it and keep the default.
|
||||
case "$SPOOLMAN_PORT" in
|
||||
*://*)
|
||||
echo "Warning: ignoring SPOOLMAN_PORT=\"$SPOOLMAN_PORT\", which is a Kubernetes service link rather than a port number. Using 8000 instead." >&2
|
||||
SPOOLMAN_PORT=8000
|
||||
;;
|
||||
esac
|
||||
|
||||
# A non-numeric PUID/PGID makes the [ -ne ] test below error out, so usermod is
|
||||
# skipped and the app silently runs as the default 1000. PUID 0 is worse: the
|
||||
# container starts as root and drops to "app" via gosu, so a PUID of 0 keeps
|
||||
# that user at uid 0, the "still root?" check never turns false, and the script
|
||||
# re-execs itself forever.
|
||||
case "$PUID" in
|
||||
'' | *[!0-9]*) fail "PUID must be a number, got \"$PUID\"." ;;
|
||||
0) fail "PUID cannot be 0: Spoolman drops root privileges and cannot run as root." ;;
|
||||
esac
|
||||
case "$PGID" in
|
||||
'' | *[!0-9]*) fail "PGID must be a number, got \"$PGID\"." ;;
|
||||
esac
|
||||
|
||||
if [ "$(id -u app)" -ne "$PUID" ]; then
|
||||
usermod -o -u "$PUID" app ||
|
||||
fail "Failed to update app UID to $PUID"
|
||||
|
||||
@@ -36,8 +36,12 @@ pre-commit:
|
||||
stage_fixed: true
|
||||
- name: Run type check on client_v2 files
|
||||
root: "client_v2/"
|
||||
# svelte-kit sync generates .svelte-kit/tsconfig.json, which svelte-check needs.
|
||||
run: ./node_modules/.bin/svelte-kit sync && ./node_modules/.bin/svelte-check --tsconfig ./tsconfig.json
|
||||
# Both prerequisites are gitignored generated code, absent on a fresh
|
||||
# clone: paraglide compiles $lib/paraglide (normally done by the vite
|
||||
# plugin, so a non-build check never triggers it), and svelte-kit sync
|
||||
# generates .svelte-kit/tsconfig.json. npm run keeps the paraglide
|
||||
# flags in package.json only, so they can't drift from vite.config.ts.
|
||||
run: npm run paraglide && ./node_modules/.bin/svelte-kit sync && ./node_modules/.bin/svelte-check --tsconfig ./tsconfig.json
|
||||
|
||||
# Same as pre-commit but no fixes
|
||||
ci:
|
||||
@@ -52,6 +56,8 @@ ci:
|
||||
run: uv run ruff format --check .
|
||||
- name: Check UV lockfile consistency
|
||||
run: uv lock --check
|
||||
- name: Run backend unit tests
|
||||
run: uv run pytest tests
|
||||
- group: # Client checks
|
||||
parallel: false
|
||||
jobs:
|
||||
@@ -72,6 +78,6 @@ ci:
|
||||
run: ./node_modules/.bin/prettier --check .
|
||||
- name: Run type check on client_v2 files
|
||||
root: "client_v2/"
|
||||
# svelte-kit sync generates .svelte-kit/tsconfig.json, which svelte-check needs.
|
||||
run: ./node_modules/.bin/svelte-kit sync && ./node_modules/.bin/svelte-check --tsconfig ./tsconfig.json
|
||||
# See the ci hook's copy of this job for why paraglide runs first.
|
||||
run: npm run paraglide && ./node_modules/.bin/svelte-kit sync && ./node_modules/.bin/svelte-check --tsconfig ./tsconfig.json
|
||||
|
||||