Compare commits

...
Author SHA1 Message Date
Donkie 46beda2264 Offer a filament's spools in the search results
Searching a filament name only ever returned the filament: a spool matches
on its own text (comment, location, lot number, extra fields) or its exact
id, so a name that lives on the filament could not reach any of its spools.
The hit named the filament you own and then dead-ended.

Each filament result now carries its first few spools as pills, with an
overflow link to the filament for the rest. GET /search gains an opt-in
spools_per_filament parameter; at 0, the default, the response is byte for
byte what it was, so nothing else that reads the endpoint changes.

The spools are fetched in one query over the matched filaments and grouped
in Python rather than with a per-group window function, which keeps the
behaviour identical on all four supported databases, and yields the exact
total count for free. They obey allow_archived like the rest of the
response, and are ordered by id so "first" means the same thing everywhere.

Fixes #993
2026-08-02 08:40:41 +02:00
Donkie a2cfe33cd3 Merge pull request #1003 from tomaioo/fix/security/unsafe-json-parsing-of-application-setti
Security: Unsafe JSON parsing of application settings
2026-08-02 08:16:32 +02:00
Donkie f8db2a0f09 Merge pull request #1001 from Donkie/fix/library-header-affordances
fix(library): make the group header's destinations findable
2026-08-02 08:15:07 +02:00
tomaioo 95230eaa73 fix(security): unsafe json parsing of application settings
The `useCurrency` and `useCurrencyFormatter` functions parse currency settings using `JSON.parse` directly on values fetched from the backend. If the backend returns malformed JSON or unexpected data types, this will throw an unhandled exception and crash the UI component relying on these hooks.

Signed-off-by: tomaioo <203048277+tomaioo@users.noreply.github.com>
2026-08-01 23:12:38 -07:00
2cdfcc1bae Translations update from Hosted Weblate (#1002)
* Translated using Weblate (Czech)

Currently translated at 47.6% (220 of 462 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/cs/

* Translated using Weblate (Swedish)

Currently translated at 35.7% (165 of 462 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/sv/

---------

Co-authored-by: Miloslav Kos <kos.m@post.cz>
Co-authored-by: Daniel Hultgren <daniel.cf.hultgren@gmail.com>
2026-08-02 05:17:00 +00:00
Donkie e175708b3e fix(ci): diff PRs from the merge base in guard-translations
The guard compared the base branch tip against the PR head directly, so
every commit that landed on master after a PR forked counted as one of
that PR every time it re-ran. Merging a Weblate sync therefore failed
unrelated PRs with client_v2/locales/sv/common.json in the offending
list, and the only way out was rebasing a PR that had never touched a
translation file.

Diffing BASE...HEAD resolves the merge base first, which is what "the
changes this PR makes" means, and it stays right however far master
moves on. Verified against PR 1001: the old form flags sv, the new form
passes, and a real hand-edit of sv is still caught.
2026-08-01 22:11:42 +02:00
Donkie a5e4bc0bf3 fix(library): drop the group header arrow
The hover underline and colour already say the title is a link, and the
arrow was one more glyph on a row that has a chevron doing something
else. Touch users lose the at-rest cue, but the manufacturer link below
is visible without hover either way.
2026-08-01 21:55:42 +02:00
Weblate (bot)andDaniel Hultgren cbc6458c5d Translated using Weblate (Swedish) (#1000)
Currently translated at 36.6% (161 of 439 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/sv/

Co-authored-by: Daniel Hultgren <daniel.cf.hultgren@gmail.com>
2026-08-01 15:18:43 +00:00
Donkie 13b7a9b62f Merge pull request #999 from Donkie/worktree-issue-992-detail-fields
Show all filament and manufacturer fields in the library detail views
2026-08-01 17:18:29 +02:00
Donkie 7c29d5f54a Merge pull request #998 from Donkie/worktree-client-v2-delete-991
client_v2: add delete for filaments and manufacturers
2026-08-01 11:14:23 +02:00
Donkie 6663a42ab5 client_v2: add delete for spools
The old client could delete spools (canDelete on the resource, rendered
by Refine's Show header buttons); client_v2 shipped with archive only.
That left the filament delete a dead end: archiving does not release the
foreign key, and there is no way to reassign a spool either, so a
filament that had ever held a spool could never be removed.

The confirm warns when filament is left on the spool, since archiving is
probably what was meant in that case.

Also corrects the blocked-filament hint, which suggested moving spools
to another filament - something this UI cannot do.
2026-08-01 11:03:24 +02:00
Donkie b34b872140 client_v2: make duplicate and delete quiet icon actions
Both are occasional next to "Add spools of this", so they no longer
compete with it: a hairline separator, then two icon-only ghost buttons
that stay muted until hovered. The accent button gets the end of the row
back.

Adds an ariaLabel prop to Button, since an icon has no text to name it.
2026-08-01 10:56:32 +02:00
Donkie dadc2ba760 Fold mirrored extra fields into the grid above them
In the spool view the filament's custom fields sat under their own "Extra
fields" heading, styled exactly like the FILAMENT and MANUFACTURER headings
beside it, so it read as a third peer section rather than as more of the
filament. Emit those rows straight into the preceding field grid instead.

Only the read-only mirrors change; an entity's own extra-fields section
keeps its heading, which is also where the "Manage fields" link lives.
2026-08-01 10:54:25 +02:00
Donkie 35cc1c7d43 Show all filament and manufacturer fields in the detail views
The spool view's filament section only listed the specs, and neither the
spool nor the filament view showed anything of the manufacturer beyond its
name. Fill both in, and render URLs in text fields as links.

- Spool view's filament section gains price, article number, external id,
  registered and comment, and always shows spool weight.
- New read-only VendorSection, shown in both the spool and filament views,
  with the manufacturer's fields and extra fields plus a link to it.
- Any URL in a comment or text extra field is now a link opening in a new
  tab with rel="nofollow noopener": inline in read-only values, and as an
  icon beside the input where the value stays editable.

Fixes #992
2026-08-01 09:52:52 +02:00
Donkie e08c930ef8 client_v2: add delete for filaments and manufacturers
Closes #991.

Both inspectors get a Delete button backed by a new ConfirmDialog that
states the consequence instead of asking a bare "are you sure?".

A filament that still has spools cannot be deleted (the spool FK is not
nullable), so the dialog says how many hold it down rather than letting
the click come back as a generic error toast. The count comes from the
server with allow_archived=true: the panel hides archived spools, but
the foreign key does not care.

A manufacturer with filaments can be deleted, and silently orphans them.
The dialog says so before the fact, and the cache clears the vendor off
those filaments to match what the server did.

Pending inline edits are dropped before the delete, so the unmount flush
does not PATCH a row on its way out.

Adds integration tests for both API behaviours, which were untested.
2026-08-01 09:52:49 +02:00
Donkie 8318a5f970 fix(library): make the group header's two destinations findable
Clicking a filament group header opens the filament, but nothing said so:
its only hover signal was the same background tint a spool row gets, and
the one glyph on the row is the collapse chevron, which says "fold", not
"open". Reaching a manufacturer was worse off -- the breadcrumb, the
search box and the group menu were the only routes, and each one asks you
to already know it is there (#989).

The title now takes a link's underline and colour on hover, beside an
arrow that stays faintly visible at rest: hover is the one affordance a
touch user never sees, and they are a good share of who could not find
this. The manufacturer already printed on every filament group is now a
link to its inspector, so the shortest route to a vendor is one click
from the main list. Inspector breadcrumbs underline on hover too, for the
same reason.

Anchors cannot nest, so the entity link became an overlay covering the
whole row, with the manufacturer link and the disclosure stacked above
it. The row keeps its full click target, and the overlay is a real
element rather than a stretched pseudo-element so tap-target tooling
measures what people actually press. The manufacturer takes its colour
from --accent-soft: --accent-link is tuned for the lighter surfaces links
normally sit on and manages only 4.2:1 against the sunken header in the
light theme, where --accent-soft clears AA in both (4.9 / 7.3).

Row height is unchanged at 52px and the mobile a11y audit reports the
same zero axe violations it did before the change. The manufacturer link
is a 28px tap target rather than the audit's preferred 44px: at 44 it
would span most of the row and start swallowing clicks meant for the
filament link underneath it.

Fixes #989
2026-08-01 09:51:19 +02:00
Donkie c7b59fe322 Merge pull request #997 from Donkie/fix/988-filament-extra-fields
fix(add): let filament extra fields be filled in while creating a filament
2026-08-01 09:32:37 +02:00
Donkie c9c59b3d9e fix(add): let filament extra fields be filled in while creating a filament
The add-spool modal is the only filament create form client_v2 has, and its
new-filament section had no extra fields at all: a value could only be set
afterwards from the inspector, so anyone with custom filament fields lost the
thing the legacy client's create form gave them (#988). Duplicating a filament
carried the original's values along invisibly, with no way to adjust them.

The section renders itself away when no filament fields are defined, so it
costs nothing for the majority who have none. It sits outside "Advanced specs"
— a custom field only exists because someone defined it, which makes it the
opposite of an advanced detail to them.

Defaults are also applied more reliably than before: field definitions are
fetched when the modal opens, so opening straight into a preset filament or a
duplicate used to run the seeding before the definitions had arrived and left
the defaults off. They are now topped up when the definitions land, which fixes
the same latent miss on the spool section.

Covered end to end in tests_frontend_v2: define a filament field, fill it in
while creating the filament, and find the value back on the filament itself.

Claude-Session: https://claude.ai/code/session_01EaLK21WpQYDE1DkKwZHK3P
2026-07-31 20:56:10 +02:00
Donkie 09c29703f0 Merge pull request #990 from Donkie/fix/last-used-null-sort
fix: last-used sort order and the phantom "in use" label
2026-07-31 16:46:59 +02:00
Donkie a8da5e312b fix(library): stop unopened spools reporting themselves as "in use"
A spool still in its vacuum pack, with no first- or last-used date, showed
"in use" in the library and its inspector (#986). Two separate causes.

Registering a spool by its measured weight computes `net + spool - measured`
in floating point, and a full 1 kg spool on a 128.11 g core lands on 2.3e-13
rather than 0 -- so `used_weight === 0` was false and the spool counted as
used from the moment it was added. It also rendered "1000.0 g" where its
identical neighbour showed "1 kg". The subtraction is now rounded at the
source, `unused` allows a hundredth of a gram (under any scale's resolution,
and under the display's, so nothing real is rounded away), and weightAuto
rounds before choosing its unit. Existing spools already carrying dust read
correctly again without touching the database.

The label itself was also wrong. "in use" was the fallback for "used, but no
date on record", and it claims something Spoolman cannot know: there is no
live link to a printer and no notion of a spool being mounted. It now reads
a plain "used", via one shared usageLabel() so the row, the spool inspector
and the filament inspector can no longer drift apart.

Claude-Session: https://claude.ai/code/session_01EaLK21WpQYDE1DkKwZHK3P
2026-07-31 14:39:03 +02:00
Donkie c8be881e03 fix(sort): keep never-used spools and filaments at the bottom
Sorting the library by "last used" led with everything that had never been
used, on PostgreSQL and CockroachDB. Those two default to NULLS FIRST on
DESC, while SQLite and MySQL treat NULL as the lowest value and put it last;
`last_used` is NULL until a spool is first used, so the default library
ordering came out inverted for half the supported databases (#984, #985).
Grouped views showed it worst: a group's date is the max over its spools, so
a filament holding nothing but unused spools aggregates to NULL and floated
to the top, above filaments used minutes ago.

order_by_clauses() now orders on an explicit "is it null" flag ahead of the
field itself, so an absent value sorts last in both directions everywhere.
It is written as `expr IS NULL` rather than SQLAlchemy's nullslast(), which
renders a literal NULLS LAST that MySQL and MariaDB reject. Applied to every
sort site -- spools, groups, filaments, vendors, extra fields -- so ordering
no longer depends on which database is underneath.

Verified against all four: 438 integration tests pass on SQLite, PostgreSQL,
MariaDB and CockroachDB, and both new regression tests fail on PostgreSQL
without the fix.

Claude-Session: https://claude.ai/code/session_01EaLK21WpQYDE1DkKwZHK3P
2026-07-31 14:38:50 +02:00
Donkie 7405ce77fd Merge pull request #983 from Donkie/worktree-weblate-client-v2
Point the translation workflows at client_v2, close the legacy client to translation
2026-07-31 10:53:59 +02:00
DonkieandClaude Opus 4.8 673ca1d2fb Drop the README wording change
Leave the feature-list line as it was; this PR is about the workflows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 10:52:54 +02:00
DonkieandClaude Opus 4.8 4e1e3c50c9 Point the translation workflows at client_v2, close the legacy client
Weblate translates client_v2 from now on. The two translation workflows
still only knew about client/public/locales/, which meant a Weblate sync
for the new client would be rejected by the auto-merge scope check and
hand-edits to client_v2 translations would sail through unguarded.

- weblate-automerge: accept locale JSON under either client_v2/locales/
  or client/public/locales/. The legacy tree stays on the allowlist only
  so a sync opened before its component was locked isn't stranded; the
  Weblate-side lock, not this allowlist, is what ends legacy translation.

- guard-translations: guard both trees, with different rules. For
  client_v2, modifying or deleting an existing non-English file is
  blocked while adding a brand-new language stays allowed (bootstrapping
  one also needs entries in project.inlang/settings.json and
  src/lib/i18n/languages.ts, which only a human PR can add — the failure
  message now says so). For the legacy client every non-English change
  is blocked, adds included, and the message redirects translators to
  the client users actually get.

- README: 18 -> 26 languages, and say outright that translations come
  through Weblate rather than pull requests.

Verified: the guard script passes 9 synthetic PR cases (en edits and new
v2 languages allowed; v2 edits/deletes, legacy edits and legacy new
languages blocked; both sections render when a PR trips each rule), and
the auto-merge regex accepts both locale trees while still rejecting
code, workflows, non-JSON and path-prefix smuggling.

Also confirmed the format risk is a non-issue: if Weblate's i18next v4
writer emits nested JSON instead of the repo's flat dotted keys,
paraglide still compiles it — nested German round-tripped intact, and a
nested _one/_other pair compiled to a correct CLDR plural with other
locales still falling back to English.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 10:35:01 +02:00
Donkie f369253c67 Bump version to 0.26.0 2026-07-31 08:19:20 +00:00
Donkie dd6b334a79 Test config.js rendering without importing main
The unit tests are supposed to run with no database, no server and no
built client, but tests/test_configjs.py imported spoolman.main, which
builds the SPA handler at module level and so needs client_v2/build to
exist. That passed locally, where the bundle is lying around, and failed
CI's style job, which never builds the client.

Moves the rendering into client.render_config_js, which the endpoint now
calls, so the test exercises the real code path without the app.
2026-07-31 10:00:47 +02:00
Donkie a8b3641a90 Document the two origin/host settings in .env.example
The CORS example was schemeless (source1.domain.com:p1), which is not an
origin and so never matches anything -- anyone following it got silent
non-functionality. Adds SPOOLMAN_ALLOWED_HOSTS alongside it and points at
the new wiki Security page.
2026-07-31 09:50:55 +02:00
Donkie 72daa29cd2 security: finish the audit's low-severity items
Task 10: config.js JSON-encodes the base path, so a backslash cannot
escape the string literal the way the old " check allowed; search.py
escapes LIKE wildcards at both its call sites, using a helper moved into
database/utils.py so it and extra_field_query share one implementation.

Task 9 was a pair of decisions rather than fixes. /info keeps its paths:
both clients render them and removing response fields breaks API v1, while
the disclosure buys nothing against an API that is unauthenticated anyway.
/metrics keeps its shape but now says what it publishes when switched on.

Also pins the unit tests to a throwaway data directory -- importing
spoolman.main creates one as a side effect, and it was the real one.
2026-07-31 09:42:32 +02:00
Donkie 7e9533965a security: make the host guard opt-in
Defaulting it on was the wrong trade. The only hostname it can refuse is a
registrable public domain, so it broke exactly the reverse-proxy-on-a-real-
domain deployments that configure nothing today -- a 400 on every request
until they found a new variable -- while the deployments rebinding can
actually reach are addressed by IPs, single-label names and .local, which
it never refuses anyway. The group that breaks and the group that gains
barely overlap.

SPOOLMAN_ALLOWED_HOSTS is now the on switch as well as the allowlist, so
there is no state where an operator has to think about this without having
asked for it. SPOOLMAN_CORS_ORIGIN deliberately does not enable it, or
setting CORS for a Fluidd instance would start refusing your own hostname.
2026-07-31 09:36:41 +02:00
Donkie 97524b1107 security: refuse requests addressed to a host we do not answer to
Closes the DNS rebinding hole, which the origin guard structurally cannot:
under rebinding the attacker's page keeps its own name, so Origin and Host
agree with each other and the origin guard trusts them both, while the
address behind that name is the victim's Spoolman instance.

The default allows everything a stranger cannot point at someone else's
machine -- any IP literal, any single-label name, and the non-registrable
suffixes -- because what the attack needs is a registrable public domain.
That leaves a reverse proxy on a real domain, which cannot be told apart
from the attack and so must declare itself via SPOOLMAN_ALLOWED_HOSTS.
2026-07-31 09:29:47 +02:00
Donkie e3dbd3e609 Move the security notes out of the README and into the Wiki's remit
The README carries no config reference at all -- env vars and deployment
guidance live in the Wiki -- so documenting SPOOLMAN_CORS_ORIGIN and the
reverse-proxy host requirement here gave them two homes to drift apart in.
2026-07-31 09:19:42 +02:00
Donkie 388e87ec96 Merge branch 'master' into security/backend-audit-todo
# Conflicts:
#	pyproject.toml
2026-07-31 09:15:22 +02:00
Donkie ff7170f0f7 Build on Node 24, which is not end-of-life
Every workflow pinned node-version '20'. Node 20 reached end-of-life on
2026-04-30, so for three months CI has been building the artifacts that
go into released images on a runtime that no longer gets security fixes.
Vite 8 also floors at ^20.19, which setup-node's '20' happens to satisfy
today only because it resolves to the latest 20.x -- a floor we were
clearing by accident is not one worth keeping.

24 rather than 22: it is Active LTS until 2026-10-20 and supported
through 2028-04-30, so this pin outlives the 22 line by a year. Node 26
does not ship until October.

The legacy client declared engines "20.x", which would have made every
npm ci in that package print EBADENGINE against the new runner. It is
widened to the floor of the Vite 7 it already runs on, so it now
describes what the package actually supports instead of naming a single
line.

Verified locally on Node 24.13.0: lefthook ci green, both clients
install clean and build, client_v2's unit tests and the
tests_frontend_v2 Playwright suite pass. tests_frontend needs the built
image so it is left to CI; its only dependency declares node >=18.

The setup-node pin itself is untouched and still on v4.0.3, whose action
runtime is the other deprecated Node 20 in the logs -- GitHub already
force-runs it on 24. That is an action upgrade, and a separate change.
2026-07-31 09:00:16 +02:00
Donkie 3c414503d6 Move client_v2 to Vite 8 and refresh the rest of its dependencies
Dependabot #982 opened the door: an esbuild advisory that can only be
closed by going forward, since the fix is upstream of us in
vite-plugin-svelte and vitest. It could not merge on its own, though --
Vitest 4 no longer augments Vite's defineConfig, so the `test` block in
vite.config.ts became a type error and the style job went red on a file
Dependabot is not allowed to touch. The config now takes defineConfig
from vitest/config, which is where that overload lives.

With the chain forced open anyway, everything else that had drifted goes
with it: SvelteKit, Svelte, paraglide, Playwright, prettier-plugin-svelte
4, globals 17 and ESLint 10. TypeScript stays on 5.9 -- 7.0.2 is out, but
typescript-eslint pins <6.1.0 and svelte-check ^5 || ^6, so it is
peer-blocked on both sides rather than merely untested.

Vite 8 replaces Rollup and esbuild with Rolldown, so the toolchain is now
one Rust binary plus lightningcss. Measured against a clean checkout of
the old tree: the production build drops from 7.7s to 4.6s, shipped
JavaScript from 383 KB gzipped across 42 chunks to 360 KB across 36. Dev
cold start is unchanged at ~2s, as expected -- dev serves unbundled ESM
either way. The armv7 image is not exposed to Rolldown's native binaries:
build-client runs on amd64 and the arch jobs only download its artifact.

ESLint 10 turns on no-useless-assignment, which caught three initialisers
that every branch overwrites; they become bare declarations. The
allowScripts block pinning esbuild@0.21.5 goes too, as esbuild is no
longer in the tree at all and nothing in the repo reads that field.

The lockfile is regenerated rather than patched: the old one pinned
vite-plugin-svelte 4 firmly enough that npm could not resolve a path to
7 without it.

Verified on Node 24: lefthook ci green, 50 unit tests, a11y audit, and
the full tests_frontend_v2 Playwright suite (21/21) against a real
backend serving the new build.
2026-07-31 08:59:46 +02:00
Donkie b00c1b875d Add dashboard and label-designer screenshots to the README
The library screenshot shows one of the client's four pages. The two
that are hardest to guess at from a feature list -- spools as draggable
cards grouped by location, and labels built from arbitrary spool fields
-- now sit under it in a two-column table, so they cost one screen's
height between them rather than one each.

They are captured at a narrower viewport than the library shot rather
than scaled down from it: at half width a 1440px page reduces to
unreadable, whereas 900px reflows the dashboard to two columns and the
designer to a tighter grid, so the text survives the downscale. Both are
cropped to a 3:2 aspect and have the nav bar removed -- it is already in
the shot above -- so the pair sits level.

Fixed along the way, since it would otherwise have been immortalised in
the dashboard screenshot: the card separators lost a space where they
met an {#if} boundary, which Svelte trims, rendering "Bambu Lab -Bambu
Green" and "1 kg- Last used 2 days ago". The optional parts are now
built as one expression, which has no block edge to trim.

demo_seed gains a note on why label designs aren't seeded: they're a
blob of a client-side schema, so writing one from Python would duplicate
it. The designer's own "Create your first label" is the honest route.
2026-07-31 08:37:47 +02:00
Donkie 0c03f8b404 Stop promoting a spool's comment to its library row title
Grouped by filament, a row had nothing of its own to put in the bold
title slot -- #id, fill, weight and location all have columns -- so it
borrowed the comment when one was set, and dropped the usage status to a
quiet second line behind it.

That assumed comments are short labels. The field is a free-form
textarea: people keep order URLs, multi-line notes and pasted receipts
there. Whatever is in it got the widest, loudest slot in the row, pushed
out "used N ago" -- the one thing every row can say -- and, since .name
is nowrap, ran any newlines together into one line before ellipsing
mid-word.

Rows now always show the usage status. The comment is unabridged in the
inspector, which is the only place it was ever readable in full.

The screenshot is regenerated against the new behaviour, and demo_seed's
comments go back to natural length -- they were written short only to
survive the row.
2026-07-30 22:09:06 +02:00
Donkie 67022e7c6d Regenerate the README screenshot from catalog-imported filaments
The old one was seeded with stress_seed.py, which builds a filament name
from one random pool and its color hex from another, so the picture
contradicted itself: "PETG Copper Bronze" had a blue swatch and "PVA
Sparkle Silver" a tan one. That is the first thing a visitor sees.

scripts/demo_seed.py imports every filament from SpoolmanDB instead,
issuing the same request the client's Add-spools flow does, so names,
colors, densities and temperatures come from one source and agree with
each other. Twenty filaments across eight real vendors are pinned by
external id, chosen for material spread and a varied swatch column; if
one leaves the catalog the script fails with the list rather than
quietly drifting.

The spools on top of them are still made up, because that half really is
per-user data -- partial weights, locations, lot numbers, prices,
first/last-used dates, a few archived and a few still sealed -- plus the
handful of extra fields a real user defines, so the inspector shows
content where it used to show its empty state.

Two details are shaped by how the list renders rather than by realism:
spools consume a whole number of grams, since a decimal wraps onto a
second line in the narrow weight column, and comments stay under ~20
characters, since the list shows a comment in place of "used N ago" and
truncated the longer ones mid-word.
2026-07-30 22:03:19 +02:00
Donkie 4ab99768ae Abort last workflow run for PRs when new push 2026-07-30 21:48:55 +02:00
Donkie c1d5c6b46e Merge pull request #978 from Donkie/feature/v2-client
New frontend
2026-07-30 21:48:03 +02:00
Donkie aad337234e Compile paraglide before type-checking client_v2
The type check assumed $lib/paraglide existed, but it's gitignored
output of the paraglide vite plugin, so it only appears once something
runs vite. svelte-check doesn't, which left the job resolving ~40
imports of $lib/paraglide/{messages,runtime} against nothing on any
fresh checkout -- CI every time, and a new clone locally. It went
unnoticed because the job had been dying at exit 127 before it could
get far enough to report, and because a working tree that has ever run
dev or build has the directory sitting there.

The compiler ships a CLI, so this costs a compile rather than a full
vite build. Its flags live in a package.json script that `check` and
the hook both call, leaving one copy to keep in sync with the plugin
options in vite.config.ts.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 21:34:26 +02:00
Donkie 9bdf4d1a5e Install client_v2 deps in the style job
lefthook's ci hook grew a client_v2 group, but the style job only ran
npm ci in client/, so all three of its jobs died at exit 127 on missing
./node_modules/.bin binaries -- eslint, prettier and svelte-kit. The
hook is invoked from the repo root and the paths are hardcoded (for
Windows' sake), so nothing resolved them from client/'s tree. That has
been failing every run since the group was added, on a branch where
client_v2 is the client that ships, so its lint, format and type checks
were the ones not running.

client_v2/docs is gitignored scratch space that prettier still walked,
which fails the hook locally on files CI never sees; the ignore files
now agree.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 21:29:07 +02:00
Donkie 24e6f7fd34 Escape backslashes in the a11y report's table escaper
CodeQL flagged esc() in the Playwright global teardown: it replaced the
markdown cell separator with an escaped one but left backslashes alone,
so an input ending in a backslash produced a doubled backslash followed
by a bare pipe -- the backslash escaped itself and the pipe went on to
split the row. axe help strings and CSS selectors are arbitrary page
content, so that is reachable, if only ever in a local audit report.

Escaping both metacharacters in a single pass fixes it: nothing the
replacer emits is re-examined, so an escape can't be consumed by the
character before it. Newlines get flattened at the same time, since a
newline ends a table row just as effectively as a stray pipe did.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 21:28:43 +02:00
Donkie f4eba480d6 Catch client_v2 up to 0.25.0
The merge brought master's version bump, which ran before bump.py knew
about client_v2 -- so client/package.json says 0.25.0 and client_v2 was
left one release behind. Set it to what the bump would have written.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 21:02:39 +02:00
Donkie 59672ecb30 Merge master into feature/v2-client
Brings the branch up to 0.25.0 and picks up cosign image signing, the
armv7/arm64 smoke tests, the armv7 greenlet/libstdc++ fix, the
PUID/PGID and Kubernetes SPOOLMAN_PORT entrypoint guards, and the
externaldb unwritable-path fix.

The only conflict was publish-images' needs list, where both sides had
added a job: test-frontend-v2 here, test-multiarch-smoke on master.
Resolved as the union, so nothing publishes until every test job has
passed. The rest of the graph is unchanged, and the new smoke tests
inherit client_v2 coverage for free -- build-arm64 and build-armv7
already download the client_v2 artifact, so booting those images to
/health now also proves the default client is present in them.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 21:02:14 +02:00
Donkie c781869d09 Refresh the README screenshot and note the legacy-client rollback
The screenshot was the 2023 React client -- a different application from
the one a new visitor now installs. The replacement is client_v2's
library with a spool's details open beside it, which is where anyone
using Spoolman spends their time, and it lives in the repo rather than
on user-content URLs so it can be regenerated with the client.

The feature list gains search/group/filter, which client_v2 makes a
first-class part of the library rather than per-column table filters.

Installation now mentions SPOOLMAN_LEGACY_CLIENT. Someone whose setup
the new client misbehaves on will look here before .env.example, and
the useful thing to know is that both clients ship in every release and
share the API, so falling back costs nothing.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 21:00:36 +02:00
Donkie a76c8ed3d9 tests_frontend_v2: cover labels, settings, search and installability
Browser coverage was smoke plus CRUD, mirroring the old React suite --
which left the subsystems with the most new code in them completely
untested. Everything added here needs a real deployment to mean
anything, which is why it lives in this suite and not in vitest.

search.spec.ts drives the top-bar search (a filament, a manufacturer
and a location each land in their own section; spools match on their
own text, not their filament's, so a location is what finds one) and
the toolbar's group / filter / sort. Group, sort and filters live in
the URL, so each is checked to survive a reload. Sorting a grouped list
by something the backend cannot order groups by flattens the view, and
that is asserted rather than left as a surprise.

labels.spec.ts round-trips a design through the label_designs server
setting and exports a label for a real spool through the "Image files"
mode, asserting a non-trivial PNG comes back -- the only way to prove
the QR encoding, template substitution, logo and mm-to-pixel maths
actually ran. The print modes are left alone on purpose: they open the
browser's print dialog, which would stall the session.

settings.spec.ts separates the two persistence models the page mixes
with no visual cue -- currency, rounding and base URL on the server,
theme in the browser -- and takes an extra field from definition to the
library's filter menu and back out again. pwa.spec.ts checks the
deployed manifest rather than the source file: served at the deploy
root, with a type browsers accept, all icons resolving, all URLs
relative.

The add-spool flow moves out of crud.spec.ts into a helper so each spec
creates its own data instead of leaning on what an earlier one left
behind, and filters pin assertions to that data -- the library is
paginated, so an unfiltered assertion about a specific row is a coin
toss on an instance with real data in it. Anything with instance-wide
reach is put back afterwards. Verified against both an empty database
and one holding ~50 seeded spools.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 21:00:24 +02:00
Donkie 7e353595c0 bump.py: keep client_v2's version in sync with the release
The bump script has only ever touched client/package.json, so
client_v2 has sat at 0.0.1 since it was created. Nothing reads it --
the footer takes the version from GET /info -- but both clients ship in
every release, and a package that claims 0.0.1 while the release says
0.25.0 is confusing to anyone who looks.

The indent has to be per-client: each tree is formatted by its own
prettier config and client_v2 uses tabs, so writing 2 spaces back would
make `npm run lint` fail on the release commit itself.

Also brings client_v2 up to the current version so the first bump after
this is a normal increment rather than a jump from 0.0.1.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 20:59:53 +02:00
Donkie 6ca753bca0 Move the stress-seed script into scripts/
It has been sitting untracked in client_v2/ -- the wrong place twice
over: it is a backend tool that talks to the API over HTTP and knows
nothing about the client, and client_v2 is excluded from ruff, so
nothing was checking it.

scripts/ already holds the repo's operational scripts, and a poe task
puts it next to the other things you run against a dev instance. The
docstring gains the warning the flags deserve: --purge deletes every
vendor, filament and spool on the target, not only the generated ones.

Ruff now sees it, so scripts/ gets per-file ignores for the rules a
generator legitimately breaks -- stdout is its user interface, its data
is deliberately random and full of literal weights and diameters, and
the generators are long by nature. Type annotations and everything else
stay enforced.

Also fix --skip-fields: it skipped creating the extra-field definitions
but still generated values for them, so every POST failed with "Unknown
extra field" and the run produced nothing.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 20:59:41 +02:00
Donkie c9ae367dc0 Say what to do when the client bundle is missing
The clients are build artifacts and are not committed, so a source
checkout has none until `npm run build` has been run in the client
directory -- and a `git pull` that brings new client sources does not
bring a bundle. Spoolman then refuses to start with StaticFiles' own
message, "Directory 'client_v2/build' does not exist", which says
nothing about what is wrong or how to fix it.

This has always been true of client/dist, but it bites harder now: an
existing source install that upgrades into client_v2 has never built
this directory even once, so it hits the wall on the upgrade rather than
on a fresh install. Docker images and spoolman.zip both ship the bundles
pre-built, so neither is affected.

Check before handing the directory to StaticFiles and name the command
to run, derived from the client directory so it is right for whichever
client is mounted.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 20:59:30 +02:00
Donkie b38468ffe2 client_v2: ship a web app manifest and PWA icons
The legacy client got its manifest and icons from vite-plugin-pwa.
client_v2 has no such plugin, and nothing replaced them: static/ held
only favicon.svg, spoolman.svg and the Ko-fi logo. So the upgrade
silently un-installs the app for everyone who had added Spoolman to a
home screen -- no manifest means no standalone display mode, no
home-screen icon, and no install prompt ever offered again.

The manifest and icons are plain files in static/ rather than plugin
output. The icons are the v1 set, unchanged, so an already-installed
app keeps the icon it had; name, colors and display mode are v1's too,
and start_url resolves to the same URL at a root deploy, which keeps the
app identity stable across the upgrade instead of orphaning the old
install.

Every URL inside the manifest is relative to the manifest itself, so the
one file is correct under any SPOOLMAN_BASE_PATH with no rebuild. Only
the document's link to it needs help: the prerendered pages emit
"./manifest.webmanifest" and resolve fine, but the SPA fallback (200.html,
which serves deep routes like the /spool/show/<id> a printed QR points at)
carries absolute paths, so the existing base-path fixup gains two
patterns for the manifest and the touch icon.

Also register the .webmanifest media type. StaticFiles takes Content-Type
from the stdlib mimetypes registry, whose built-in table only learned
this extension in recent Pythons and otherwise falls back to the host's
/etc/mime.types -- absent in slim images. We support 3.10+, so leaving it
to the environment means some installs serve the manifest as text/plain.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 20:58:49 +02:00
Donkie d2cabd5d29 client_v2: unregister the legacy client's service worker on upgrade
The old React client injected a vite-plugin-pwa registration into its
index.html, so every visitor who ever opened Spoolman has an active
worker at the deploy root -- not just people who installed it to a home
screen. Its precache holds a complete copy of the old app shell, and
`createHandlerBoundToURL("index.html")` serves that shell for every
navigation.

Upgrading does not touch any of that. client_v2/build ships no sw.js and
SinglePageApplication 404s missing non-.html files, so the browser's
update check fails -- and per the service worker spec a failed update
leaves the registration intact. The result is the old React UI, fully
working against the new backend, with nothing to indicate the upgrade
happened. Chrome has historically unregistered on a 404, but that is
browser-dependent and not something to rest a rewrite launch on.

So answer the update check instead of 404ing it. static/sw.js takes over
(skipWaiting, so it does not queue behind the outgoing worker in
"waiting" -- a state that only clears when every tab is closed, which is
exactly the user who is stuck), drops every cache, unregisters itself,
and reloads the windows it controls, which unregister alone leaves
sitting on whatever the old worker handed them. static/ lands in the
build root, so it answers at `sw.js` relative to the deploy base path --
the exact URL the old registration polls, base path or not.

The test stands in a worker with the two behaviours that do the trapping
(a precache and a navigate handler answering from it), because only one
client is mounted at a time and the deployment under test already serves
the real sw.js at the legacy registration's URL. It proves the stale
worker does keep serving the old shell, then swaps the real one back in
and asserts the new UI returns with no reload from the test. Removing
build/sw.js fails it on the user-visible symptom: still on the old shell.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 19:52:17 +02:00
Donkie 87a07a5560 tests_frontend_v2: follow the locations page to the dashboard
The suite has been red since 2165121 turned the locations page into the
dashboard: two of its three tests click a top-bar tab named "Locations",
which no longer exists, and spent 30s timing out on it.

The dashboard groups by location out of the box, so the CRUD test's last
step still works as written once it goes to the right tab -- the new
location is a card of its own.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 18:11:25 +02:00
Donkie ae3208a9c3 Let an extra field's value be cleared again
Picking the empty option of a single-choice extra field did nothing --
in either client, on spools, filaments and vendors alike. Emptying an
integer, range, datetime or multi-choice field was just as stuck. There
was no way to get a value back to unset once it had one.

Nothing in the API could express it. A key left out of the map means
"leave it alone", and every value that could stand in for empty is
rejected by the field's type check: "" is not one of a choice field's
choices. So `extra` values now accept null, meaning the entity has no
value for that field -- no row is stored, which clears whatever was
there. Null was a 422 before it reached any logic, so nothing that used
to work behaves differently.

A filament's and a vendor's extra were also REPLACED wholesale by a
patch rather than merged per key, unlike a spool's since 28f6f8e
(v0.22.0). Anyone patching one field of a filament -- as client_v2 did
-- silently destroyed the rest of its custom fields; anyone relying on
the replacement to delete a key can now say so with null. All three
entities merge per key now, and the field descriptions say so instead
of saying nothing.

client_v2's saver was where the frontend half died: an emptied input
produced no patch at all. It sends the cleared key as null now, and
keeps sending only the keys that changed, which is what stops two people
editing different fields of one entity from overwriting each other. It
also captures the entity id when the edit is made -- the inspectors are
reused across selections rather than remounted, so a debounced patch
could otherwise land on whatever you clicked next.

The legacy client cleared a filament's or vendor's field by omitting it,
which the merge would now ignore, so its form sends null for a field
left empty. That also buys it clearing on spools, which merged (and so
ignored the omission) all along.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 18:11:02 +02:00
Donkie 04d6ee72bd client_v2: make the group-by hint go where it points
With no custom spool fields defined, the group-by menu has a single
entry and a note explaining that adding a text or choice field to spools
in Settings gets you more views. It told you where to go and then left
you to find it: three pages away, in a section that has to be scrolled
to and whose entity tab has to be switched.

The note is the link now, straight to the spool tab of the extra-fields
manager -- the same /settings?fields=spool#extra-fields deep link the
inspectors use, so it selects the tab and scrolls to the section.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 18:10:18 +02:00
Donkie 680f2a063b client_v2: let the dashboard header wrap instead of squeezing
The header packed the title, the group-by button, the drag hint and
"New group" into one non-wrapping row. On a phone there is nowhere near
enough width for that, so the hint -- the longest and least urgent thing
in the row -- was squeezed into a column one word wide, six lines tall,
with the group-by button stranded beside it (baseline alignment lines it
up with the first line of a paragraph that is now a column).

The row wraps now, and below 640px the hint takes the whole of the
second line while the three controls share the first. Alignment moves
from baseline to centre, which is what looks right once the items are
different heights rather than all being text. On a desktop the row is
unchanged: everything still fits on one line.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 18:09:47 +02:00
Donkie bf15e64fe5 client_v2: scale the label preview down to the screen it's on
The designer's canvas zoom was solved against a hard-coded 500x340 px
viewport, so on a phone a perfectly ordinary 50x25 mm label rendered 500
px wide inside a ~300 px stage and lost both its edges. Nothing recovers
from that: there is no zoom control, and the stage's scrollbar only
reaches what you already can't see.

The stage now measures itself and the fit uses the width it really has.
The measuring box is a full-width child whose own width comes from the
grid track, never from the canvas inside it, so feeding it back into
pxPerMm can't oscillate. The 2 px/mm floor stays for an absurdly large
label, and .stage-area keeps scrolling for that case.

Two things had to give way for the label to actually get the width. The
"Preview with" row was pushing the stage wider than the column (its
select had a fixed max-width and no room to shrink), which scrolled the
label out of view sideways; it now shrinks with the stage. And below
560px the stage drops most of its padding, which is worth ~28 px of
label on a 393 px screen.

The print tab's preview had the same fixed 260 px assumption and gets
the same treatment -- it fit a 393 px phone by 12 px, and didn't fit a
320 px one at all.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 18:08:22 +02:00
Donkie 814ddfcd92 client_v2: point people at extra fields instead of hiding them
The extra-fields section only rendered once you had defined a field, so
the feature was invisible to anyone who hadn't -- and nothing in an
inspector said where the fields you DO see come from.

Spool, filament and vendor details now always show the section, with a
"Manage fields" link beside the header and a line saying there are none
yet. Forms and read-only mirrors keep the old hide-when-empty behaviour:
the add-spool modal and the filament block inside the spool inspector
would only be advertising an empty section someone else owns. The
section waits for the definitions to actually load, so "none yet" is an
answer rather than a flash.

The link has to land somewhere specific, so the manager's entity tab
moves into the URL:

    /settings?fields=filament#extra-fields

which selects the tab and scrolls the page's own scroller to the
section, on a cold load as well as a client-side one. The tabs write
that URL as you switch, so the address bar always names the tab you are
looking at and it can be shared or bookmarked.

Switching entity now also closes an open field editor. It edits one
entity's field, and leaving it open across a tab change meant a save
could land on the wrong entity.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 14:27:19 +02:00
Donkie aa47cd015a client_v2: fold a group's spools away behind its header
A filament you own thirty spools of pushed the rest of the library off
the screen with no way to shut it, and nothing said the rows under a
header were ALL of that group's spools rather than the first few.

The group header answers both. Its right-hand meta gains a second line
under the weight -- mirroring the title/subtitle opposite it -- and a
disclosure sits at the trailing edge:

    [swatch]  PLA universal filament  PLA        1.7 kg   [v]
              Clas Ohlson . 1.75 mm            13 spools

Putting it in the header rather than in a strip of its own is what makes
it work: the header is sticky, so the fold stays reachable while you are
scrolled deep inside a long group -- which is exactly when you want it.
The row is two sibling controls, not one nested in the other: the body
still opens the filament/vendor (inert for material/location groupings,
as before) and the disclosure is a real button, so both are keyboard
reachable. The glyph rotates, not the button, which would swing its box
past the edge of the row.

A collapsed group doesn't fetch at all. A page of groups costs one
request per visible group, so this is most of what collapsing buys on a
big library; collapsing mid-flight aborts through the existing effect
cleanup, and expanding keeps what was already loaded so the rows paint
at once and refresh behind it.

Collapsing also has to leave you where you were. Folding a long group
shrinks the scroller and the browser clamps it to the shorter content,
which used to leave you somewhere else in the library entirely, so the
group is pulled back into view afterwards (`nearest`, so it is a no-op
when it never moved). Measured on a 160-spool instance: scrollTop
1200 -> 1197 while the content shrank 346px.

The collapsed set is remembered per browser in localStorage, keyed
`field:key` so it works for vendor/material/location groupings too, not
just filament. Only 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 is simply never asked about again.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 14:26:43 +02:00
Donkie 372554adbd client_v2: stop the add-spool form assuming you know what it means
Spool Weight asked beginners for a number they have no way of knowing, so
its help now names the usual ranges and offers two one-click starting
values. Location read as abstract metadata rather than the shelf the
spool goes on, so it gets an always-visible hint (not another popup —
testers didn't open those) and examples that look like places.
2026-07-30 11:22:43 +02:00
Donkie c4be425dcc client_v2: give a new label design something worth printing
The default design was a QR and one text block holding name, material and
spool id. It printed, but it looked like a debug dump: no manufacturer
anywhere, no color anywhere, and no visual hierarchy, because a text
element carries one size, weight and color for its whole template.

Lay out the right-hand column as four elements instead of one:

    Clas Ohlson          <- manufacturer, 2.5mm grey
    PLA universal        <- name, 3.2mm bold, the only wrapping block
    filament
    PLA . #2             <- material . id, 2.5mm
    [============]       <- color swatch, bottom-aligned with the QR

Splitting them is what buys the hierarchy; it also lets the manufacturer
sit in a conditional block, so a filament with no vendor prints nothing
there rather than the bare "?" a plain placeholder resolves to. The
material does the same inside the id line, taking its " . " separator
with it.

DEFAULT_SPOOL_TEXT / DEFAULT_FILAMENT_TEXT now hold just that id line,
which is the only part that differs between the two label kinds. That
would have quietly broken the spool/filament toggle for designs already
saved: setDesignKind retargets a text block only when it still matches
the *other* kind's default, and every existing design carries the old
three-line template. So the comparison runs over a list of known default
pairs, newest first, with the pre-redesign pair kept as the second entry.

Also explain "Skip Items", which reads like a mystery until you know
sheets are reusable. The hint goes full-width under the row rather than
in the field's own column, where at three columns it stacked seven lines
deep, and opens with the field name like helpMargin and
helpPrinterMargin already do.

Verified in the browser against a real spool, and the kind-switch
retargeting -- current default, pre-redesign default, and a user-edited
template that must stay untouched -- is covered by unit tests.
2026-07-30 09:47:40 +02:00
Donkie aa447dd05b Let any spool field value be renamed everywhere at once
Renaming a dashboard card only worked for Location, which has a
server-side rename that moves every spool in one statement. A custom
field had no equivalent, so the card name was read-only unless the group
was empty -- renaming it from the client would have meant patching each
spool one at a time, and only the ones the board had actually paged in.

Generalise the location rename to the spool's other string fields:

    PATCH /spool/field/{field}  {"value": "Shelf A", "new_value": "Shelf B"}
      -> {"spools_updated": 6}

where field is `location` or `extra.<key>`. Archived spools are included
-- the value is the value, and skipping them would leave half the spools
behind. Renaming onto a value already in use merges the two, though the
dashboard still refuses that so it can't happen by accident.

The old value travels in the body rather than the path, unlike
/location/{name}. Field keys are ^[a-z0-9_]+$ and so are path-safe, but
values are not: a location like "Rack A/B" needs an encoded slash, which
proxies routinely mangle. No reason to carry that flaw into the general
form.

Only fields the spool owns can be renamed. material/vendor/filament are
refused because they belong to another entity, and rewriting them "for
these spools" would change filaments that other spools share -- the same
rule that decides what the dashboard can group by, so grouping and
renaming now share one validator.

Like rename_location, no spool event is broadcast per row: the change is
one statement over what may be hundreds of spools, and that fan-out is
what the websocket layer avoids elsewhere.

Verified on all four databases, and in the browser on a 40-spool group
of which the board had loaded 30 -- all 40 moved, and the card renamed in
place without losing its position.
2026-07-30 09:08:58 +02:00
Donkie 21651216af Turn the locations page into a group-by-anything dashboard
The locations board could only ever answer one question: where is this
spool. The same card-and-drag interaction works for any field a spool
carries, so make the field a view mode and keep the rest.

Location is still the default view and behaves exactly as before. The
other views come from the user's own spool fields: any single-value text
or choice field now appears in a Group-by menu. A choice field renders a
card per choice, including the empty ones, which turns a "Status" field
into a kanban board.

Which field a card belongs to is decided by one small interface
(src/lib/dashboard/fields.ts), so the page never special-cases a field.
A field only qualifies if the SPOOL OWNS IT, because a drag moves one
spool and must mean exactly one change. That rules out material and
vendor, where writing one spool's card would silently rewrite every other
spool of the same filament, and lot number, which is stamped on by the
manufacturer and would be recording something untrue. Those stay library
filters. It also rules out numbers, dates, booleans and multi-choice,
whose values don't name a card you can drag into.

Backing changes:

- /spool/group grows group_by=extra.<key> for a spool's custom fields.
  The field table is aliased so it stays a distinct FROM element -- the
  extra-field filters reference the un-aliased table inside correlated
  subqueries, and without the alias those would correlate to this join
  instead of standing alone. Grouping is rejected for field types whose
  value isn't a single repeatable string. group_by is no longer a
  Literal, so an unsupported value is a 400 rather than a 422.

- The saved layout is per field: dashboard_groups and
  dashboard_spoolorders replace the location-only pair. The old
  `locations` / `locations_spoolorders` are read once as a seed for the
  location view and never written back, so an upgrade keeps the shelves
  it had and the old client keeps working.

- The view mode lives in the URL (?by=) so a board can be bookmarked and
  shared, and is mirrored to localStorage so a bare /dashboard resumes
  the last one looked at. The URL wins when it says something; a stale
  ?by= naming a deleted field falls back to location.

Verified against SQLite, PostgreSQL, MariaDB and CockroachDB -- Postgres
caught a real difference here, it sorts NULLs last where SQLite sorts
them first, so the new group assertions are keyed rather than ordered.
2026-07-30 08:41:36 +02:00
Donkie bf3480cfba client_v2: let you duplicate a filament into another color
Buying the same filament in several colors meant re-entering every spec
through the add-spool modal for each one. Add a duplicate flow reachable
from three places, all sharing one startDuplicate() path:

- a Duplicate button in the filament inspector header
- a "duplicate" link on the chosen filament in add-spool step 2, for when
  you found the right product but the wrong color
- "Add & new" after creating a filament, which now hands back the same
  form pre-copied instead of an empty search

The copy carries manufacturer (reusing the existing vendor), material,
specs, weights, price, comment and custom fields. Color is cleared and
the article number dropped since it is a per-color SKU; external_id never
copies. The name is kept as a starting point with the caret at its end,
plus a nudge that stays until it is changed.

Also fixes two things this surfaced in the add-spool modal: the advanced
block had its Extruder Temp and Bed Temp labels swapped (stored data was
always correct, only the labels lied), and the name field showed a red
"Required" before the user had typed anything, which left no room for the
new naming hint. The error now waits for first input or blur; validation
and canSubmit are unchanged.
2026-07-29 21:21:51 +02:00
Donkie 39f6ededfd client_v2: show spool weight in the add-spool search results
Vendors list the same filament in several sizes, so "Polymaker · PLA"
rows for the 1 kg and 250 g variants were indistinguishable in both the
catalog and SpoolmanDB result lists. Show the net weight on each row,
and on the chosen-filament card so the pick can be confirmed on step 2.

The row weight sits outside .res-name rather than being appended to the
vendor/material line: that element truncates with an ellipsis, which
would drop the one field that tells two matching results apart. The
chosen card's .cs line wraps instead, so a plain append is safe there.

A local filament's weight maps to 0 when unrecorded, so both row sites
guard on it rather than rendering "0 g".
2026-07-29 20:57:23 +02:00
+2 4219230aa1 Translations update (#977)
* Translated using Weblate (Estonian)

Currently translated at 17.3% (50 of 288 strings)

Translated using Weblate (Persian)

Currently translated at 96.5% (278 of 288 strings)

Translated using Weblate (French)

Currently translated at 98.9% (285 of 288 strings)

Translated using Weblate (Greek)

Currently translated at 72.9% (210 of 288 strings)

Translated using Weblate (Dutch)

Currently translated at 99.3% (286 of 288 strings)

Translated using Weblate (Hungarian)

Currently translated at 72.2% (208 of 288 strings)

Translated using Weblate (Danish)

Currently translated at 85.4% (246 of 288 strings)

Translated using Weblate (Thai)

Currently translated at 98.6% (284 of 288 strings)

Translated using Weblate (Japanese)

Currently translated at 99.3% (286 of 288 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/da/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/el/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/et/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/fa/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/fr/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/hu/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/ja/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/nl/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/th/
Translation: Spoolman/Web Client

* Translated using Weblate (Ukrainian)

Currently translated at 93.0% (268 of 288 strings)

Translated using Weblate (Greek)

Currently translated at 72.9% (210 of 288 strings)

Translated using Weblate (Hungarian)

Currently translated at 72.2% (208 of 288 strings)

Co-authored-by: Daniel Hultgren <daniel.cf.hultgren@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/el/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/hu/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/uk/
Translation: Spoolman/Web Client

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 98.6% (284 of 288 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.3% (286 of 288 strings)

Translated using Weblate (Hindi (Latin script))

Currently translated at 3.8% (11 of 288 strings)

Translated using Weblate (Swedish)

Currently translated at 94.7% (273 of 288 strings)

Translated using Weblate (Ukrainian)

Currently translated at 93.0% (268 of 288 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/hi_Latn/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/sv/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/uk/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/zh_Hant/
Translation: Spoolman/Web Client

* Translated using Weblate (Danish)

Currently translated at 87.1% (251 of 288 strings)

Co-authored-by: srbjessen <srbjessen@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/da/
Translation: Spoolman/Web Client

* Translated using Weblate (Czech)

Currently translated at 100.0% (288 of 288 strings)

Co-authored-by: Miloslav Kos <kos.m@post.cz>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/cs/
Translation: Spoolman/Web Client

* Translated using Weblate (Korean)

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Korean)

Currently translated at 78.1% (225 of 288 strings)

Added translation using Weblate (Korean)

Co-authored-by: 김태남 <imedia0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/ko/
Translation: Spoolman/Web Client

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (288 of 288 strings)

Co-authored-by: Kayz C <kayzed.x@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/zh_Hant/
Translation: Spoolman/Web Client

* Translated using Weblate (Slovenian)

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Slovenian)

Currently translated at 38.1% (110 of 288 strings)

Translated using Weblate (Slovenian)

Currently translated at 14.9% (43 of 288 strings)

Added translation using Weblate (Slovenian)

Co-authored-by: Jernej Pangerc <jernejp21@tuta.com>
Co-authored-by: jernejp21 <jernejp21@tuta.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/sl/
Translation: Spoolman/Web Client

* Translated using Weblate (Slovak)

Currently translated at 10.4% (30 of 288 strings)

Translated using Weblate (Slovak)

Currently translated at 9.7% (28 of 288 strings)

Added translation using Weblate (Slovak)

Co-authored-by: Pavol Vrba <pvrba2000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/sk/
Translation: Spoolman/Web Client

* Translated using Weblate (Hungarian)

Currently translated at 94.0% (271 of 288 strings)

Co-authored-by: Tamas Veres <tamas.veres@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/hu/
Translation: Spoolman/Web Client

* Translated using Weblate (Danish)

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Danish)

Currently translated at 92.0% (265 of 288 strings)

Co-authored-by: JonasBentin <jonasbentin19+github@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/da/
Translation: Spoolman/Web Client

* Translated using Weblate (Latvian)

Currently translated at 4.5% (13 of 288 strings)

Added translation using Weblate (Latvian)

Co-authored-by: Jurijs Kiricenko <jurijskiricenko@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/lv/
Translation: Spoolman/Web Client

* Translated using Weblate (Russian)

Currently translated at 100.0% (288 of 288 strings)

Translation: Spoolman/Web Client
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/ru/

---------

Co-authored-by: Daniel Hultgren <daniel.cf.hultgren@gmail.com>
Co-authored-by: srbjessen <srbjessen@gmail.com>
Co-authored-by: Miloslav Kos <kos.m@post.cz>
Co-authored-by: 김태남 <imedia0@gmail.com>
Co-authored-by: Kayz C <kayzed.x@gmail.com>
Co-authored-by: jernejp21 <jernejp21@tuta.com>
Co-authored-by: Pavol Vrba <pvrba2000@gmail.com>
Co-authored-by: Tamas Veres <tamas.veres@gmail.com>
Co-authored-by: JonasBentin <jonasbentin19+github@gmail.com>
Co-authored-by: Jurijs Kiricenko <jurijskiricenko@gmail.com>
Co-authored-by: Dr_Perry_Coke <dr.perrycoke@gmail.com>
2026-07-29 18:06:17 +00:00
Donkie 17f8b9fee1 client_v2: add Playwright frontend integration tests
Mirrors tests_frontend, but drives the Svelte client instead of the legacy
React one: same production image, same PostgreSQL-backed compose stack, same
"only the first navigation uses a URL" discipline.

- smoke.spec.ts clicks through every top-bar tab, asserting each route's title
  and the app shell, with no browser console errors; a second test proves the
  library reached the API rather than falling into its offline state.
- crud.spec.ts walks the first-use happy path. client_v2 folds the legacy
  client's three create forms into one "Add spools" modal, so a manufacturer,
  a filament and a spool are created by a single submit, then verified back in
  the library list and on the locations page.

Two client_v2-specific helpers earn their keep: the top bar renders its nav
twice (desktop + mobile, one hidden by CSS) so nav locators need narrowing to
the visible one, and NumberInput's accessible name picks up its stepper and
help buttons, so numeric fields are found via their form item instead of
getByLabel.

The suite publishes on host port 8001 so it can run alongside the legacy one.
CI gains a test-frontend-v2 job cloned from test-frontend, gating
publish-images alongside it.

Also fixes tests_frontend/run.py: it only built client/dist, but the Dockerfile
copies client_v2/build unconditionally, so the image could not be built from a
clean checkout.
2026-07-29 19:08:12 +02:00
Donkie 6af053b682 client_v2: split the accent fill from --accent so white text clears AA
White on the dark theme's --accent (#be682f) is 4.03:1, so every solid accent
button failed WCAG AA -- the a11y audit flagged .btn.primary and .seg-btn.active
as serious. No foreground fixes it: on #be682f the best any color reaches is
5.21:1 (pure black), and every readable one scores worse than white.

So the fill becomes its own token rather than the accent moving. --accent-fill is
#b2612c, the minimal same-hue, same-saturation darkening that clears 4.5:1 (4.53)
-- 3.3 L* below --accent and imperceptible beside it. --accent is untouched and
still drives links, borders, washes and focus rings, where it sits on a dark
surface and passes comfortably.

Worth being precise about what is and isn't the brand color here: Spoolman orange
is #dc7734 (static/spoolman.svg, and the v1 client's colorPrimary). It appears
nowhere in client_v2/src -- both themes already ship a darkened adaptation of it,
7.5 L* down in dark and 12 L* in light, the latter labelled as such in app.css.
White on the brand orange itself would be 3.12:1. So this is a third step along an
adaptation already two steps in, not a new brand color, and the logo is untouched.

The fill DARKENS on hover (--accent-fill-hover #9b5526, 5.64:1) where --accent
lightens; lightening would drop straight back below AA. The light theme already
darkened on hover, so the two themes now agree. Light's own fill tokens just
mirror its existing accent pair (4.72:1 / 5.88:1) -- they exist so both themes
resolve the same variables, not because light needed changing.

Applied to every white-on-accent surface, not just the two axe caught: primary
buttons, the extra-fields manager's primary button, the mobile add button, the
spool inspector's active mode button, the settings segmented tab, and the date
picker's selected day and Done button. Toggle's track and the locations loading
bar keep plain --accent -- no text sits on them.

Audit is now clean on all six surfaces in both themes: zero axe violations.
2026-07-29 11:08:03 +02:00
Donkie 2befcfc389 client_v2: give inspector form controls accessible names
The a11y audit reports 14 `label` and 2 `select-name` violations on the inspector
drawer, both critical, in both themes. Every editable field in the filament, spool
and vendor inspectors is affected: a screen reader announces the whole panel as a
column of unnamed edit boxes, so it is unusable there.

The cause is structural. AddSpoolModal wraps each control in a <label>, which names
it implicitly -- that surface has no violations. The inspectors instead use
FieldGrid, a two-column grid where <Field> emits the label as one cell and the
control as its sibling. Nothing connects them.

<Field> now publishes the id of its label text through context, and the generic
controls that can sit in a value cell -- EditableField, NumberInput, Combobox --
point aria-labelledby at it. Outside a <Field> the context is undefined and the
attribute is omitted, so the same components keep their implicit labels in
AddSpoolModal. aria-labelledby rather than <label for>: a value cell can hold two
controls (a range's min/max) or no labelable one at all (ColorEditor), where a
`for` would have to pick exactly one and would dangle when there isn't one. The id
goes on an inner span around just the label text, because the ⓘ help toggle is a
sibling and would otherwise fold "Help" into every name.

ExtraFieldInput names its inputs from field.name directly, since it already knows
it; range inputs get "<field> min"/"<field> max" so the pair is distinguishable.
This also fixes its boolean Toggle, which announced the type ("Boolean") rather
than the field, so every boolean extra field had the same name.

Audit is clean on all six surfaces in both themes. Verified the names resolve to
the visible labels rather than merely existing -- Name, Material, Diameter,
Density, ..., Integer range min, Integer range max, Single choice.
2026-07-29 10:55:02 +02:00
Donkie 8f7794e487 client_v2: make the FilamentList live refetch leading-edge
The list coalesces websocket events because one revision bump refetches the group
page and every visible GroupRow, so a burst -- a bulk import, a printer streaming
use updates, the initial sync -- would otherwise fire that whole fan-out per event.

It did so with a trailing debounce, which charges the 300 ms to every event rather
than to bursts. The overwhelmingly common case is a single change: one edit in
another tab, one printer reporting one spool. Each of those waited out the full
window before anything moved, which is exactly the latency a live view should not
have, and the burst it was paying for never arrived.

Same window, leading edge instead: the first event refetches immediately and opens
a cooldown, and anything arriving during it is folded into one catch-up refetch when
the cooldown ends. An isolated change is now instant; a burst still costs one
fan-out per window rather than one per event. The magic number is named
COALESCE_MS and the comment says what the window is trading off.
2026-07-29 10:37:12 +02:00
Donkie 7ab45ca1ab client_v2: don't quote "0 g each" for unused spools of unknown weight
UnusedRow's sameWeight check only asks whether the collapsed spools agree, so a
group where they all agree on nothing takes the "Y g each" branch. mapSpool falls
back to 0 when a spool has no initial_weight and its filament has no weight either,
which makes that branch print "0 g each" and the divider "N unused - 0 g each" for
spools that are in fact full.

Treat a weight of 0 as unknown and fall through to the mixed variants, which state
the count and location and simply omit the quantity -- the same thing already done
when the spools genuinely differ.
2026-07-29 10:36:48 +02:00
Donkie e8b363c10b client_v2: restore the muted/dim/faint contrast ramp
18214ee fixed a real WCAG AA failure by brightening --text-muted/--text-dim/
--text-faint (dark) and darkening them (light), but it moved all three to nearly
the same place: 0xa0/0x97/0x90 in dark and 0x5f/0x66/0x6d in light, about 2.6 L*
apart. Three tokens exist to express three levels of emphasis; at that spacing they
render as one gray and the hierarchy they encode is gone.

The squeeze came from anchoring the whole ramp above the AA floor rather than on it.
#959595 is the darkest gray that clears 4.5:1 on the lightest dark surface
(--surface-raised #2e2e2e), so faint now sits exactly there and muted/dim step up
from it in even ~7 L* increments -- the same perceptual spacing the pre-AA ramp had.
Light mirrors it from the #676767 ceiling over --surface-sunken #e8e6e2, with
tighter ~4.5 L* steps because --text-2 #444444 caps the range.

Contrast against the worst-case surface in each theme is 7.0/5.6/4.5 (dark) and
6.3/5.4/4.5 (light). Every one of the six values moved further from its background
than 18214ee left it, so this cannot regress the audit it was fixing. The comments
now record where the floor comes from, so the next person retuning these knows
which end is fixed and why.
2026-07-29 10:36:27 +02:00
Donkie 64e94bd97f client_v2: re-enable no-navigation-without-resolve, scoped per file
5eb660f turned svelte/no-navigation-without-resolve off globally because every hit
at the time was a false positive: the href had already been resolved by a params.ts
helper or handed in as a pre-resolved prop. Switching the rule off repo-wide also
removed the only automated guard against the real bug it exists to catch, and that
bug is one nobody developing at the root path can see -- a missing resolve() works
perfectly until an operator deploys under SPOOLMAN_BASE_PATH.

The rule is on again. The eleven known-good sites are silenced where they live:

- Ten .svelte files carry a file-level disable inside their <script>, with the
  reason attached via the `--` description syntax. Per-line disables don't work for
  most of them -- the rule anchors on the href attribute, which prettier puts on its
  own line inside a multi-line element, where an HTML comment can't go.
- params.ts's two goto() calls take eslint-disable-next-line each. Both targets are
  base-path-independent already: a bare `?query` resolves against the current URL,
  and the other is built from resolve('/').

A new file, or a new component that builds an href itself, is caught again. Verified
by adding a raw <a href="/spool/show/1"> and watching the rule fire.
2026-07-29 10:36:03 +02:00
Donkie 89ffd3e4b1 Key the external filament catalog cache on size as well as mtime
_load_filaments memoizes the parsed catalog on the cache file's st_mtime alone.
Two writes landing in the same mtime tick therefore look identical to the cache and
the second one is never picked up -- the process keeps serving the stale parse until
some later sync happens to change the stamp.

The window is small on Linux, where st_mtime is nanosecond-resolution, but it is not
theoretical: Spoolman's data directory is routinely a bind mount, and filesystems
with 1-second mtime granularity are common there. A catalog refresh triggered close
after another write would be silently dropped.

Including st_size makes the key sensitive to any rewrite that changes length, which
is the realistic shape of a catalog update.
2026-07-29 10:35:50 +02:00
Donkie 2e396d6cda Fail loudly when the SPA fallback base-path rewrite matches nothing
load_and_rewrite_fallback_base_path does blind string replacement on 200.html to
insert the operator's base path into SvelteKit's absolute asset references and its
hardcoded `base: ""`. str.replace on a pattern that isn't there is a silent no-op,
so a SvelteKit upgrade that changes any of those three emission shapes would ship a
fallback document whose assets 404 and whose client-side router builds URLs without
the base path.

That failure is invisible in development and in CI, both of which run at the root
path where this code doesn't execute at all. It would surface only on an operator's
reverse-proxied instance, as a blank page on exactly the routes the rewrite exists
to serve -- direct loads of /spool/show/<id>, the target of printed QR labels.

Check each pattern is present before rewriting and raise naming the ones that are
not. A build/server mismatch is a packaging error, so refusing to start with an
actionable message beats serving a broken page.
2026-07-29 10:35:41 +02:00
Donkie f08e27c6fd Record why remaining_length does not need the dropped fan-out
Comments only. 2924850 removed the filament->spool fan-out, but neither comment
named remaining_length, which is the field 6ade405 added the fan-out for. That
omission is how someone re-adds it.

Spell out that remaining_length is derived from density and diameter and is not
cached client-side -- SpoolInspector computes it from the $derived filament, so
the filament event alone refreshes it -- while the stored initial/remaining
weights are the part that genuinely needs reweighSpoolsOfFilament.
2026-07-28 21:16:24 +02:00
Donkie 733dcf9209 client_v2: add vitest and cover migrateV1 and format
client_v2 had no unit tests and no test script; the only spec was the Playwright
a11y audit, which is a separate run.

Adds vitest (`npm test`) configured for src/**/*.test.ts, and starts with the two
files the review called out.

labels/migrateV1.ts is the priority: it rewrites label templates and print presets
users authored in the v1 client, runs unattended at first load, and its output is
what they see afterwards -- a silent mistranslation quietly damages work they
can't recover. Covers the whole path-rewriting table, the bare/wrapped/extra-field
token forms, that a path with no v2 equivalent is left alone rather than mangled,
and the guards that keep a hand-edited preset from yielding a zero-size canvas.

utils/format.ts is second: it formats every weight, length and percentage on
screen. Pins the deliberate rounding rules -- notably that weightAuto floors kg so
a displayed "1.1 kg" never overstates what is on the spool -- and checks
lengthMeters against the known ~335 m for 1 kg of 1.75 mm PLA.

40 tests.
2026-07-28 21:03:48 +02:00
Donkie 1262eb664a Document SPOOLMAN_LEGACY_CLIENT in .env.example
The var is the operator's rollback path if the new client misbehaves, but it was
only discoverable by reading env.py or the Dockerfile. Nothing else documents
Spoolman's env vars, so .env.example is the place.
2026-07-28 20:56:17 +02:00
Donkie 9a93892a91 Add allow_archived to /search and stop the candidate cap hiding new rows
Two gaps in the new /search endpoint, neither yet released:

Archived spools were always included, unlike GET /spool. /search now takes
allow_archived (default false) and applies the same false-or-null clause
spool.find uses, so the two endpoints agree on what "archived" hides. The
exact-id shortcut honours it too -- otherwise searching an archived spool's id
surfaced it regardless.

Candidate selection ordered by id ASC before LIMIT 200, so on a database with
more than 200 matches for a term only the 200 lowest ids were ever ranked and a
newly added spool could not be found at all. Ordering descending makes the newest
rows the ones that get ranked, which is the useful bias for an inventory that
only grows. Ranking still re-sorts the survivors by match quality, so this
changes which rows are considered, not how results are ordered.

Integration tests cover both defaults and both opt-ins.
2026-07-28 20:56:10 +02:00
Donkie 2924850317 Drop the filament->spool websocket fan-out
filament.update re-broadcast every spool of the filament (archived included)
after commit, so a filament shared by hundreds of spools turned a single PATCH
into hundreds of ORM loads and websocket frames inside one request. This is the
unbounded fan-out flagged in review; the same reasoning is why no equivalent was
ever added for vendor.update.

Subscribers now treat a filament event as invalidating the spools that reference
it. In client_v2 the cache is already normalized -- spools hold a filamentId and
resolve their filament through filamentById -- so nearly everything a spool
displays was live regardless. The exception is a spool with no initial_weight of
its own, whose initial/remaining weights the server derived from filament.weight.
The cache now recomputes exactly those, mirroring Spool.from_db's formula
including its clamp at 0, which needs used_weight and the initial_weight override
carried on the cached spool.

Reverts the behavior added in 6ade405 (unreleased). Its integration test is
repurposed to pin the decision from both sides: no spool event reaches a /spool
subscriber, and the filament event subscribers rely on is still emitted.
2026-07-28 20:51:36 +02:00
Donkie b369cf049e Clamp grouped remaining weight at 0 for over-used spools
Spool.from_db reports remaining_weight as max(initial - used, 0), but
find_groups' remaining_expr omitted the clamp. A spool used past empty
therefore contributed a negative number to total_remaining_weight, so a
group's total no longer matched the sum of the spools it contains — two
500 g spools, one with 200 g used and one with 800 g, reported 0 g instead
of 300 g.

Mirror the clamp with CASE rather than func.greatest, which is not portable
across all four supported databases. Covered by a new integration test.
2026-07-28 20:27:59 +02:00
Donkie d1ab338b91 client_v2: stop dropdown Escape from closing the add-spool modal
AddSpoolModal closes on any Escape reaching <svelte:window>, but Combobox and
DateTimeField let their own Escape keep propagating. Dismissing a location,
vendor or material dropdown — or the date popover — inside the modal therefore
discarded the whole form. Regression from bf3896b, which moved the modal's
keyboard close onto the window.

Both now stopPropagation() after closing their own popup. DateTimeField's
listener is on document, which is still ahead of window in the bubble path.
2026-07-28 20:27:37 +02:00
Donkie a2e9e66153 client_v2: fix WS reconnect race clobbering the live socket
reconnectAllNow() skips only OPEN/CONNECTING sockets, so a wake that fires
while a socket is still CLOSING opens a replacement. The old socket's onclose
then unconditionally cleared sock.pingTimer and nulled sock.ws, killing the
replacement's keepalive and leaving a live socket the code believes is down.

Guard the handlers with an identity check: onopen/onclose only touch the shared
ResourceSocket state while they are still the current socket, and each socket
now clears its own keepalive interval so a superseded one can't leak it.
2026-07-28 20:27:15 +02:00
Donkie 5d812e269e fix: return 400 instead of 500 for malformed sort parameters
Each list endpoint parsed `sort` inline with a bare split(":"), so `?sort=name`
raised "not enough values to unpack" and `?sort=name:sideways` a KeyError from
SortOrder[...]. Both surfaced as a 500 (todo.md task 8).

parse_nested_field also gated on hasattr, so any attribute that merely existed
passed through to order_by() -- `?sort=metadata:asc` got as far as calling
.asc() on the MetaData object. It now checks the SQLAlchemy mapper's columns,
so only real columns are accepted; relationship traversal through filament. and
vendor. still works.

Sort parsing moves into a shared parse_sort() used by all four call sites, which
tolerates surrounding whitespace and rejects a missing colon, an empty field
name or an unknown direction with a message saying what was expected.

database/vendor.py turned out not to use parse_nested_field at all -- it did a
bare getattr(models.Vendor, fieldstr) -- so /vendor kept returning 500 even
after the helper was fixed. It now uses the shared helper like filament.py.

All three endpoints verified end-to-end: 400 for each malformed form, 200 for
valid single and multi-field sorts, and no unhandled exceptions in the log.
2026-07-27 20:03:14 +02:00
Donkie 7d683eb246 security: escape CSV formulas and bound extra-field sizes
Two lower-severity findings from the audit (todo.md tasks 6 and 7).

A spreadsheet executes a cell that begins with =, +, -, @, tab or CR, so a
vendor named `=cmd|' /C calc'!A0` runs when someone opens the CSV export
(CWE-1236). Such cells are now prefixed with a single quote, which spreadsheets
read as "this is literal text" and do not display. Only strings are escaped --
numbers reach the writer as numeric types, so a negative number cannot be
mistaken for a formula and must not grow a stray quote. Header names need no
escaping: they are fixed attribute names or extra-field keys, which are already
constrained to ^[a-z0-9_]+$.

Exports also gained Content-Disposition, so they download under a sensible name
rather than rendering in the browser.

Extra-field values had no size limit and the columns are Text(), so a single
request could store megabytes: unbounded database growth, and the resulting
websocket event overflowed the 1 MiB default frame limit, which killed live
updates for every connected client. Values are now capped at the same 65535
characters as settings. The number of extra fields per entity is capped too --
the per-entity key count was already bounded, since unknown keys are rejected,
but the registry itself could be grown without limit, and it is cached in memory
and embedded in every entity response.

Finally, a setting larger than the column limit returned 500; the ValueError is
now caught and returned as a 400.

Verified end-to-end: a 2 MB extra value is refused on both create and update, an
oversized setting returns 400 and leaves the setting unset, normal values still
work, and a vendor named `=cmd|' /C calc'!A0` exports as `'=cmd|' /C calc'!A0`
while the JSON export keeps the raw name.
2026-07-27 19:58:52 +02:00
Donkie 1b1dbbb78f security: drop CORS credentials under a wildcard origin, document the model
allow_origins=["*"] with allow_credentials=True makes Starlette echo the
caller's origin back and set Access-Control-Allow-Credentials, which tells the
browser it may send cookies to whichever site asked. Spoolman has no cookies of
its own, so this looked harmless, but plenty of instances sit behind a reverse
proxy that does the authentication with one -- and there it let any website the
user visited make authenticated requests as them. Now credentials are refused
whenever the resolved origin list contains "*" (todo.md task 5).

Adds a README Security section. The README has no configuration reference at
all -- that lives in the Wiki -- so rather than starting one, this documents the
things a user has to know before deciding where to run Spoolman: that there is
no authentication by design, that it belongs behind an authenticating reverse
proxy if it is reachable beyond the LAN, what SPOOLMAN_CORS_ORIGIN is for, why
"*" is a bad idea on a shared network, and that a reverse proxy needs to forward
the original Host.

Verified with SPOOLMAN_CORS_ORIGIN=* against a real server: the response now
carries `access-control-allow-origin: *` and no credentials header, where the
audit saw the caller's origin echoed with credentials allowed.
2026-07-27 19:36:47 +02:00
Donkie 404b03173b security: stop backup rotation from churning away the restore history
Rotating deletes the oldest restore point and shifts the rest down, so enough
calls in quick succession leave nothing but snapshots of whatever state the
database is in now. POST /backup takes no parameters, so a bodyless cross-origin
form post reached it; the audit destroyed a full six-deep history that way
(todo.md task 2).

The origin guard from the previous commit already refuses that request, but the
rotation itself should not be this easy to abuse, so two further changes:

Rotation is skipped when the database has not actually changed. Not by comparing
the live database file, which the audit suggested: under WAL the live file does
not yet contain recent commits, so comparing it would skip backups that were
genuinely needed. Instead the snapshot is written to a pending file first and
compared against the newest backup -- sqlite's backup API reads through the
connection, so it sees WAL content. Identical means the pending file is
discarded and the history is left alone. This is fail-safe: if the comparison
ever came out unequal for an unchanged database, the result is the old behaviour
of rotating, never a missed backup.

Rotation is also rate-limited to one per five minutes, returning the newest
existing backup instead. Never applied when there is no backup to fall back on.

BackupResponse gained a `created` field so a client can tell the difference.
Additive, so v1-compatible, but load-bearing: telling a user they have a fresh
restore point when they do not is exactly the wrong failure mode here.

Verified against a real server -- six rapid calls now leave exactly one backup
and never rotate.
2026-07-27 19:33:07 +02:00
Donkie c33f65243c security: refuse cross-origin writes and websockets, and non-JSON setting bodies
Closes the two confirmed CSRF findings and the cross-site websocket hijack
from the backend security audit (todo.md tasks 1 and 3).

POST /setting/{key} took a bare `str` body, and FastAPI only JSON-parses
application/*json -- anything else arrived as raw bytes that lax-mode Pydantic
coerced. A text/plain body was therefore accepted, which is exactly what
<form enctype="text/plain"> sends, so any website the user visited could
rewrite base_url, label_designs, locations or extra_fields_* through their
browser. Now requires a JSON content type.

The audit suggested Body(media_type="application/json") for this, but that only
annotates the OpenAPI schema and is not enforced at runtime -- a text/plain post
to a pinned endpoint still returns 200. Hence an explicit dependency. Both
clients already send application/json, and a charset parameter is accepted, so
this is wire-compatible.

TrustedOriginMiddleware refuses state-changing HTTP methods and every websocket
handshake from an untrusted origin, using the helper added in the previous
commit. A middleware rather than per-endpoint dependencies so that none of the
nine websocket endpoints can be missed, and one added later cannot forget to
opt in. Reads are left alone -- the same-origin policy already covers them --
but every websocket is guarded, since websockets are exempt from CORS and
otherwise leak the whole inventory to any origin that opens one.

It is ordered inside the CORS middleware so a legitimate cross-origin client
can read the 403 body instead of an opaque CORS error.

Writing a malformed extra_fields_* array through the settings endpoint bypassed
validate_extra_field and left GET /field/{entity} permanently returning 500;
that array is now validated, and the registry cache is invalidated on write so
the /setting and /field paths no longer disagree until restart.

Verified against a real uvicorn server by replaying the audit's exploit: the
text/plain write is refused, a bodyless form post that would have reset a
setting is refused, a websocket from a foreign origin gets HTTP 403 on the
handshake, and clients that send no Origin at all -- Moonraker, OctoPrint,
Home Assistant, curl -- are unaffected on both HTTP and websockets.
2026-07-27 19:24:59 +02:00
Donkie 7d4728ae9b security: add shared origin-trust helper
Tasks 1-3 of the backend security audit all need to answer "is this browser
origin someone we trust". Build it once, driven entirely by the existing
SPOOLMAN_CORS_ORIGIN rather than a new env var.

- Unset means same-origin only, which is right for nearly every deployment.
  Non-browser clients (Moonraker, OctoPrint) send no Origin and are unaffected.
- A list means those origins plus our own.
- "*" and debug mode mean "I do not want origin checks" and are honoured as
  such, with a loud startup warning. An operator who writes "*" is opting out;
  reinterpreting it as something narrower would leave them no way to say so.

Also accept X-Forwarded-Host, so a reverse proxy that rewrites Host (nginx
without proxy_set_header Host $host, Apache without ProxyPreserveHost On) does
not see the guard reject its own web UI. A malicious page cannot set that
header on any of the vectors the guard exists to stop: forms cannot set request
headers, nor can the browser WebSocket API, and a fetch that adds one needs a
CORS preflight it will not get.

get_cors_origin() now normalizes its entries -- it was a bare split(","), so
"https://a, https://b" yielded " https://b", which no Origin header can match.
CORS itself benefits from the same fix. The raw value is kept in the startup
log so a typo stays visible.

Adds tests/ for backend unit tests, runnable with `poe test` and wired into the
lefthook ci group. Nothing enforces this helper yet; that is tasks 1-3.
2026-07-27 08:51:11 +02:00
Donkie eced411a51 ci: bump issue-manager to 0.8.1 and fix waiting delay
Bump tiangolo/issue-manager 0.5.1 -> 0.8.1. Correct the waiting label
delay from 60400s (~16.8h) to 604800s (7 days) to match its message.
2026-07-24 10:22:08 +02:00
Donkie 6e1065009c Bump version to 0.25.0 2026-07-22 14:10:59 +00:00
Aleksei Sviridkin c2c6a8dd8f fix(entrypoint): reject an unusable PUID or PGID (#962)
A non-numeric PUID or PGID makes the [ -ne ] comparison error out, so usermod
is skipped and the app silently runs as the default UID 1000 instead of the
requested one. PUID 0 is worse: the container starts as root and drops to the
"app" user via gosu, so a PUID of 0 leaves that user at uid 0, the "am I still
root?" check never turns false, and the entrypoint re-execs itself forever.

Reject both up front with a clear message.

Assisted-By: Claude <noreply@anthropic.com>

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-22 16:09:22 +02:00
dependabot[bot] 0076058280 Bump fast-uri from 3.1.3 to 3.1.4 in /client (#972)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.3 to 3.1.4.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.3...v3.1.4)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 16:08:58 +02:00
dependabot[bot] 54b67f53ef Bump setuptools from 78.1.1 to 83.0.0 (#970)
Bumps [setuptools](https://github.com/pypa/setuptools) from 78.1.1 to 83.0.0.
- [Release notes](https://github.com/pypa/setuptools/releases)
- [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst)
- [Commits](https://github.com/pypa/setuptools/compare/v78.1.1...v83.0.0)

---
updated-dependencies:
- dependency-name: setuptools
  dependency-version: 83.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 15:56:09 +02:00
Aleksei Sviridkin 129650237a fix(docker): link libstdc++ into the armv7 greenlet extension (#971)
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. The extension then fails to import with an undefined
libstdc++ typeinfo symbol (_ZTVN10__cxxabiv120__si_class_type_infoE), which
takes the whole app down during the startup DB migration -- the container
crashloops on any armv7 host such as a 32-bit Raspberry Pi.

Add libstdc++ to the extension's NEEDED list with patchelf so the dynamic
loader pulls it in. amd64 and arm64 use a correctly linked prebuilt wheel and
are untouched.

Verified by patching the shipped 0.24.0 armv7 image's greenlet .so the same
way: `alembic upgrade head` then runs to completion instead of crashing.

Assisted-By: Claude <noreply@anthropic.com>

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-22 15:55:33 +02:00
Aleksei Sviridkin 5c5e8b44e1 fix(entrypoint): ignore Kubernetes service-link value in SPOOLMAN_PORT (#961)
Kubernetes sets a Docker-style service-link variable for every Service that
exists in the namespace when a pod starts, so a Service named "spoolman" makes
SPOOLMAN_PORT=tcp://<clusterIP>:8000 inside the container. That is the Service
address, not a port, and uvicorn refuses to start on it, so the pod crashloops
- seemingly at random, since it only happens when the Service predates the pod.

Ignore the service-link form and keep the default port.

Assisted-By: Claude <noreply@anthropic.com>

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 22:30:21 +02:00
Donkie a0987e063e Add smoke tests in CI for armv7/arm64 targets 2026-07-21 22:16:00 +02:00
Aleksei Sviridkin fc606ec8fc ci: sign the released images with cosign (#963)
Published images carry no signature, so there is no way for a user to verify
that the image they pull was built by this workflow from this repository.

Sign them keyless, using the Github OIDC token, so verification needs no key
material on either side. The manifest is signed by digest rather than by tag:
every tag produced for a release points at the same digest, and the same
digest is valid in both registries the build pushes to, so signing each tag
would only repeat the same signature.

Verify with:

    cosign verify ghcr.io/donkie/spoolman:<version> \
      --certificate-identity-regexp '^https://github\.com/Donkie/Spoolman/\.github/workflows/ci\.yml@refs/' \
      --certificate-oidc-issuer https://token.actions.githubusercontent.com

Assisted-By: Claude <noreply@anthropic.com>

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 22:07:57 +02:00
Aleksei Sviridkin f7da164599 fix(externaldb): report the unwritable path instead of raising NameError (#964)
The fallback to an in-memory cache never worked when the data directory
itself is not writable. Evaluating cache_path calls get_cache_dir(), which
creates the data directory, so a PermissionError raised there propagates out
before cache_path is ever bound. The handler then dereferences it and dies
with "NameError: name 'cache_path' is not defined", which both hides the real
cause and takes the application down at import time, before the startup code
that would have reported the permission problem in plain language runs.

Take the path from the exception instead. It is set for both cases: the data
directory that could not be created, and the cache directory underneath it.

Verified against the published image by running it as a UID that owns neither
directory, which is what a restricted Kubernetes SecurityContext does:

  docker run --rm --user 1000670000:0 ghcr.io/donkie/spoolman:0.24.0

Before, that failed with the NameError above. Now it logs "Ensure the path
/.local is writable" and carries on to the regular startup check.

Assisted-By: Claude <noreply@anthropic.com>

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 21:50:06 +02:00
174 changed files with 13849 additions and 2858 deletions

No files matched your search

+30 -7
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

+218 -10
View File
@@ -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
#
+103 -53
View File
@@ -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
+2 -2
View File
@@ -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."
}
}
+1 -1
View File
@@ -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: |
+15 -7
View File
@@ -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"
+13
View File
@@ -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
+19 -2
View File
@@ -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:**
![image](https://github.com/Donkie/Spoolman/assets/2332094/33928d5e-440f-4445-aca9-456c4370ad0d)
![The Spoolman web client, showing the spool library with a spool's details open alongside it](.github/media/client-screenshot.png)
<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.
+6 -6
View File
@@ -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": [
{
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+7 -3
View File
@@ -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,
+5 -1
View File
@@ -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
+5 -1
View File
@@ -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
+11 -2
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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 -2
View File
@@ -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('');
+8 -7
View File
@@ -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.
+90 -1
View File
@@ -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",
+66 -20
View File
@@ -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.",
+40 -1
View File
@@ -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",
+1245 -1203
View File
File diff suppressed because it is too large. Load diff
+23 -22
View File
@@ -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
}
}
+41 -13
View File
@@ -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;
+11
View File
@@ -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
+20 -2
View File
@@ -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);
+41 -8
View File
@@ -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;
+31 -5
View File
@@ -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;
+84 -11
View File
@@ -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');
+25 -3
View File
@@ -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;
+248 -24
View File
@@ -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);
+39 -9
View File
@@ -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);
+13 -1
View File
@@ -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>
+14 -2
View File
@@ -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>
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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 {
+111
View File
@@ -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';
+48
View File
@@ -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)
);
}
+56
View File
@@ -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
});
}
+183
View File
@@ -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');
});
});
+41
View File
@@ -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}']);
});
});
+74 -17
View File
@@ -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');
});
});
+52
View File
@@ -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';
}
+5
View File
@@ -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 });
}
+38
View File
@@ -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();
+10 -3
View File
@@ -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);
}
+61 -4
View File
@@ -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));
}
}
+14
View File
@@ -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() {
+17 -1
View File
@@ -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 {
+144
View File
@@ -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');
});
});
+16 -2
View File
@@ -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. */
+47
View File
@@ -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');
});
});
+30 -8
View File
@@ -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 };
}
+82
View File
@@ -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);
});
});
+96
View File
@@ -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);
}
+150
View File
@@ -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' } }]);
});
});
+60 -19
View File
@@ -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 };
}
+1
View File
@@ -51,6 +51,7 @@
<AddSpoolModal
open={ui.addModalOpen}
presetFilamentId={ui.addModalFilamentId}
duplicateFilamentId={ui.addModalDuplicateId}
onclose={() => ui.closeAddModal()}
/>
File diff suppressed because it is too large. Load diff
-957
View File
@@ -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>
+13 -3
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

+17
View File
@@ -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" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

+56
View File
@@ -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(() => {})
)
);
})()
);
});
+8 -2
View File
@@ -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'
}
});
+24
View File
@@ -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"
+10 -4
View File
@@ -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
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff. Show more