mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-20 00:59:47 -05:00
Compare commits
297
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
362d80e55d | ||
|
|
5fc8e6cf42 | ||
|
|
b7a282d2eb | ||
|
|
c979e63513 | ||
|
|
0e88109cc6 | ||
|
|
ed9124cb06 | ||
|
|
f399855d3b | ||
|
|
d102f9475b | ||
|
|
61e85c29bf | ||
|
|
ef3dee4728 | ||
|
|
8656e04055 | ||
|
|
744284391d | ||
|
|
f8642bedd6 | ||
|
|
8285a4da45 | ||
|
|
f6f28a8df8 | ||
|
|
abdfc73b8d | ||
|
|
90713bf5e2 | ||
|
|
636e12e737 | ||
|
|
896e473807 | ||
|
|
b01e4e5bbe | ||
|
|
c881740e69 | ||
|
|
dc18a3017f | ||
|
|
f7c3988fed | ||
|
|
8d363bbec4 | ||
|
|
57ef303c93 | ||
|
|
bcf340bdc7 | ||
|
|
69e541ce67 | ||
|
|
6746bc4fce | ||
|
|
c492cc05fe | ||
|
|
1b039bca19 | ||
|
|
bd9553342f | ||
|
|
6a9904adbb | ||
|
|
74f93b4afb | ||
|
|
073bf15108 | ||
|
|
15f0124a4c | ||
|
|
1c87bb9c0e | ||
|
|
715cd47b9f | ||
|
|
fa92ab465d | ||
|
|
a9e1802c2a | ||
|
|
72031f0ea5 | ||
|
|
a3977509eb | ||
|
|
3a8ac87b13 | ||
|
|
3e9c3b68bd | ||
|
|
9708d02498 | ||
|
|
58a01e3389 | ||
|
|
bb0bcc1b5f | ||
|
|
6b6c13e4f1 | ||
|
|
b0faab5cfd | ||
|
|
62963935eb | ||
|
|
3f8cb8f1d1 | ||
|
|
89fba3f4a8 | ||
|
|
e72b2d1821 | ||
|
|
4658f42798 | ||
|
|
abe5bd909f | ||
|
|
645ef72937 | ||
|
|
12199575de | ||
|
|
975e0cc702 | ||
|
|
15381fcf01 | ||
|
|
ca007aa561 | ||
|
|
43101b2a2d | ||
|
|
a31beee46b | ||
|
|
6447198fe3 | ||
|
|
c35983ee52 | ||
|
|
1e800ec5f8 | ||
|
|
016ca6791e | ||
|
|
e5944c94e0 | ||
|
|
a35331e50a | ||
|
|
bcc1ba1ace | ||
|
|
629b8228d8 | ||
|
|
7fbe604cc2 | ||
|
|
bff2a485e6 | ||
|
|
7e83451226 | ||
|
|
30928407a7 | ||
|
|
02f5c07c13 | ||
|
|
022285d64d | ||
|
|
6b1e01ecd8 | ||
|
|
594e14b465 | ||
|
|
bce0d8069a | ||
|
|
cc34b8ea24 | ||
|
|
82137bf35b | ||
|
|
7c4dcc7da7 | ||
|
|
dcb4618399 | ||
|
|
7a1114f8fc | ||
|
|
423cf234be | ||
|
|
2c15af00b8 | ||
|
|
ac9a32fa23 | ||
|
|
787d912dec | ||
|
|
8badd51707 | ||
|
|
53f09eb2bc | ||
|
|
b3d35d751d | ||
|
|
9f29826588 | ||
|
|
f7c53187c1 | ||
|
|
1e9d74cd6e | ||
|
|
1a04139c1d | ||
|
|
aae2b4df56 | ||
|
|
e1ad9feb30 | ||
|
|
5b93b3b37e | ||
|
|
bd4b9351aa | ||
|
|
958d5bd9ee | ||
|
|
c9b1215cb6 | ||
|
|
586299aaa6 | ||
|
|
7e98987b87 | ||
|
|
ba62681856 | ||
|
|
05e4da7a10 | ||
|
|
0543b9145b | ||
|
|
25d25e3701 | ||
|
|
a353983f85 | ||
|
|
bed3f14ee7 | ||
|
|
27ea256e2d | ||
|
|
7b82f2afb2 | ||
|
|
45cc8246d3 | ||
|
|
0c4caa527e | ||
|
|
2ab68cdff9 | ||
|
|
fc6aa55d27 | ||
|
|
92e093b16d | ||
|
|
55d11d0dc4 | ||
|
|
6c5d03d4ec | ||
|
|
058490a3c6 | ||
|
|
9131454aa2 | ||
|
|
c73bce4301 | ||
|
|
1c0f668c16 | ||
|
|
de81990a29 | ||
|
|
e4a78bb352 | ||
|
|
196ebd66d0 | ||
|
|
d1f3401b37 | ||
|
|
d848ddef97 | ||
|
|
5c65cb5495 | ||
|
|
72d2cbe986 | ||
|
|
3ecdf5dfc6 | ||
|
|
d5a49e3340 | ||
|
|
83897b9744 | ||
|
|
c386ac34e2 | ||
|
|
205d8fb813 | ||
|
|
c74f77b478 | ||
|
|
a40b20df8c | ||
|
|
dc56305a97 | ||
|
|
ae2319a215 | ||
|
|
9f90c2238d | ||
|
|
c157946eca | ||
|
|
0966ecada9 | ||
|
|
9ae9827f4c | ||
|
|
e9e76a088f | ||
|
|
a4c749ad14 | ||
|
|
994e5d422b | ||
|
|
1db2ed3851 | ||
|
|
b9b5784108 | ||
|
|
c01342d182 | ||
|
|
0388fb60bf | ||
|
|
feb67ab500 | ||
|
|
ed946223d9 | ||
|
|
c405739b1d | ||
|
|
e744049343 | ||
|
|
179fc0a117 | ||
|
|
6520ce6d1b | ||
|
|
83b0238bb4 | ||
|
|
f55188e346 | ||
|
|
4fee8db323 | ||
|
|
81636f253e | ||
|
|
0f705e80c0 | ||
|
|
3c7f68e2bd | ||
|
|
64b29a561b | ||
|
|
8f9c214dac | ||
|
|
1305857efb | ||
|
|
dd5a0a89dc | ||
|
|
1eeb2948b2 | ||
|
|
348499f469 | ||
|
|
d34fb88b31 | ||
|
|
a63af285f5 | ||
|
|
798d8b1ded | ||
|
|
f8ffaa0837 | ||
|
|
09fea9f668 | ||
|
|
789b7fd88f | ||
|
|
5424a638b1 | ||
|
|
c281114c6f | ||
|
|
9566f7848d | ||
|
|
4f9cc1b75d | ||
|
|
42721c7ebc | ||
|
|
d1dc40b3cc | ||
|
|
92be2975b8 | ||
|
|
a62e4ce6c1 | ||
|
|
0a3404cefe | ||
|
|
c2002e43f7 | ||
|
|
85e3ac7163 | ||
|
|
33aa3de515 | ||
|
|
e238592e4d | ||
|
|
107a134da8 | ||
|
|
3cf7a9fa61 | ||
|
|
1e5b28ab7d | ||
|
|
035922b49f | ||
|
|
eced7f22db | ||
|
|
4c2febbba6 | ||
|
|
e37045dcd4 | ||
|
|
f83b9d9ea4 | ||
|
|
0b3d7ba140 | ||
|
|
b6217c4b30 | ||
|
|
09953643dc | ||
|
|
a6512da598 | ||
|
|
6da0902c8e | ||
|
|
6bcda3d307 | ||
|
|
9dd0f73be1 | ||
|
|
5171330c44 | ||
|
|
009f9e3bae | ||
|
|
a039095360 | ||
|
|
205de5f0ce | ||
|
|
05a1b12e33 | ||
|
|
f458995104 | ||
|
|
9f048a34c8 | ||
|
|
ba891d5830 | ||
|
|
f841da6561 | ||
|
|
931e3c9b3b | ||
|
|
9775c2c42e | ||
|
|
a9e50559bb | ||
|
|
91d5dc8f42 | ||
|
|
8528847957 | ||
|
|
5799fac1a6 | ||
|
|
ce1c781ae7 | ||
|
|
dc72f3d5eb | ||
|
|
aa5821f005 | ||
|
|
13f7667bf4 | ||
|
|
ed732170d1 | ||
|
|
8465481ab3 | ||
|
|
9935d74886 | ||
|
|
35d10f899d | ||
|
|
785984e685 | ||
|
|
8b71fb9345 | ||
|
|
9bb8973af2 | ||
|
|
332b3e0fe4 | ||
|
|
8d9eb7395d | ||
|
|
413254b79a | ||
|
|
21c7b98844 | ||
|
|
97758f2c4c | ||
|
|
9aea271ee5 | ||
|
|
6ab045cde8 | ||
|
|
90492ff75d | ||
|
|
1f2670cf81 | ||
|
|
565abec742 | ||
|
|
afb3094c1b | ||
|
|
96065fbd9f | ||
|
|
8d65e53e7c | ||
|
|
48221c270a | ||
|
|
f9c2c3838a | ||
|
|
bd877c1ec7 | ||
|
|
328c508c39 | ||
|
|
052d3e9eb9 | ||
|
|
9a98e8ba5d | ||
|
|
6dce8717a0 | ||
|
|
5b480f7db7 | ||
|
|
1b4beb9593 | ||
|
|
33ad8d7173 | ||
|
|
713cb51f09 | ||
|
|
fa39c6d08e | ||
|
|
61e2c0c27c | ||
|
|
83ea5001fd | ||
|
|
6fd64f9ed4 | ||
|
|
2edd96dbb0 | ||
|
|
61e1b62a38 | ||
|
|
7e2e98b30a | ||
|
|
6d97cd34b3 | ||
|
|
5624a0a605 | ||
|
|
6e289d76ca | ||
|
|
5b39b2848f | ||
|
|
99932ceda8 | ||
|
|
fbaa83eead | ||
|
|
d2db504cc5 | ||
|
|
74a86683b3 | ||
|
|
2434b424db | ||
|
|
678213f67e | ||
|
|
21e0b71efe | ||
|
|
6a786bac88 | ||
|
|
38b77a9b14 | ||
|
|
fb1567a9b0 | ||
|
|
01c7ff65be | ||
|
|
d43552bd26 | ||
|
|
ff2e3ec31a | ||
|
|
5144b64a8a | ||
|
|
9d77b08114 | ||
|
|
b4292398f5 | ||
|
|
9ec5181d4c | ||
|
|
91dec46936 | ||
|
|
d67a2afbe4 | ||
|
|
64e70076f1 | ||
|
|
ac7865ecbc | ||
|
|
419a4763d5 | ||
|
|
3f1d3b4c7b | ||
|
|
1507f9b3b8 | ||
|
|
7f42bd6f02 | ||
|
|
fd9b06d33c | ||
|
|
de533bc74d | ||
|
|
8979f80e74 | ||
|
|
f261dee61b | ||
|
|
422657d18f | ||
|
|
01be8ddd5b | ||
|
|
bf265c12c3 | ||
|
|
c90cc3acab | ||
|
|
9423acbc9e | ||
|
|
05628eb593 | ||
|
|
f02ebbc93e |
No files matched your search
@@ -53,6 +53,10 @@ SPOOLMAN_PORT=7912
|
||||
# Default: FALSE
|
||||
#SPOOLMAN_METRICS_ENABLED=TRUE
|
||||
|
||||
# Periodically check GitHub for a newer Spoolman release, shown in the UI footer and server log
|
||||
# Default: TRUE
|
||||
#SPOOLMAN_UPDATE_CHECK=FALSE
|
||||
|
||||
# Collect items (filaments, materials, etc.) from an external database
|
||||
# Set this to a URL of an external database. Set to an empty string to disable
|
||||
# Default: https://donkie.github.io/SpoolmanDB/
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
github: Donkie # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: donkie
|
||||
ko_fi:
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,67 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 800 570" width="800" height="570">
|
||||
<!-- Generated by https://github.com/antfu-collective/sponsorkit -->
|
||||
<style>
|
||||
text {
|
||||
font-weight: 300;
|
||||
font-size: 14px;
|
||||
fill: #777777;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
.sponsorkit-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
.sponsorkit-tier-title {
|
||||
font-weight: 500;
|
||||
font-size: 20px;
|
||||
}
|
||||
</style>
|
||||
<text x="400" y="40" text-anchor="middle" class="sponsorkit-tier-title">Gold Sponsors</text><a href="https://joshix.com" class="sponsorkit-link" target="_blank" id="joshix">
|
||||
<text x="400" y="203" text-anchor="middle" class="sponsorkit-name" fill="currentColor">Josh Wood</text>
|
||||
|
||||
<clipPath id="c0">
|
||||
<rect x="340" y="65" width="120" height="120" rx="60" ry="60" />
|
||||
</clipPath>
|
||||
<image x="340" y="65" width="120" height="120" href="data:image/webp;base64,UklGRsgGAABXRUJQVlA4ILwGAACwJACdASp4AHgAPm0skkYkIqGhL5ENkIANiUfPo+yShwQpAbER6gG8GTCe9OgMo/Ux8Mb6gcyqPI8xGY/5PrE+/PpB5ypAjRkDFzrivwF62s/DK34rzvVhAPgCBtQjb6uCuPF1IciZ/eOsAm2YcRY0JTDqwAeM+a8G/bPnitwjyrcNv4JeKAdm+2kIGNTUplFYHTh5H/6cvX/J2boEqumFpsZ4H+BTtbcCJ1umGOvIdIZ7mJJ4wKAIqBezjekx2kJcEuJv1TQjTzvFltO1PQyGeQS85A0kZIQo/eL9EMzL7yHbRNB95Za7xMB23tgYLYRGioR+cObcTdJh1itU3qSDjOx5GX2qxaVkPqMDgaM/dJltCbRr48Uj9txgtIhRKvgNxLd0fZ2iU1+9n5MIMAD+/PQBSjyc0vbGv1rJpHHondsozJ5is5nynZ+bxtrKmbuULzqkfkHTd2EZMZR4pKqYe4vgUljlur1epNBEkArOAPbtmGjmkwqXRkZtDettTONSDKtn7bi5F52h+cnz5FvLoE7c+pK+ZkBlpZy9Y7Wzx9bvKE/Org0ItinAq7f9TmihQ1eizXWPtgBKFd+Qu7BF+wqFv/68qGwtXD/9iMyD9G0NFBxF/YHjCzsUt0rvHlkPkG3u0s43UFSi+tFMWXfJVDMX9bqV1Y7dvCwhQ7NY+SZCNaoq487tNhvEhIlUb1mqdXv3rxhJ8QLM0iZ5muNzgAZH8rnxR/JlNXsfZhe4ZvsIgsa8/f3eUhXSZ/acnJ1dh8SayPmOJysvTMy4SmXCvIWvFZrAdbXjfUKSV2SDj8CFjFa3S/U+VcTXqTm5RTpQy2R6citHAtZ/FAo0R2l+ubQvHAZbG1naXrdCmvx9IQxQE9DrQrFccqLpAroykRLrIPEyTcvzXPoy6DcrqpzWpVsskaKDksL8HIeM6LvYhAMaz7kmdYfHfQhxnTzQEV3bii5mXAh7RLbsvuml5HPVj3hHYPeWcyqXuwdbKs1ei9Is2LWps/OYoY1/S8HkS7yWsH3lJGIp3YRtJrl0CBj80B5Kc7FCt1kLT7l02XMn2sp4G/Tmc2PTOSToCkOEzWm60rUaX7gU/NBUAHcFJzcveQBzWbtm/QvAzmyYRv27bSozA8jcjTB11XwBz7aRW6bOdhZevq1oNodI75fAQ9SjxsqijU6icL/C17hatpRfNDRK5339AsbtAKnhUpcmbBrYXmYrjfmcXLsTjT9m4yYPLzmAr38FhZbZYTXEwcQ611OLliezqUGH63rwrrD/Y1D7DP5TeGiQ6fSqUs5MbV+bW1zQlCU4ZZ6GFhrOnXhlH6oN2N9aO/Eu35SHfnCxZk2cch4YgaPmOGn8R7/6u2il6/EgXYMDeK7HcSEmss7n1CREawmfINYUCUxLB4s3z4pQQkVOTAy0PM3uylOfQt6LDpQ7ZjnhwGGNbVqPRwsClzPpxRnIAPXaDHJW0tFtNHZSNOmo3m9VzNkVN0wUOHb+/i1tW3RUHhuE+6p8D8C4px/D5MTU4l9INiryqSPO8KR9Pauk1PT7p4Iuf1GbZ9pYXVqIQcpIkYNKRyyJObLYCEVJYyUQ9s1sHx3kWGY8BVNVsZ45IfByrst+0iJywhlRMZLvY719IU2ssAyx9bQyJ+FOZOH11nib/g5i86gKhw1inytSYr7KSrOZ3vEZK0d131xF8D4Tqq5fmEuoaR7R9e/DvkPRxiSPwp6caZYHNjW0vw28jfBIeuc2Hu86STZP0dSWeaIOiTXK74vJEmLdMAUbYCX3jw2eTZJxYSuEI9YivnsnsJSPF9gk8RFk5dx+BVE3a+ypXgbjVO8Bg1tuNmJDEAH22PMgOxKSCbOhQ/ox19PW73FJ4ybJojgF3dPjPKzue/4Hf429x0Jsn2/tlNCrJf0T7XyE1cIgKvt5DbNFfh5Ss0cZe+I/9HUA7JwZCQ5LF2GqEB21XI1qJZor9y46SIjj2HxWJnJPdqHCEe6Tb7D+NLFsjfl+ZJGZu/HX6vjuORb0RIkmbq0aEVzHGjIawLzSZRKs+q7uRCXS2erdaj3ZPTqTV4YfqwFYAW3uYbwD+3Ktu9mIXLpFMsw8IiQNZopAHBbVQo+/Qk+1LBLeHztLhFDya0zWaexlFWshmq4c3J6RepzwNd/yweyeESecwL9eXf8fanxvJ2hOMKKU7Rh1g1ZBdGFJYXrZzN9PgAoBk/w77D0swqmrjebS9hmnON+C9owqx15EgS8f/BGWnupWZs+frjmVzRfc881ei87vK+ccntgAq2JzNWMLH1DB2R+Y8pkBzwAAAA==" clip-path="url(#c0)"/>
|
||||
</a><text x="400" y="255" text-anchor="middle" class="sponsorkit-tier-title">Supporters</text><a href="https://github.com/swtaylor" class="sponsorkit-link" target="_blank" id="swtaylor">
|
||||
<text x="360" y="348" text-anchor="middle" class="sponsorkit-name" fill="currentColor">Scott</text>
|
||||
|
||||
<clipPath id="c1">
|
||||
<rect x="335" y="280" width="50" height="50" rx="25" ry="25" />
|
||||
</clipPath>
|
||||
<image x="335" y="280" width="50" height="50" href="data:image/webp;base64,UklGRpoEAABXRUJQVlA4II4EAABQFgCdASpQAFAAPmkqkUWkIqGXrAdgQAaEsYBhWgnHTNnEshj28rNZ+GmOXmp93KFPcld/L/07TSyJCfZvbGHb5moRzw0e45eO2MkuhDHxKdpqpve1uYdG0BsLtaR1qQkiaMCv1f1r0sWQvsyKv+yTj4NBtLITGsATKV0IENN3Bl8f6aAHqFPbX/IIwHqJ5dhsp8hpZiO3hdsJnuJjXBSNoo/ohDiJMtm1nZDTgJRCWMgANxShzJ+lEFPAAP79QB9OMqjqK/Wb/XKJUhuVyjCLtjHwBkqY5p46HUIIHw/VhGYrttpwJLsPtJ3UJc+EdV/S8DwYN/yByU6U0rMLIGZSGqa2ErEr7V4GGC9IxFbTvXtQDpVVaddvvt0O1HhsxCFwCfsVJwpY1+j6FX0BkGY97/sXD6e4FiqF4u1+mgpFo5AvXaoOAnyAXGYvs9mrbX7kY2vf2rzqVsTsXUDRBIdM9zf8QYGvyqij+qJIWtiJDJtziH6+PIJPNyYaF3dIun0bWAOTdHDJDIxzmdua4QIZ3+SGIdZ6zj9igFRf/D/OkJzQCGsrZMNHiAul/RsCjH5bv2NQCrlcm8Tek/Bol07kSj6qBCsK/tnGmvsT5mrYoWbQsc4tflV2jYurFzbXEjfmMJTron7S5k+b88/hOxLGsnpY7GN+cajmuqSNIAdZ5Es22QFpRJOcqh4DbtElMbp4JmiIP45o+v5aoyEyIsS6tJ/LWKnofIE+2BdiITSXHMzWWzLZ4yBVsG5jcvHbzsqFp9QFsEMe6GYubnNwvK+3uPptk8jnZN/0geWJiLFMxprrPEhv3N3VEKZ1n4yf3EoOYfywMYJ8syuqMKLLH7PB7tDql0Qxsl77fR4fURVBPGgoHbDUjrhRmEeoMSuuWD6FfZ2sx4D8aLlWKdTcRjMO7i2yNb+apfklcWh9CkNjigw1K6OlcaslrUAguez3uDHP3/e0RlxlD9oXf2i7bPFmeW32p6/krBex+5LE4YroYFb6azBBWEixY90qpd5zwSzarPhNoxV4gRRgKTbMmmIQBTtNa7l3sPIIf0znFnxX+RXwIzLmfRPY66VGkoxV5zEcJwTYngDDe8zfqp4uBhCEIq76aOUn/Kf6ydnnBwkv9EvRuTQ5wEh7jIfn74wudig9529et3sKwVIC/3DyAKgOvwkmC2wiuE/MdSRyVEgJfYBkOWSn8IJEl+ZBUelXSxrKGBeAn3CJUN7vn0hBYCKhrVCuYqCQxB4RtM9ewors7V6olzJUbu6E8WazDVYTU69/pqNi1eOfeYqbv3/il9BdKqx1InGxSUo06kUmrmTyn6cmrDkyIUYrAOttFM6Sy9SKCxPlXgDG3YV1NWmkW8vZQli8jp71xHO/80aXNIOPq4kWo3h4dKuH3JF6yDQVpAN3PJb6rD5ZE8m6JN4hbYWXPjH5YnM0xk8X7xJCgZyaSgFgvc5CeJSohiXsOdwanYvLqgpgjznJYJw+YLcVlbPsLGeg0I1HVpaxWt8Sds1iBtQ+I5ZF2VWTAbdhn9vK+oC8c498LHPQiREbCHMgAA==" clip-path="url(#c1)"/>
|
||||
</a>
|
||||
<a href="https://github.com/tlangkabel" class="sponsorkit-link" target="_blank" id="tlangkabel">
|
||||
<text x="440" y="348" text-anchor="middle" class="sponsorkit-name" fill="currentColor">tlangkabel</text>
|
||||
|
||||
<clipPath id="c2">
|
||||
<rect x="415" y="280" width="50" height="50" rx="25" ry="25" />
|
||||
</clipPath>
|
||||
<image x="415" y="280" width="50" height="50" href="data:image/webp;base64,UklGRlADAABXRUJQVlA4IEQDAACQEQCdASpQAFAAPm0wlEikIqGhJnM6aIANiWVfalewGPbCULeZf9lVKyeAFRcq/sb9iNrYjtrl20Tl9Brpxl6pzt/hrSsHFFGzOJtddJg/shyIiZ1LzVI7175shK6DAWErqJbPidMLUWg3ubKTpxfjBC+/K7Eh9dbQf4YG/eS9ejJPeMpsfu0N2Ta9ObSG201Sy7FkAAD+/lcRS9KSg1a5BZKoJe3h3qj9X3MvB+vnUNlwpci/lOuX9adPeFnDrws/r9sfemPaCgBt/l/Us8nItNrXxn5gmJbR+d1kXCM15yJi87WEtT4HCgIDAlIcW7CNJpCpFzXlmUiZuTpXY9h0L3P1/9DlXWJ6UxR6Eka9lGj1h3gbBdw5XJNayBK6l5knJ75c3VxeMfA41IqIUfRBLqAJxTUAFirrc8GjSidiV2PrpxHD/vIDyqxi0CwSZHZneIXXKdQgElMsiWY7lv2ZYJRbz852fWqChfw8V7O8CmRHJeflh1n6bsJZJW15B0GdmNkd9rEcEEN9c02fOga7TRSa5J9qqm5mQK6ttlG3o20FKndVI0RGhhZ0QoJZiWB2E6EVN8GReTD++Q4tmgx+anuuTQYLlqIbPj0fiNMWruZYNaWBR7lQfGtCltKKoXpGrdsEsb78iI+dXE+PByx8SYCDE7YMosWquKmh38fWmtCWRExHFt6J+fOK7878sckHHUxk7dOZiMG6VJPl1M6s7RVupY4pG4KbEsXrIVmKJ2PMgZqR81XYynE68U6nfMgqjmpQKS+5rFiSdo9GtJr0g9EWmfCGX21fjpknlYAQ4LQMZxVpxCGUkeedmYL/GqnCnxtjZJ4m7M8/y8rkFZZ1HEy8m7h9/qg9sTYZEd+SuGny1fVkAXSsJO6xrQFyB4W5X+QY+dKANG78hoe1ZJqb7LqT4nlWM/49WvYLYLjxLvUEcDmySZFru6bT8XZangg+KyZQR73z0+f7IiRj/PuASJHhqcqirbnHPTDWfKrQu6dWrKHR5kcRlz5Dpnb0gBEPKIQrv5DXBVJRILmF23xJK0rUzUkwQ/L6pkq0Ty8GGXnfzba9DbTT4HHDUwuMOzyNOyuL3/r/ySX1m8qwdpWmnnoAAA==" clip-path="url(#c2)"/>
|
||||
</a><text x="400" y="400" text-anchor="middle" class="sponsorkit-tier-title">Backers</text><a href="https://polymorphic.dk" class="sponsorkit-link" target="_blank" id="polymorphicdk">
|
||||
|
||||
<clipPath id="c3">
|
||||
<rect x="372.5" y="425" width="25" height="25" rx="12.5" ry="12.5" />
|
||||
</clipPath>
|
||||
<image x="372.5" y="425" width="25" height="25" href="data:image/webp;base64,UklGRjYCAABXRUJQVlA4ICoCAADwCwCdASoyADIAPmUqkEWkIquZ/Z4AuAZEsYBfefuIopNZ6c4YXlAnC2/DzVHzOazQULy+IzbYtxgfWvdvZMf3fvpN64RGUtlBqaalSwhyxpsxwa2VBwP9czxhfRUkgHDC3n7wM35VAAD++cjP0J212mopM4/Rh0hOscBf9Z3H09zvnh+baFS5+i/jyZ8i0WlzZRzLr9W56mlBTdHVe7poj6YN27piHQHv4HFq8LNPFIaEo2UCdds7haNciUEC46JgnRrnv7x+SK2UmmeRCGWLE0tMvr9Lj7Msgwnee0YpKdP2gtm7UAQ6hAMR+/hIP28TYk/3Jc3oOFFIcOOAwbvtVofjEaORNOQj5vpSLYgA1YmU3MvsWKJiHK3wAxZSAEODjaEXSgWuFQDo/u4k0dv0NcRqX5vjk4COQg+LoZEOWWOnfY3J0rqmkONX0GtvUXQ1vhgSv0fst67tyvesub67hOcUmsPZCS2W/NVUshMelGBQ6+shQOlHXxNysIkuO9ciE/I5vxamqTWlG92zlO5w2IZIbq2x10b5KfSCjkFr/Lspe+4EXhtwhfaZOKBx8eESeBLmBmMMDoK+v1nSYACGfyvQ7ii2bxvOkchviukeoj4B7Q3i8cHNE2d+he9657d5nwMMJ1cubwm7umN5MGKk5B731r3aX3uCbazwf6lY5MQLnw7xU4XxGtQNkQEHQqsG5ajWgOeLm5upXvEKFTLc8trHi1L8A4pAAA==" clip-path="url(#c3)"/>
|
||||
</a>
|
||||
<a href="https://github.com/Kreism" class="sponsorkit-link" target="_blank" id="Kreism">
|
||||
|
||||
<clipPath id="c4">
|
||||
<rect x="402.5" y="425" width="25" height="25" rx="12.5" ry="12.5" />
|
||||
</clipPath>
|
||||
<image x="402.5" y="425" width="25" height="25" href="data:image/webp;base64,UklGRlAEAABXRUJQVlA4IEQEAAAQEwCdASoyADIAPm0wkkakIyGhKhzK6IANiUATpygqc9AuwDC76jadzy/+jFdrvoARipqP9l4STP6Jkfek45SHQI9T+wT+s2+cmrUBCJ9Mic9iBdMAlBmtbr3k3fO+bnxwdUPe9YN0drh6w7q4kO+jqNk0uEzUpkgTMYiFhrnVc9qKcB0G35IUcNmQFww80vxGnlkoIEV/s7YNLN7cVA0xQAD++ScfQpSJONSUqCMK+Ol5ZCh0b26aEtYQ7wo7d/iK4GFJ/Oln8/RBtU+uqnwhlmNv/XvVpC/PS5bd8Mbw8mdK7EK/bTtH2GXbGoqm+fSThwv9egzABKWJmjoyNIybs62ApVKMR1syEfaHs9rrVJa4Ku+3KznIaFb3nP3rKOJTl0JxjplCTw0qEoyER2/l8s9Fx7Wj27E7je+BerqTiLU2WKIiK6oWo6qxLj7DM4pmLTmy2HRcdZW4HbJKEvw2qtft+aVqnIpmkpHQ7gIUZB8YqXVLOuf479mRmJSRWUM8XDM+/ZHsiRjQLB8/63zHaV36axymJybL7DPubsyezxXHl3gW1oAHTPcn4dG6tghnVGt61bBjLL4xKpu+jhr9KGulx5hg9zz03dwj6wQGMzLoAS0KS1ootiTv7tSxN8h8BQKdViSfbkmzM/WQZgxko9b7T0mJ3RPfTsolRpzwAOjDjSkxnIA6VC2hoKAHSGYvW/ug0PznrxyLQlqs0eNgZM46ou98sNg2+VDv3Lc+ke9E7g3i9yt7ws/XeGzfyvfyZZlBCDKPStuncmbbhTf808x5SIglbQFAZGxHtRnUaqkoa8E4PlA6mlfzqRey1HGPP/oBPQe25l86Y71vr3eeZn2GcLFzKwjIaDNA9OMteOhej30/TXc+yBzpdTsWtQPyLo3IqXfdT5c8eyWgB4lmNJaOIgcTBCsZW/E/js1Iww91n/KKNlN9puBaomIKqdBj9FbL1nVpR97y+x9x7Vuw2LRI8xu8jNj73/Guv+NyuTRu6t7+QlUXob0HmqrdNuLf8HmZoRE6bv4f6MTQWWfGBSeT6vkpw9I2Qpz3NyC7sC4HMdPjh5JvW4J93BcKqA1N6Nw+bVRvl2ew47w+Kc2vN3IAj5IngbnUvDKEqQZgN+zQ/7BqM5d4YIk6vkyUxeXcAZ0pwBuwstcAJh0sx2PbQrT71ohYigAPz7xq1HAZ/mYsBogBIa5gO2HGgEFavUP9k+jD/GJZvLziNZzsbcX7YdjCdKTH0xwuEqiuufKzOaECClx4DsZuvipljXph50HXFc5XAfZgS+sQOZbpmb6Kf3mMjtfCCDW7efAAxeoPhCCHRcVOV3vJgGILkvW4p2k4VUrVyfDLJ3k9+AriK2tAZroSkIBkalglP1yJ4tYR22YS5slOQ3/CLnU0an8lFGOhWHQVw1CN0JCygQkClgJR4q5+ghh23OXWA/3HEAKNDvo3oAA=" clip-path="url(#c4)"/>
|
||||
</a><text x="400" y="485" text-anchor="middle" class="sponsorkit-tier-title">Past Sponsors</text><a href="https://github.com/marko-p" class="sponsorkit-link" target="_blank" id="marko-p">
|
||||
|
||||
<clipPath id="c5">
|
||||
<rect x="372.5" y="510" width="25" height="25" rx="12.5" ry="12.5" />
|
||||
</clipPath>
|
||||
<image x="372.5" y="510" width="25" height="25" href="data:image/webp;base64,UklGRl4BAABXRUJQVlA4IFIBAAAwCQCdASoyADIAPm00lEakIyIhLhkoAIANiUAYoZG41zSo4e3a0MDqVd5hvyUF1IEn9JgXObpn2Mt9prYfftYf4GRNRZ7hZ/fWD/6kU0akoeFYAP7mh/5A2Gfo2763tH9O1yC1D3TebcFfF/bO2z76334lot7jYxzsHcClMluiJtad5Z2A9ZtsHn0CgO1qVt/DcFFWwoCom7DWqiX/JeWTPmV7+u5ecLmedHJQ6jheSaRadpREwA9UiSGzpAggcCbnuZdKF5py6aWBIPoYdwJPwQTuDBoyC3glR90OjgWIADT7RZMYVBOeJZ0jA7g8++YxIx+GNqekY+gLy77edX+cDhTWBlwoSyzAKTVFGooGOFJwHGufQopr/cr1nMvbodaPTkzq9FZtdY+eVmTADFYRaCF0QDCX+qURMnt+yeGaWDzHEhEsTMeIJbGyRhUdMgAAAA==" clip-path="url(#c5)"/>
|
||||
</a>
|
||||
<a href="https://github.com/mrgirosky" class="sponsorkit-link" target="_blank" id="mrgirosky">
|
||||
|
||||
<clipPath id="c6">
|
||||
<rect x="402.5" y="510" width="25" height="25" rx="12.5" ry="12.5" />
|
||||
</clipPath>
|
||||
<image x="402.5" y="510" width="25" height="25" href="data:image/webp;base64,UklGRqoBAABXRUJQVlA4IJ4BAADwCwCdASoyADIAPm0wlUYkIyIhMBYJAIANiWYAy5x8BkkfdwjET0C9gJwHmAfqB0gPMB0EPOg6lXn2PaFvyUJKOZTATBzOiskTHqIVkrjKyrKQ96FyqvM1Gv1jQBXFMKi+U5hNm1EI8AD+8QHHdvGR1JUV2sahGaCbtFiJaByUNdMrLaM575u+kR44ggAKv3SXhizdVAvYYtYu0xpoP1QuZzHV05ERx1UDN3JJOZQA2eCKoCPnSiD3aW7Cp+XxYc07Q+Xfur3IkZX89Dy0NN7ogFFINEj83SaUWhjo7BEkmq5OUe8vjhUSout2T6n2+fwYwcD6/3bekvfYqCCWM1HLHyjGroEjvI40NzvNVty9uCAfSiHRL48OpEWqqoi85QWoAQLMK4WoSLcWsXpVCQCI88AiLHFkRtMAV4H+PUfk2ZsEzGSka1EFOtnn3npbU1/aitYgLuS4rcly4D0v9dMawDumQfY5uMacYPo6dkjOTJAdPQDXloB9LbiSBWrbcdnYyZZ/WOaAySoDhCCl3eQFOdBCSqnKG0gHZXAAAAA=" clip-path="url(#c6)"/>
|
||||
</a>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.env
|
||||
.cache.json
|
||||
Generated
+852
@@ -0,0 +1,852 @@
|
||||
{
|
||||
"name": "spoolman-sponsorkit",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "spoolman-sponsorkit",
|
||||
"devDependencies": {
|
||||
"sponsorkit": "18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
|
||||
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
|
||||
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
|
||||
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
|
||||
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
|
||||
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
|
||||
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
|
||||
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
|
||||
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
|
||||
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
|
||||
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
|
||||
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
|
||||
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
|
||||
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
|
||||
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
|
||||
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
|
||||
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
|
||||
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
|
||||
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@quansync/fs": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
|
||||
"integrity": "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"quansync": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/ansis": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz",
|
||||
"integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz",
|
||||
"integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
||||
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-hierarchy": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.7",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/destr": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch-native": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/normalize-url": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.1.tgz",
|
||||
"integrity": "sha512-ARftfC5HdUNu9jJeL8pHj8debUIHA2b91FizCoMzY4lG6dDX13jdvTK0TBe24IBDRf2HvJSzzwEPvmbkQWHRSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ofetch": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz",
|
||||
"integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"destr": "^2.0.5",
|
||||
"node-fetch-native": "^1.6.7",
|
||||
"ufo": "^1.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.1.tgz",
|
||||
"integrity": "sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yocto-queue": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz",
|
||||
"integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
||||
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.8.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.5",
|
||||
"@img/sharp-darwin-x64": "0.35.5",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.4",
|
||||
"@img/sharp-linux-arm": "0.35.5",
|
||||
"@img/sharp-linux-arm64": "0.35.5",
|
||||
"@img/sharp-linux-ppc64": "0.35.5",
|
||||
"@img/sharp-linux-riscv64": "0.35.5",
|
||||
"@img/sharp-linux-s390x": "0.35.5",
|
||||
"@img/sharp-linux-x64": "0.35.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.5",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.5",
|
||||
"@img/sharp-win32-arm64": "0.35.5",
|
||||
"@img/sharp-win32-ia32": "0.35.5",
|
||||
"@img/sharp-win32-x64": "0.35.5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/sponsorkit": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/sponsorkit/-/sponsorkit-18.0.0.tgz",
|
||||
"integrity": "sha512-9Jr+n4SjU8Qtldq8i0UlwwqBEAVy5JFbIQNXRUIs1N+LPTCnX2/of5oyHYftCLi3KxAODVMmyEdWqkJMTagR6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansis": "^4.3.1",
|
||||
"cac": "^7.0.0",
|
||||
"consola": "^3.4.2",
|
||||
"d3-hierarchy": "^3.1.2",
|
||||
"normalize-url": "^9.0.1",
|
||||
"ofetch": "^1.5.1",
|
||||
"p-limit": "^7.3.1",
|
||||
"sharp": "^0.35.3",
|
||||
"unconfig": "^7.5.0"
|
||||
},
|
||||
"bin": {
|
||||
"sponsorkit": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || ^24.11.0 || >=26.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
|
||||
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unconfig": {
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/unconfig/-/unconfig-7.5.0.tgz",
|
||||
"integrity": "sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@quansync/fs": "^1.0.0",
|
||||
"defu": "^6.1.4",
|
||||
"jiti": "^2.6.1",
|
||||
"quansync": "^1.0.0",
|
||||
"unconfig-core": "7.5.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/unconfig-core": {
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.5.0.tgz",
|
||||
"integrity": "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@quansync/fs": "^1.0.0",
|
||||
"quansync": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "spoolman-sponsorkit",
|
||||
"private": true,
|
||||
"description": "Generates the sponsors image rendered in the README. Run by .github/workflows/sponsors.yml.",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"sponsorkit": "18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { defineConfig, tierPresets } from 'sponsorkit'
|
||||
import type { Provider, Sponsorship } from 'sponsorkit'
|
||||
|
||||
/**
|
||||
* People who tipped on Ko-fi and asked to be credited on the sponsors board.
|
||||
* Ko-fi has no API we can query, so these are transcribed by hand from the
|
||||
* Ko-fi dashboard. A tip counts toward a tier for KOFI_TIP_EFFECTIVITY_DAYS,
|
||||
* after which the entry drops to "Past Sponsors" on its own - the same way a
|
||||
* one-time GitHub sponsorship ages out, so nothing here needs pruning.
|
||||
*/
|
||||
const kofiTips: KofiTip[] = [
|
||||
{ login: 'marko-p', dollars: 10, date: '2026-09-01' },
|
||||
]
|
||||
|
||||
/** Days a one-time tip keeps counting. Matches sponsorkit's own Ko-fi provider. */
|
||||
const KOFI_TIP_EFFECTIVITY_DAYS = 30
|
||||
|
||||
interface KofiTip {
|
||||
/** GitHub login. Drives the avatar and the link on the board. */
|
||||
login: string
|
||||
/** Display name, defaults to the login. */
|
||||
name?: string
|
||||
/** Tip amount in USD. Decides the tier, see the `tiers` list below. */
|
||||
dollars: number
|
||||
/** Date of the tip, YYYY-MM-DD. */
|
||||
date: string
|
||||
}
|
||||
|
||||
function kofiTipToSponsorship(tip: KofiTip): Sponsorship {
|
||||
const expireAt = new Date(Date.parse(tip.date) + KOFI_TIP_EFFECTIVITY_DAYS * 24 * 60 * 60 * 1000)
|
||||
return {
|
||||
sponsor: {
|
||||
type: 'User',
|
||||
login: tip.login,
|
||||
name: tip.name ?? tip.login,
|
||||
avatarUrl: `https://github.com/${tip.login}.png`,
|
||||
linkUrl: `https://github.com/${tip.login}`,
|
||||
},
|
||||
// -1 is sponsorkit's marker for a sponsorship that has run out.
|
||||
monthlyDollars: expireAt > new Date() ? tip.dollars : -1,
|
||||
isOneTime: true,
|
||||
privacyLevel: 'PUBLIC',
|
||||
tierName: 'Ko-fi',
|
||||
createdAt: new Date(tip.date).toISOString(),
|
||||
expireAt: expireAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const KofiManualProvider: Provider = {
|
||||
name: 'kofi-manual',
|
||||
async fetchSponsors() {
|
||||
return kofiTips.map(kofiTipToSponsorship)
|
||||
},
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
github: {
|
||||
login: 'Donkie',
|
||||
type: 'user',
|
||||
},
|
||||
|
||||
// Listing providers explicitly disables auto-detection, so GitHub has to be
|
||||
// named here alongside the hand-maintained Ko-fi list.
|
||||
providers: ['github', KofiManualProvider],
|
||||
|
||||
width: 800,
|
||||
renderer: 'tiers',
|
||||
formats: ['svg', 'png'],
|
||||
// Relative to this directory. Lands next to the other README images.
|
||||
outputDir: '../media',
|
||||
name: 'sponsors',
|
||||
// Resolved against outputDir; keep the fetch cache out of the media folder.
|
||||
cacheFile: '../sponsorkit/.cache.json',
|
||||
|
||||
tiers: [
|
||||
{
|
||||
title: 'Backers',
|
||||
preset: tierPresets.xs,
|
||||
},
|
||||
{
|
||||
title: 'Supporters',
|
||||
monthlyDollars: 10,
|
||||
preset: tierPresets.medium,
|
||||
},
|
||||
{
|
||||
title: 'Sponsors',
|
||||
monthlyDollars: 25,
|
||||
preset: tierPresets.large,
|
||||
},
|
||||
{
|
||||
title: 'Silver Sponsors',
|
||||
monthlyDollars: 50,
|
||||
preset: tierPresets.xl,
|
||||
},
|
||||
{
|
||||
title: 'Gold Sponsors',
|
||||
monthlyDollars: 100,
|
||||
preset: {
|
||||
...tierPresets.xl,
|
||||
avatar: { size: 120 },
|
||||
boxWidth: 150,
|
||||
boxHeight: 160,
|
||||
name: { maxLength: 25 },
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Past Sponsors',
|
||||
monthlyDollars: -1,
|
||||
preset: tierPresets.xs,
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -3,7 +3,13 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
# Every branch builds, so a fix can be handed to a bug reporter as a Docker
|
||||
# image before it merges (see publish-images). Weblate pushes locale-only
|
||||
# commits constantly and dependabot's branches arrive as PRs anyway, so
|
||||
# bot branches skip push runs and are built by the pull_request run instead.
|
||||
- "**"
|
||||
- "!weblate*"
|
||||
- "!dependabot/**"
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
@@ -14,12 +20,12 @@ 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.
|
||||
# A new push makes the previous run's result irrelevant, so cancel it and let the
|
||||
# new one have the runners. Master and tags are exempt: both publish images, and a
|
||||
# cancelled run there is a half-published release.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' && !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
env:
|
||||
GHCR_IMAGE: donkie/spoolman
|
||||
@@ -29,6 +35,11 @@ jobs:
|
||||
# Linting
|
||||
#
|
||||
style:
|
||||
# Same-repo branches already got a full run from the push trigger, so skip the
|
||||
# duplicate pull_request run. Bot branches excluded above still need it, as do
|
||||
# forks, whose pushes fire no event here. Skipping the three jobs with no `needs`
|
||||
# cascades to every other job, since a skipped dependency skips its dependents.
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || startsWith(github.head_ref, 'dependabot/') || startsWith(github.head_ref, 'weblate') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -71,6 +82,11 @@ jobs:
|
||||
# This is done on native hardware to speed things up, vite is very slow on emulated hardware
|
||||
#
|
||||
build-client:
|
||||
# Same-repo branches already got a full run from the push trigger, so skip the
|
||||
# duplicate pull_request run. Bot branches excluded above still need it, as do
|
||||
# forks, whose pushes fire no event here. Skipping the three jobs with no `needs`
|
||||
# cascades to every other job, since a skipped dependency skips its dependents.
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || startsWith(github.head_ref, 'dependabot/') || startsWith(github.head_ref, 'weblate') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -218,6 +234,11 @@ jobs:
|
||||
# Build tester image for integration tests
|
||||
#
|
||||
build-tester:
|
||||
# Same-repo branches already got a full run from the push trigger, so skip the
|
||||
# duplicate pull_request run. Bot branches excluded above still need it, as do
|
||||
# forks, whose pushes fire no event here. Skipping the three jobs with no `needs`
|
||||
# cascades to every other job, since a skipped dependency skips its dependents.
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || startsWith(github.head_ref, 'dependabot/') || startsWith(github.head_ref, 'weblate') }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
@@ -349,6 +370,10 @@ jobs:
|
||||
test-frontend-v2:
|
||||
needs: [build-amd64]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# Argos authenticates via OIDC; fork PRs get no id-token and fall back to tokenless upload.
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -376,10 +401,20 @@ jobs:
|
||||
- name: Start Spoolman
|
||||
run: docker compose -f tests_frontend_v2/docker-compose.yml up -d --wait
|
||||
|
||||
# Screenshots first: they seed a fixed dataset and need the database empty.
|
||||
# The Argos reporter uploads them and posts the visual diff on the PR.
|
||||
- name: Take client_v2 screenshots
|
||||
env:
|
||||
ARGOS_UPLOAD: "1"
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cd tests_frontend_v2
|
||||
npx playwright test --project screenshots
|
||||
|
||||
- name: Run client_v2 frontend integration tests
|
||||
run: |
|
||||
cd tests_frontend_v2
|
||||
npx playwright test
|
||||
npx playwright test --project chromium
|
||||
|
||||
- name: Dump Spoolman logs on failure
|
||||
if: failure()
|
||||
@@ -494,8 +529,28 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v3.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3.6.1
|
||||
|
||||
# Keep uv's cache of wheels built from source (asyncpg, greenlet, ...) across
|
||||
# runs. They have no armv7 wheels, and compiling them under QEMU is ~9 of the
|
||||
# ~11 minutes of a build that misses the layer cache (lockfile or base image
|
||||
# changed). The Dockerfile prunes the cache down to just those wheels, so it's
|
||||
# small enough to save on every run and always restore the newest one.
|
||||
- name: Cache uv wheels
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: uv-cache
|
||||
key: uv-cache-armv7-${{ github.run_id }}
|
||||
restore-keys: uv-cache-armv7-
|
||||
|
||||
- name: Inject uv cache into BuildKit
|
||||
uses: reproducible-containers/buildkit-cache-dance@v3.4.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
cache-map: |
|
||||
{ "uv-cache": "/root/.cache/uv" }
|
||||
|
||||
- name: Log in to the Github Container registry
|
||||
uses: docker/login-action@v3.3.0
|
||||
with:
|
||||
@@ -644,6 +699,10 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# The trailing type=ref,event=branch is what gives an unmerged branch a
|
||||
# pullable image (ghcr.io/donkie/spoolman:my-branch) for a bug reporter to
|
||||
# test. It is GHCR-only and skips master (already published as :edge), so
|
||||
# Docker Hub keeps carrying nothing but :edge and tagged releases.
|
||||
- name: Extract metadata (tags, labels) for GHCR
|
||||
id: meta-ghcr
|
||||
uses: docker/metadata-action@v5.5.1
|
||||
@@ -655,6 +714,7 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=ref,event=branch,enable=${{ github.ref_name != 'master' }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker Hub
|
||||
id: meta-dh
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
name: Label sponsor issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: sponsor-label-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Label the issue if its author is a sponsor
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
# The sponsorship lookup is viewer-relative: it answers "is this person
|
||||
# sponsoring *me*" for whoever owns the token. GITHUB_TOKEN's viewer is
|
||||
# the Actions bot, which nobody sponsors, so it always comes back empty.
|
||||
# It has to be the PAT, and reading privacyLevel needs its read:user scope.
|
||||
github-token: ${{ secrets.PAT }}
|
||||
script: |
|
||||
// The Supporter tier and up. Below this, no tag was promised.
|
||||
const MIN_MONTHLY_DOLLARS = 10
|
||||
const LABEL = 'sponsor'
|
||||
|
||||
const issue = context.payload.issue
|
||||
const author = issue.user.login
|
||||
if (author.endsWith('[bot]')) return
|
||||
|
||||
const query = `query($login: String!) {
|
||||
user(login: $login) {
|
||||
sponsorshipForViewerAsSponsorable(activeOnly: true) {
|
||||
privacyLevel
|
||||
tier { monthlyPriceInDollars }
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
let sponsorship
|
||||
try {
|
||||
const data = await github.graphql(query, { login: author })
|
||||
sponsorship = data.user?.sponsorshipForViewerAsSponsorable
|
||||
} catch (error) {
|
||||
// Deleted accounts and app logins never resolve to a user.
|
||||
core.info(`Could not look up ${author}: ${error.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!sponsorship) {
|
||||
core.info(`${author} is not an active sponsor`)
|
||||
return
|
||||
}
|
||||
|
||||
const dollars = sponsorship.tier.monthlyPriceInDollars
|
||||
if (dollars < MIN_MONTHLY_DOLLARS) {
|
||||
core.info(`${author} sponsors at $${dollars}, below the $${MIN_MONTHLY_DOLLARS} tier`)
|
||||
return
|
||||
}
|
||||
|
||||
// A private sponsor chose not to be listed publicly. This label is
|
||||
// public, so applying it would out them; skip rather than disclose.
|
||||
if (sponsorship.privacyLevel === 'PRIVATE') {
|
||||
core.info(`${author} sponsors privately, leaving the issue unlabelled`)
|
||||
return
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [LABEL],
|
||||
})
|
||||
core.info(`Labelled #${issue.number}, opened by $${dollars}/month sponsor ${author}`)
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Generate sponsors image
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * *' # daily
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: sponsors
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sponsorkit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Checks out with the PAT rather than the default GITHUB_TOKEN: the commit
|
||||
# step below pushes straight to master, and the ruleset there only lets a
|
||||
# bypass-capable actor through.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: true
|
||||
token: ${{ secrets.PAT }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: .github/sponsorkit/package-lock.json
|
||||
|
||||
- name: Install sponsorkit
|
||||
working-directory: .github/sponsorkit
|
||||
run: npm ci
|
||||
|
||||
- name: Generate sponsors image
|
||||
working-directory: .github/sponsorkit
|
||||
run: npx sponsorkit
|
||||
env:
|
||||
SPONSORKIT_GITHUB_TOKEN: ${{ secrets.PAT }}
|
||||
|
||||
# Only commits when the rendered image actually differs from what is checked in.
|
||||
- name: Commit sponsors image
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
commit_message: 'chore: update sponsors image'
|
||||
file_pattern: '.github/media/sponsors.svg .github/media/sponsors.png'
|
||||
skip_dirty_check: false
|
||||
@@ -4,20 +4,18 @@ name: Auto-merge Weblate PRs
|
||||
# which opens a pull request for every sync back to this repo (see guard-translations.yml). Those PRs
|
||||
# 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.
|
||||
# auto-merge once a week 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:
|
||||
# - Every sync still runs through style/tests/build before it can land — nothing merges unvalidated.
|
||||
# - Every sync still runs through style/tests/build before it can land; nothing merges unvalidated.
|
||||
# - No actor gains unrestricted, un-CI'd direct write to master. A compromised Weblate token can only
|
||||
# open a PR; auto-merge fires solely on green CI for Weblate-authored PRs.
|
||||
# - GitHub write/bypass can't be scoped to locale files, so direct write would grant all-file access.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
# contents+pull-requests write let the built-in GITHUB_TOKEN enable auto-merge and perform the merge.
|
||||
# The token is not a ruleset bypass actor and doesn't need to be: with 0 required approvals it
|
||||
@@ -30,65 +28,23 @@ jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
# Only ever act on Weblate's own translation-sync PRs. Human PRs are never touched.
|
||||
# pull_request_target runs in the base-repo context (so GITHUB_TOKEN has write access even for
|
||||
# branch PRs), and this job is safe under it because it never checks out or executes PR code —
|
||||
# it only calls the GitHub API to enable auto-merge.
|
||||
if: github.event.pull_request.user.login == 'weblate'
|
||||
# Scheduled runs use the default-branch workflow. This job never checks out or executes
|
||||
# PR code; it only calls the GitHub API to inspect PRs and enable auto-merge or merge them.
|
||||
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 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.
|
||||
# fails the check, 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.
|
||||
#
|
||||
# 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
|
||||
- name: Verify and merge open Weblate PRs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
files="$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/files" --jq '.[].filename')"
|
||||
|
||||
if [ -z "$files" ]; then
|
||||
echo "::error title=Empty diff::Weblate PR reports no changed files; refusing to auto-merge."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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 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"
|
||||
echo "\`client_v2/locales/\` or \`client/public/locales/\`:"
|
||||
echo ""
|
||||
echo '```'
|
||||
echo "$offending"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Weblate sync PRs are only auto-merged when every changed file is a translation JSON"
|
||||
echo "file. This PR isn't, so it has been left for a maintainer to review and merge by hand."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All $(printf '%s\n' "$files" | wc -l) changed file(s) are locale JSON. Safe to auto-merge."
|
||||
|
||||
- name: Enable auto-merge
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
# Merge commit, NOT squash. This is load-bearing for Weblate, not a style preference.
|
||||
#
|
||||
# Weblate keeps its own clone of this repo and rebases it onto master to pick up our changes.
|
||||
@@ -110,4 +66,71 @@ jobs:
|
||||
# sees them as already upstream and drops them silently. Nothing else forces squash here:
|
||||
# the repo has allow_merge_commit enabled and the master ruleset carries no linear-history
|
||||
# rule, and human PRs are merge-committed too.
|
||||
run: gh pr merge --auto --merge "$PR_URL"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
prs="$(gh pr list --repo "$REPO" --author weblate --state open --limit 10000 --json number,url)"
|
||||
rows="$(printf '%s\n' "$prs" | jq -r '.[] | [.number, .url] | @tsv')"
|
||||
failed=0
|
||||
while IFS=$'\t' read -r PR_NUMBER PR_URL; do
|
||||
[ -n "$PR_NUMBER" ] || continue
|
||||
if ! files="$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/files" --jq '.[].filename')"; then
|
||||
echo "::error::Could not read changed files for $PR_URL; refusing to auto-merge."
|
||||
echo "## ❌ Auto-merge blocked: could not read files for $PR_URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -z "$files" ]; then
|
||||
echo "::error title=Empty diff::Weblate PR $PR_URL reports no changed files; refusing to auto-merge."
|
||||
echo "## ❌ Auto-merge blocked: empty diff for $PR_URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
# 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_URL touches files outside the locale trees; not auto-merging."
|
||||
{
|
||||
echo "## ❌ Auto-merge blocked: out-of-scope changes"
|
||||
echo ""
|
||||
echo "Weblate PR: $PR_URL"
|
||||
echo ""
|
||||
echo "This Weblate PR changes files that are **not** locale JSON under"
|
||||
echo "\`client_v2/locales/\` or \`client/public/locales/\`:"
|
||||
echo ""
|
||||
echo '```'
|
||||
echo "$offending"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Weblate sync PRs are only auto-merged when every changed file is a translation JSON"
|
||||
echo "file. This PR isn't, so it has been left for a maintainer to review and merge by hand."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "$PR_URL: all $(printf '%s\n' "$files" | wc -l) changed file(s) are locale JSON. Safe to auto-merge."
|
||||
if merge_output="$(gh pr merge --repo "$REPO" --auto --merge "$PR_URL" 2>&1)"; then
|
||||
echo "$merge_output"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "$merge_output"
|
||||
# GitHub can refuse auto-merge when the PR is already ready to merge.
|
||||
if [[ "$merge_output" == *"clean status"* || "$merge_output" == *"already mergeable"* ]]; then
|
||||
if gh pr merge --repo "$REPO" --merge "$PR_URL"; then
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "::error::Could not merge $PR_URL."
|
||||
echo "## ❌ Auto-merge blocked: merge failed for $PR_URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
failed=1
|
||||
done <<< "$rows"
|
||||
|
||||
exit "$failed"
|
||||
+29
-19
@@ -1,16 +1,25 @@
|
||||
FROM python:3.14-slim-bookworm AS python-builder
|
||||
|
||||
# A multi-platform build runs this stage once per platform in parallel. Each gets
|
||||
# its own uv cache: wheels are per-platform anyway, and one shared cache made the
|
||||
# platforms queue on uv's cache lock until the slow emulated armv7 sync pushed the
|
||||
# others past uv's 300 s lock timeout whenever uv.lock changed.
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
ENV UV_LINK_MODE=copy
|
||||
ENV UV_NO_DEV=1
|
||||
ENV UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
# No python3-dev: the base image already ships the 3.14 headers, and python3-dev
|
||||
# would pull in Debian's whole python3.11 stack. patchelf is for armv7, see below.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
g++ \
|
||||
python3-dev \
|
||||
libpq-dev \
|
||||
libffi-dev \
|
||||
patchelf \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -19,10 +28,23 @@ RUN pip install --no-cache-dir uv
|
||||
|
||||
# Install dependencies
|
||||
WORKDIR /home/app/spoolman
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
RUN --mount=type=cache,id=uv-${TARGETARCH}${TARGETVARIANT},target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project
|
||||
uv sync --locked --no-install-project \
|
||||
&& uv cache prune --ci
|
||||
|
||||
# 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. Done before the app is copied in so
|
||||
# the layer stays cached across source-only changes.
|
||||
RUN if [ "$(uname -m)" = "armv7l" ]; then \
|
||||
patchelf --add-needed libstdc++.so.6 \
|
||||
"$(find /home/app/spoolman/.venv -name '_greenlet*.so')"; \
|
||||
fi
|
||||
|
||||
# Copy and install app. No --chown here: the "app" user only exists in the
|
||||
# runner stage, and Podman (unlike Docker/BuildKit) refuses to resolve it. Final
|
||||
@@ -30,21 +52,9 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
COPY migrations /home/app/spoolman/migrations
|
||||
COPY spoolman /home/app/spoolman/spoolman
|
||||
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
|
||||
RUN --mount=type=cache,id=uv-${TARGETARCH}${TARGETVARIANT},target=/root/.cache/uv \
|
||||
uv sync --locked \
|
||||
&& uv cache prune --ci
|
||||
|
||||
FROM python:3.14-slim-bookworm AS python-runner
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ Spoolman is a self-hosted web service designed to help you efficiently manage yo
|
||||
|
||||
[](https://github.com/Donkie/Spoolman/wiki)
|
||||
[](https://github.com/Donkie/Spoolman/releases)
|
||||
[](https://github.com/sponsors/Donkie)
|
||||
|
||||
### Features
|
||||
* **Filament Management**: Keep comprehensive records of filament types, manufacturers, and individual spools.
|
||||
@@ -56,3 +57,10 @@ If the new client misbehaves on your setup, set `SPOOLMAN_LEGACY_CLIENT=TRUE` 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.
|
||||
|
||||
## Sponsors
|
||||
Spoolman is developed and maintained in my spare time. If it is useful to you, consider [sponsoring the project](https://github.com/sponsors/Donkie).
|
||||
|
||||
<a href="https://github.com/sponsors/Donkie">
|
||||
<img alt="The people currently sponsoring Spoolman" src=".github/media/sponsors.svg">
|
||||
</a>
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"endOfLine": "auto",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["public/locales/**/*.json"],
|
||||
|
||||
Generated
+1174
-816
File diff suppressed because it is too large.
Load diff
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "spoolman-ui",
|
||||
"version": "0.26.1",
|
||||
"version": "0.27.0",
|
||||
"engines": {
|
||||
"node": "^20.19 || ^22.12 || >=24"
|
||||
},
|
||||
@@ -22,13 +22,13 @@
|
||||
"html-to-image": "^1.11.13",
|
||||
"i18next": "^25.7.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"i18next-http-backend": "^4.0.2",
|
||||
"react": "^19.2.3",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-i18next": "^16.5.1",
|
||||
"react-router": "^7.11.0",
|
||||
"react-router": "^7.18.3",
|
||||
"react-to-print": "^3.2.0",
|
||||
"uuid": "^14.0.0",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.9 KiB |
@@ -60,8 +60,8 @@
|
||||
"printing": {
|
||||
"generic": {
|
||||
"title": "Probíhá tisk",
|
||||
"description": "Níže uvedeným nastavením dosáhnete požadovaného rozložení tisku. Mějte na paměti, že tiskárny a váš operační systém mohou použít vlastní okraje a měřítka, takže možná budete muset provést několik pokusů a omylů, než bude vše správně nastaveno. Před tiskem na skutečné štítky si to vyzkoušejte na kousku běžného papíru.",
|
||||
"helpMargin": "Okraje by měly být nastaveny tak, aby odpovídaly papíru na štítky a tiskárně, jejich změna ovlivní velikost celé mřížky.",
|
||||
"description": "Níže uvedeným nastavením dosáhnete požadovaného rozložení tisku. Mějte na paměti, že tiskárny a váš operační systém mohou použít vlastní okraje a měřítka, takže možná budete muset provést několik pokusů a omylů, než bude vše správně nastaveno. Před tiskem na skutečné etikety si to vyzkoušejte na listu běžného papíru.",
|
||||
"helpMargin": "Okraje by měly být nastaveny tak, aby odpovídaly listu etiket a tiskárně, jejich změna ovlivní velikost celé mřížky.",
|
||||
"helpPrinterMargin": "Bezpečná zóna by měla být nastavena tak, jak blízko okraje papíru může tiskárna tisknout, jejich změna neovlivní celou mřížku.",
|
||||
"print": "Tisk",
|
||||
"columns": "Sloupce",
|
||||
@@ -102,16 +102,16 @@
|
||||
"saveAsImage": "Uložit jako obrázek"
|
||||
},
|
||||
"qrcode": {
|
||||
"button": "Tisk štítků",
|
||||
"title": "Tisk štítku",
|
||||
"button": "Tisk etiket",
|
||||
"title": "Tisk etikety",
|
||||
"spoolWeight": "Hmotnost cívky: {{weight}}",
|
||||
"lotNr": "Číslo šarže: {{lot}}",
|
||||
"bedTemp": "BT: {{temp}}",
|
||||
"extruderTemp": "ET: {{temp}}",
|
||||
"textSize": "Velikost textu štítku",
|
||||
"textSize": "Velikost textu etikety",
|
||||
"showSpoolmanIcon": "Zobrazit ikonu Spoolman",
|
||||
"showVendor": "Výrobce",
|
||||
"showContent": "Tisk štítku",
|
||||
"showContent": "Tisk etikety",
|
||||
"showLotNr": "Číslo šarže",
|
||||
"showSpoolWeight": "Hmotnost cívky",
|
||||
"showTemperatures": "Teploty",
|
||||
@@ -127,7 +127,7 @@
|
||||
},
|
||||
"preview": "Náhled:"
|
||||
},
|
||||
"template": "Šablona štítku",
|
||||
"template": "Šablona etikety",
|
||||
"templateHelp": "Pomocí {} vložíte hodnoty objektu cívky jako text. Například, {id} bude nahrazeno ID cívky nebo {filament.material} bude nahrazeno materiálem cívky. Pokud hodnota chybí, bude nahrazena znakem „?“. K odstranění této hodnoty lze použít druhou sadu {}. Kromě toho bude odstraněn jakýkoli text mezi sadami {}, pokud hodnota chybí. Například {Lot Nr: {lot_nr}} zobrazí popisek pouze v případě, že cívka má číslo šarže. Text uzavřete dvojitou hvězdičkou **, aby byl tučný. Kliknutím na tlačítko zobrazíte seznam všech dostupných značek.",
|
||||
"showQRCode": "Tisk QR kódu",
|
||||
"showQRCodeMode": {
|
||||
@@ -138,7 +138,7 @@
|
||||
},
|
||||
"spoolSelect": {
|
||||
"title": "Vybrat cívky",
|
||||
"description": "Vyberte cívky, pro které chcete tisknout štítky.",
|
||||
"description": "Vyberte cívky, pro které chcete tisknout etikety.",
|
||||
"showArchived": "Zobrazit archivované",
|
||||
"noSpoolsSelected": "Nevybrali jste žádné cívky.",
|
||||
"selectAll": "Vybrat/odebrat vše",
|
||||
|
||||
@@ -407,5 +407,6 @@
|
||||
"no_locations_help": "Diese Seite lässt Sie Ihre Spulen zu Orten zuweisen, fügen Sie ein paar Spulen hinzu um loszulegen!",
|
||||
"locations": "Orte",
|
||||
"new_location": "Neuer Ort"
|
||||
}
|
||||
},
|
||||
"sponsor": "Unterstütze mich auf GitHub"
|
||||
}
|
||||
@@ -46,7 +46,7 @@
|
||||
"saveSuccessful": "Save successful!",
|
||||
"validationError": "Validation error: {{error}}"
|
||||
},
|
||||
"kofi": "Tip me on Ko-fi",
|
||||
"sponsor": "Sponsor me on GitHub",
|
||||
"loading": "Loading",
|
||||
"version": "Version",
|
||||
"unknown": "Unknown",
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
"registered": "Enregistré",
|
||||
"first_used": "Première utilisation",
|
||||
"used_weight": "Poids utilisé",
|
||||
"id": "ID",
|
||||
"id": "Identifiant",
|
||||
"remaining_weight": "Poids restant",
|
||||
"price": "Prix",
|
||||
"initial_weight": "Poids initial",
|
||||
@@ -238,7 +238,7 @@
|
||||
"settings_bed_temp": "Température du lit",
|
||||
"vendor": "Fabricant",
|
||||
"density": "Densité",
|
||||
"id": "ID",
|
||||
"id": "Identifiant",
|
||||
"color_hex": "Couleur",
|
||||
"article_number": "Numéro d'article",
|
||||
"spools": "Afficher les bobines",
|
||||
@@ -260,7 +260,7 @@
|
||||
"form": {
|
||||
"filament_updated": "Ce filament a été mis à jour par quelqu'un/quelque chose depuis que vous avez ouvert cette page. Enregistrer écrasera ces changements !",
|
||||
"import_external": "Importer depuis externe",
|
||||
"import_external_description": "Sélectionnez un filament dans la liste pour renseigner automatiquement ses détails dans le formulaire de création de filament. Cela écrasera toutes les données que vous avez saisies dans le formulaire.<br><br>Un objet fabricant sera créé automatiquement lorsque vous cliquerez sur OK, si nécessaire."
|
||||
"import_external_description": "Sélectionnez un filament dans la liste pour renseigner automatiquement ses détails dans le formulaire de création de filament. Cela écrasera toutes les données que vous avez saisies dans le formulaire.<br><br>Un objet fabricant sera créé automatiquement lorsque vous cliquerez sur Ok, si nécessaire."
|
||||
},
|
||||
"buttons": {
|
||||
"add_spool": "Ajouter une bobine"
|
||||
@@ -314,7 +314,7 @@
|
||||
"registered": "Enregistré",
|
||||
"comment": "Commentaire",
|
||||
"name": "Nom",
|
||||
"id": "ID",
|
||||
"id": "Identifiant",
|
||||
"empty_spool_weight": "Poids de la bobine vide",
|
||||
"external_id": "ID externe"
|
||||
},
|
||||
@@ -352,7 +352,7 @@
|
||||
"home": {
|
||||
"home": "Accueil",
|
||||
"welcome": "Bienvenue sur votre instance Spoolman !",
|
||||
"description": ""
|
||||
"description": "Il semble que vous n'ayez pas encore ajouté de bobines. Consultez la <helpPageLink>page d'aide</helpPageLink> pour bien démarrer."
|
||||
},
|
||||
"settings": {
|
||||
"settings": "Paramètres",
|
||||
@@ -385,7 +385,8 @@
|
||||
"choices_missing_error": "Vous ne pouvez pas retirer les choix. Les choix suivants sont manquants : {{choices}}",
|
||||
"key_not_changed": "Changer la clé pour quelque chose d'autre.",
|
||||
"delete_confirm": "Supprimer le champ {{name}} ?",
|
||||
"description": "<p>Ici vous pouvez ajouter des champs personnalisés supplémentaires à vos entités.</p><p>Une fois qu'un champ est ajouté, vous ne pouvez plus changer sa clé ou son type, et pour les champs de types \"Choix\", vous ne pouvez plus retirer un choix ou changer l'état des choix multiples. Si vous supprimez un champ, les données associées aux entités seront supprimées.</p><p>La clé correspond à ce que les autres programmes lisent/écrivent, donc si votre champs doit s'intégrer avec un programme tiers, assurez-vous de le configurer correctement. La valeur par défaut n'est appliquées qu'aux nouveaux éléments.</p><p>Les champs supplementaires ne peuvent pas être triés ou filtrés dans les vues de table.</p>"
|
||||
"description": "<p>Ici vous pouvez ajouter des champs personnalisés supplémentaires à vos entités.</p><p>Une fois qu'un champ est ajouté, vous ne pouvez plus changer sa clé ou son type, et pour les champs de types \"Choix\", vous ne pouvez plus retirer un choix ou changer l'état des choix multiples. Si vous supprimez un champ, les données associées aux entités seront supprimées.</p><p>La clé correspond à ce que les autres programmes lisent/écrivent, donc si votre champs doit s'intégrer avec un programme tiers, assurez-vous de le configurer correctement. La valeur par défaut n'est appliquées qu'aux nouveaux éléments.</p><p>Les champs supplémentaires peuvent être affichés, triés ou filtrés dans les vues de table. Les champs de type \"Choix\" et booléen offrent également des options de filtrage, et tous les types de champs supplémentaires prennent en charge le filtrage des valeurs vides. </p>",
|
||||
"choices_reorder_hint": "Glissez, ou utilisez les touches fléchées, pour réorganiser ce choix."
|
||||
},
|
||||
"header": "Paramètres",
|
||||
"general": {
|
||||
@@ -398,8 +399,8 @@
|
||||
"tooltip": "La base de l'URL à utiliser lors de la génération de QR codes."
|
||||
},
|
||||
"round_prices": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
"label": "Arrondir les prix",
|
||||
"tooltip": "Arrondir les prix au nombre entier le plus proche."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -408,5 +409,12 @@
|
||||
"new_location": "Créer un emplacement",
|
||||
"no_location": "Pas de localisation",
|
||||
"no_locations_help": "Cette page vous permet d'organiser vos bobines par lieu, ajoutez une bobine pour commencer !"
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"label": "Thème",
|
||||
"system": "Système",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre"
|
||||
},
|
||||
"sponsor": "Soutenez-moi sur GitHub"
|
||||
}
|
||||
@@ -354,7 +354,8 @@
|
||||
"non_unique_key_error": "Raktas turi būti unukalus.",
|
||||
"key_not_changed": "Prašome pakeisti raktą į ką nors kitą.",
|
||||
"delete_confirm": "Šalinti lauką {{name}}?",
|
||||
"delete_confirm_description": "Tai ištrins lauką ir visus susijusius duomenis visiems objektams."
|
||||
"delete_confirm_description": "Tai ištrins lauką ir visus susijusius duomenis visiems objektams.",
|
||||
"choices_reorder_hint": "Temkite arba naudokite rodyklių klavišus, kad pakeistumėte eilės tvarką."
|
||||
}
|
||||
},
|
||||
"documentTitle": {
|
||||
@@ -396,5 +397,10 @@
|
||||
"new_location": "Nauja vieta",
|
||||
"no_location": "Be vietos",
|
||||
"no_locations_help": "Šiame puslapyje galite tvarkyti rites pagal vietas, pridėkite keletą ričių, kad pradėtumėte!"
|
||||
},
|
||||
"theme": {
|
||||
"label": "Tema",
|
||||
"system": "Sistema",
|
||||
"dark": "Tamsus"
|
||||
}
|
||||
}
|
||||
@@ -277,7 +277,7 @@
|
||||
"external_id": "Extern ID",
|
||||
"single_color": "Enkel",
|
||||
"multi_color": "Meervoudig",
|
||||
"coaxial": "Gecoëxtrudeerde",
|
||||
"coaxial": "Gecoëxtrudeerd",
|
||||
"longitudinal": "Longitudinaal"
|
||||
},
|
||||
"titles": {
|
||||
@@ -291,11 +291,11 @@
|
||||
"filament": "Filamenten",
|
||||
"form": {
|
||||
"filament_updated": "Dit filament is bijgewerkt door iemand/iets anders sinds je deze pagina geopend hebt. Deze veranderingen worden overschreven als je op slaat!",
|
||||
"import_external_description": "Selecteer een filament in de lijst om de details ervan automatisch in te vullen in het formulier voor het maken van filamenten. Hierdoor worden alle gegevens die u in het formulier hebt ingevoerd, overschreven.<br><br>Er wordt indien nodig automatisch een Fabrikant-object gemaakt als u op OK klikt.",
|
||||
"import_external_description": "Selecteer een filament uit de lijst om de details automatisch aan het formulier voor het maken van filamenten toe te voegen. Hierdoor worden alle gegevens die je in het formulier ingevoerd hebt, overschreven.<br><br>Er wordt indien nodig automatisch een fabrikant-object aangemaakt als je op OK klikt.",
|
||||
"import_external": "Importeren van extern"
|
||||
},
|
||||
"buttons": {
|
||||
"add_spool": "Spoel toevoegen"
|
||||
"add_spool": "Spoel Toevoegen"
|
||||
}
|
||||
},
|
||||
"scanner": {
|
||||
@@ -314,8 +314,8 @@
|
||||
"titles": {
|
||||
"show": "Fabrikant tonen",
|
||||
"edit": "Fabrikant bewerken",
|
||||
"clone": "Fabrikant klonen",
|
||||
"create": "Fabrikant maken",
|
||||
"clone": "Fabrikant Klonen",
|
||||
"create": "Fabrikant Aanmaken",
|
||||
"list": "Fabrikanten",
|
||||
"show_title": "[Fabrikant #{{id}}] {{name}}"
|
||||
},
|
||||
@@ -324,8 +324,8 @@
|
||||
"comment": "Opmerking",
|
||||
"name": "Naam",
|
||||
"id": "ID",
|
||||
"empty_spool_weight": "Gewicht lege spoel",
|
||||
"external_id": "Extern ID"
|
||||
"empty_spool_weight": "Gewicht Lege Spoel",
|
||||
"external_id": "Externe ID"
|
||||
},
|
||||
"vendor": "Fabrikanten",
|
||||
"form": {
|
||||
@@ -350,8 +350,8 @@
|
||||
"kofi": "Tip mij op Ko-fi",
|
||||
"home": {
|
||||
"home": "Thuis",
|
||||
"welcome": "",
|
||||
"description": ""
|
||||
"welcome": "Welkom bij je eigen Spoolman-account!",
|
||||
"description": "Het lijkt erop dat je nog geen spoelen hebt toegevoegd. Zie de <helpPageLink>Help-pagina</helpPageLink> voor hulp bij het opstarten."
|
||||
},
|
||||
"settings": {
|
||||
"header": "Instellingen",
|
||||
@@ -373,7 +373,7 @@
|
||||
"tab": "Extra velden",
|
||||
"params": {
|
||||
"name": "Naam",
|
||||
"key": "Key",
|
||||
"key": "Sleutel",
|
||||
"field_type": "Type",
|
||||
"unit": "Eenheid",
|
||||
"order": "Volgorde",
|
||||
@@ -398,7 +398,8 @@
|
||||
"key_not_changed": "Key niet gewijzigd.",
|
||||
"delete_confirm": "Veld {{name}} verwijderen?",
|
||||
"delete_confirm_description": "Hierdoor worden het veld en alle bijbehorende gegevens voor alle entiteiten verwijderd.",
|
||||
"description": "<p>Hier kunt u extra aangepaste velden toevoegen aan uw entiteiten.</p><p>Zodra een veld is toegevoegd, kunt u de key of het type ervan niet wijzigen, en voor velden met keuzetypes kunt u geen keuzes verwijderen of de meervoudige velden wijzigen. Als u een veld verwijdert, worden de bijbehorende gegevens voor alle entiteiten verwijderd.</p><p>De key is hoe andere programma's de gegevens lezen/schrijven, dus of uw aangepaste veld moet worden geïntegreerd met een derde partij programma, zorg ervoor dat u het correct instelt. De standaardwaarde wordt alleen toegepast op nieuwe items.</p><p>Extra velden kunnen niet worden gesorteerd of gefilterd in de tabelweergaven.</p>"
|
||||
"description": "<p>Hier kunt u extra aangepaste velden toevoegen aan uw entiteiten.</p><p>Zodra een veld is toegevoegd, kunt u de key of het type ervan niet wijzigen, en voor velden met keuzetypes kunt u geen keuzes verwijderen of de meervoudige velden wijzigen. Als u een veld verwijdert, worden de bijbehorende gegevens voor alle entiteiten verwijderd.</p><p>De key is hoe andere programma's de gegevens lezen/schrijven, dus of uw aangepaste veld moet worden geïntegreerd met een derde partij programma, zorg ervoor dat u het correct instelt. De standaardwaarde wordt alleen toegepast op nieuwe items.</p><p>Extra velden kunnen worden weergegeven en gesorteerd in de tabelweergaven. Keuze- en booleaanse-velden bieden daar ook filteropties, en alle typen extra velden ondersteunen het filteren op lege waarden.</p>",
|
||||
"choices_reorder_hint": "Sleep of gebruik de pijltjestoetsen om deze keuze opnieuw te ordenen."
|
||||
},
|
||||
"settings": "Instellingen"
|
||||
},
|
||||
@@ -407,5 +408,12 @@
|
||||
"no_locations_help": "Op deze pagina kunt u uw spoelen op verschillende locaties ordenen. Voeg spoelen toe om aan de slag te gaan!",
|
||||
"locations": "Locaties",
|
||||
"new_location": "Nieuwe locatie"
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"light": "Licht",
|
||||
"dark": "Donker",
|
||||
"system": "Systeem",
|
||||
"label": "Thema"
|
||||
},
|
||||
"sponsor": "Steun me op GitHub"
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"buttons": {
|
||||
"edit": "Edytuj",
|
||||
"unArchive": "Odarchiwizuj",
|
||||
"unArchive": "Przywróć",
|
||||
"cancel": "Anuluj",
|
||||
"create": "Utwórz",
|
||||
"save": "Zapisz",
|
||||
@@ -56,7 +56,7 @@
|
||||
"first_used": "Pierwsze użycie",
|
||||
"last_used": "Ostatnie użycie",
|
||||
"comment": "Komentarz",
|
||||
"archived": "Zarchiwizowano",
|
||||
"archived": "Zarchiwizowana",
|
||||
"used_weight": "Zużyta waga",
|
||||
"registered": "Zarejestrowano",
|
||||
"measured_weight": "Zmierzona waga",
|
||||
@@ -111,7 +111,7 @@
|
||||
"importProgress": "Importowanie: {{processed}}/{{total}}",
|
||||
"error": "Błąd (kod statusu: {{statusCode}})",
|
||||
"createError": "Wystąpił błąd podczas tworzenia {{resource}} (kod statusu: {{statusCode}})",
|
||||
"saveSuccessful": "Pomyślnie zapisano!",
|
||||
"saveSuccessful": "Zapisano pomyślnie!",
|
||||
"validationError": "Błąd walidacji: {{error}}"
|
||||
},
|
||||
"loading": "Wczytywanie",
|
||||
@@ -125,38 +125,38 @@
|
||||
"printing": {
|
||||
"generic": {
|
||||
"title": "Drukowanie",
|
||||
"helpMargin": "Marginesy powinny być skonfigurowane tak, aby pasowały do papieru etykietowego i drukarki, ich zmiana wpłynie na rozmiar całej siatki.",
|
||||
"helpPrinterMargin": "Bezpieczna strefa powinna być ustawiona tak, aby znajdowała się jak najbliżej krawędzi papieru, na którym drukarka może drukować, zmiana tych ustawień nie wpłynie na całą siatkę.",
|
||||
"helpMargin": "Marginesy powinny odpowiadać papierowi etykiet i drukarce — ich zmiana wpływa na rozmiar całej siatki.",
|
||||
"helpPrinterMargin": "Strefę bezpieczną ustaw na to, jak blisko krawędzi papieru drukarka jeszcze drukuje. Zmiana tych wartości nie wpływa na całą siatkę.",
|
||||
"print": "Drukuj",
|
||||
"columns": "Kolumny",
|
||||
"rows": "Wiersze",
|
||||
"paperSize": "Rozmiar papieru",
|
||||
"customSize": "Niestandardowy",
|
||||
"customSize": "Własny",
|
||||
"dimensions": "Wymiary",
|
||||
"showBorder": "Pokaż obramowanie",
|
||||
"previewScale": "Skala podglądu",
|
||||
"skipItems": "Pomiń elementy",
|
||||
"skipItems": "Pomiń pozycje",
|
||||
"contentSettings": "Ustawienia zawartości",
|
||||
"layoutSettings": "Ustawienia układu",
|
||||
"verticalSpacing": "Pionowy odstęp",
|
||||
"marginLeft": "Lewy margines",
|
||||
"marginRight": "Prawy margines",
|
||||
"marginTop": "Górny margines",
|
||||
"marginBottom": "Dolny margines",
|
||||
"printerMarginLeft": "Bezpieczna strefa po lewej",
|
||||
"printerMarginRight": "Bezpieczna strefa po prawej",
|
||||
"printerMarginTop": "Bezpieczna strefa od góry",
|
||||
"verticalSpacing": "Odstęp pionowy",
|
||||
"marginLeft": "Margines lewy",
|
||||
"marginRight": "Margines prawy",
|
||||
"marginTop": "Margines górny",
|
||||
"marginBottom": "Margines dolny",
|
||||
"printerMarginLeft": "Strefa bezpieczna lewo",
|
||||
"printerMarginRight": "Strefa bezpieczna prawo",
|
||||
"printerMarginTop": "Strefa bezpieczna góra",
|
||||
"borders": {
|
||||
"none": "Brak",
|
||||
"border": "Obwódka",
|
||||
"grid": "Siatka"
|
||||
},
|
||||
"description": "Dostosuj poniższe ustawienia, aby uzyskać pożądany układ wydruku. Należy pamiętać, że drukarki i system operacyjny mogą stosować własne marginesy i skalowanie, więc może być konieczne wykonanie kilku prób i błędów, zanim wszystko będzie prawidłowe. Przed wydrukowaniem etykiet przetestuj je na zwykłym papierze.",
|
||||
"horizontalSpacing": "Poziomy odstęp",
|
||||
"printerMarginBottom": "Bezpieczna strefa od dołu",
|
||||
"description": "Dopasuj ustawienia poniżej, aby uzyskać właściwy układ wydruku. Pamiętaj, że drukarka i system mogą dodać własne marginesy i skalowanie, więc zwykle trzeba trochę poeksperymentować. Przetestuj na zwykłej kartce, zanim zujesz właściwe etykiety.",
|
||||
"horizontalSpacing": "Odstęp poziomy",
|
||||
"printerMarginBottom": "Strefa bezpieczna dół",
|
||||
"newSetting": "Nowe",
|
||||
"defaultSettings": "Domyślne",
|
||||
"itemCopies": "Skopiuj element",
|
||||
"itemCopies": "Kopie pozycji",
|
||||
"settings": "Presety",
|
||||
"saveAsImage": "Zapisz jako obraz",
|
||||
"duplicateSettings": "Zduplikuj",
|
||||
@@ -186,12 +186,12 @@
|
||||
"template": "Wzór etykiety",
|
||||
"useHTTPUrl": {
|
||||
"preview": "Podgląd:",
|
||||
"label": "Link kodu QR",
|
||||
"label": "Link w kodzie QR",
|
||||
"options": {
|
||||
"default": "Domyślne",
|
||||
"default": "Domyślny",
|
||||
"url": "URL"
|
||||
},
|
||||
"tooltip": "Zostanie użyty link który będzie działał tylko gdy zostanie zeskanowany przez funkcję skanowania Spoolmana (Domyślna opcja). Link URL używa bazowego URL ustawionego w ustawieniach lub aktualnego URL strony jeżeli nie jest ustawiony w ustawieniach."
|
||||
"tooltip": "Użyje zastrzeżonego linku, który zadziała tylko po zeskanowaniu za pomocą funkcji skanowania w Spoolman (domyślnie). Opcja URL używa podstawowego adresu URL określonego w ustawieniach lub, jeśli nie jest ustawiony, adresu URL bieżącej strony."
|
||||
},
|
||||
"showQRCode": "Drukuj kod QR",
|
||||
"showQRCodeMode": {
|
||||
@@ -199,7 +199,7 @@
|
||||
"withIcon": "Z ikoną",
|
||||
"simple": "Proste"
|
||||
},
|
||||
"templateHelp": "Użyj {}, aby wstawić wartości obiektu szpuli jako tekst. Na przykład {id} zostanie zastąpione identyfikatorem szpuli lub {filament.material} zostanie zastąpione materiałem szpuli. jeśli brakuje wartości, zostanie ona zastąpiona „?”. Drugi zestaw {} może być użyty do usunięcia tego. Ponadto każdy tekst między zestawami {} zostanie usunięty, jeśli brakuje wartości. Na przykład {Lot Nr: {lot_nr}} pokaże etykietę tylko wtedy, gdy szpula ma numer partii. Otocz tekst podwójną gwiazdką **, aby go pogrubić. Kliknij przycisk, aby wyświetlić listę wszystkich dostępnych tagów."
|
||||
"templateHelp": "Użyj {} do wprowadzenia wartości elementu szpuli jako tekst. Na przykład, {id} zostanie zastąpione przez id szpuli lub {filament.material} zostanie zastąpione przez materiał szpuli. Jeżeli brakuje wartości, to zostanie ona zastąpiona znakiem \"?\". Drugi zestaw {} można użyć, aby usunąć brakujące wartości. Dodatkowo każdy tekst między znakami {} będzie usunięty jeżeli brakuje wartości. Na przykład {Numer partii: {lot_nr}} pokaże etykietę wtedy gdy szpula będzie miała numer partii. Otocz tekst gwiazdkami by go pogrubić. Kliknij na przycisk by zobaczyć listę dostępnych znaczników."
|
||||
},
|
||||
"spoolSelect": {
|
||||
"title": "Wybierz szpule",
|
||||
@@ -216,13 +216,13 @@
|
||||
"title": "Skaner kodów QR",
|
||||
"error": {
|
||||
"notAllowed": "Nie zezwolono na dostęp do kamery.",
|
||||
"insecureContext": "Strona nie jest dostarczana przez HTTPS.",
|
||||
"streamApiNotSupported": "Przeglądarka nie obsługuje API MediaStream.",
|
||||
"insecureContext": "Strona nie jest serwowana przez HTTPS.",
|
||||
"streamApiNotSupported": "Przeglądarka nie obsługuje MediaStream API.",
|
||||
"notReadable": "Kamera jest niedostępna do odczytu.",
|
||||
"notFound": "Nie znaleziono kamery.",
|
||||
"unknown": "Wystąpił nieznany błąd. ({{error}})"
|
||||
},
|
||||
"description": "Zeskanuj kod QR Spoolman, aby wyświetlić szczegółowe informacje o szpuli."
|
||||
"description": "Zeskanuj kod QR Spoolmana, aby zobaczyć szczegóły szpuli."
|
||||
},
|
||||
"filament": {
|
||||
"filament": "Filamenty",
|
||||
@@ -248,7 +248,7 @@
|
||||
"single_color": "Pojedynczy",
|
||||
"multi_color": "Wielo",
|
||||
"longitudinal": "Wzdłużny",
|
||||
"coaxial": "Koekstrudowany"
|
||||
"coaxial": "Współwytłaczany"
|
||||
},
|
||||
"fields_help": {
|
||||
"material": "Np. PLA, ABS, PETG itp.",
|
||||
@@ -348,7 +348,7 @@
|
||||
"help": "Pomoc",
|
||||
"description": "<title>Pomoc</title><p>Oto kilka wskazówek na początek.</p><p>Spoolman przechowuje 3 różne typy danych:</p><itemsHelp/><p>Aby dodać nową szpulę do bazy danych, zacznij od utworzenia obiektu <filamentCreateLink>Filament</filamentCreateLink>. Gdy już to zrobisz, przejdź do utworzenia obiektu <spoolCreateLink>Szpula</spoolCreateLink> dla tej konkretnej szpuli. Jeśli później zdobędziesz dodatkowe szpule tego samego filamentu, po prostu wygeneruj dodatkowe obiekty Szpula i ponownie użyj tego samego obiektu Filament.</p><p>Opcjonalnie możesz także wygenerować obiekt <vendorCreateLink>Producent</vendorCreateLink> dla firmy produkującej filament, jeśli chcesz śledzić te informacje.</p><p>Masz możliwość połączenia innych usług drukarek 3D ze Spoolmanem, takich jak Moonraker, który może automatycznie monitorować zużycie filamentu i aktualizować obiekty Szpula. Wskazówki dotyczące konfiguracji można znaleźć w <readmeLink>Spoolman README</readmeLink>.</p>"
|
||||
},
|
||||
"kofi": "Daj napiwek na Ko-fi",
|
||||
"kofi": "Wesprzyj mnie na Ko-fi",
|
||||
"home": {
|
||||
"home": "Strona główna",
|
||||
"welcome": "Witaj w instancji Spoolman!",
|
||||
@@ -395,11 +395,12 @@
|
||||
"boolean": "Wartość logiczna"
|
||||
},
|
||||
"non_unique_key_error": "Klucz musi być unikalny.",
|
||||
"description": "<p>Tutaj możesz dodać dodatkowe pola niestandardowe do swoich podmiotów.</p><p>Po dodaniu pola nie można zmienić jego klucza ani typu, a w przypadku pól typu wyboru nie można usunąć wyborów ani zmienić stanu wielokrotnego wyboru. Jeśli usuniesz pole, powiązane dane dla wszystkich podmiotów zostaną usunięte.</p><p>Klucz jest tym, co inne programy odczytują/zapisują jako dane, więc jeśli twoje niestandardowe pole ma być zintegrowane z programem innej firmy, upewnij się, że ustawiłeś je poprawnie. Wartość domyślna jest stosowana tylko do nowych elementów.</p><p>Dodatkowych pól nie można sortować ani filtrować w widokach tabeli.</p>",
|
||||
"description": "<p>Tutaj możesz dodać dodatkowe pola niestandardowe do swoich encji.</p><p>Po dodaniu pola nie można zmienić jego klucza ani typu, a w przypadku pól typu „wybór” nie można usuwać opcji ani zmieniać ustawienia wielu wyborów. Usunięcie pola spowoduje usunięcie powiązanych danych dla wszystkich encji.</p><p>Klucz jest wartością, którą inne programy odczytują i zapisują jako dane, więc jeśli Twoje pole niestandardowe ma integrować się z programem zewnętrznym, upewnij się, że jest ustawione poprawnie. Wartość domyślna jest stosowana tylko do nowych elementów.</p><p>Pola dodatkowe można wyświetlać i sortować w widokach tabeli. Pola typu „wybór” i logiczne (boolean) udostępniają tam również opcje filtrowania, a wszystkie typy pól dodatkowych obsługują filtrowanie wartości pustych.</p>",
|
||||
"choices_missing_error": "Nie możesz usunąc możliwośći wyboru. Ten wybór nie istnieje: {{choices}}",
|
||||
"key_not_changed": "Zmień klucz na inny.",
|
||||
"delete_confirm": "Usunąć pole {{name}}?",
|
||||
"delete_confirm_description": "Ta akcja usunie wszystkie pola i powiązane z nimi dane dla wszystkich obiektów."
|
||||
"delete_confirm_description": "Ta akcja usunie wszystkie pola i powiązane z nimi dane dla wszystkich obiektów.",
|
||||
"choices_reorder_hint": "Przeciągnij lub użyj klawiszy strzałek, aby zmienić kolejność tej opcji."
|
||||
},
|
||||
"header": "Ustawienia"
|
||||
},
|
||||
@@ -408,5 +409,12 @@
|
||||
"new_location": "Nowa lokalizacja",
|
||||
"no_location": "Brak lokalizacji",
|
||||
"no_locations_help": "Ta strona pozwala uporządkować szpule w lokalizacjach, dodaj kilka szpul aby rozpocząć!"
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"label": "Motyw",
|
||||
"system": "System",
|
||||
"light": "Jasny",
|
||||
"dark": "Ciemny"
|
||||
},
|
||||
"sponsor": "Wesprzyj mnie na GitHubie"
|
||||
}
|
||||
@@ -376,15 +376,16 @@
|
||||
"boolean": "Booleano",
|
||||
"choice": "Opção"
|
||||
},
|
||||
"key_not_changed": "Por favor, mude a chave por outra.",
|
||||
"key_not_changed": "Por favor, altere a chave para algo diferente.",
|
||||
"delete_confirm": "Apagar o campo {{name}}?",
|
||||
"delete_confirm_description": "Isto irá apagar o campo e todos os dados associados em todas as entidades.",
|
||||
"delete_confirm_description": "Isto irá apagar o campo e todos os dados associados a todas as entidades.",
|
||||
"description": "<p>Aqui pode adicionar campos personalizados extras às suas entidades.</p><p>Uma vez que um campo é adicionado, não se pode alterar sua chave ou tipo, e para campos do tipo \"opção\" não se pode remover as opções ou alterar o estado da múltipla opção. Se remover um campo, os dados associados para todas as entidades serão excluídos.</p><p>A chave é como outros programas leem/gravam os dados, portanto, se o seu campo personalizado se destina a integrar com um programa de terceiros, certifique-se de defini-lo corretamente. O valor padrão só é aplicado a novos itens.</p><p>Campos extras não podem ser ordenados ou filtrados nas visualizações de tabela.</p>",
|
||||
"choices_missing_error": "Não pode remover as opções. As seguintes opções serão perdidas: {{choices}}",
|
||||
"tab": "Campos Extras",
|
||||
"boolean_true": "Sim",
|
||||
"boolean_false": "Não",
|
||||
"non_unique_key_error": "A chave deve ser única."
|
||||
"non_unique_key_error": "A chave deve ser única.",
|
||||
"choices_reorder_hint": "Arraste ou use as setas para reordenar esta escolha."
|
||||
},
|
||||
"settings": "Configurações",
|
||||
"header": "Configurações",
|
||||
@@ -408,5 +409,11 @@
|
||||
"new_location": "Novo Local",
|
||||
"no_location": "Sem Local",
|
||||
"no_locations_help": "Esta página permite organizar as suas bobines em locais, adicione algumas bobines para começar!"
|
||||
},
|
||||
"theme": {
|
||||
"label": "Tema",
|
||||
"system": "Sistema",
|
||||
"light": "Claro",
|
||||
"dark": "Escuro"
|
||||
}
|
||||
}
|
||||
@@ -356,7 +356,7 @@
|
||||
},
|
||||
"settings": {
|
||||
"extra_fields": {
|
||||
"description": "<p>Здесь вы можете добавлять дополнительные пользовательские поля к своим сущностям.</p><p>Как только поле добавлено, вы не можете изменить его ключ или тип, а для полей типа выбора вы не можете удалить варианты или изменить состояние множественного выбора. Если вы удалите поле, связанные данные для всех сущностей будут удалены.</p><p>Ключевым моментом является то, как другие программы считывают/записывают данные, поэтому, если предполагается, что ваше пользовательское поле интегрируется со сторонней программой, убедитесь, что оно настроено правильно. Значение по умолчанию применяется только к новым элементам.</p><p>Дополнительные поля нельзя отсортировать или отфильтровать в табличных представлениях.</p>",
|
||||
"description": "<p>Здесь вы можете добавить дополнительные пользовательские поля к сущностям.</p><p>После добавления поля нельзя изменить его ключ или тип, а для полей выбора нельзя удалять варианты или изменять состояние множественного выбора. Если вы удалите поле, связанные данные для всех сущностей будут удалены.</p><p>Ключ используется другими программами для чтения и записи данных, поэтому если ваше пользовательское поле должно интегрироваться со сторонней программой, убедитесь, что задали его правильно. Значение по умолчанию применяется только к новым элементам.</p><p>Дополнительные поля можно отображать и сортировать в табличных представлениях. Поля выбора и логические поля также предоставляют там параметры фильтрации, а все типы дополнительных полей поддерживают фильтрацию по пустым значениям.</p>",
|
||||
"tab": "Дополнительные поля",
|
||||
"params": {
|
||||
"key": "Ключ",
|
||||
@@ -384,7 +384,8 @@
|
||||
"delete_confirm_description": "Это приведет к удалению поля и всех связанных с ним данных для всех объектов.",
|
||||
"boolean_true": "Да",
|
||||
"boolean_false": "Нет",
|
||||
"choices_missing_error": "Вы не можете удалить варианты. Отсутствуют следующие варианты: {{choices}}"
|
||||
"choices_missing_error": "Вы не можете удалить варианты. Отсутствуют следующие варианты: {{choices}}",
|
||||
"choices_reorder_hint": "Перетащите или используйте стрелки."
|
||||
},
|
||||
"header": "Настройки",
|
||||
"general": {
|
||||
@@ -414,5 +415,6 @@
|
||||
"system": "Как в системе",
|
||||
"light": "Светлая",
|
||||
"dark": "Тёмная"
|
||||
}
|
||||
},
|
||||
"sponsor": "Поддержать меня на GitHub"
|
||||
}
|
||||
@@ -248,8 +248,9 @@
|
||||
"key_not_changed": "Spremenite geslo v nekaj drugega.",
|
||||
"delete_confirm": "Zbrišem polje {{name}}?",
|
||||
"delete_confirm_description": "S tem boste izbrisali polje in vse povezane podatke za vse vnose.",
|
||||
"description": "<p>Tukaj lahko svojim entitetam dodate dodatna polja po meri.</p><p>Ko je polje dodano, njegovega ključa ali tipa ni mogoče spremeniti, pri poljih tipa »izbira« pa ni mogoče odstraniti možnosti ali spremeniti nastavitve za večkratno izbiro. Če polje odstranite, bodo povezani podatki za vse entitete izbrisani.</p><p>Ključ je tisto, kar drugi programi uporabljajo za branje/pisanje podatkov, zato če naj bi se vaše polje po meri integriralo s programom tretje osebe, poskrbite, da ga nastavite pravilno. Privzeta vrednost se uporablja le za nove elemente.</p><p>Dodatna polja ni mogoče razvrstiti ali filtrirati v pogledih tabel.</p>",
|
||||
"choices_missing_error": "Možnosti ni mogoče odstraniti. Manjkajo naslednje možnosti: {{choices}}"
|
||||
"description": "<p>Tukaj lahko svojim entitetam dodate dodatna polja po meri.</p><p>Ko je polje dodano, njegovega ključa ali tipa ni mogoče spremeniti, pri poljih tipa »izbira« pa ni mogoče odstraniti možnosti ali spremeniti nastavitve za večkratno izbiro. Če polje odstranite, bodo povezani podatki za vse entitete izbrisani.</p><p>Ključ je tisto, kar drugi programi uporabljajo za branje/pisanje podatkov, zato če naj bi se vaše polje po meri integriralo s programom tretje osebe, poskrbite, da ga nastavite pravilno. Privzeta vrednost se uporablja le za nove elemente.</p><p>V preglednicah je mogoče prikazati in razvrstiti dodatna polja. Polja tipa »izbira« in »booleanska« tam omogočajo tudi možnosti filtriranja, vsi tipi dodatnih polj pa podpirajo filtriranje po praznih vrednostih.</p>",
|
||||
"choices_missing_error": "Možnosti ni mogoče odstraniti. Manjkajo naslednje možnosti: {{choices}}",
|
||||
"choices_reorder_hint": "Za spremembo vrstnega reda izbire, povlecite ali uporabite smerne tipke."
|
||||
}
|
||||
},
|
||||
"scanner": {
|
||||
@@ -397,5 +398,12 @@
|
||||
"new_location": "Nova lokacija",
|
||||
"no_location": "Brez lokacije",
|
||||
"no_locations_help": "Na tej strani lahko kolute razvrstite po lokacijah. Za začetek dodajte nekaj kolutov."
|
||||
},
|
||||
"sponsor": "Sponzorirajte me na GitHubu",
|
||||
"theme": {
|
||||
"label": "Tema",
|
||||
"system": "Sistem",
|
||||
"light": "Svetla",
|
||||
"dark": "Temna"
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
"create": "สร้างม้วนพลาสติก",
|
||||
"adjust": "ปรับการใช้ม้วนพลาสติก",
|
||||
"edit": "แก้ไขม้วนพลาสติก",
|
||||
"list": "รายการม้วนพลาสติก",
|
||||
"list": "ม้วนพลาสติก",
|
||||
"show": "แสดงม้วนพลาสติก"
|
||||
},
|
||||
"form": {
|
||||
@@ -175,10 +175,10 @@
|
||||
},
|
||||
"filament": {
|
||||
"fields_help": {
|
||||
"spool_weight": "น้ำหนักม้วนพลาสติกเปล่า ใช้เพื่อคำนวณน้ำหนักรวมของม้วนพลาสติก",
|
||||
"spool_weight": "น้ำหนักของม้วนพลาสติกเปล่า ใช้เพื่อคำนวณน้ำหนักที่วัดได้ของม้วนพลาสติก",
|
||||
"material": "ตัวอย่างเช่น PLA, ABS, PETG, TPU, ASA, PC, PA เป็นต้น",
|
||||
"article_number": "เช่น EAN, UPC, เป็นต้น",
|
||||
"price": "ราคาของม้วนพลาสติกเต็ม (เส้นพลาสติกทั้งหมด)",
|
||||
"price": "ราคาของม้วนพลาสติกเต็ม",
|
||||
"weight": "น้ำหนักของเส้นพลาสติกในม้วนพลาสติก (สุทธิ) ไม่รวมม้วนพลาสติกเปล่า เป็นค่าน้ำหนักที่มักระบุบนบรรจุภัณฑ์",
|
||||
"name": "ชื่อเส้นพลาสติก เพื่อแยกความต่างระหว่างเส้นพลาสติกแบบเดียวกันจากผู้ผลิตเดียวกัน โดยทั่วไปควรระบุสีในชื่อด้วย",
|
||||
"multi_color_direction": "เส้นพลาสติกหลายสีสามารถมีหลายสีได้ 2 รูปแบบ: แบบ coextrusion (เช่น เส้นพลาสติกสองสีพร้อมกัน) หรือแบบไล่สีตามความยาว (เช่น เส้นพลาสติกเปลี่ยนสีไปเรื่อยๆ)"
|
||||
@@ -193,15 +193,15 @@
|
||||
"material": "วัสดุ",
|
||||
"id": "ID",
|
||||
"settings_bed_temp": "อุณหภูมิฐานพิมพ์",
|
||||
"vendor_name": "ยี่ห้อ",
|
||||
"vendor_name": "ผู้ผลิต",
|
||||
"diameter": "เส้นผ่านศูนย์กลาง",
|
||||
"article_number": "เลขบทความ",
|
||||
"article_number": "รหัสสินค้า",
|
||||
"multi_color": "หลายสี",
|
||||
"external_id": "รหัสภายนอก",
|
||||
"color_hex": "สี",
|
||||
"price": "ราคา",
|
||||
"spool_weight": "น้ำหนักม้วนพลาสติกเปล่า",
|
||||
"coaxial": "เส้นหลายสี",
|
||||
"coaxial": "เส้นประกบหลายสี",
|
||||
"name": "ชื่อ",
|
||||
"spools": "แสดงม้วนพลาสติก",
|
||||
"registered": "ลงทะเบียน",
|
||||
@@ -209,7 +209,7 @@
|
||||
},
|
||||
"titles": {
|
||||
"edit": "แก้ไขเส้นพลาสติก",
|
||||
"list": "รายการเส้นพลาสติก",
|
||||
"list": "เส้นพลาสติก",
|
||||
"show": "แสดงเส้นพลาสติก",
|
||||
"clone": "ทำซ้ำเส้นพลาสติก",
|
||||
"show_title": "[เส้นพลาสติก #{{id}}] {{name}}",
|
||||
@@ -218,7 +218,7 @@
|
||||
"form": {
|
||||
"filament_updated": "เส้นพลาสติกนี้ได้รับการอัปเดตโดยผู้อื่นหลังจากที่คุณเปิดหน้านี้ การบันทึกจะเขียนทับการเปลี่ยนแปลง!",
|
||||
"import_external": "นำเข้าจากภายนอก",
|
||||
"import_external_description": "เลือกเส้นพลาสติกในรายการเพื่อกรอกข้อมูลอัตโนมัติในฟอร์มนี้ การดำเนินการนี้จะเขียนทับข้อมูลที่คุณกรอกไว้ อ็อบเจ็กต์ผู้ผลิตจะถูกสร้างขึ้นโดยอัตโนมัติเมื่อคุณคลิกตกลง"
|
||||
"import_external_description": "เลือกเส้นพลาสติกในรายการเพื่อกรอกข้อมูลอัตโนมัติในฟอร์มนี้ การดำเนินการนี้จะเขียนทับข้อมูลที่คุณกรอกไว้<br><br>อ็อบเจ็กต์ผู้ผลิตจะถูกสร้างขึ้นโดยอัตโนมัติเมื่อคุณคลิกตกลง หากจำเป็น"
|
||||
},
|
||||
"buttons": {
|
||||
"add_spool": "เพิ่มม้วนพลาสติก"
|
||||
@@ -270,7 +270,7 @@
|
||||
"locations": {
|
||||
"list": "สถานที่เก็บ | Spoolman"
|
||||
},
|
||||
"default": "สปูลแมน"
|
||||
"default": "Spoolman"
|
||||
},
|
||||
"notifications": {
|
||||
"error": "เกิดข้อผิดพลาด (รหัสสถานะ: {{statusCode}})",
|
||||
@@ -296,12 +296,12 @@
|
||||
},
|
||||
"vendor": {
|
||||
"titles": {
|
||||
"create": "สร้างผู้ผลิต/ยี่ห้อ",
|
||||
"list": "รายการผู้ผลิต/ยี่ห้อ",
|
||||
"clone": "ทำซ้ำผู้ผลิต/ยี่ห้อ",
|
||||
"create": "สร้างผู้ผลิต",
|
||||
"list": "ผู้ผลิต",
|
||||
"clone": "ทำซ้ำผู้ผลิต",
|
||||
"show_title": "[ผู้ผลิต #{{id}}] {{name}}",
|
||||
"edit": "แก้ไขผู้ผลิต/ยี่ห้อ",
|
||||
"show": "แสดงผู้ผลิต/ยี่ห้อ"
|
||||
"edit": "แก้ไขผู้ผลิต",
|
||||
"show": "แสดงผู้ผลิต"
|
||||
},
|
||||
"fields": {
|
||||
"empty_spool_weight": "น้ำหนักม้วนพลาสติกเปล่า",
|
||||
@@ -312,21 +312,21 @@
|
||||
"name": "ชื่อ"
|
||||
},
|
||||
"fields_help": {
|
||||
"empty_spool_weight": "น้ำหนักม้วนพลาสติกเปล่าของผู้ผลิตนี้"
|
||||
"empty_spool_weight": "น้ำหนักของม้วนพลาสติกเปล่าจากผู้ผลิตนี้"
|
||||
},
|
||||
"form": {
|
||||
"vendor_updated": "ผู้ผลิตนี้ถูกอัปเดตโดยผู้อื่นหลังจากที่คุณเปิดหน้านี้ การบันทึกจะเขียนทับการเปลี่ยนแปลง!"
|
||||
"vendor_updated": "ผู้ผลิตนี้ได้รับการอัปเดตโดยผู้อื่นหลังจากที่คุณเปิดหน้านี้ การบันทึกจะเขียนทับการเปลี่ยนแปลง!"
|
||||
},
|
||||
"vendor": "ผู้ผลิต"
|
||||
},
|
||||
"help": {
|
||||
"resources": {
|
||||
"vendor": "บริษัทที่ผลิตเส้นพลาสติก",
|
||||
"spool": "ม้วนพลาสติกจริงสำหรับเส้นพลาสติกเฉพาะ",
|
||||
"filament": "แบรนด์เส้นพลาสติก มีคุณสมบัติต่างๆ เช่น ชื่อ วัสดุ สี เส้นผ่านศูนย์กลาง และอื่นๆ"
|
||||
"vendor": "บริษัทที่เป็นผู้ผลิตเส้นพลาสติก",
|
||||
"spool": "ม้วนพลาสติกจริงของเส้นพลาสติกเฉพาะแต่ละม้วน",
|
||||
"filament": "แบรนด์ของเส้นพลาสติก ซึ่งมีคุณสมบัติต่างๆ เช่น ชื่อ วัสดุ สี เส้นผ่านศูนย์กลาง และอื่นๆ"
|
||||
},
|
||||
"help": "วิธีใช้",
|
||||
"description": "<title>วิธีใช้</title><p>นี่คือเคล็ดลับบางส่วนเพื่อเริ่มต้น</p><p>Spoolman เก็บข้อมูลหลัก 3 ประเภท:</p><itemsHelp/><p>ในการเพิ่มม้วนพลาสติกใหม่ลงในฐานข้อมูล ให้เริ่มด้วยการสร้างอ็อบเจ็กต์ <filamentCreateLink>เส้นพลาสติก</filamentCreateLink> เมื่อเสร็จแล้วจึงสร้างอ็อบเจ็กต์ <spoolCreateLink>ม้วนพลาสติก</spoolCreateLink> สำหรับม้วนพลาสติกนั้น หากคุณได้ม้วนพลาสติกเพิ่มในภายหลัง ก็เพียงสร้างอ็อบเจ็กต์ม้วนพลาสติกใหม่และใช้วัตถุ เส้นพลาสติก เดิมได้</p><p>คุณสามารถสร้างอ็อบเจ็กต์ <vendorCreateLink>Manufacturer</vendorCreateLink> เพื่อเก็บข้อมูลบริษัทผู้ผลิตเส้นพลาสติกหากคุณต้องการติดตามข้อมูลนั้น</p><p>คุณสามารถเชื่อมต่อบริการ 3D printer อื่นๆ เข้ากับ Spoolman เช่น Moonraker เพื่อบันทึกปริมาณการใช้เส้นพลาสติกโดยอัตโนมัติและอัปเดตอ็อบเจ็กต์ม้วนพลาสติกของคุณ ดูเพิ่มเติมได้ที่ <readmeLink>Spoolman README</readmeLink></p>"
|
||||
"description": "<title>วิธีใช้</title><p>นี่คือเคล็ดลับบางส่วนเพื่อเริ่มต้นใช้งาน</p><p>Spoolman เก็บข้อมูล 3 ประเภทที่แตกต่างกัน:</p><itemsHelp/><p>ในการเพิ่มม้วนพลาสติกใหม่ลงในฐานข้อมูล ให้เริ่มด้วยการสร้างอ็อบเจ็กต์ <filamentCreateLink>Filament</filamentCreateLink> เมื่อเสร็จแล้วจึงสร้างอ็อบเจ็กต์ <spoolCreateLink>Spool</spoolCreateLink> สำหรับม้วนพลาสติกนั้น หากคุณได้ม้วนพลาสติกของเส้นพลาสติกเดิมเพิ่มในภายหลัง ก็เพียงสร้างอ็อบเจ็กต์ Spool ใหม่และใช้อ็อบเจ็กต์ Filament เดิมซ้ำได้</p><p>นอกจากนี้คุณยังสามารถสร้างอ็อบเจ็กต์ <vendorCreateLink>Manufacturer</vendorCreateLink> สำหรับบริษัทที่ผลิตเส้นพลาสติกได้หากต้องการติดตามข้อมูลนั้น</p><p>คุณสามารถเชื่อมต่อบริการเครื่องพิมพ์ 3D อื่นๆ เข้ากับ Spoolman เช่น Moonraker ซึ่งสามารถตรวจสอบการใช้เส้นพลาสติกโดยอัตโนมัติและอัปเดตอ็อบเจ็กต์ Spool ให้คุณ ดูคำแนะนำในการตั้งค่าได้ที่ <readmeLink>Spoolman README</readmeLink></p>"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "แดชบอร์ด"
|
||||
@@ -340,17 +340,17 @@
|
||||
"label": "สกุลเงิน"
|
||||
},
|
||||
"base_url": {
|
||||
"label": "URLพื้นฐาน",
|
||||
"tooltip": "URLพื้นฐาน ที่ใช้สำหรับสร้างฟีเจอร์ต่างๆ เช่น QR code"
|
||||
"label": "URL พื้นฐาน",
|
||||
"tooltip": "URL พื้นฐานที่ใช้สำหรับสร้างฟีเจอร์ต่างๆ เช่น QR code"
|
||||
},
|
||||
"round_prices": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
"label": "ปัดเศษราคา",
|
||||
"tooltip": "ปัดเศษราคาเป็นจำนวนเต็มที่ใกล้ที่สุด"
|
||||
}
|
||||
},
|
||||
"extra_fields": {
|
||||
"params": {
|
||||
"multi_choice": "เลือกหลายตัว",
|
||||
"multi_choice": "เลือกได้หลายรายการ",
|
||||
"key": "คีย์",
|
||||
"name": "ชื่อ",
|
||||
"field_type": "ประเภท",
|
||||
@@ -359,7 +359,7 @@
|
||||
"default_value": "ค่าเริ่มต้น",
|
||||
"choices": "ตัวเลือก"
|
||||
},
|
||||
"delete_confirm_description": "สิ่งนี้จะลบฟิลด์และข้อมูลที่เกี่ยวข้องทั้งหมดสำหรับเอนทิตี",
|
||||
"delete_confirm_description": "สิ่งนี้จะลบฟิลด์และข้อมูลที่เกี่ยวข้องทั้งหมดของทุกเอนทิตี",
|
||||
"field_type": {
|
||||
"text": "ข้อความ",
|
||||
"integer": "จำนวนเต็ม",
|
||||
@@ -367,7 +367,7 @@
|
||||
"float": "ทศนิยม",
|
||||
"float_range": "ช่วงทศนิยม",
|
||||
"datetime": "วันเวลา",
|
||||
"boolean": "บูลีน(ใส่เฉพาะ ใช่/ไม่)",
|
||||
"boolean": "บูลีน",
|
||||
"choice": "ตัวเลือก"
|
||||
},
|
||||
"tab": "ฟิลด์เพิ่มเติม",
|
||||
@@ -377,7 +377,8 @@
|
||||
"non_unique_key_error": "คีย์ต้องไม่ซ้ำ",
|
||||
"key_not_changed": "โปรดเปลี่ยนคีย์เป็นอย่างอื่น",
|
||||
"delete_confirm": "ลบฟิลด์ {{name}} หรือไม่?",
|
||||
"description": "<p>คุณสามารถเพิ่มฟิลด์ที่กำหนดเองเพิ่มเติมสำหรับเอนทิตีของคุณได้ที่นี่</p><p>เมื่อเพิ่มฟิลด์แล้ว คุณจะไม่สามารถเปลี่ยนคีย์หรือประเภทของฟิลด์ได้ และสำหรับฟิลด์ประเภทตัวเลือกคุณจะไม่สามารถลบตัวเลือกหรือเปลี่ยนโหมด multi choice ได้ หากคุณลบฟิลด์ ข้อมูลของเอนทิตีทั้งหมดจะถูกลบด้วย</p><p>คีย์คือสิ่งที่โปรแกรมอื่นจะใช้เพื่ออ่าน/เขียนข้อมูล ดังนั้นหากฟิลด์ที่คุณสร้างเพื่อเชื่อมต่อกับโปรแกรมอื่น กรุณาตั้งค่าให้ถูกต้อง ค่าดีฟอลต์จะถูกนำไปใช้เฉพาะกับรายการใหม่เท่านั้น</p><p>ฟิลด์เพิ่มเติมไม่สามารถเรียงหรือกรองได้ในตาราง</p>"
|
||||
"description": "<p>คุณสามารถเพิ่มฟิลด์ที่กำหนดเองเพิ่มเติมสำหรับเอนทิตีของคุณได้ที่นี่</p><p>เมื่อเพิ่มฟิลด์แล้ว คุณจะไม่สามารถเปลี่ยนคีย์หรือประเภทของฟิลด์ได้ และสำหรับฟิลด์ประเภทตัวเลือกคุณจะไม่สามารถลบตัวเลือกหรือเปลี่ยนสถานะตัวเลือกหลายรายการได้ หากคุณลบฟิลด์ ข้อมูลที่เกี่ยวข้องของทุกเอนทิตีจะถูกลบไปด้วย</p><p>คีย์คือสิ่งที่โปรแกรมอื่นจะใช้เพื่ออ่าน/เขียนข้อมูล ดังนั้นหากฟิลด์ที่คุณสร้างต้องการเชื่อมต่อกับโปรแกรมภายนอก กรุณาตั้งค่าให้ถูกต้อง ค่าเริ่มต้นจะถูกนำไปใช้เฉพาะกับรายการใหม่เท่านั้น</p><p>ฟิลด์เพิ่มเติมสามารถแสดงและจัดเรียงในมุมมองตารางได้ ฟิลด์ประเภทตัวเลือกและบูลีนยังมีตัวเลือกการกรองที่นั่นด้วย และฟิลด์เพิ่มเติมทุกประเภทรองรับการกรองหาค่าว่าง</p>",
|
||||
"choices_reorder_hint": "ลาก หรือใช้ปุ่มลูกศร เพื่อจัดเรียงตัวเลือกนี้"
|
||||
}
|
||||
},
|
||||
"locations": {
|
||||
@@ -388,11 +389,17 @@
|
||||
},
|
||||
"home": {
|
||||
"home": "หน้าแรก",
|
||||
"welcome": "",
|
||||
"description": ""
|
||||
"welcome": "ยินดีต้อนรับสู่ Spoolman ของคุณ!",
|
||||
"description": "ดูเหมือนว่าคุณยังไม่ได้เพิ่มม้วนพลาสติกใดๆ ดูคำแนะนำการเริ่มต้นใช้งานได้ที่ <helpPageLink>หน้าวิธีใช้</helpPageLink>"
|
||||
},
|
||||
"warnWhenUnsavedChanges": "คุณแน่ใจหรือไม่ว่าต้องการออก? คุณมีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก",
|
||||
"table": {
|
||||
"actions": "การดำเนินการ"
|
||||
},
|
||||
"theme": {
|
||||
"label": "ธีม",
|
||||
"system": "ตามระบบ",
|
||||
"light": "โหมดสว่าง",
|
||||
"dark": "โหมดมืด"
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
"cancel": "取消",
|
||||
"confirm": "您確定嗎?",
|
||||
"continue": "繼續",
|
||||
"filter": "過濾器",
|
||||
"filter": "篩選",
|
||||
"clear": "清除",
|
||||
"refresh": "重新整理",
|
||||
"show": "檢視",
|
||||
@@ -61,8 +61,8 @@
|
||||
"printing": {
|
||||
"generic": {
|
||||
"title": "列印中",
|
||||
"description": "調整以下設定以獲得所需的列印佈局。請注意,列表機和您的操作系統可能會套用自己的邊距和縮放比例,因此可能需要進行一些反覆測試才能達到理想效果。在實際標籤上列印之前,先在普通紙上測試。",
|
||||
"helpMargin": "邊距應配置為與您的標籤紙和列表機相符,調整這些將影響整個網格的大小。",
|
||||
"description": "調整以下設定以取得所需的列印版面。請注意,印表機與作業系統可能會套用自己的邊距和縮放比例,因此可能需要多次測試。列印實際標籤前,請先使用一般紙張測試。",
|
||||
"helpMargin": "邊距應設定為與您的標籤紙和印表機相符,調整這些將影響整個網格的大小。",
|
||||
"rows": "列",
|
||||
"paperSize": "紙張大小",
|
||||
"customSize": "自訂",
|
||||
@@ -70,7 +70,7 @@
|
||||
"showBorder": "顯示外框",
|
||||
"previewScale": "預覽縮放比例",
|
||||
"skipItems": "跳過項目",
|
||||
"itemCopies": "項目複製",
|
||||
"itemCopies": "每項份數",
|
||||
"contentSettings": "內容設定",
|
||||
"layoutSettings": "佈局設定",
|
||||
"horizontalSpacing": "水平間距",
|
||||
@@ -100,7 +100,7 @@
|
||||
"columns": "欄",
|
||||
"verticalSpacing": "垂直間距",
|
||||
"marginTop": "上邊界",
|
||||
"helpPrinterMargin": "安全區域應設定為列表機能列印的距離紙張的邊界,更改此選項不影響整個網格。"
|
||||
"helpPrinterMargin": "安全區域應設定為印表機能列印的距離紙張的邊界,更改此選項不影響整個網格。"
|
||||
},
|
||||
"qrcode": {
|
||||
"title": "標籤列印中",
|
||||
@@ -110,7 +110,7 @@
|
||||
"textSize": "標籤文字大小",
|
||||
"showContent": "列印標籤",
|
||||
"useHTTPUrl": {
|
||||
"label": "QR碼連結",
|
||||
"label": "QR 碼連結",
|
||||
"preview": "預覽:",
|
||||
"options": {
|
||||
"default": "預設",
|
||||
@@ -131,11 +131,11 @@
|
||||
"showArchived": "檢視已封存",
|
||||
"noSpoolsSelected": "您尚未選擇任何料盤。",
|
||||
"selectAll": "選擇/取消全選",
|
||||
"selectedTotal_other": "已選擇 {{count}} 個料盤"
|
||||
"selectedTotal_other": "已選擇 {{count}} 個線軸"
|
||||
}
|
||||
},
|
||||
"scanner": {
|
||||
"description": "掃描Spoolman的QR碼以檢視關於料盤的詳細資料。",
|
||||
"description": "掃描 Spoolman QR 碼以檢視線軸的詳細資料。",
|
||||
"error": {
|
||||
"notAllowed": "您未被允許存取攝影機。",
|
||||
"streamApiNotSupported": "瀏覽器不支援MediaStream API。",
|
||||
@@ -144,10 +144,10 @@
|
||||
"insecureContext": "此頁面未通過 HTTPS 提供服務。",
|
||||
"unknown": "發生未知的錯誤。({{error}})"
|
||||
},
|
||||
"title": "掃描QR碼"
|
||||
"title": "QR 碼掃描器"
|
||||
},
|
||||
"spool": {
|
||||
"spool": "料盤",
|
||||
"spool": "線軸",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"filament": "線材",
|
||||
@@ -346,7 +346,7 @@
|
||||
"general": {
|
||||
"tab": "一般",
|
||||
"currency": {
|
||||
"label": "貨幣設定標籤"
|
||||
"label": "貨幣"
|
||||
},
|
||||
"base_url": {
|
||||
"tooltip": "生成 QR 碼等功能時使用的基本 URL。",
|
||||
@@ -394,5 +394,6 @@
|
||||
"no_location": "未設定位置",
|
||||
"no_locations_help": "這個頁面讓您將料盤按位置進行整理,請從增加一些線盤來開始!",
|
||||
"new_location": "新增位置"
|
||||
}
|
||||
},
|
||||
"sponsor": "在 GitHub 上贊助專案"
|
||||
}
|
||||
@@ -21,10 +21,10 @@
|
||||
"undo": "撤销",
|
||||
"import": "导入",
|
||||
"clone": "克隆",
|
||||
"archive": "存档",
|
||||
"unArchive": "取消存档",
|
||||
"archive": "归档",
|
||||
"unArchive": "解除归档",
|
||||
"hideArchived": "隐藏存档",
|
||||
"showArchived": "显示存档",
|
||||
"showArchived": "显示已归档项",
|
||||
"notAccessTitle": "您没有访问权限",
|
||||
"hideColumns": "隐藏列",
|
||||
"clearFilters": "清除筛选器",
|
||||
@@ -34,7 +34,7 @@
|
||||
"warnWhenUnsavedChanges": "您确定要离开吗?您有未保存的更改。",
|
||||
"notifications": {
|
||||
"success": "操作成功",
|
||||
"error": "出现错误 (状态码:{{statusCode}})",
|
||||
"error": "发生错误 (状态码:{{statusCode}})",
|
||||
"undoable": "您有 {{seconds}} 秒可以撤销。",
|
||||
"createSuccess": "成功创建 {{resource}}",
|
||||
"createError": "创建 {{resource}} 时出现错误 (状态码:{{statusCode}})",
|
||||
@@ -60,30 +60,30 @@
|
||||
"printing": {
|
||||
"generic": {
|
||||
"title": "打印中",
|
||||
"description": "调整以下设置以获得所需的打印布局。请记住,打印机和您的操作系统可能会应用自己的边距和缩放,因此您可能需要多次尝试后才能正确打印。在实际标签上打印之前,请在一张普通纸上进行测试。",
|
||||
"helpMargin": "页边距应设置为与您的标签纸和打印机相匹配,更改这些将影响整个网格的大小。",
|
||||
"helpPrinterMargin": "安全区应设置为打印机可以打印的离纸张边缘距离,更改这些不会影响整个网格。",
|
||||
"description": "在下方微调设置以获取理想的打印布局。请注意,打印机驱动和操作系统可能会自动应用边距与缩放,因此您可能需要进行几次试打印。建议在正式打印前先用普通纸测试。",
|
||||
"helpMargin": "页边距应与您的标签纸和打印机相匹配,修改此项会影响整个标签网格的位置。",
|
||||
"helpPrinterMargin": "安全打印区域应设置为打印机允许贴近纸张边缘打印的极限距离,修改此项不会改变标签网格的位置。",
|
||||
"print": "打印",
|
||||
"columns": "行",
|
||||
"columns": "列数",
|
||||
"rows": "列",
|
||||
"paperSize": "纸张尺寸",
|
||||
"customSize": "自定义",
|
||||
"dimensions": "尺寸标注",
|
||||
"customSize": "自定义尺寸",
|
||||
"dimensions": "尺寸规格",
|
||||
"showBorder": "显示边框",
|
||||
"previewScale": "预览比例",
|
||||
"skipItems": "跳过项目",
|
||||
"skipItems": "跳过初始格子数",
|
||||
"contentSettings": "内容设置",
|
||||
"layoutSettings": "布局设置",
|
||||
"horizontalSpacing": "水平间距",
|
||||
"verticalSpacing": "垂直间距",
|
||||
"marginLeft": "左边界",
|
||||
"marginRight": "右边界",
|
||||
"marginTop": "上边界",
|
||||
"marginBottom": "下边界",
|
||||
"printerMarginLeft": "安全区左侧",
|
||||
"printerMarginRight": "安全区右侧",
|
||||
"printerMarginTop": "安全区顶部",
|
||||
"printerMarginBottom": "安全区底部",
|
||||
"horizontalSpacing": "列间距 (水平)",
|
||||
"verticalSpacing": "行间距 (垂直)",
|
||||
"marginLeft": "左边距",
|
||||
"marginRight": "右边距",
|
||||
"marginTop": "上边距",
|
||||
"marginBottom": "下边距",
|
||||
"printerMarginLeft": "左侧安全区域",
|
||||
"printerMarginRight": "右侧安全区域",
|
||||
"printerMarginTop": "顶部安全区域",
|
||||
"printerMarginBottom": "底部安全区域",
|
||||
"borders": {
|
||||
"none": "无",
|
||||
"border": "外边框",
|
||||
@@ -97,7 +97,7 @@
|
||||
"deleteSettingsConfirm": "确定删除此预设吗?",
|
||||
"saveAsImage": "保存为图片",
|
||||
"newSetting": "新增",
|
||||
"itemCopies": "复制项目",
|
||||
"itemCopies": "单个标签打印份数",
|
||||
"duplicateSettings": "复制",
|
||||
"deleteSettings": "删除当前预设"
|
||||
},
|
||||
@@ -127,11 +127,11 @@
|
||||
},
|
||||
"templateHelp": "使用 {} 将料盘的值插入为文本。例如,{id} 将被替换为料盘的ID,或者 {filament.material} 将被替换为料盘的材质。如果某个值缺失,将用 \"?\" 替代。可以使用第二组 {} 来删除缺失值相关的内容。此外,任意位于两组 {} 之间的文本,如果值缺失,将被一同删除。例如,{Lot Nr: {lot_nr}} 只有在料盘存在批号时才会显示标签内容。用双星号 ** 包裹文本可以将其加粗。点击按钮查看所有可用的标签列表。",
|
||||
"useHTTPUrl": {
|
||||
"tooltip": "将使用专有链接(默认情况下,仅能通过 **Spoolman** 的扫描功能访问)。URL 将使用设置中指定的基础 URL;如果未设置基础 URL,则使用当前页面的 URL。",
|
||||
"label": "二维码链接",
|
||||
"tooltip": "将使用专有链接,该链接仅在通过 Spoolman 的内置扫描功能扫描时生效(默认)。URL 将使用设置中指定的基准 URL;若未设置,则使用当前页面的 URL。",
|
||||
"label": "二维码编码内容",
|
||||
"options": {
|
||||
"default": "默认",
|
||||
"url": "URL"
|
||||
"default": "默认(Spoolman 专属格式)",
|
||||
"url": "标准网页 URL"
|
||||
},
|
||||
"preview": "预览:"
|
||||
}
|
||||
@@ -142,42 +142,42 @@
|
||||
"showArchived": "显示存档",
|
||||
"noSpoolsSelected": "您尚未选择任何料盘。",
|
||||
"selectAll": "选择/取消选择全部",
|
||||
"selectedTotal_other": "已选择{{count}}个料盘"
|
||||
"selectedTotal_other": "已选择 {{count}} 卷耗材"
|
||||
}
|
||||
},
|
||||
"scanner": {
|
||||
"title": "扫码二维码",
|
||||
"description": "扫描Spoolman二维码,查看有关料盘的详细信息。",
|
||||
"title": "二维码扫描器",
|
||||
"description": "扫描 Spoolman 专属二维码以快速查看该耗材卷的详细信息。",
|
||||
"error": {
|
||||
"notAllowed": "您不被允许访问相机。",
|
||||
"insecureContext": "该页面不是通过HTTPS传输。",
|
||||
"streamApiNotSupported": "浏览器不支持MediaStream API。",
|
||||
"notAllowed": "您尚未授权允许使用摄像头。",
|
||||
"insecureContext": "当前页面未通过安全的 HTTPS 协议提供服务。",
|
||||
"streamApiNotSupported": "当前浏览器不支持 MediaStream API 视频流。",
|
||||
"notReadable": "相机不可读。",
|
||||
"notFound": "没有找到相机。",
|
||||
"unknown": "发生未知错误。({{error}})"
|
||||
"notFound": "未检测到可用摄像头。",
|
||||
"unknown": "发生未知错误。({{error}})"
|
||||
}
|
||||
},
|
||||
"spool": {
|
||||
"spool": "料盘",
|
||||
"spool": "耗材卷",
|
||||
"fields": {
|
||||
"id": "编号",
|
||||
"id": "编号 (ID)",
|
||||
"filament_name": "耗材",
|
||||
"filament": "耗材",
|
||||
"material": "材料",
|
||||
"filament": "耗材规格",
|
||||
"material": "材质",
|
||||
"weight_to_use": "重量",
|
||||
"used_weight": "已用重量",
|
||||
"remaining_weight": "剩余重量",
|
||||
"measured_weight": "净重",
|
||||
"used_length": "已用长度",
|
||||
"remaining_length": "剩余长度",
|
||||
"location": "位置",
|
||||
"location": "存放位置",
|
||||
"lot_nr": "批号",
|
||||
"first_used": "首次使用",
|
||||
"last_used": "最后使用",
|
||||
"registered": "登记时间",
|
||||
"registered": "录入时间",
|
||||
"comment": "备注",
|
||||
"archived": "存档",
|
||||
"price": "价格",
|
||||
"archived": "已归档",
|
||||
"price": "采购价格",
|
||||
"spool_weight": "空料盘重量",
|
||||
"initial_weight": "初始重量",
|
||||
"filament_internal": "内部",
|
||||
@@ -224,20 +224,20 @@
|
||||
}
|
||||
},
|
||||
"filament": {
|
||||
"filament": "耗材",
|
||||
"filament": "耗材规格",
|
||||
"fields": {
|
||||
"id": "编号",
|
||||
"id": "ID",
|
||||
"vendor_name": "制造商",
|
||||
"vendor": "制造商",
|
||||
"vendor": "厂商",
|
||||
"name": "名称",
|
||||
"material": "材料",
|
||||
"material": "材质",
|
||||
"price": "价格",
|
||||
"density": "密度",
|
||||
"diameter": "直径",
|
||||
"weight": "重量",
|
||||
"diameter": "线径",
|
||||
"weight": "耗材净重",
|
||||
"spool_weight": "料盘重量",
|
||||
"article_number": "产品编号",
|
||||
"registered": "登记时间",
|
||||
"registered": "录入时间",
|
||||
"comment": "备注",
|
||||
"settings_extruder_temp": "挤出头温度",
|
||||
"settings_bed_temp": "热床温度",
|
||||
@@ -245,8 +245,8 @@
|
||||
"spools": "显示料盘",
|
||||
"external_id": "外部ID",
|
||||
"multi_color": "多色",
|
||||
"longitudinal": "纵向",
|
||||
"coaxial": "共挤出",
|
||||
"longitudinal": "纵向渐变",
|
||||
"coaxial": "双色共挤",
|
||||
"single_color": "单色"
|
||||
},
|
||||
"fields_help": {
|
||||
@@ -278,9 +278,9 @@
|
||||
"vendor": {
|
||||
"vendor": "制造商",
|
||||
"fields": {
|
||||
"id": "编号",
|
||||
"name": "名称",
|
||||
"registered": "登记时间",
|
||||
"id": "ID",
|
||||
"name": "厂商名称",
|
||||
"registered": "录入时间",
|
||||
"comment": "备注",
|
||||
"external_id": "外部ID",
|
||||
"empty_spool_weight": "空料盘重量"
|
||||
@@ -340,8 +340,8 @@
|
||||
"kofi": "在Ko-fi上给我打赏一点小费",
|
||||
"home": {
|
||||
"home": "主页",
|
||||
"welcome": "",
|
||||
"description": ""
|
||||
"welcome": "欢迎使用您的 Spoolman 实例!",
|
||||
"description": "看来您尚未添加任何耗材。请参阅 <helpPageLink> 帮助页面 </helpPageLink> ,了解入门指南。"
|
||||
},
|
||||
"help": {
|
||||
"help": "帮助",
|
||||
@@ -367,7 +367,7 @@
|
||||
"choice": "选项"
|
||||
},
|
||||
"delete_confirm_description": "这将删除该字段以及所有实体的关联数据。",
|
||||
"description": "<p>这里你可以为你的实体添加额外的自定义字段。</p><p>一旦添加了字段,就无法更改其键或类型;对于选择类型字段,无法删除选项或更改多选状态。如果删除字段,则所有实体的相关数据将被删除。</p><p>键是其他程序读取/写入数据的标识,因此,如果你的自定义字段需要与第三方程序集成,请确保设置正确。默认值仅适用于新项目。</p><p>额外字段在表视图中无法进行排序或过滤。</p>",
|
||||
"description": "<p>您可以在此处为不同的实体对象添加自定义扩展字段。</p><p>字段一旦添加,其键名 (Key) 和数据类型将无法修改;对于单选/多选字段,已有的选项将无法删除,也无法更改其多选状态。如果您删除某个字段,所有对应实体中存储的该字段数据都将被永久抹除。</p><p>键名 (Key) 是第三方程序读取/写入数据时使用的唯一标识。如果您需要将自定义字段与第三方程序集成,请务必设置正确的键名。默认值仅应用于新建的项目。</p><p>扩展字段可以显示在表格视图中并支持排序。单选框与布尔值字段还提供相应的筛选选项,且所有扩展字段类型均支持筛选空值。</p>",
|
||||
"params": {
|
||||
"name": "名称",
|
||||
"field_type": "类型",
|
||||
@@ -383,13 +383,14 @@
|
||||
"choices_missing_error": "您无法删除选择。以下选项丢失:{{choices}}",
|
||||
"non_unique_key_error": "键必须是唯一的。",
|
||||
"key_not_changed": "请将键改成其他的。",
|
||||
"delete_confirm": "删除字段 {{name}}?"
|
||||
"delete_confirm": "删除字段 {{name}}?",
|
||||
"choices_reorder_hint": "拖动或使用方向键来重新排列此选项的顺序。"
|
||||
},
|
||||
"header": "设置",
|
||||
"header": "系统设置",
|
||||
"general": {
|
||||
"tab": "常规",
|
||||
"tab": "常规设置",
|
||||
"currency": {
|
||||
"label": "货币"
|
||||
"label": "货币单位"
|
||||
},
|
||||
"base_url": {
|
||||
"label": "基础URL",
|
||||
@@ -406,5 +407,12 @@
|
||||
"locations": "位置",
|
||||
"new_location": "新位置",
|
||||
"no_location": "没有位置"
|
||||
},
|
||||
"sponsor": "在 GitHub 上赞助项目",
|
||||
"theme": {
|
||||
"label": "主题",
|
||||
"system": "系统",
|
||||
"light": "明亮",
|
||||
"dark": "暗黑"
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,15 @@ import { useTranslate } from "@refinedev/core";
|
||||
import { Button } from "antd";
|
||||
import { Footer } from "antd/es/layout/layout";
|
||||
import Logo from "../icon.svg?react";
|
||||
import { getBasePath } from "../utils/url";
|
||||
import { Header } from "./header";
|
||||
import { Version } from "./version";
|
||||
|
||||
const SponsorHeart = () => (
|
||||
<svg viewBox="0 0 16 16" width="1.2em" height="1.2em" fill="#db61a2" aria-hidden="true">
|
||||
<path d="m8 14.25.345.666a.75.75 0 0 1-.69 0l-.008-.004-.018-.01a7.152 7.152 0 0 1-.31-.17 22.055 22.055 0 0 1-3.434-2.414C2.045 10.731 0 8.35 0 5.5 0 2.836 2.086 1 4.25 1 5.797 1 7.153 1.802 8 3.02 8.847 1.802 10.203 1 11.75 1 13.914 1 16 2.836 16 5.5c0 2.85-2.045 5.231-3.885 6.818a22.066 22.066 0 0 1-3.744 2.584l-.018.01-.006.003h-.002Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SpoolmanFooter = () => {
|
||||
const t = useTranslate();
|
||||
|
||||
@@ -26,19 +31,13 @@ const SpoolmanFooter = () => {
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
icon={
|
||||
<img
|
||||
src={getBasePath() + "/kofi_s_logo_nolabel.png"}
|
||||
style={{
|
||||
height: "1.6em",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
icon={<SponsorHeart />}
|
||||
type="text"
|
||||
href="https://ko-fi.com/donkie"
|
||||
href="https://github.com/sponsors/Donkie"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("kofi")}
|
||||
{t("sponsor")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 110,
|
||||
"endOfLine": "auto",
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
@@ -31,10 +31,18 @@
|
||||
"add.pickColor": "Vyberte barvu",
|
||||
"add.searchPlaceholder": "Prohledejte svůj katalog a {{name}}...",
|
||||
"add.searching": "Hledám...",
|
||||
"add.section.filament": "Filament",
|
||||
"add.section.filamentNote": "Výrobek na cívce – jedna položka na každou barvu.",
|
||||
"add.section.manufacturer": "Výrobce",
|
||||
"add.section.manufacturerNote": "Subjekt, který filament vyrobil. Uvedeno jednou, poté se tento údaj používá pro všechny jejich filamenty.",
|
||||
"add.section.spool": "Cívka",
|
||||
"add.section.spoolNote": "Fyzické cívky, které se přidávají do vašeho inventáře.",
|
||||
"add.section.spoolSharedNote": "Jako výchozí hodnoty nového filamentu se uloží také hmotnost, hmotnost cívky a cena.",
|
||||
"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.step2New": "Krok 2 · Údaje o filamentu a cívce",
|
||||
"add.summary_few": "{{count}} cívky",
|
||||
"add.summary_one": "{{count}} cívka",
|
||||
"add.summary_other": "{{count}} cívek",
|
||||
@@ -44,7 +52,9 @@
|
||||
"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.weightPresets": "Běžné rozměry rolí",
|
||||
"add.yourCatalog": "Váš katalog",
|
||||
"buttons.apply": "Použít",
|
||||
"buttons.archive": "Archivovat",
|
||||
"buttons.cancel": "Zrušit",
|
||||
"buttons.clear": "Vymazat",
|
||||
@@ -54,6 +64,7 @@
|
||||
"buttons.filter": "Filtr",
|
||||
"buttons.save": "Uložit",
|
||||
"buttons.showArchived": "Zobrazit archivované",
|
||||
"buttons.showNoSpools": "Zobrazit filamenty bez cívek",
|
||||
"buttons.unArchive": "Zrušit archivaci",
|
||||
"changeFilament.action": "Výměna",
|
||||
"changeFilament.adoptWeight": "Nastavte celkovou hmotnost této cívky na {{full}}",
|
||||
@@ -65,10 +76,12 @@
|
||||
"changeFilament.failed": "Nepodařilo se vyměnit filament.",
|
||||
"changeFilament.importNote": "Tento filament bude přidán do vašeho katalogu, jakmile změnu potvrdíte.",
|
||||
"changeFilament.intro": "Cívka č. {{id}} si zachovává své číslo, umístění, data a historii použití – mění se pouze filament, který obsahuje.",
|
||||
"changeFilament.newTag": "nový",
|
||||
"changeFilament.next": "Změnit na",
|
||||
"changeFilament.now": "V současné době",
|
||||
"changeFilament.pickBelow": "Vyberte si z níže uvedených filamentů",
|
||||
"changeFilament.title": "Vyměnit filament",
|
||||
"changeFilament.useExisting": "Vyberte existující filament",
|
||||
"changeFilament.weightFollows": "Tento filamentnemá žádnou vlastní plnou hmotnost, takže přebírá plnou hmotnost nového filamentu: {{full}}, přičemž po odečtení již spotřebovaného množství {{used}} zbývá {{remaining}}.",
|
||||
"changeVendor.action": "Výměna",
|
||||
"changeVendor.apply": "Změnit výrobce",
|
||||
@@ -123,7 +136,7 @@
|
||||
"datetime.prevMonth": "Předchozí měsíc",
|
||||
"datetime.time": "Čas",
|
||||
"documentTitle.dashboard.list": "Ovládací panel | Spoolman",
|
||||
"documentTitle.labels.list": "Štítky | Spoolman",
|
||||
"documentTitle.labels.list": "Etikety | Spoolman",
|
||||
"documentTitle.library.list": "Knihovna | Spoolman",
|
||||
"documentTitle.locations.list": "Umístění | Spoolman",
|
||||
"documentTitle.settings.list": "Nastavení | Spoolman",
|
||||
@@ -141,6 +154,7 @@
|
||||
"filament.fields.material": "Materiál",
|
||||
"filament.fields.multiColor": "Více",
|
||||
"filament.fields.name": "Název",
|
||||
"filament.fields.opacity": "Průhlednost",
|
||||
"filament.fields.price": "Cena",
|
||||
"filament.fields.registered": "Registrováno",
|
||||
"filament.fields.removeColor": "Odstranit barvu",
|
||||
@@ -202,7 +216,7 @@
|
||||
"inspector.measuredHelp": "Celková hmotnost cívky + zbývajícího filamentu, jak ji ukazuje váha.",
|
||||
"inspector.multi": "multi ×{{count}}",
|
||||
"inspector.newGross": "Nová celková hmotnost:",
|
||||
"inspector.noExtraFields": "",
|
||||
"inspector.noExtraFields": "Zatím žádná — přidejte si vlastní pole, abyste mohli sledovat vše, co Spoolman nesleduje.",
|
||||
"inspector.notFound": "Tuto položku se nepodařilo najít. Možná byla smazána.",
|
||||
"inspector.ofRemaining": "zbývá {{weight}} · {{length}} m",
|
||||
"inspector.openFilament": "Otevřený filament",
|
||||
@@ -236,17 +250,20 @@
|
||||
"labels.chooseField": "Vyberte pole...",
|
||||
"labels.clearFill": "Průhledné vyplnění (pouze obrys)",
|
||||
"labels.copies": "Kopie",
|
||||
"labels.cornerRadiusMm": "",
|
||||
"labels.createFirst": "",
|
||||
"labels.cornerRadiusMm": "Zaoblení rohu",
|
||||
"labels.createFirst": "Vytvořte svůj první šítek",
|
||||
"labels.cutGuides": "Řezací vodítka",
|
||||
"labels.deleteConfirm": "Chcete tento návrh štítku smazat?",
|
||||
"labels.deleteConfirm": "Chcete tento návrh etikety smazat?",
|
||||
"labels.deleteElement": "Odstranit prvek",
|
||||
"labels.designName": "Název návrhu",
|
||||
"labels.designerDesc": "",
|
||||
"labels.designerDesc": "Navrhujte etikety s QR kódy s libovolnými poli pro cívky, filamenty nebo dodavatele a poté je vytiskněte na archy nebo na tiskárně etiket. Návrhy se ukládají na server.",
|
||||
"labels.designerTitle": "Návrhář etiket",
|
||||
"labels.dpi": "Rozlišení",
|
||||
"labels.dpiHint": "",
|
||||
"labels.duplicate": "Duplikát",
|
||||
"labels.exportFormat": "Formát souboru",
|
||||
"labels.exportFormatOptions.aml": "Formát souboru etiket AML",
|
||||
"labels.exportFormatOptions.png": "Obrázek PNG",
|
||||
"labels.fields.bedTemp": "Teplota podložky",
|
||||
"labels.fields.diameterMm": "Průměr (mm)",
|
||||
"labels.fields.groupVendor": "Dodavatel",
|
||||
@@ -260,7 +277,7 @@
|
||||
"labels.fitWidth": "Přizpůsobit šířku sloupcům",
|
||||
"labels.fitWidthHint": "Nastavte šířku popisků tak, aby zvolený počet sloupců vyplnil celou stránku.",
|
||||
"labels.fontMm": "Písmo",
|
||||
"labels.gridInfo": "{{cols}} × {{rows}} = {{perPage}} štítků / stránka",
|
||||
"labels.gridInfo": "{{cols}} × {{rows}} = {{perPage}} etiket / stránka",
|
||||
"labels.hMm": "H",
|
||||
"labels.heightMm": "Výška",
|
||||
"labels.hintNowrap": "Text zůstává na jednom řádku; vše, co přesahuje šířku, se ořízne.",
|
||||
@@ -268,16 +285,16 @@
|
||||
"labels.hintWrap": "",
|
||||
"labels.imageDesc": "",
|
||||
"labels.imageZipNote": "",
|
||||
"labels.insertField": "",
|
||||
"labels.insertField": "Přidat pole",
|
||||
"labels.kind.qr": "QR kód",
|
||||
"labels.kind.rect": "Obdelník",
|
||||
"labels.kind.swatch": "Vzorek",
|
||||
"labels.kind.text": "Text",
|
||||
"labels.labelSize": "Velikost štítku",
|
||||
"labels.labelType": "Typ štítku",
|
||||
"labels.labelSize": "Velikost etikety",
|
||||
"labels.labelType": "Typ etikety",
|
||||
"labels.layout": "Rozvržení",
|
||||
"labels.modeImage": "Obrázkové soubory",
|
||||
"labels.modeLabel": "Tiskárna štítků",
|
||||
"labels.modeLabel": "Tiskárna etiket",
|
||||
"labels.modeSheet": "",
|
||||
"labels.new": "Nový",
|
||||
"labels.newText": "Nový text",
|
||||
@@ -289,10 +306,11 @@
|
||||
"labels.preview": "Náhled",
|
||||
"labels.previewWith": "Náhled pomocí",
|
||||
"labels.printN_few": "Vytisknout {{count}} štítky",
|
||||
"labels.printN_one": "Vytisknout {{count}} štítek",
|
||||
"labels.printN_one": "Vytisknout {{count}} etiket",
|
||||
"labels.printN_other": "Vytisknout {{count}} štítků",
|
||||
"labels.radiusMm": "Poloměr",
|
||||
"labels.sampleData": "Ukázková data",
|
||||
"labels.saveAsFormat": "Uložit jako {{format}}",
|
||||
"labels.saved": "Uloženo",
|
||||
"labels.saving": "Ukládání...",
|
||||
"labels.searchFilaments": "Vyhledávám filamenty...",
|
||||
@@ -309,11 +327,11 @@
|
||||
"labels.showingReal": "",
|
||||
"labels.showingSpool": "",
|
||||
"labels.showingTags": "",
|
||||
"labels.sizeMm": "",
|
||||
"labels.sizeMm": "Velikost",
|
||||
"labels.skipHint": "",
|
||||
"labels.sortHint": "Seřadit cívky",
|
||||
"labels.sortId": "#",
|
||||
"labels.sortNewest": "",
|
||||
"labels.sortNewest": "Nejnovější",
|
||||
"labels.stroke": "",
|
||||
"labels.strokeMm": "",
|
||||
"labels.tabDesign": "",
|
||||
@@ -330,18 +348,23 @@
|
||||
"labels.yMm": "Y",
|
||||
"library.apiError": "",
|
||||
"library.collapseGroup": "",
|
||||
"library.dateFilter.never": "Nikdy",
|
||||
"library.dateFilter.olderThan": "Starší než {{span}}",
|
||||
"library.dateFilter.since": "Od {{date}}",
|
||||
"library.dateFilter.to": "Do (volitelně)",
|
||||
"library.emptyBody": "",
|
||||
"library.emptyFiltered": "",
|
||||
"library.emptyTitle": "",
|
||||
"library.expandGroup": "",
|
||||
"library.filterBy": "",
|
||||
"library.groupBy": "",
|
||||
"library.emptyTitle": "Zatím žádné cívky",
|
||||
"library.expandGroup": "Zobrazit tyto cívky",
|
||||
"library.filterBy": "Filtrovat podle",
|
||||
"library.groupBy": "Skupina",
|
||||
"library.groupNone": "",
|
||||
"library.groupSpools_few": "{{count}} cívky",
|
||||
"library.groupSpools_one": "{{count}} cívka",
|
||||
"library.groupSpools_other": "{{count}} cívek",
|
||||
"library.nUnused": "",
|
||||
"library.noLocation": "bez uvedení místa",
|
||||
"library.noSpools": "Žádné cívky",
|
||||
"library.noValues": "Žádné hodnoty",
|
||||
"library.openGroup": "",
|
||||
"library.openManufacturer": "",
|
||||
@@ -367,7 +390,7 @@
|
||||
"locations.newLocation": "Nové umístění",
|
||||
"locations.noLocation": "Žádné umístění",
|
||||
"locations.noLocationsHelp": "Na této stránce můžete uspořádat své cívky podle umístění, přidejte několik cívek a začněte!",
|
||||
"nav.labels": "Štítky",
|
||||
"nav.labels": "Etikety",
|
||||
"nav.library": "Knihovna",
|
||||
"no": "Ne",
|
||||
"notifications.error": "Chyba (kód chyby: {{statusCode}})",
|
||||
@@ -380,10 +403,10 @@
|
||||
"pagination.range": "",
|
||||
"printing.generic.columns": "Sloupce",
|
||||
"printing.generic.customSize": "Vlastní",
|
||||
"printing.generic.description": "Níže uvedeným nastavením dosáhnete požadovaného rozložení tisku. Mějte na paměti, že tiskárny a váš operační systém mohou použít vlastní okraje a měřítka, takže možná budete muset provést několik pokusů a omylů, než bude vše správně nastaveno. Před tiskem na skutečné štítky si to vyzkoušejte na kousku běžného papíru.",
|
||||
"printing.generic.description": "Níže uvedeným nastavením dosáhnete požadovaného rozložení tisku. Mějte na paměti, že tiskárny a váš operační systém mohou použít vlastní okraje a měřítka, takže možná budete muset provést několik pokusů a omylů, než bude vše správně nastaveno. Před tiskem na skutečné etikety si to vyzkoušejte na listu běžného papíru.",
|
||||
"printing.generic.dimensions": "Rozměry",
|
||||
"printing.generic.gridTooWide": "",
|
||||
"printing.generic.helpMargin": "Okraje by měly být nastaveny tak, aby odpovídaly papíru na štítky a tiskárně, jejich změna ovlivní velikost celé mřížky.",
|
||||
"printing.generic.helpMargin": "Okraje by měly být nastaveny tak, aby odpovídaly listu etiket a tiskárně, jejich změna ovlivní velikost celé mřížky.",
|
||||
"printing.generic.helpPrinterMargin": "Bezpečná zóna by měla být nastavena tak, jak blízko okraje papíru může tiskárna tisknout, jejich změna neovlivní celou mřížku.",
|
||||
"printing.generic.horizontalSpacing": "Vodorovná rozteč",
|
||||
"printing.generic.itemCopies": "Kopie položky",
|
||||
@@ -399,7 +422,7 @@
|
||||
"printing.generic.saveAsImage": "Uložit jako obrázek",
|
||||
"printing.generic.skipItems": "Vynechat položky",
|
||||
"printing.generic.verticalSpacing": "Svislá rozteč",
|
||||
"printing.qrcode.button": "Tisk štítků",
|
||||
"printing.qrcode.button": "Tisk etiket",
|
||||
"printing.qrcode.customUrl.caveat": "",
|
||||
"printing.qrcode.customUrl.hint": "",
|
||||
"printing.qrcode.customUrl.label": "",
|
||||
@@ -421,14 +444,15 @@
|
||||
"scanner.error.unknown": "Došlo k neznámé chybě. ({{error}})",
|
||||
"scanner.starting": "",
|
||||
"scanner.title": "Skener QR kódu",
|
||||
"search.colorHint": "",
|
||||
"search.noResults": "",
|
||||
"search.searching": "",
|
||||
"search.colorHint": "Názvy barev je třeba zadávat v angličtině.",
|
||||
"search.moreSpools": "+{{count}} více",
|
||||
"search.noResults": "Nebyly nalezeny žádné výsledky",
|
||||
"search.searching": "Vyhledávám...",
|
||||
"search.section.filaments": "Filamenty",
|
||||
"search.section.spools": "Cívky",
|
||||
"search.section.vendors": "Výrobci",
|
||||
"search.threshold": "",
|
||||
"search.unknownFilament": "",
|
||||
"search.threshold": "Shoda barev",
|
||||
"search.unknownFilament": "Neznámý filament",
|
||||
"settings.appearance.tab": "",
|
||||
"settings.appearance.theme.dark": "Tmavé",
|
||||
"settings.appearance.theme.desc": "Barevné téma (pouze v tomto prohlížeči)",
|
||||
|
||||
+366
-229
@@ -50,6 +50,7 @@
|
||||
"add.vendorHint.existing": "Verwende vorhandenen Hersteller \"{{name}}\"",
|
||||
"add.vendorHint.new": "Neuer Hersteller \"{{name}}\" wird erstellt",
|
||||
"add.vendorHint.optional": "Optional — leer lassen für keinen Hersteller",
|
||||
"add.weightPresets": "häufige Spulengrößen",
|
||||
"add.yourCatalog": "Dein Katalog",
|
||||
"buttons.apply": "Zustimmen",
|
||||
"buttons.archive": "Archiv",
|
||||
@@ -61,6 +62,7 @@
|
||||
"buttons.filter": "Filter",
|
||||
"buttons.save": "Speichern",
|
||||
"buttons.showArchived": "Archivierte anzeigen",
|
||||
"buttons.showNoSpools": "Zeige Filamente die keine Spulen haben",
|
||||
"buttons.unArchive": "Archivieren rückgängig machen",
|
||||
"changeFilament.action": "Ändern",
|
||||
"changeFilament.adoptWeight": "Setze das volle Gewicht dieser Spule auf {{full}}",
|
||||
@@ -72,10 +74,12 @@
|
||||
"changeFilament.failed": "Konnte das Filament nicht ändern.",
|
||||
"changeFilament.importNote": "Wenn du der Änderung zustimmst, wird dieses Filament deinem Katalog hinzugefügt.",
|
||||
"changeFilament.intro": "Spule #{{id}} behält ihre Nummer, Standort, Daten und Verbrauchshistorie — nur das Filament, das es hält, verändert sich.",
|
||||
"changeFilament.newTag": "neu",
|
||||
"changeFilament.next": "Ändere zu",
|
||||
"changeFilament.now": "Momentan",
|
||||
"changeFilament.pickBelow": "Wähle ein Filament von unten",
|
||||
"changeFilament.title": "Ändere Filament",
|
||||
"changeFilament.useExisting": "Wähle ein bestehendes Filament",
|
||||
"changeFilament.weightFollows": "Diese Spule hat selbst kein volles Gewicht, deshalb wird die des Filaments verwendet: {{full}}, damit bleiben {{remaining}} nachdem {{used}} verwendet wurden.",
|
||||
"changeVendor.action": "Ändern",
|
||||
"changeVendor.apply": "Ändere Hersteller",
|
||||
@@ -96,6 +100,8 @@
|
||||
"changeVendor.now": "Momentan",
|
||||
"changeVendor.pickBelow": "Wähle einen Hersteller von unten",
|
||||
"changeVendor.searchPlaceholder": "Suche oder nenne einen Hersteller",
|
||||
"changeVendor.tareFollows": "Dieses Filament hat kein eigenes Leergewicht der Spule, also das seines Herstellers übernommen. Nach der Änderung beträgt es {{value}}, also das Gewicht einer leeren Spule, wenn man eine wiegt.",
|
||||
"changeVendor.tareGone": "Dieses Filament hat kein eigenes Spulenleergewicht und übernimmt das des Herstellers {{current}}. Nach der Änderung gibt es keins zu übernehmen, bis du dem Filament eines gibst.",
|
||||
"changeVendor.title": "Ändere Hersteller",
|
||||
"changeVendor.willBeCreated": "Wird erzeugt",
|
||||
"common.decrement": "Verringern",
|
||||
@@ -145,22 +151,24 @@
|
||||
"filament.fields.material": "Material",
|
||||
"filament.fields.multiColor": "Mehrfarbig",
|
||||
"filament.fields.name": "Name",
|
||||
"filament.fields.opacity": "Deckkraft",
|
||||
"filament.fields.price": "Preis",
|
||||
"filament.fields.registered": "Registriert",
|
||||
"filament.fields.removeColor": "Farbe entfernen",
|
||||
"filament.fields.settingsBedTemp": "Betttemperatur",
|
||||
"filament.fields.settingsExtruderTemp": "Düsentemperatur",
|
||||
"filament.fields.settingsExtruderTemp": "Extruder Temperatur",
|
||||
"filament.fields.singleColor": "Einfarbig",
|
||||
"filament.fields.spoolWeight": "Gewicht Spule",
|
||||
"filament.fields.vendor": "Hersteller",
|
||||
"filament.fields.weight": "Gewicht",
|
||||
"filament.fieldsHelp.articleNumber": "z.B. EAN, UPC o.Ä.",
|
||||
"filament.fieldsHelp.multiColorDirection": "Filamente können auf zwei Arten mehrere Farben haben: entweder durch Koextrusion, wie bei zweifarbigen Filamenten mit konsistenten Mehrfarben, oder durch Farbveränderungen in Längsrichtung, wie bei Gradient-Filamenten, welche die Farben entlang der Spule verschieben.",
|
||||
"filament.fieldsHelp.spoolWeight": "Das Gewicht der leeren Spule. Wird verwendet, um das gemessene Gewicht der Spule zu berechnen.",
|
||||
"filament.fieldsHelp.spoolWeight": "Das Gewicht einer leeren Spule. Wird verwendet, um das gemessene Gewicht einer Spule zu bestimmen. Nicht sicher? Pappspulen wiegen normalerweise 130–150 g und Kunststoffspulen 180–250 g – für eine genaue Zahl einfach die Spule auf die Waage legen, sobald sie leer ist.",
|
||||
"filament.fieldsHelp.weight": "Das Gewicht einer vollen Spule (Nettogewicht). Sollte nicht das Gewicht der Spule, sondern nur dass des Filaments auf der Spule enthalten. Entspricht normalerweise dem Gewicht, das auf der Spule aufgedruckt ist.",
|
||||
"filament.filament": "Filamente",
|
||||
"footer.documentation": "Dokumentation",
|
||||
"footer.reportIssue": "Ein Problem melden",
|
||||
"footer.updateAvailable": "v{{version}} verfügbar",
|
||||
"help.help": "Hilfe",
|
||||
"inspector.addSpoolsOfThis": "Diese Spulen hinzufügen",
|
||||
"inspector.adjustFailed": "Anpassung des Gewichts fehlgeschlagen.",
|
||||
@@ -170,199 +178,250 @@
|
||||
"inspector.applying": "Zustimmen...",
|
||||
"inspector.colorHex": "Farbe hex",
|
||||
"inspector.consumeAmount": "Verbrauchsmenge:",
|
||||
"inspector.defaultFrom.filament": "{{value}} · Standard des Filaments",
|
||||
"inspector.defaultFrom.manufacturer": "{{value}} · Standard des Herstellers",
|
||||
"inspector.delete.deleting": "Löschen...",
|
||||
"inspector.delete.errorGone": "Es wurde bereits gelöscht.",
|
||||
"inspector.delete.errorSpoolsExist": "Kann das Filament nicht löschen — Spule ist in Benutzung.",
|
||||
"inspector.delete.errorUnknown": "Löschen fehlgeschlagen. Für Details, im ServerLog schauen..",
|
||||
"inspector.delete.filament": "Filament löschen",
|
||||
"inspector.delete.filamentBlockedHint": "",
|
||||
"inspector.delete.filamentBlockedTitle": "",
|
||||
"inspector.delete.filamentBody": "",
|
||||
"inspector.delete.filamentDone": "",
|
||||
"inspector.delete.filamentTitle": "",
|
||||
"inspector.delete.spool": "",
|
||||
"inspector.delete.spoolBody": "",
|
||||
"inspector.delete.spoolDone": "",
|
||||
"inspector.delete.spoolRemaining": "",
|
||||
"inspector.delete.spoolTitle": "",
|
||||
"inspector.delete.vendor": "",
|
||||
"inspector.delete.vendorBody": "",
|
||||
"inspector.delete.vendorDone": "",
|
||||
"inspector.delete.vendorTitle": "",
|
||||
"inspector.duplicateFilament": "",
|
||||
"inspector.empty": "",
|
||||
"inspector.enterValidLength": "",
|
||||
"inspector.enterValidWeight": "",
|
||||
"inspector.loading": "",
|
||||
"inspector.manageExtraFields": "",
|
||||
"inspector.measuredHelp": "",
|
||||
"inspector.multi": "",
|
||||
"inspector.newGross": "",
|
||||
"inspector.noExtraFields": "",
|
||||
"inspector.notFound": "",
|
||||
"inspector.ofRemaining": "",
|
||||
"inspector.openFilament": "",
|
||||
"inspector.openLink": "",
|
||||
"inspector.openManufacturer": "",
|
||||
"inspector.specs": "",
|
||||
"inspector.spoolsCount": "",
|
||||
"inspector.spoolsTotalRemaining": "",
|
||||
"inspector.used": "",
|
||||
"inspector.delete.filamentBlockedHint": "Lösche diese Spulen zunächst. Archivieren ist nicht ausreichend.",
|
||||
"inspector.delete.filamentBlockedTitle": "Dieses Filament kann nicht gelöscht werden",
|
||||
"inspector.delete.filamentBlocked_one": "{{count}} Spule verwendet \"{{name}}\", inklusive Archivierter.",
|
||||
"inspector.delete.filamentBlocked_other": "{{count}} Spulen verwenden \"{{name}}\", inklusive Archivierter.",
|
||||
"inspector.delete.filamentBody": "“{{name}}” wird aus Deinem Katalog entfernt. Das kann nicht rückgängig gemacht werden.",
|
||||
"inspector.delete.filamentDone": "Filament gelöscht.",
|
||||
"inspector.delete.filamentTitle": "Dieses Filament löschen?",
|
||||
"inspector.delete.spool": "Spule löschen",
|
||||
"inspector.delete.spoolBody": "Spule #{{id}} von “{{name}}” wird gelöscht, gemeinsam mit ihrer Nutzungshistorie. Das kann nicht rückgängig gemacht werden.",
|
||||
"inspector.delete.spoolDone": "Spule gelöscht.",
|
||||
"inspector.delete.spoolRemaining": "Die Spule hat {{weight}} Filament übrig. Archiviere sie stattdessen, falls Du sie nur aus dem Weg haben möchtest.",
|
||||
"inspector.delete.spoolTitle": "Diese Spule löschen?",
|
||||
"inspector.delete.vendor": "Hersteller löschen",
|
||||
"inspector.delete.vendorBody": "\"{{name}}\" wird gelöscht. Das kann nicht rückgängig gemacht werden.",
|
||||
"inspector.delete.vendorDone": "Hersteller gelöscht.",
|
||||
"inspector.delete.vendorOrphans_one": "Das {{count}} Filament wird behalten, aber es bleibt ohne Hersteller.",
|
||||
"inspector.delete.vendorOrphans_other": "Die {{count}} Filamente werden behalten, aber sie bleiben ohne Hersteller.",
|
||||
"inspector.delete.vendorTitle": "Diesen Hersteller löschen?",
|
||||
"inspector.duplicateFilament": "Duplizieren",
|
||||
"inspector.empty": "Wähle eine Spule, ein Filament oder einen Hersteller aus der Liste, um Details dazu hier anzuzeigen.",
|
||||
"inspector.enterValidLength": "Gib eine valide Länge ein.",
|
||||
"inspector.enterValidWeight": "Gib ein valides Gewicht ein.",
|
||||
"inspector.loading": "Lade...",
|
||||
"inspector.manageExtraFields": "Felder bearbeiten",
|
||||
"inspector.measuredHelp": "Gesamtgewicht der Spule + übriges Filament, wie auf der Waage gemessen.",
|
||||
"inspector.multi": "mehrfach x{{count}}",
|
||||
"inspector.newGross": "Neues Bruttogewicht:",
|
||||
"inspector.noExtraFields": "Noch keine — füge deine eigenen Felder hinzu, um alles zu verfolgen, was Spoolman nicht tut.",
|
||||
"inspector.notFound": "Dieses Objekt konnte nicht gefunden werden. Es könnte gelöscht worden sein.",
|
||||
"inspector.ofRemaining": "von {{weight}} übrig · {{length}} m",
|
||||
"inspector.openFilament": "Filament öffnen",
|
||||
"inspector.openLink": "{{url}} in einem neuen Tab öffnen",
|
||||
"inspector.openManufacturer": "Hersteller öffnen",
|
||||
"inspector.override.byFilament": "Überschrieben durch dieses Filament · {{value}}",
|
||||
"inspector.override.bySpool": "Überschrieben durch diese Spule · {{value}}",
|
||||
"inspector.override.overridesFilament": "Überschreibt Filament · {{value}}",
|
||||
"inspector.override.overridesManufacturer": "Überschreibt Hersteller · {{value}}",
|
||||
"inspector.specs": "Spezifikationen — Direktbearbeitung",
|
||||
"inspector.spoolsCount": "Spulen · {{count}}",
|
||||
"inspector.spoolsTotalRemaining": "Gesamt {{weight}}",
|
||||
"inspector.used": "verwendet",
|
||||
"inspector.vendorSub_one": "{{count}} Filament",
|
||||
"inspector.vendorSub_other": "{{count}} Filamente",
|
||||
"kofi": "Schick mir ein Trinkgeld über Ko-fi",
|
||||
"labels.addHead": "",
|
||||
"labels.addQr": "",
|
||||
"labels.addRect": "",
|
||||
"labels.addSwatch": "",
|
||||
"labels.addText": "",
|
||||
"labels.align": "",
|
||||
"labels.alignCenter": "",
|
||||
"labels.alignLeft": "",
|
||||
"labels.alignRight": "",
|
||||
"labels.blank": "",
|
||||
"labels.bold": "",
|
||||
"labels.canvasHint": "",
|
||||
"labels.centerLogo": "",
|
||||
"labels.chooseField": "",
|
||||
"labels.clearFill": "",
|
||||
"labels.copies": "",
|
||||
"labels.cornerRadiusMm": "",
|
||||
"labels.createFirst": "",
|
||||
"labels.cutGuides": "",
|
||||
"labels.deleteConfirm": "",
|
||||
"labels.deleteElement": "",
|
||||
"labels.designName": "",
|
||||
"labels.designerDesc": "",
|
||||
"labels.designerTitle": "",
|
||||
"labels.dpi": "",
|
||||
"labels.dpiHint": "",
|
||||
"labels.duplicate": "",
|
||||
"labels.fields.bedTemp": "",
|
||||
"labels.fields.diameterMm": "",
|
||||
"labels.fields.groupVendor": "",
|
||||
"labels.fields.initialG": "",
|
||||
"labels.fields.netWeightG": "",
|
||||
"labels.fields.remainingG": "",
|
||||
"labels.fill": "",
|
||||
"labels.fitWidth": "",
|
||||
"labels.fitWidthHint": "",
|
||||
"labels.fontMm": "",
|
||||
"labels.gridInfo": "",
|
||||
"labels.hMm": "",
|
||||
"labels.heightMm": "",
|
||||
"labels.hintNowrap": "",
|
||||
"labels.hintSwatch": "",
|
||||
"labels.hintWrap": "",
|
||||
"labels.addHead": "Hinzufügen",
|
||||
"labels.addQr": "QR Code",
|
||||
"labels.addRect": "Rechteck",
|
||||
"labels.addSwatch": "Farbmuster",
|
||||
"labels.addText": "Text / Feld",
|
||||
"labels.align": "Ausrichten",
|
||||
"labels.alignCenter": "Mittig",
|
||||
"labels.alignLeft": "Links",
|
||||
"labels.alignRight": "Rechts",
|
||||
"labels.blank": "Du hast bisher kein Label Design.",
|
||||
"labels.bold": "Fettgedruckt",
|
||||
"labels.canvasHint": "Ziehen zum Verschieben · Klicken zum Auswählen · Eckpunkte zum Ändern der Größe.",
|
||||
"labels.centerLogo": "Zentriere Spoolman Logo",
|
||||
"labels.chooseField": "Wähle ein Feld aus...",
|
||||
"labels.clearFill": "Lösche Füllung (nur Rahmen)",
|
||||
"labels.copies": "Kopien",
|
||||
"labels.cornerRadiusMm": "Eckenradius",
|
||||
"labels.createFirst": "Erstelle Dein erstes Label",
|
||||
"labels.cutGuides": "Schnittlinie",
|
||||
"labels.deleteConfirm": "Dieses Label Design löschen?",
|
||||
"labels.deleteElement": "Element löschen",
|
||||
"labels.designName": "Name des Designs",
|
||||
"labels.designerDesc": "Erstelle QR-Code Etiketten mit beliebigen Spulen-, Filament-, oder Herstellerfeldern und drucke sie auf Papier oder mit einem Etikettendrucker. Die Designs werden auf dem Server gespeichert.",
|
||||
"labels.designerTitle": "Label Designer",
|
||||
"labels.dpi": "Auflösung",
|
||||
"labels.dpiHint": "Stelle deinen Drucker ein, um kleine Etiketten scharf zu halten: 203 dpi für die meisten Thermodrucker, 300 oder mehr für Laser- und Tintenstrahldrucker.",
|
||||
"labels.duplicate": "Duplizieren",
|
||||
"labels.exportDescAml": "Speichert für jedes ausgewählte Etikett eine .aml-Datei, jeweils {{w}}×{{h}} mm. Kann in den LPAPI-basierten Apps geöffnet werden, die mit den meisten Thermodruckern geliefert werden und die physische Größe aus der Datei lesen.",
|
||||
"labels.exportDescPng": "Speichert ein PNG-Bild pro ausgewähltem Label, jeweils {{w}}×{{h}} mm.",
|
||||
"labels.exportFormat": "Dateiformat",
|
||||
"labels.exportFormatOptions.aml": "AML Label Datei",
|
||||
"labels.exportFormatOptions.png": "PNG Bild",
|
||||
"labels.fields.bedTemp": "Druckbetttemperatur",
|
||||
"labels.fields.diameterMm": "Durchmesser (mm)",
|
||||
"labels.fields.groupVendor": "Verkäufer",
|
||||
"labels.fields.initialG": "Initial Gewicht (g)",
|
||||
"labels.fields.netWeightG": "Nettogewicht (g)",
|
||||
"labels.fields.remainingG": "Verbleibend (g)",
|
||||
"labels.filamentsSelected_one": "{{count}} Filament ausgewählt",
|
||||
"labels.filamentsSelected_other": "{{count}} Filamente ausgewählt",
|
||||
"labels.fill": "Füllen",
|
||||
"labels.fitWidth": "Breite an Spalten anpassen",
|
||||
"labels.fitWidthHint": "Setzt die Breite der Etiketten so, dass die ausgewählte Anzahl an Spalten die Seite ausfüllt.",
|
||||
"labels.fontMm": "Schriftart",
|
||||
"labels.gridInfo": "{{cols}} × {{rows}} = {{perPage}} Etiketten / Seite",
|
||||
"labels.hMm": "H",
|
||||
"labels.heightMm": "Höhe",
|
||||
"labels.hintNowrap": "Text bleibt in einer Zeile; alles, was über die Breite hinausgeht, wird abgeschnitten.",
|
||||
"labels.hintSwatch": "Wird mit den Filamentfarbe(n) der Spule zum Druckzeitpunkt gefüllt.",
|
||||
"labels.hintWrap": "Umhülle ein Feld, um es zu verbergen, wenn es leer ist:",
|
||||
"labels.imageDesc": "",
|
||||
"labels.imageZipNote": "",
|
||||
"labels.insertField": "",
|
||||
"labels.kind.qr": "",
|
||||
"labels.kind.rect": "",
|
||||
"labels.kind.swatch": "",
|
||||
"labels.kind.text": "",
|
||||
"labels.labelSize": "",
|
||||
"labels.labelType": "",
|
||||
"labels.layout": "",
|
||||
"labels.modeImage": "",
|
||||
"labels.modeLabel": "",
|
||||
"labels.modeSheet": "",
|
||||
"labels.new": "",
|
||||
"labels.newText": "",
|
||||
"labels.noDesignsOption": "",
|
||||
"labels.noFilaments": "",
|
||||
"labels.noSpools": "",
|
||||
"labels.onePerLabel": "",
|
||||
"labels.preparing": "",
|
||||
"labels.preview": "",
|
||||
"labels.previewWith": "",
|
||||
"labels.radiusMm": "",
|
||||
"labels.sampleData": "",
|
||||
"labels.saved": "",
|
||||
"labels.saving": "",
|
||||
"labels.searchFilaments": "",
|
||||
"labels.searchSpools": "",
|
||||
"labels.selectAllShort": "",
|
||||
"labels.selectAtLeastOne": "",
|
||||
"labels.selectAtLeastOneFilament": "",
|
||||
"labels.selectDesign": "",
|
||||
"labels.selectElement": "",
|
||||
"labels.selectNoneShort": "",
|
||||
"labels.selectRecentHint": "",
|
||||
"labels.selectRecentShort": "",
|
||||
"labels.showingFilament": "",
|
||||
"labels.showingReal": "",
|
||||
"labels.showingSpool": "",
|
||||
"labels.showingTags": "",
|
||||
"labels.sizeMm": "",
|
||||
"labels.skipHint": "",
|
||||
"labels.sortHint": "",
|
||||
"labels.sortId": "",
|
||||
"labels.sortNewest": "",
|
||||
"labels.stroke": "",
|
||||
"labels.strokeMm": "",
|
||||
"labels.tabDesign": "",
|
||||
"labels.tabPrint": "",
|
||||
"labels.textTemplate": "",
|
||||
"labels.typeFilament": "",
|
||||
"labels.typeHint": "",
|
||||
"labels.typeSpool": "",
|
||||
"labels.unknownFilament": "",
|
||||
"labels.wMm": "",
|
||||
"labels.widthMm": "",
|
||||
"labels.wrapText": "",
|
||||
"labels.xMm": "",
|
||||
"labels.yMm": "",
|
||||
"library.apiError": "",
|
||||
"library.collapseGroup": "",
|
||||
"library.emptyBody": "",
|
||||
"library.emptyFiltered": "",
|
||||
"library.emptyTitle": "",
|
||||
"library.expandGroup": "",
|
||||
"library.filterBy": "",
|
||||
"library.groupBy": "",
|
||||
"library.groupNone": "",
|
||||
"library.nUnused": "",
|
||||
"library.noLocation": "",
|
||||
"library.noValues": "",
|
||||
"library.openGroup": "",
|
||||
"library.openManufacturer": "",
|
||||
"library.section.extra": "",
|
||||
"library.section.filament": "",
|
||||
"library.section.spool": "",
|
||||
"library.section.vendor": "",
|
||||
"library.showMore": "",
|
||||
"library.sort.nozzle": "",
|
||||
"library.sortBy": "",
|
||||
"library.unassigned": "",
|
||||
"library.unitGroups": "",
|
||||
"library.unitSpools": "",
|
||||
"library.unused": "",
|
||||
"library.unusedGroup": "",
|
||||
"library.unusedGroupMixed": "",
|
||||
"library.unusedSub": "",
|
||||
"library.unusedSubMixed": "",
|
||||
"library.used": "",
|
||||
"library.usedAgo": "",
|
||||
"labels.imageZipNote": "Mehr als {{threshold}} Etiketten werden als eine einzige zip Datei geliefert.",
|
||||
"labels.insertField": "Feld hinzufügen",
|
||||
"labels.kind.qr": "QR Code",
|
||||
"labels.kind.rect": "Rechteck",
|
||||
"labels.kind.swatch": "Farbmuster",
|
||||
"labels.kind.text": "Text",
|
||||
"labels.labelSize": "Größe des Etiketts",
|
||||
"labels.labelType": "Art des Etiketts",
|
||||
"labels.layout": "Layout",
|
||||
"labels.modeImage": "Dateien",
|
||||
"labels.modeLabel": "Etikettendrucker",
|
||||
"labels.modeSheet": "Blatt (Kachel)",
|
||||
"labels.new": "Neu",
|
||||
"labels.newText": "Neuer Text",
|
||||
"labels.noDesignsOption": "Noch keine Designs",
|
||||
"labels.noFilaments": "Keine Filamente.",
|
||||
"labels.noSpools": "Keine Spulen.",
|
||||
"labels.onePerLabel": "Eine {{w}}×{{h}} mm Seite pro Etikett.",
|
||||
"labels.preparing": "Bereite vor...",
|
||||
"labels.preview": "Vorschau",
|
||||
"labels.previewWith": "Vorschau mit",
|
||||
"labels.printN_one": "{{count}} Etikett drucken",
|
||||
"labels.printN_other": "{{count}} Etiketten drucken",
|
||||
"labels.radiusMm": "Radius",
|
||||
"labels.sampleData": "Beispieldaten",
|
||||
"labels.saveAsFormat": "Speichern als {{format}}",
|
||||
"labels.saved": "Gespeichert",
|
||||
"labels.saving": "Speichere...",
|
||||
"labels.searchFilaments": "Filamente suchen...",
|
||||
"labels.searchSpools": "Spulen suchen...",
|
||||
"labels.selectAllShort": "Alle",
|
||||
"labels.selectAtLeastOne": "Wähle mindestens eine Spule.",
|
||||
"labels.selectAtLeastOneFilament": "Wähle mindestens ein Filament.",
|
||||
"labels.selectDesign": "Wähle Label Design",
|
||||
"labels.selectElement": "Wähle ein Element aus, um es zu bearbeiten, oder füge eines aus der Palette hinzu.",
|
||||
"labels.selectNoneShort": "Keine",
|
||||
"labels.selectRecentHint": "Wähle alle Spulen, die in den letzten 24 Stunden hinzugefügt wurden",
|
||||
"labels.selectRecentShort": "Kürzlich ({{count}})",
|
||||
"labels.showingFilament": "Zeige Filament #{{id}}",
|
||||
"labels.showingReal": "Echte Spulendaten anzeigen.",
|
||||
"labels.showingSpool": "Zeige Spule #{{id}}",
|
||||
"labels.showingTags": "Felder werden als Tags angezeigt, bis du oben eine Spule auswählst.",
|
||||
"labels.sizeMm": "Größe",
|
||||
"labels.skipHint": "Mit 'Objekt überspringen' werden so viele Zellen vor dem ersten Etikett leer gelassen, dass du ein teilweise benutztes Blatt wiederverwenden kannst: Zähle die Etiketten, die schon abgezogen wurden, von oben nach unten und von links nach rechts.",
|
||||
"labels.sortHint": "Sortiere Spulen",
|
||||
"labels.sortId": "#",
|
||||
"labels.sortNewest": "Neueste",
|
||||
"labels.stroke": "Rahmen",
|
||||
"labels.strokeMm": "Rahmen",
|
||||
"labels.tabDesign": "Design",
|
||||
"labels.tabPrint": "Drucken",
|
||||
"labels.textTemplate": "Text / Vorlage",
|
||||
"labels.typeFilament": "'Filamen",
|
||||
"labels.typeHint": "Filament-Etiketten lassen spool-only-Felder weg, und ihr QR-Code verlinkt zum Filament statt zu einer Spule.",
|
||||
"labels.typeSpool": "Spule",
|
||||
"labels.unknownFilament": "Unbekanntes Filament",
|
||||
"labels.wMm": "B",
|
||||
"labels.widthMm": "Breite",
|
||||
"labels.wrapText": "Zeilenumbruch",
|
||||
"labels.xMm": "X",
|
||||
"labels.yMm": "Y",
|
||||
"library.apiError": "Konnte die Spoolman API nicht erreichen. Läuft das Backend?",
|
||||
"library.catalog.allFilaments": "Alle Filamente",
|
||||
"library.catalog.allManufacturers": "Alle Hersteller",
|
||||
"library.catalog.backToSpools": "Zurück zu Spulen",
|
||||
"library.catalog.filamentCount_one": "{{count}} Filament",
|
||||
"library.catalog.filamentCount_other": "{{count}} Filamente",
|
||||
"library.catalog.manufacturerCount_one": "{{count}} Hersteller",
|
||||
"library.catalog.manufacturerCount_other": "{{count}} Hersteller",
|
||||
"library.catalog.noManufacturer": "kein Hersteller",
|
||||
"library.catalog.notSet": "Nicht gesetzt",
|
||||
"library.catalog.switchList": "Liste wechseln",
|
||||
"library.catalog.unitFilaments": "Filamente",
|
||||
"library.catalog.unitManufacturers": "Hersteller",
|
||||
"library.collapseGroup": "Verstecke diese Spulen",
|
||||
"library.dateFilter.ago": "vor {{span}}",
|
||||
"library.dateFilter.between": "{{from}} – {{to}}",
|
||||
"library.dateFilter.customRange": "Benutzerdefinierter Bereich",
|
||||
"library.dateFilter.from": "Von",
|
||||
"library.dateFilter.last": "Letzte {{span}}",
|
||||
"library.dateFilter.never": "Nie",
|
||||
"library.dateFilter.olderThan": "Älter als {{span}}",
|
||||
"library.dateFilter.since": "Seit {{date}}",
|
||||
"library.dateFilter.to": "Bis (optional)",
|
||||
"library.dateFilter.until": "Bis {{date}}",
|
||||
"library.emptyBody": "Füge deine erste Spule hinzu, um dein Filament zu verfolgen.",
|
||||
"library.emptyFiltered": "Keine {{unit}} passen zu den aktuellen Filtern.",
|
||||
"library.emptyTitle": "Noch keine Spulen",
|
||||
"library.expandGroup": "Zeige diese Spulen",
|
||||
"library.filterBy": "Filtern nach",
|
||||
"library.groupBy": "Gruppieren",
|
||||
"library.groupNone": "Keine (flach)",
|
||||
"library.groupSpools_one": "{{count}} Spule",
|
||||
"library.groupSpools_other": "{{count}} Spulen",
|
||||
"library.nUnused": "×{{count}} unbenutzt",
|
||||
"library.noLocation": "kein Ort",
|
||||
"library.noSpools": "Keine Spulen",
|
||||
"library.noValues": "Keine Werte",
|
||||
"library.openGroup": "Öffne {{name}}",
|
||||
"library.openManufacturer": "Öffne Hersteller {{name}}",
|
||||
"library.resizeList": "Größe der Liste ändern (doppelklicken zum Zurücksetzen)",
|
||||
"library.reverseSort": "Sortierreihenfolge umkehren",
|
||||
"library.section.extra": "Zusätzliche Felder",
|
||||
"library.section.filament": "Filament",
|
||||
"library.section.spool": "Spule",
|
||||
"library.section.vendor": "Hersteller",
|
||||
"library.showMore": "zeige {{count}} mehr",
|
||||
"library.sort.nozzle": "Düsen Temperatur",
|
||||
"library.sortBy": "Sortieren",
|
||||
"library.unassigned": "nicht zugewiesen",
|
||||
"library.unitGroups": "Gruppen",
|
||||
"library.unitSpools": "Spulen",
|
||||
"library.unused": "ungenutzt",
|
||||
"library.unusedGroup": "{{count}} unbenutzt · {{weight}} g jede",
|
||||
"library.unusedGroupMixed": "{{count}} unbenutzt",
|
||||
"library.unusedSub": "jede {{weight}} g · {{location}}",
|
||||
"library.unusedSubMixed": "{{location}}",
|
||||
"library.used": "verbraucht",
|
||||
"library.usedAgo": "vor {{time}} verwendet",
|
||||
"library.viewMenu.browse": "Durchsuchen",
|
||||
"library.views": "Bibliotheksansichten",
|
||||
"loading": "Läd",
|
||||
"locations.locations": "Orte",
|
||||
"locations.newLocation": "Neuer Ort",
|
||||
"locations.noLocation": "Kein Ort",
|
||||
"locations.noLocationsHelp": "Diese Seite lässt Sie Ihre Spulen zu Orten zuweisen, fügen Sie ein paar Spulen hinzu um loszulegen!",
|
||||
"nav.labels": "",
|
||||
"nav.library": "",
|
||||
"nav.labels": "Labels",
|
||||
"nav.library": "Bibliothek",
|
||||
"no": "Nein",
|
||||
"notifications.error": "Fehler (status code: {{statusCode}})",
|
||||
"notifications.saveSuccessful": "Erfolgreich gespeichert!",
|
||||
"pagination.empty": "",
|
||||
"pagination.next": "",
|
||||
"pagination.pageSize": "",
|
||||
"pagination.perPage": "",
|
||||
"pagination.prev": "",
|
||||
"pagination.range": "",
|
||||
"pagination.empty": "Keine {{unit}}",
|
||||
"pagination.next": "Nächste Seite",
|
||||
"pagination.pageSize": "Seitengröße",
|
||||
"pagination.perPage": "{{size}} / Seite",
|
||||
"pagination.prev": "vorherige Seite",
|
||||
"pagination.range": "{{from}}–{{to}} von {{total}} {{unit}}",
|
||||
"printing.generic.columns": "Spalten",
|
||||
"printing.generic.customSize": "Benutzerdefiniert",
|
||||
"printing.generic.description": "Passe die Einstellungen unten an, um das gewünschte Drucklayout zu bekommen. Denk daran, dass Drucker und dein OS eigene Abstände und Skalierungen anwenden könnte, also musst du vielleicht ein bisschen ausprobieren, bis alles passt. Teste es auf einem normalen Blatt Papier, bevor du auf die richtigen Labels druckst.",
|
||||
"printing.generic.dimensions": "Dimensionen",
|
||||
"printing.generic.gridTooWide": "",
|
||||
"printing.generic.gridTooWide": "Die Etiketten sind breiter als die Seite. Reduziere die Anzahl der Spalten, den Abstand oder die Ränder.",
|
||||
"printing.generic.helpMargin": "Abstände sollten so konfiguriert werden, dass sie zum Papier und Drucker passen. Änderungen beeinflussen die Abmessungen des gesamten Gitters.",
|
||||
"printing.generic.helpPrinterMargin": "Der Sicherheitsabstand sollte so eingestellt werden, wie dicht der Drucker am Rand des Papiers drucken kann. Änderungen beeinflussen nicht die Abmessungen des Gitters.",
|
||||
"printing.generic.horizontalSpacing": "Horizontaler Abstand",
|
||||
@@ -380,43 +439,49 @@
|
||||
"printing.generic.skipItems": "Objekt überspringen",
|
||||
"printing.generic.verticalSpacing": "Vertikaler Abstand",
|
||||
"printing.qrcode.button": "Beschriftung drucken",
|
||||
"printing.qrcode.customUrl.caveat": "",
|
||||
"printing.qrcode.customUrl.hint": "",
|
||||
"printing.qrcode.customUrl.label": "",
|
||||
"printing.qrcode.customUrl.caveat": "Eine benutzerdefinierte URL wird nicht zurück in Spoolman gescannt – benutze sie, um einen gedruckten QR-Code auf eine externe App oder einen anderen Host zu verweisen.",
|
||||
"printing.qrcode.customUrl.fieldsHint": "Beschriftungsfelder funktionieren zum Beispiel auch",
|
||||
"printing.qrcode.customUrl.hint": "Setze die Spulen Id irgendwo mit dem Platzhalter ein",
|
||||
"printing.qrcode.customUrl.label": "Benutzerdefinierte URL-Vorlage",
|
||||
"printing.qrcode.useHTTPUrl.label": "QR Code Link",
|
||||
"printing.qrcode.useHTTPUrl.options.custom": "",
|
||||
"printing.qrcode.useHTTPUrl.options.custom": "Benutzerdefinierte URL",
|
||||
"printing.qrcode.useHTTPUrl.options.default": "Standard",
|
||||
"printing.qrcode.useHTTPUrl.options.url": "URL",
|
||||
"printing.qrcode.useHTTPUrl.preview": "Vorschau:",
|
||||
"printing.qrcode.useHTTPUrl.tooltip": "Die Standardeinstellung verwendet einen proprietären Link, der nur funktioniert, wenn er mit der Scanfunktion von Spoolman gescannt wird. URL verwendet entweder die in den Einstellungen angegebene Basis-URL oder wenn diese nicht festgelegt ist die aktuelle Seiten-URL.",
|
||||
"printing.spoolSelect.selectedTotal_one": "{{count}} Spule ausgewählt",
|
||||
"printing.spoolSelect.selectedTotal_other": "{{count}} Spulen ausgewählt",
|
||||
"scanner.camera": "Kamera",
|
||||
"scanner.cameraNumbered": "Kamera {{number}}",
|
||||
"scanner.description": "Spoolman QR-Code scannen, um Details der Spule anzuzeigen.",
|
||||
"scanner.error.inUse": "",
|
||||
"scanner.error.inUse": "Die Kamera wird bereits von einer anderen Anwendung genutzt.",
|
||||
"scanner.error.insecureContext": "Die Seite wird nicht über HTTPS bereitgestellt.",
|
||||
"scanner.error.notAllowed": "Zugriff auf Kamera wurde nicht erlaubt.",
|
||||
"scanner.error.notFound": "Keine Kamera gefunden.",
|
||||
"scanner.error.streamApiNotSupported": "Der Browser unterstützt die MediaStream API nicht.",
|
||||
"scanner.error.unknown": "Ein unbekannter Fehler ist aufgetreten. ({{error}})",
|
||||
"scanner.starting": "",
|
||||
"scanner.starting": "Starte Kamera…",
|
||||
"scanner.title": "QR-Code Scanner",
|
||||
"search.colorHint": "",
|
||||
"search.noResults": "",
|
||||
"search.searching": "",
|
||||
"search.section.filaments": "",
|
||||
"search.section.spools": "",
|
||||
"search.section.vendors": "",
|
||||
"search.threshold": "",
|
||||
"search.unknownFilament": "",
|
||||
"settings.appearance.tab": "",
|
||||
"settings.appearance.theme.dark": "",
|
||||
"settings.appearance.theme.desc": "",
|
||||
"settings.appearance.theme.label": "",
|
||||
"settings.appearance.theme.light": "",
|
||||
"settings.appearance.theme.system": "",
|
||||
"settings.extraFields.addChoice": "",
|
||||
"settings.extraFields.addChoicesFirst": "",
|
||||
"settings.extraFields.addEntityField": "",
|
||||
"search.browseAll": "Durchsuche alle",
|
||||
"search.colorHint": "Farbnamen müssen in englisch eingegeben werden.",
|
||||
"search.hint": "Suche Spulen, Filamente und Hersteller nach Name, Farbe, Material oder nach jedem anderen Feld.",
|
||||
"search.moreSpools": "+{{count}} mehr",
|
||||
"search.noResults": "Keine Treffer gefunden",
|
||||
"search.searching": "Suchen…",
|
||||
"search.section.filaments": "Filamente",
|
||||
"search.section.spools": "Spulen",
|
||||
"search.section.vendors": "Hersteller",
|
||||
"search.threshold": "Farbtreffer",
|
||||
"search.unknownFilament": "Unbekanntes Filament",
|
||||
"settings.appearance.tab": "Darstellung",
|
||||
"settings.appearance.theme.dark": "Dunkel",
|
||||
"settings.appearance.theme.desc": "Farbschema (nur dieser Browser)",
|
||||
"settings.appearance.theme.label": "Schema",
|
||||
"settings.appearance.theme.light": "Hell",
|
||||
"settings.appearance.theme.system": "System",
|
||||
"settings.extraFields.addChoice": "Option hinzufügen…",
|
||||
"settings.extraFields.addChoicesFirst": "Füge zuerst Optionen hinzu",
|
||||
"settings.extraFields.addEntityField": "Feld {{entity}} hinzufügen",
|
||||
"settings.extraFields.booleanFalse": "Nein",
|
||||
"settings.extraFields.booleanTrue": "Ja",
|
||||
"settings.extraFields.deleteConfirm": "Feld {{name}} des Objekts {{entity}} löschen?",
|
||||
@@ -424,14 +489,14 @@
|
||||
"settings.extraFields.description.intro": "Hier können Sie zusätzliche benutzerdefinierte Felder zu Ihren Elementen hinzufügen.",
|
||||
"settings.extraFields.description.keyUsage": "Der Schlüssel ist derjenige, unter dem andere Programme die Daten lesen/schreiben, wenn Ihr benutzerdefiniertes Feld also mit einem Programm eines Drittanbieters integriert werden soll, stellen Sie sicher, dass Sie ihn richtig einstellen. Der Standardwert wird nur auf neue Elemente angewendet.",
|
||||
"settings.extraFields.description.tableViews": "Zusätzliche Felder können in den Tabellenansichten nicht sortiert oder gefiltert werden.",
|
||||
"settings.extraFields.editorEdit": "",
|
||||
"settings.extraFields.editorNew": "",
|
||||
"settings.extraFields.errors.choiceNeeded": "",
|
||||
"settings.extraFields.errors.choicesRemoved": "",
|
||||
"settings.extraFields.errors.deleteFailed": "",
|
||||
"settings.extraFields.errors.keyFormat": "",
|
||||
"settings.extraFields.errors.nameRequired": "",
|
||||
"settings.extraFields.errors.saveFailed": "",
|
||||
"settings.extraFields.editorEdit": "Feld {{entity}} bearbeiten",
|
||||
"settings.extraFields.editorNew": "Neues Feld {{entity}}",
|
||||
"settings.extraFields.errors.choiceNeeded": "Auswahlfelder brauchen mindestens eine Auswahlmöglichkeit.",
|
||||
"settings.extraFields.errors.choicesRemoved": "Bestehende Optionen können nicht entfernt werden: {{choices}}.",
|
||||
"settings.extraFields.errors.deleteFailed": "Feld konnte nicht gelöscht werden.",
|
||||
"settings.extraFields.errors.keyFormat": "Der Schlüssel darf nur Kleinbuchstaben, Zahlen und Unterstriche enthalten.",
|
||||
"settings.extraFields.errors.nameRequired": "Name wird benötigt.",
|
||||
"settings.extraFields.errors.saveFailed": "Feld konnte nicht gespeichert werden.",
|
||||
"settings.extraFields.fieldType.boolean": "Wahr/Falsch",
|
||||
"settings.extraFields.fieldType.choice": "Auswahl",
|
||||
"settings.extraFields.fieldType.datetime": "Datum und Uhrzeit",
|
||||
@@ -440,13 +505,13 @@
|
||||
"settings.extraFields.fieldType.integer": "Ganze Zahl",
|
||||
"settings.extraFields.fieldType.integerRange": "Ganzzahlbereich",
|
||||
"settings.extraFields.fieldType.text": "Text",
|
||||
"settings.extraFields.max": "",
|
||||
"settings.extraFields.min": "",
|
||||
"settings.extraFields.multiSuffix": "",
|
||||
"settings.extraFields.multiple": "",
|
||||
"settings.extraFields.namePlaceholder": "",
|
||||
"settings.extraFields.max": "max",
|
||||
"settings.extraFields.min": "min",
|
||||
"settings.extraFields.multiSuffix": " ·· multi",
|
||||
"settings.extraFields.multiple": "Mehrere",
|
||||
"settings.extraFields.namePlaceholder": "Anzeigename",
|
||||
"settings.extraFields.nonUniqueKeyError": "Der Schlüssel muss einzigartig sein.",
|
||||
"settings.extraFields.none": "",
|
||||
"settings.extraFields.none": "Noch keine zusätzlichen {{entity}} Felder definiert.",
|
||||
"settings.extraFields.params.choices": "Auswahlmöglichkeiten",
|
||||
"settings.extraFields.params.defaultValue": "Standardwert",
|
||||
"settings.extraFields.params.fieldType": "Typ",
|
||||
@@ -454,21 +519,37 @@
|
||||
"settings.extraFields.params.name": "Name",
|
||||
"settings.extraFields.params.order": "Reihenfolge",
|
||||
"settings.extraFields.params.unit": "Einheit",
|
||||
"settings.extraFields.saveField": "",
|
||||
"settings.extraFields.reorderChoice": "Ziehen oder benutze die Pfeiltasten, um diese Auswahl neu zu ordnen.",
|
||||
"settings.extraFields.saveField": "Feld speichern",
|
||||
"settings.extraFields.tab": "Extra Felder",
|
||||
"settings.general.baseUrl.label": "Basis URL",
|
||||
"settings.general.baseUrl.tooltip": "Die basis URL für die Generierung von Funktionen wie QR Codes.",
|
||||
"settings.general.currency.desc": "",
|
||||
"settings.general.currency.desc": "Dreistelliger ISO Code (z.B. EUR, USD) der für Preise genutzt wird",
|
||||
"settings.general.currency.label": "Währung",
|
||||
"settings.general.roundPrices.label": "Gerundeter Preis",
|
||||
"settings.general.roundPrices.tooltip": "Rundet die Preise auf die nächsthöhere ganze Zahl.",
|
||||
"settings.general.tab": "Allgemein",
|
||||
"settings.header": "Einstellungen",
|
||||
"settings.language.desc": "",
|
||||
"settings.language.label": "",
|
||||
"settings.library.lowThreshold.desc": "",
|
||||
"settings.library.lowThreshold.label": "",
|
||||
"settings.library.tab": "",
|
||||
"settings.language.desc": "Benutzeroberflächensprache (nur für diesen Browser)",
|
||||
"settings.language.label": "Sprache",
|
||||
"settings.library.lowThreshold.desc": "Spulen mit diesem Gewicht oder weniger werden rot markiert (nur in diesem Browser)",
|
||||
"settings.library.lowThreshold.label": "Niedriger Lagerbestand Schwelle",
|
||||
"settings.library.tab": "Bibliotheksverhalten",
|
||||
"settings.scanner.autoNavigate.desc": "Navigiere automatisch in diesem Webbrowser zu einer Spule oder Filament, die von einem Tag Lesegerät gelesen wird.",
|
||||
"settings.scanner.autoNavigate.label": "Auto-Navigation",
|
||||
"settings.scanner.docs": "Wie man einen Tag Leser einrichtet",
|
||||
"settings.scanner.lastSeen": "vor {{when}}",
|
||||
"settings.scanner.pair": "Zum Koppeln tippen",
|
||||
"settings.scanner.reader.anyDesc": "Scans von jedem Leser, öffnet Spulen in diesem Webbrowser. Wenn du mehr Kontrolle willst, kannst du dich mit einem bestimmten koppeln. Das ist auch der Leser, der verwendet wird, um neue Tags zu Spulen oder Filamenten hinzuzufügen.",
|
||||
"settings.scanner.reader.label": "Tag Leser",
|
||||
"settings.scanner.reader.pairedDesc": "Nur Scans von diesem Leser öffnen Spulen in diesem Webbrowser.",
|
||||
"settings.scanner.recent": "Kürzliche Leser",
|
||||
"settings.scanner.recentNone": "Kein Leser im Speicher, richte einen Tag Leser ein und lese dann einen beliebigen Tag, damit er hier erscheint.",
|
||||
"settings.scanner.tab": "Tag Scannen",
|
||||
"settings.scanner.unpair": "Benutze irgendeinen Leser",
|
||||
"settings.scanner.use": "Benutze diesen",
|
||||
"settings.scanner.waiting": "Tippe auf einen beliebigen Tag auf dem Leser, den du möchtest...",
|
||||
"sponsor": "Unterstütze mich auf GitHub",
|
||||
"spool.fields.archived": "Archiviert",
|
||||
"spool.fields.comment": "Kommentar",
|
||||
"spool.fields.filament": "Filament",
|
||||
@@ -482,26 +563,81 @@
|
||||
"spool.fields.price": "Preis",
|
||||
"spool.fields.registered": "Registriert",
|
||||
"spool.fields.remainingWeight": "Verbleibendes Gewicht",
|
||||
"spool.fields.spoolWeight": "Spulengewicht",
|
||||
"spool.fields.usedWeight": "Verbrauchtes Gewicht",
|
||||
"spool.fields.weight": "Gewicht",
|
||||
"spool.fieldsHelp.location": "Ort, an dem die Spule gelagert wird.",
|
||||
"spool.fieldsHelp.lotNr": "Chargennummer des Herstellers. Kann verwendet werden um gleichmäßige Farben zu garantieren, wenn mehrere Spulen verwendet werden.",
|
||||
"spool.fieldsHelp.measuredWeight": "Gesamtgewicht von Spule und Filament.",
|
||||
"spool.fieldsHelp.price": "Preis einer vollen Spule. Falls nicht ausgewählt, wird der Preis des Filaments geschätzt.",
|
||||
"spool.fieldsHelp.remainingWeight": "Wie viel Filament noch auf der Spule ist. Entspricht dem auf der Spule angegeben Nettogewicht, wenn die Spule neu ist.",
|
||||
"spool.fieldsHelp.spoolWeight": "Was diese Spule wiegt, wenn sie leer ist. Lasse es leer, um das Gewicht der Spule (Filament) zu nehmen, und stelle es ein, wenn diese spezielle Spule anders ist – eine neu aufgewickelte Spule oder eine Pappspule, die gegen einen Plastikspule getauscht wurde. Das ist das, was vom gewogenen Gewicht abgezogen wird, wenn du es nach gemessenem Gewicht anpasst, also gibt ein falsches Spulengewicht einen falschen Wert, wie viel Filament noch übrig ist.",
|
||||
"spool.fieldsHelp.usedWeight": "Wie viel Gewicht bereits verbraucht wurde z.B. 0g für eine neue Spule.",
|
||||
"spool.fieldsHelp.weight": "Wie viel Filament diese Spule hatte, als sie voll war (Nettogewicht, ohne die Spule selbst). Lasse es leer, um das Gewicht der Spule (Filament) zu nehmen, und trage es ein, wenn diese spezielle Spule anders ist – eine partielle Spule oder dasselbe Filament auf einer größeren Spule gekauft.",
|
||||
"spool.form.measurementType.length": "Länge",
|
||||
"spool.form.measurementType.weight": "Gewicht",
|
||||
"spool.spool": "Spulen",
|
||||
"topbar.addSpools": "",
|
||||
"topbar.searchPlaceholder": "",
|
||||
"tags.add": "Tag hinzufügen",
|
||||
"tags.added": "Tag verlinkt.",
|
||||
"tags.addedOn": "Hinzugefügt {{date}}",
|
||||
"tags.modal.alreadyHere": "Dieser Tag kennzeichnet diese Spule bereits.",
|
||||
"tags.modal.alreadyHereFilament": "Dieses Tag kennzeichnet dieses Filament bereits.",
|
||||
"tags.modal.badUid": "Eine Tag UID ist hexadezimal, wie 04A2B3C4.",
|
||||
"tags.modal.checking": "Prüfen…",
|
||||
"tags.modal.failed": "Konnte den Tag nicht verknüpfen.",
|
||||
"tags.modal.filamentNamed": "Filament {{name}}",
|
||||
"tags.modal.free": "Dieser Tag ist noch mit nichts verbunden.",
|
||||
"tags.modal.intro": "Wenn du diesen Tag auf einem Reader antippst, findest du dann Spule #{{id}}.",
|
||||
"tags.modal.introFilament": "Wenn man diesen Tag auf einem Leser antippt, wird dieses Filament dann gefunden.",
|
||||
"tags.modal.lastSeenOn": "{{reader}} zuletzt gelesen {{uid}}.",
|
||||
"tags.modal.link": "Verbinde Tag",
|
||||
"tags.modal.linking": "Verbinden…",
|
||||
"tags.modal.move": "Verschiebe es hierher von #{{id}}",
|
||||
"tags.modal.moveFailed": "Konnte den Tag nicht verschieben. Er ist nun möglicherweise mit keiner Spule verbunden – versuche ihn erneut zu verlinken.",
|
||||
"tags.modal.moveFailedFilament": "Der Tag konnte nicht verschoben werden. Er ist jetzt möglicherweise mit nichts verknüpft – versuche, ihn erneut zu verknüpfen.",
|
||||
"tags.modal.moveHere": "Bewege es hierhin",
|
||||
"tags.modal.moving": "Verschieben…",
|
||||
"tags.modal.phoneReading": "Halte den Tag an dein Handy...",
|
||||
"tags.modal.phoneScan": "Lese den Tag mit deinem Handy",
|
||||
"tags.modal.spoolNamed": "Spule #{{id}}, {{name}}",
|
||||
"tags.modal.spoolShort": "Spule #{{id}}",
|
||||
"tags.modal.takenBy": "Derzeit mit {{spool}} verbunden.",
|
||||
"tags.modal.title": "Verbinde einen Tag",
|
||||
"tags.modal.uidHelp": "Trennzeichen und Großschreibung machen keinen Unterschied: 04:a2:b3:c4, 04-A2-B3-C4 und 04a2b3c4 sind alle derselbe Tag.",
|
||||
"tags.modal.uidLabel": "Tag UID",
|
||||
"tags.modal.uidPlaceholder": "04A2B3C4",
|
||||
"tags.modal.useLastSeen": "Benutze es",
|
||||
"tags.modal.waitingAny": "Warte auf ein Tippen auf einem beliebigen Leser.",
|
||||
"tags.modal.waitingOn": "Warte auf ein Tippen auf {{reader}}.",
|
||||
"tags.moved": "Tag wurde von Spule #{{id}} hierher verschoben.",
|
||||
"tags.movedFromFilament": "Tag wurde von Filament {{name}} hierher verschoben.",
|
||||
"tags.nfc.insecureContext": "Das Lesen von Tags erfordert eine sichere Verbindung. Öffne Spoolman über HTTPS, um das NFC deines Handy zu nutzen.",
|
||||
"tags.nfc.notAllowed": "Diese Seite darf NFC nicht verwenden. Gewähre ihr die Erlaubnis in den Seiten Einstellungen deines Browsers und versuche es erneut.",
|
||||
"tags.nfc.notReadable": "Der NFC-Leser ist nicht verfügbar — eine andere App könnte ihn gerade benutzen.",
|
||||
"tags.nfc.notSupported": "Dieses Handy hat kein NFC oder NFC ist in den Systemeinstellungen ausgeschaltet.",
|
||||
"tags.nfc.unknown": "Konnte keinen Tag lesen.",
|
||||
"tags.none": "Keine Tag's verbunden.",
|
||||
"tags.scan.openedFilament": "Tag gescannt: Filament {{name}} geöffnet.",
|
||||
"tags.scan.openedSpool": "Tag gescannt: Spule #{{id}}, {{name}} geöffnet.",
|
||||
"tags.scan.unknown": "Unbekannter Tag {{uid}}. Öffne die zugehörige Spule und benutze „Tag hinzufügen“, um ihn zu verknüpfen.",
|
||||
"tags.section": "Tag's",
|
||||
"tags.unlink": "Verknüpfung aufheben",
|
||||
"tags.unlinkBody": "Tag {{uid}} wird stoppen, diese Spule zu identifizieren. Nichts wird direkt auf dem Tag selbst geschrieben, daher kannst du ihn jederzeit wieder — mit dieser Spule oder einer Anderen — verknüpfen.",
|
||||
"tags.unlinkBodyFilament": "Tag {{uid}} wird aufhören, dieses Filament zu identifizieren. Es wird nichts auf den Tag selbst geschrieben, sodass du ihn jederzeit wieder verknüpfen kannst — hier oder irgendwo anders.",
|
||||
"tags.unlinkFailed": "Konnte die Verbindung des Tag's nicht entfernen.",
|
||||
"tags.unlinkTitle": "Verbindung für diesen Tag aufheben?",
|
||||
"tags.unlinked": "Verbindung des Tag's aufgehoben.",
|
||||
"tags.unlinking": "Verbindung aufheben…",
|
||||
"topbar.addSpools": "Spulen hinzufügen",
|
||||
"topbar.searchPlaceholder": "Suchen — versuche \"rot\" oder \"pla\"",
|
||||
"unknown": "Unbekannt",
|
||||
"validation.hexDigits": "",
|
||||
"validation.maxChars": "",
|
||||
"validation.mustBeGt": "",
|
||||
"validation.mustBeMax": "",
|
||||
"validation.mustBeMin": "",
|
||||
"validation.mustBeNumber": "",
|
||||
"validation.required": "",
|
||||
"validation.hexDigits": "Benutze 6 oder 8 hexadezimal Stellen",
|
||||
"validation.maxChars": "Höchstens {{max}} Zeichen",
|
||||
"validation.mustBeGt": "Muss größer als {{value}} sein",
|
||||
"validation.mustBeMax": "Muss {{value}}, oder weniger sein",
|
||||
"validation.mustBeMin": "Muss {{value}}, oder mehr sein",
|
||||
"validation.mustBeNumber": "Muss eine Zahl sein",
|
||||
"validation.required": "Erforderlich",
|
||||
"vendor.fields.comment": "Kommentar",
|
||||
"vendor.fields.emptySpoolWeight": "Gewicht der leeren Spule",
|
||||
"vendor.fields.externalId": "Externe ID",
|
||||
@@ -509,5 +645,6 @@
|
||||
"vendor.fields.name": "Name",
|
||||
"vendor.fields.registered": "Registriert",
|
||||
"vendor.fieldsHelp.emptySpoolWeight": "Das Gewicht einer leeren Spule von diesem Hersteller.",
|
||||
"vendor.showAsList": "Zeige als Liste",
|
||||
"yes": "Ja"
|
||||
}
|
||||
@@ -62,6 +62,8 @@
|
||||
"buttons.filter": "Filter",
|
||||
"buttons.save": "Save",
|
||||
"buttons.showArchived": "Show Archived",
|
||||
"buttons.showLotNumbers": "Show lot numbers",
|
||||
"buttons.showNoSpools": "Show filaments with no spools",
|
||||
"buttons.unArchive": "Unarchive",
|
||||
"changeFilament.adoptWeight": "Set this spool’s full weight to {{full}}",
|
||||
"changeFilament.adoptWeightHelp": "It is recorded as {{current}}, from the filament it held before. Used weight ({{used}}) is kept either way, leaving {{remaining}}.",
|
||||
@@ -73,10 +75,12 @@
|
||||
"changeFilament.failed": "Couldn’t change the filament.",
|
||||
"changeFilament.importNote": "This filament will be added to your catalog when you apply the change.",
|
||||
"changeFilament.intro": "Spool #{{id}} keeps its number, location, dates and usage history — only the filament it holds changes.",
|
||||
"changeFilament.newTag": "new",
|
||||
"changeFilament.next": "Change to",
|
||||
"changeFilament.now": "Currently",
|
||||
"changeFilament.pickBelow": "Pick a filament below",
|
||||
"changeFilament.title": "Change filament",
|
||||
"changeFilament.useExisting": "Pick an existing filament",
|
||||
"changeFilament.weightFollows": "This spool has no full weight of its own, so it takes the new filament’s: {{full}}, leaving {{remaining}} after the {{used}} already used.",
|
||||
"changeVendor.action": "Change",
|
||||
"changeVendor.apply": "Change manufacturer",
|
||||
@@ -164,6 +168,7 @@
|
||||
"filament.filament": "Filaments",
|
||||
"footer.documentation": "Documentation",
|
||||
"footer.reportIssue": "Report an issue",
|
||||
"footer.updateAvailable": "v{{version}} available",
|
||||
"help.help": "Help",
|
||||
"inspector.addSpoolsOfThis": "Add spools of this",
|
||||
"inspector.adjustFailed": "Failed to adjust weight.",
|
||||
@@ -223,7 +228,6 @@
|
||||
"inspector.used": "used",
|
||||
"inspector.vendorSub_one": "{{count}} filament",
|
||||
"inspector.vendorSub_other": "{{count}} filaments",
|
||||
"kofi": "Tip me on Ko-fi",
|
||||
"labels.addHead": "Add",
|
||||
"labels.addQr": "QR code",
|
||||
"labels.addRect": "Rectangle",
|
||||
@@ -347,6 +351,18 @@
|
||||
"library.dateFilter.since": "Since {{date}}",
|
||||
"library.dateFilter.to": "To (optional)",
|
||||
"library.dateFilter.until": "Until {{date}}",
|
||||
"library.catalog.allFilaments": "All filaments",
|
||||
"library.catalog.allManufacturers": "All manufacturers",
|
||||
"library.catalog.backToSpools": "Back to spools",
|
||||
"library.catalog.filamentCount_one": "{{count}} filament",
|
||||
"library.catalog.filamentCount_other": "{{count}} filaments",
|
||||
"library.catalog.manufacturerCount_one": "{{count}} manufacturer",
|
||||
"library.catalog.manufacturerCount_other": "{{count}} manufacturers",
|
||||
"library.catalog.noManufacturer": "No manufacturer",
|
||||
"library.catalog.notSet": "Not set",
|
||||
"library.catalog.switchList": "Switch list",
|
||||
"library.catalog.unitFilaments": "filaments",
|
||||
"library.catalog.unitManufacturers": "manufacturers",
|
||||
"library.emptyFiltered": "No {{unit}} match the current filters.",
|
||||
"library.emptyTitle": "No spools yet",
|
||||
"library.emptyBody": "Add your first spool to start tracking your filament.",
|
||||
@@ -358,10 +374,12 @@
|
||||
"library.groupSpools_other": "{{count}} spools",
|
||||
"library.nUnused": "×{{count}} unused",
|
||||
"library.noLocation": "no location",
|
||||
"library.noSpools": "No spools",
|
||||
"library.noValues": "No values",
|
||||
"library.openGroup": "Open {{name}}",
|
||||
"library.openManufacturer": "Open manufacturer {{name}}",
|
||||
"library.resizeList": "Resize the list (double-click to reset)",
|
||||
"library.reverseSort": "Reverse the sort order",
|
||||
"library.section.extra": "Extra fields",
|
||||
"library.section.filament": "Filament",
|
||||
"library.section.spool": "Spool",
|
||||
@@ -379,6 +397,8 @@
|
||||
"library.unusedSubMixed": "{{location}}",
|
||||
"library.used": "used",
|
||||
"library.usedAgo": "used {{time}} ago",
|
||||
"library.viewMenu.browse": "Browse",
|
||||
"library.views": "Library views",
|
||||
"loading": "Loading",
|
||||
"nav.labels": "Labels",
|
||||
"nav.library": "Library",
|
||||
@@ -413,6 +433,7 @@
|
||||
"printing.generic.verticalSpacing": "Vertical Spacing",
|
||||
"printing.qrcode.button": "Print Labels",
|
||||
"printing.qrcode.customUrl.caveat": "A custom URL won't scan back into Spoolman — use it to point a printed QR at an external app or a different host.",
|
||||
"printing.qrcode.customUrl.fieldsHint": "Label fields work too, for example",
|
||||
"printing.qrcode.customUrl.hint": "Insert the spool id anywhere with the placeholder",
|
||||
"printing.qrcode.customUrl.label": "Custom URL template",
|
||||
"printing.qrcode.useHTTPUrl.label": "QR code link",
|
||||
@@ -423,6 +444,8 @@
|
||||
"printing.qrcode.useHTTPUrl.tooltip": "Default encodes a Spoolman-only code that only Spoolman's own scanner can read. URL encodes a normal web link that any scanner can open, built from the base URL in settings — or this site's address if that's unset.",
|
||||
"printing.spoolSelect.selectedTotal_one": "{{count}} spool selected",
|
||||
"printing.spoolSelect.selectedTotal_other": "{{count}} spools selected",
|
||||
"scanner.camera": "Camera",
|
||||
"scanner.cameraNumbered": "Camera {{number}}",
|
||||
"scanner.description": "Scan a Spoolman QR code to view details about the spool.",
|
||||
"scanner.error.insecureContext": "The page is not served over HTTPS.",
|
||||
"scanner.error.notAllowed": "You have not allowed access to the camera.",
|
||||
@@ -433,6 +456,8 @@
|
||||
"scanner.starting": "Starting camera...",
|
||||
"scanner.title": "QR Code Scanner",
|
||||
"search.colorHint": "Color names must be entered in English.",
|
||||
"search.browseAll": "Browse all",
|
||||
"search.hint": "Search spools, filaments and manufacturers by name, color, material or any field.",
|
||||
"search.moreSpools": "+{{count}} more",
|
||||
"search.noResults": "No matches found",
|
||||
"search.searching": "Searching...",
|
||||
@@ -503,6 +528,21 @@
|
||||
"settings.library.lowThreshold.desc": "Spools at or below this weight are flagged red (this browser only)",
|
||||
"settings.library.lowThreshold.label": "Low-stock threshold",
|
||||
"settings.library.tab": "Library behavior",
|
||||
"settings.scanner.autoNavigate.desc": "Automatically navigate in this web browser to a spool or filament that gets read by a tag reader.",
|
||||
"settings.scanner.autoNavigate.label": "Auto-navigate",
|
||||
"settings.scanner.docs": "How to set up a tag reader",
|
||||
"settings.scanner.lastSeen": "{{when}} ago",
|
||||
"settings.scanner.pair": "Pair by tapping",
|
||||
"settings.scanner.reader.anyDesc": "Scans from any reader open spools or filaments in this web browser. You can pair with a specific one if you want finer control. This is also the reader that will be used for adding new tags to spools and filaments.",
|
||||
"settings.scanner.reader.label": "Tag Reader",
|
||||
"settings.scanner.reader.pairedDesc": "Only scans from this reader open spools in this web browser.",
|
||||
"settings.scanner.recent": "Recent readers",
|
||||
"settings.scanner.recentNone": "No reader in memory, set up a tag reader then read any tag for it to appear here.",
|
||||
"settings.scanner.tab": "Tag Scanning",
|
||||
"settings.scanner.unpair": "Use any reader",
|
||||
"settings.scanner.use": "Use this one",
|
||||
"settings.scanner.waiting": "Tap any tag on the reader you want...",
|
||||
"sponsor": "Sponsor me on GitHub",
|
||||
"spool.fields.archived": "Archived",
|
||||
"spool.fields.comment": "Comment",
|
||||
"spool.fields.filament": "Filament",
|
||||
@@ -530,6 +570,57 @@
|
||||
"spool.form.measurementType.length": "Length",
|
||||
"spool.form.measurementType.weight": "Weight",
|
||||
"spool.spool": "Spools",
|
||||
"tags.add": "Add tag",
|
||||
"tags.added": "Tag linked.",
|
||||
"tags.addedOn": "Added {{date}}",
|
||||
"tags.modal.alreadyHere": "This tag already identifies this spool.",
|
||||
"tags.modal.alreadyHereFilament": "This tag already identifies this filament.",
|
||||
"tags.modal.badUid": "A tag UID is hexadecimal, like 04A2B3C4.",
|
||||
"tags.modal.checking": "Checking...",
|
||||
"tags.modal.failed": "Could not link the tag.",
|
||||
"tags.modal.filamentNamed": "filament {{name}}",
|
||||
"tags.modal.free": "This tag is not linked to anything yet.",
|
||||
"tags.modal.intro": "Tapping this tag on a reader will then find spool #{{id}}.",
|
||||
"tags.modal.introFilament": "Tapping this tag on a reader will then find this filament.",
|
||||
"tags.modal.lastSeenOn": "{{reader}} last read {{uid}}.",
|
||||
"tags.modal.link": "Link tag",
|
||||
"tags.modal.linking": "Linking...",
|
||||
"tags.modal.move": "Move it here from #{{id}}",
|
||||
"tags.modal.moveFailed": "Could not move the tag. It may now be linked to neither spool — try linking it again.",
|
||||
"tags.modal.moveFailedFilament": "Could not move the tag. It may now be linked to nothing — try linking it again.",
|
||||
"tags.modal.moveHere": "Move it here",
|
||||
"tags.modal.moving": "Moving...",
|
||||
"tags.modal.phoneReading": "Hold the tag against your phone...",
|
||||
"tags.modal.phoneScan": "Read a tag with this phone",
|
||||
"tags.modal.spoolNamed": "spool #{{id}}, {{name}}",
|
||||
"tags.modal.spoolShort": "spool #{{id}}",
|
||||
"tags.modal.takenBy": "Currently linked to {{spool}}.",
|
||||
"tags.modal.title": "Link a tag",
|
||||
"tags.modal.uidHelp": "Separators and capitalisation make no difference: 04:a2:b3:c4, 04-A2-B3-C4 and 04a2b3c4 are all the same tag.",
|
||||
"tags.modal.uidLabel": "Tag UID",
|
||||
"tags.modal.uidPlaceholder": "04A2B3C4",
|
||||
"tags.modal.useLastSeen": "Use it",
|
||||
"tags.modal.waitingAny": "Waiting for a tap on any reader.",
|
||||
"tags.modal.waitingOn": "Waiting for a tap on {{reader}}.",
|
||||
"tags.moved": "Tag moved here from spool #{{id}}.",
|
||||
"tags.movedFromFilament": "Tag moved here from filament {{name}}.",
|
||||
"tags.nfc.insecureContext": "Reading tags needs a secure connection. Open Spoolman over HTTPS to use your phone's NFC.",
|
||||
"tags.nfc.notAllowed": "This site is not allowed to use NFC. Grant it permission in your browser's site settings and try again.",
|
||||
"tags.nfc.notReadable": "The NFC reader is unavailable — another app may be using it.",
|
||||
"tags.nfc.notSupported": "This phone has no NFC, or NFC is switched off in system settings.",
|
||||
"tags.nfc.unknown": "Could not read a tag.",
|
||||
"tags.none": "No tags linked.",
|
||||
"tags.scan.openedFilament": "Tag scanned: opened filament {{name}}.",
|
||||
"tags.scan.openedSpool": "Tag scanned: opened spool #{{id}}, {{name}}.",
|
||||
"tags.scan.unknown": "Unknown tag {{uid}}. Open the spool or filament it belongs to and use Add tag to link it.",
|
||||
"tags.section": "Tags",
|
||||
"tags.unlink": "Unlink",
|
||||
"tags.unlinkBody": "Tag {{uid}} will stop identifying this spool. Nothing is written to the tag itself, so you can link it again — to this spool or another one — whenever you like.",
|
||||
"tags.unlinkBodyFilament": "Tag {{uid}} will stop identifying this filament. Nothing is written to the tag itself, so you can link it again — here or somewhere else — whenever you like.",
|
||||
"tags.unlinkFailed": "Could not unlink the tag.",
|
||||
"tags.unlinkTitle": "Unlink this tag?",
|
||||
"tags.unlinked": "Tag unlinked.",
|
||||
"tags.unlinking": "Unlinking...",
|
||||
"topbar.addSpools": "Add spools",
|
||||
"topbar.searchPlaceholder": "Search — try \"red\" or \"pla\"",
|
||||
"unknown": "Unknown",
|
||||
@@ -546,6 +637,7 @@
|
||||
"vendor.fields.id": "ID",
|
||||
"vendor.fields.name": "Name",
|
||||
"vendor.fields.registered": "Registered",
|
||||
"vendor.showAsList": "Show as a list",
|
||||
"vendor.fieldsHelp.emptySpoolWeight": "The weight of an empty spool from this manufacturer.",
|
||||
"yes": "Yes"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"add.addAndNew": "",
|
||||
"add.addN_many": "Añadir {{count}} bobinas",
|
||||
"add.addN_one": "Añadir {{count}} bobina",
|
||||
"add.addN_other": "Añadir {{count}} bobinas",
|
||||
"add.adding": "",
|
||||
"add.advanced": "",
|
||||
"add.advancedNote": "",
|
||||
"add.advanced": "Especificaciones Avanzadas",
|
||||
"add.advancedNote": "densidad, diámetro, temperaturas (autorrellenado desde material)",
|
||||
"add.change": "",
|
||||
"add.count": "",
|
||||
"add.createNew": "",
|
||||
|
||||
+498
-322
@@ -1,102 +1,163 @@
|
||||
{
|
||||
"add.addAndNew": "",
|
||||
"add.adding": "",
|
||||
"add.advanced": "",
|
||||
"add.advancedNote": "",
|
||||
"add.change": "",
|
||||
"add.count": "",
|
||||
"add.createNew": "",
|
||||
"add.createNewSub": "",
|
||||
"add.dbUnavailable": "",
|
||||
"add.duplicate": "",
|
||||
"add.duplicateNote": "",
|
||||
"add.duplicateRename": "",
|
||||
"add.duplicateTitle": "",
|
||||
"add.filamentNamePlaceholder": "",
|
||||
"add.fill.full": "",
|
||||
"add.fillLevel": "",
|
||||
"add.importNote": "",
|
||||
"add.inCatalog": "",
|
||||
"add.locationHint": "",
|
||||
"add.locationPlaceholder": "",
|
||||
"add.manufacturerPlaceholder": "",
|
||||
"add.nameHint": "",
|
||||
"add.newFilament": "",
|
||||
"add.newFilamentTitle": "",
|
||||
"add.noCatalog": "",
|
||||
"add.noManufacturer": "",
|
||||
"add.pickColor": "",
|
||||
"add.searchPlaceholder": "",
|
||||
"add.searching": "",
|
||||
"add.spoolWeightPreset.cardboard": "",
|
||||
"add.spoolWeightPreset.plastic": "",
|
||||
"add.spoolWeightPresetsLead": "",
|
||||
"add.step2": "",
|
||||
"add.typeToSearchAll": "",
|
||||
"add.typeToSearchMatches": "",
|
||||
"add.useExisting": "",
|
||||
"add.vendorHint.existing": "",
|
||||
"add.vendorHint.new": "",
|
||||
"add.vendorHint.optional": "",
|
||||
"add.yourCatalog": "",
|
||||
"add.addAndNew": "Ajouter & nouveau",
|
||||
"add.addN_many": "Ajouter {{count}} bobines",
|
||||
"add.addN_one": "Ajouter {{count}} bobine",
|
||||
"add.addN_other": "Ajouter {{count}} bobines",
|
||||
"add.adding": "Ajout...",
|
||||
"add.advanced": "Spécifications avancées",
|
||||
"add.advancedNote": "densité, diamètre, températures (remplies automatiquement à partir du matériau)",
|
||||
"add.change": "changer",
|
||||
"add.count": "Décompte",
|
||||
"add.createNew": "Créer un nouveau filament",
|
||||
"add.createNewSub": "n'est pas dans votre catalogue ou {{name}}",
|
||||
"add.dbUnavailable": "{{name}} indisponible",
|
||||
"add.duplicate": "dupliquer",
|
||||
"add.duplicateNote": "Les caractéristiques, le fabricant et le prix ont été repris. Donnez-lui un nouveau nom et une nouvelle couleur.",
|
||||
"add.duplicateRename": "Il porte toujours le même nom que l'original : renommez-le pour les distinguer.",
|
||||
"add.duplicateTitle": "Nouveau filament copie de “{{name}}”",
|
||||
"add.filamentNamePlaceholder": "Exemple : PolyTerra Matte Sage",
|
||||
"add.fill.full": "Rempli",
|
||||
"add.fillLevel": "Niveau de remplissage",
|
||||
"add.importNote": "Ce filament sera ajouté à votre catalogue lorsque vous ajouterez les bobines.",
|
||||
"add.inCatalog": "au catalogue",
|
||||
"add.locationHint": "L’endroit où vous rangez physiquement la bobine — une étagère, une caisse ou une imprimante. Laissez vide si vous les gardez toutes au même endroit.",
|
||||
"add.locationPlaceholder": "ex. Étagère A, Caisson 2, Imprimante 1",
|
||||
"add.manufacturerPlaceholder": "exemple : Polymaker",
|
||||
"add.nameHint": "Indiquez la couleur : c’est ce qui permet de distinguer vos filaments.",
|
||||
"add.newFilament": "Ajouter filament",
|
||||
"add.newFilamentTitle": "Nouveau filament",
|
||||
"add.noCatalog": "Aucun filament dans votre catalogue",
|
||||
"add.noManufacturer": "Sans fabriquant",
|
||||
"add.pickColor": "Choisissez une couleur",
|
||||
"add.searchPlaceholder": "Recherchez dans votre catalogue et {{name}}…",
|
||||
"add.searching": "Recherche…",
|
||||
"add.section.filament": "Filament",
|
||||
"add.section.filamentNote": "Le produit sur la bobine — une entrée par couleur.",
|
||||
"add.section.manufacturer": "Fabriquant",
|
||||
"add.section.manufacturerNote": "Qui fabrique le filament ? Ajouté une seule fois, puis réutilisé pour tous leurs filaments.",
|
||||
"add.section.spool": "Bobine",
|
||||
"add.section.spoolNote": "Les bobines physiques qui rejoignent votre inventaire.",
|
||||
"add.section.spoolSharedNote": "Le poids, le poids de la bobine et le prix sont également enregistrés comme valeurs par défaut pour le nouveau filament.",
|
||||
"add.spoolWeightPreset.cardboard": "Carton {{weight}}g",
|
||||
"add.spoolWeightPreset.plastic": "Plastique {{weight}}g",
|
||||
"add.spoolWeightPresetsLead": "Partez d'une bobine standard :",
|
||||
"add.step2": "Étape 2 · Détails de la bobine",
|
||||
"add.step2New": "Étape 2 · Détails du filament et de la bobine",
|
||||
"add.summary_many": "{{count}} bobines",
|
||||
"add.summary_one": "{{count}} bobine",
|
||||
"add.summary_other": "{{count}} bobines",
|
||||
"add.typeToSearchAll": "Tapez pour rechercher parmi des milliers de filaments",
|
||||
"add.typeToSearchMatches": "Tapez pour rechercher — aucun résultat",
|
||||
"add.useExisting": "utiliser un existant",
|
||||
"add.vendorHint.existing": "Utilisation du fabricant existant \"{{name}}\"",
|
||||
"add.vendorHint.new": "Le nouveau fabricant « {{name}} » sera créé",
|
||||
"add.vendorHint.optional": "Facultatif — laissez vide pour ne pas indiquer de fabricant",
|
||||
"add.weightPresets": "Tailles de bobines courantes",
|
||||
"add.yourCatalog": "Votre catalogue",
|
||||
"buttons.apply": "Appliquer",
|
||||
"buttons.archive": "Archiver",
|
||||
"buttons.cancel": "Abandonner",
|
||||
"buttons.clear": "Effacer",
|
||||
"buttons.close": "",
|
||||
"buttons.close": "Fermer",
|
||||
"buttons.delete": "Supprimer",
|
||||
"buttons.edit": "Editer",
|
||||
"buttons.filter": "Filtre",
|
||||
"buttons.save": "Enregistrer",
|
||||
"buttons.showArchived": "Afficher les archives",
|
||||
"buttons.showNoSpools": "Afficher les filaments sans bobine",
|
||||
"buttons.unArchive": "Désarchiver",
|
||||
"common.decrement": "",
|
||||
"common.increment": "",
|
||||
"common.remove": "",
|
||||
"common.search": "",
|
||||
"dashboard.addFieldHint": "",
|
||||
"dashboard.dashboard": "",
|
||||
"dashboard.deleteGroup": "",
|
||||
"dashboard.dragHint": "",
|
||||
"dashboard.dropHere": "",
|
||||
"dashboard.emptyHelp": "",
|
||||
"dashboard.errorEmptyName": "",
|
||||
"dashboard.errorExists": "",
|
||||
"dashboard.errorRename": "",
|
||||
"dashboard.lastUsed": "",
|
||||
"dashboard.newGroup": "",
|
||||
"dashboard.newGroupName": "",
|
||||
"dashboard.reorderGroup": "",
|
||||
"dashboard.unassigned": "",
|
||||
"datetime.done": "",
|
||||
"datetime.hour": "",
|
||||
"datetime.minute": "",
|
||||
"datetime.nextMonth": "",
|
||||
"datetime.now": "",
|
||||
"datetime.pick": "",
|
||||
"datetime.prevMonth": "",
|
||||
"datetime.time": "",
|
||||
"documentTitle.dashboard.list": "",
|
||||
"documentTitle.labels.list": "",
|
||||
"documentTitle.library.list": "",
|
||||
"changeFilament.action": "Changer",
|
||||
"changeFilament.adoptWeight": "Fixer le poids plein de cette bobine à {{full}}",
|
||||
"changeFilament.adoptWeightHelp": "Il est enregistré à {{current}}, hérité du filament précédent. Le poids consommé ({{used}}) est conservé dans les deux cas, ce qui laisse {{remaining}}.",
|
||||
"changeFilament.apply": "Changer le filament",
|
||||
"changeFilament.applying": "Changement en cours…",
|
||||
"changeFilament.current": "actuel",
|
||||
"changeFilament.done": "Filament changé.",
|
||||
"changeFilament.failed": "Le changement de filament a échoué.",
|
||||
"changeFilament.importNote": "Ce filament sera ajouté à votre catalogue lorsque vous validerez le changement.",
|
||||
"changeFilament.intro": "La bobine n° {{id}} conserve son numéro, son emplacement, ses dates et son historique d’utilisation — seul le filament qu’elle contient change.",
|
||||
"changeFilament.newTag": "nouveau",
|
||||
"changeFilament.next": "Remplacer par",
|
||||
"changeFilament.now": "Actuellement",
|
||||
"changeFilament.pickBelow": "Choisissez un filament ci-dessous",
|
||||
"changeFilament.title": "Changer le filament",
|
||||
"changeFilament.useExisting": "Choisir un filament existant",
|
||||
"changeFilament.weightFollows": "Cette bobine n’a pas de poids plein propre : elle reprend celui du nouveau filament, soit {{full}}, ce qui laisse {{remaining}} après les {{used}} déjà consommés.",
|
||||
"changeVendor.action": "Changer",
|
||||
"changeVendor.apply": "Changer le fabricant",
|
||||
"changeVendor.applying": "Changement en cours…",
|
||||
"changeVendor.clearHint": "Laisser ce filament sans fabricant",
|
||||
"changeVendor.create": "Créer « {{name}} »",
|
||||
"changeVendor.createHint": "Ne fait pas encore partie de vos fabricants — il sera ajouté",
|
||||
"changeVendor.current": "actuel",
|
||||
"changeVendor.done": "Fabricant changé.",
|
||||
"changeVendor.emptySpool": "Bobine vide {{value}}",
|
||||
"changeVendor.failed": "Le changement de fabricant a échoué.",
|
||||
"changeVendor.intro": "« {{name}} » conserve ses bobines, ses caractéristiques et son historique — seul le fabricant sous lequel il est classé change.",
|
||||
"changeVendor.next": "Remplacer par",
|
||||
"changeVendor.noEmptySpool": "Aucun poids de bobine vide",
|
||||
"changeVendor.noMatches": "Aucun fabricant ne correspond",
|
||||
"changeVendor.none": "Aucun fabricant pour l’instant",
|
||||
"changeVendor.noneNote": "Classé sans marque",
|
||||
"changeVendor.now": "Actuellement",
|
||||
"changeVendor.pickBelow": "Choisissez un fabricant ci-dessous",
|
||||
"changeVendor.searchPlaceholder": "Rechercher ou nommer un fabricant",
|
||||
"changeVendor.tareFollows": "Ce filament n’a pas de poids de bobine vide propre : il reprend celui de son fabricant. Après le changement, ce sera {{value}}, c’est-à-dire ce que pèse une bobine vide de ce filament lorsque vous la mesurez.",
|
||||
"changeVendor.tareGone": "Ce filament n’a pas de poids de bobine vide propre et reprend les {{current}} de son fabricant. Après le changement, il n’y en aura plus à reprendre, jusqu’à ce que vous en donniez un au filament.",
|
||||
"changeVendor.title": "Changer le fabricant",
|
||||
"changeVendor.willBeCreated": "Sera créé",
|
||||
"common.decrement": "Diminuer",
|
||||
"common.increment": "Augmenter",
|
||||
"common.remove": "Retirer",
|
||||
"common.search": "Rechercher",
|
||||
"dashboard.addFieldHint": "Ajoutez un champ texte ou à choix aux bobines dans les Réglages pour pouvoir aussi les grouper par ce champ",
|
||||
"dashboard.dashboard": "Tableau de bord",
|
||||
"dashboard.deleteGroup": "Supprimer le groupe",
|
||||
"dashboard.dragHint": "Faites glisser une bobine vers une autre carte pour changer son champ {{field}}, ou à l’intérieur d’une carte pour la réordonner",
|
||||
"dashboard.dropHere": "déposez des bobines ici",
|
||||
"dashboard.emptyHelp": "Cette page regroupe vos bobines selon le champ de votre choix. Ajoutez des bobines pour commencer !",
|
||||
"dashboard.errorEmptyName": "Le nom ne peut pas être vide",
|
||||
"dashboard.errorExists": "Un groupe portant ce nom existe déjà",
|
||||
"dashboard.errorRename": "Le renommage du groupe a échoué",
|
||||
"dashboard.lastUsed": "Utilisée il y a {{time}}",
|
||||
"dashboard.newGroup": "Nouveau groupe",
|
||||
"dashboard.newGroupName": "Nouveau groupe {{n}}",
|
||||
"dashboard.reorderGroup": "Faites glisser pour réordonner le groupe",
|
||||
"dashboard.spoolCount_many": "{{count}} bobines",
|
||||
"dashboard.spoolCount_one": "{{count}} bobine",
|
||||
"dashboard.spoolCount_other": "{{count}} bobines",
|
||||
"dashboard.unassigned": "Sans groupe",
|
||||
"datetime.done": "Terminé",
|
||||
"datetime.hour": "Heure",
|
||||
"datetime.minute": "Minute",
|
||||
"datetime.nextMonth": "Mois suivant",
|
||||
"datetime.now": "Maintenant",
|
||||
"datetime.pick": "Choisir la date et l’heure",
|
||||
"datetime.prevMonth": "Mois précédent",
|
||||
"datetime.time": "Heure",
|
||||
"documentTitle.dashboard.list": "Tableau de bord | Spoolman",
|
||||
"documentTitle.labels.list": "Étiquettes | Spoolman",
|
||||
"documentTitle.library.list": "Bibliothèque | Spoolman",
|
||||
"documentTitle.locations.list": "Emplacements | Spoolman",
|
||||
"documentTitle.settings.list": "",
|
||||
"filament.fields.addColor": "",
|
||||
"documentTitle.settings.list": "Réglages | Spoolman",
|
||||
"filament.fields.addColor": "Ajouter une couleur",
|
||||
"filament.fields.articleNumber": "Numéro d'article",
|
||||
"filament.fields.coaxial": "Coextrudée",
|
||||
"filament.fields.colorHex": "Couleur",
|
||||
"filament.fields.colorType": "",
|
||||
"filament.fields.colorType": "Type de couleur",
|
||||
"filament.fields.comment": "Commentaire",
|
||||
"filament.fields.density": "Densité",
|
||||
"filament.fields.diameter": "Diamètre",
|
||||
"filament.fields.externalId": "ID externe",
|
||||
"filament.fields.id": "ID",
|
||||
"filament.fields.id": "Identifiant",
|
||||
"filament.fields.longitudinal": "Longitudinale",
|
||||
"filament.fields.material": "Matériau",
|
||||
"filament.fields.multiColor": "Multi",
|
||||
"filament.fields.name": "Nom",
|
||||
"filament.fields.opacity": "Opacité",
|
||||
"filament.fields.price": "Prix",
|
||||
"filament.fields.registered": "Enregistré",
|
||||
"filament.fields.removeColor": "",
|
||||
"filament.fields.removeColor": "Retirer la couleur",
|
||||
"filament.fields.settingsBedTemp": "Température du lit",
|
||||
"filament.fields.settingsExtruderTemp": "Température de l'extrudeuse",
|
||||
"filament.fields.singleColor": "Unique",
|
||||
@@ -108,210 +169,253 @@
|
||||
"filament.fieldsHelp.spoolWeight": "Le poids d'une bobine vide. Utilisé pour déterminer le poids d'une bobine.",
|
||||
"filament.fieldsHelp.weight": "Le poids du filament d'une bobine complète (poids net). Ce poids ne doit pas inclure le poids de la bobine elle-même, mais uniquement celui du filament. C'est ce qui est généralement indiqué sur l'emballage.",
|
||||
"filament.filament": "Filaments",
|
||||
"footer.documentation": "",
|
||||
"footer.reportIssue": "",
|
||||
"footer.documentation": "Documentation",
|
||||
"footer.reportIssue": "Signaler un problème",
|
||||
"help.help": "Aide",
|
||||
"inspector.addSpoolsOfThis": "",
|
||||
"inspector.adjustFailed": "",
|
||||
"inspector.adjustHelp": "",
|
||||
"inspector.adjustWeight": "",
|
||||
"inspector.apply": "",
|
||||
"inspector.applying": "",
|
||||
"inspector.colorHex": "",
|
||||
"inspector.consumeAmount": "",
|
||||
"inspector.delete.deleting": "",
|
||||
"inspector.delete.errorGone": "",
|
||||
"inspector.delete.errorSpoolsExist": "",
|
||||
"inspector.delete.errorUnknown": "",
|
||||
"inspector.delete.filament": "",
|
||||
"inspector.delete.filamentBlockedHint": "",
|
||||
"inspector.delete.filamentBlockedTitle": "",
|
||||
"inspector.delete.filamentBody": "",
|
||||
"inspector.delete.filamentDone": "",
|
||||
"inspector.delete.filamentTitle": "",
|
||||
"inspector.delete.spool": "",
|
||||
"inspector.delete.spoolBody": "",
|
||||
"inspector.delete.spoolDone": "",
|
||||
"inspector.delete.spoolRemaining": "",
|
||||
"inspector.delete.spoolTitle": "",
|
||||
"inspector.delete.vendor": "",
|
||||
"inspector.delete.vendorBody": "",
|
||||
"inspector.delete.vendorDone": "",
|
||||
"inspector.delete.vendorTitle": "",
|
||||
"inspector.duplicateFilament": "",
|
||||
"inspector.empty": "",
|
||||
"inspector.enterValidLength": "",
|
||||
"inspector.enterValidWeight": "",
|
||||
"inspector.loading": "",
|
||||
"inspector.manageExtraFields": "",
|
||||
"inspector.measuredHelp": "",
|
||||
"inspector.multi": "",
|
||||
"inspector.newGross": "",
|
||||
"inspector.noExtraFields": "",
|
||||
"inspector.notFound": "",
|
||||
"inspector.ofRemaining": "",
|
||||
"inspector.openFilament": "",
|
||||
"inspector.openLink": "",
|
||||
"inspector.openManufacturer": "",
|
||||
"inspector.specs": "",
|
||||
"inspector.spoolsCount": "",
|
||||
"inspector.spoolsTotalRemaining": "",
|
||||
"inspector.used": "",
|
||||
"inspector.addSpoolsOfThis": "Ajouter des bobines de ce filament",
|
||||
"inspector.adjustFailed": "L’ajustement du poids a échoué.",
|
||||
"inspector.adjustHelp": "Une valeur positive consomme du filament, une valeur négative en rend.",
|
||||
"inspector.adjustWeight": "Ajuster le poids",
|
||||
"inspector.apply": "Appliquer",
|
||||
"inspector.applying": "Application en cours…",
|
||||
"inspector.colorHex": "Couleur hexadécimale",
|
||||
"inspector.consumeAmount": "Quantité à consommer :",
|
||||
"inspector.defaultFrom.filament": "{{value}} — valeur par défaut du filament",
|
||||
"inspector.defaultFrom.manufacturer": "{{value}} — valeur par défaut du fabricant",
|
||||
"inspector.delete.deleting": "Suppression en cours…",
|
||||
"inspector.delete.errorGone": "L’élément avait déjà été supprimé.",
|
||||
"inspector.delete.errorSpoolsExist": "Impossible de supprimer ce filament — des bobines l’utilisent encore.",
|
||||
"inspector.delete.errorUnknown": "La suppression a échoué. Consultez les journaux du serveur pour en savoir plus.",
|
||||
"inspector.delete.filament": "Supprimer le filament",
|
||||
"inspector.delete.filamentBlockedHint": "Supprimez d’abord ces bobines. Les archiver ne suffit pas.",
|
||||
"inspector.delete.filamentBlockedTitle": "Ce filament ne peut pas être supprimé",
|
||||
"inspector.delete.filamentBlocked_many": "{{count}} bobines utilisent encore « {{name}} », archives comprises.",
|
||||
"inspector.delete.filamentBlocked_one": "{{count}} bobine utilise encore « {{name}} », archives comprises.",
|
||||
"inspector.delete.filamentBlocked_other": "{{count}} bobines utilisent encore « {{name}} », archives comprises.",
|
||||
"inspector.delete.filamentBody": "« {{name}} » sera retiré de votre catalogue. Cette action est irréversible.",
|
||||
"inspector.delete.filamentDone": "Filament supprimé.",
|
||||
"inspector.delete.filamentTitle": "Supprimer ce filament ?",
|
||||
"inspector.delete.spool": "Supprimer la bobine",
|
||||
"inspector.delete.spoolBody": "La bobine n° {{id}} de « {{name}} » sera supprimée, ainsi que son historique d’utilisation. Cette action est irréversible.",
|
||||
"inspector.delete.spoolDone": "Bobine supprimée.",
|
||||
"inspector.delete.spoolRemaining": "Il lui reste encore {{weight}} de filament. Archivez-la plutôt, si vous voulez simplement l’écarter.",
|
||||
"inspector.delete.spoolTitle": "Supprimer cette bobine ?",
|
||||
"inspector.delete.vendor": "Supprimer le fabricant",
|
||||
"inspector.delete.vendorBody": "« {{name}} » sera supprimé. Cette action est irréversible.",
|
||||
"inspector.delete.vendorDone": "Fabricant supprimé.",
|
||||
"inspector.delete.vendorOrphans_many": "Ses {{count}} filaments sont conservés, mais se retrouvent sans fabricant.",
|
||||
"inspector.delete.vendorOrphans_one": "Son {{count}} filament est conservé, mais se retrouve sans fabricant.",
|
||||
"inspector.delete.vendorOrphans_other": "Ses {{count}} filaments sont conservés, mais se retrouvent sans fabricant.",
|
||||
"inspector.delete.vendorTitle": "Supprimer ce fabricant ?",
|
||||
"inspector.duplicateFilament": "Dupliquer",
|
||||
"inspector.empty": "Sélectionnez une bobine, un filament ou un fabricant dans la liste pour en afficher le détail ici.",
|
||||
"inspector.enterValidLength": "Saisissez une longueur valide.",
|
||||
"inspector.enterValidWeight": "Saisissez un poids valide.",
|
||||
"inspector.loading": "Chargement…",
|
||||
"inspector.manageExtraFields": "Gérer les champs",
|
||||
"inspector.measuredHelp": "Poids total de la bobine et du filament restant, tel que lu sur une balance.",
|
||||
"inspector.multi": "multi ×{{count}}",
|
||||
"inspector.newGross": "Nouveau poids brut :",
|
||||
"inspector.noExtraFields": "Aucun pour l’instant — ajoutez vos propres champs pour suivre ce que Spoolman ne gère pas.",
|
||||
"inspector.notFound": "Cet élément est introuvable. Il a peut-être été supprimé.",
|
||||
"inspector.ofRemaining": "sur {{weight}} restants · {{length}} m",
|
||||
"inspector.openFilament": "Ouvrir le filament",
|
||||
"inspector.openLink": "Ouvrir {{url}} dans un nouvel onglet",
|
||||
"inspector.openManufacturer": "Ouvrir le fabricant",
|
||||
"inspector.override.byFilament": "Remplacé par ce filament — {{value}}",
|
||||
"inspector.override.bySpool": "Remplacé par cette bobine — {{value}}",
|
||||
"inspector.override.overridesFilament": "Remplace le filament — {{value}}",
|
||||
"inspector.override.overridesManufacturer": "Remplace le fabricant — {{value}}",
|
||||
"inspector.specs": "Caractéristiques — modifiables sur place",
|
||||
"inspector.spoolsCount": "Bobines · {{count}}",
|
||||
"inspector.spoolsTotalRemaining": "{{weight}} au total",
|
||||
"inspector.used": "consommé",
|
||||
"inspector.vendorSub_many": "{{count}} filaments",
|
||||
"inspector.vendorSub_one": "{{count}} filament",
|
||||
"inspector.vendorSub_other": "{{count}} filaments",
|
||||
"kofi": "Me donner un pourboire sur Ko-fi",
|
||||
"labels.addHead": "",
|
||||
"labels.addQr": "",
|
||||
"labels.addRect": "",
|
||||
"labels.addSwatch": "",
|
||||
"labels.addText": "",
|
||||
"labels.align": "",
|
||||
"labels.alignCenter": "",
|
||||
"labels.alignLeft": "",
|
||||
"labels.alignRight": "",
|
||||
"labels.blank": "",
|
||||
"labels.bold": "",
|
||||
"labels.canvasHint": "",
|
||||
"labels.centerLogo": "",
|
||||
"labels.chooseField": "",
|
||||
"labels.clearFill": "",
|
||||
"labels.copies": "",
|
||||
"labels.cornerRadiusMm": "",
|
||||
"labels.createFirst": "",
|
||||
"labels.cutGuides": "",
|
||||
"labels.deleteConfirm": "",
|
||||
"labels.deleteElement": "",
|
||||
"labels.designName": "",
|
||||
"labels.designerDesc": "",
|
||||
"labels.designerTitle": "",
|
||||
"labels.dpi": "",
|
||||
"labels.dpiHint": "",
|
||||
"labels.duplicate": "",
|
||||
"labels.fields.bedTemp": "",
|
||||
"labels.fields.diameterMm": "",
|
||||
"labels.fields.groupVendor": "",
|
||||
"labels.fields.initialG": "",
|
||||
"labels.fields.netWeightG": "",
|
||||
"labels.fields.remainingG": "",
|
||||
"labels.fill": "",
|
||||
"labels.fitWidth": "",
|
||||
"labels.fitWidthHint": "",
|
||||
"labels.fontMm": "",
|
||||
"labels.gridInfo": "",
|
||||
"labels.hMm": "",
|
||||
"labels.heightMm": "",
|
||||
"labels.hintNowrap": "",
|
||||
"labels.hintSwatch": "",
|
||||
"labels.hintWrap": "",
|
||||
"labels.addHead": "Ajouter",
|
||||
"labels.addQr": "QR code",
|
||||
"labels.addRect": "Rectangle",
|
||||
"labels.addSwatch": "Pastille de couleur",
|
||||
"labels.addText": "Texte / champ",
|
||||
"labels.align": "Alignement",
|
||||
"labels.alignCenter": "Centré",
|
||||
"labels.alignLeft": "À gauche",
|
||||
"labels.alignRight": "À droite",
|
||||
"labels.blank": "Vous n’avez encore aucun modèle d’étiquette.",
|
||||
"labels.bold": "Gras",
|
||||
"labels.canvasHint": "Glissez pour déplacer · cliquez pour sélectionner · poignées pour redimensionner.",
|
||||
"labels.centerLogo": "Centrer le logo Spoolman",
|
||||
"labels.chooseField": "Choisissez un champ…",
|
||||
"labels.clearFill": "Supprimer le remplissage (contour seul)",
|
||||
"labels.copies": "Exemplaires",
|
||||
"labels.cornerRadiusMm": "Rayon des coins",
|
||||
"labels.createFirst": "Créez votre première étiquette",
|
||||
"labels.cutGuides": "Repères de coupe",
|
||||
"labels.deleteConfirm": "Supprimer ce modèle d’étiquette ?",
|
||||
"labels.deleteElement": "Supprimer l’élément",
|
||||
"labels.designName": "Nom du modèle",
|
||||
"labels.designerDesc": "Concevez des étiquettes à QR code avec n’importe quel champ de bobine, de filament ou de fabricant, puis imprimez-les sur planche ou sur une imprimante d’étiquettes. Les modèles sont enregistrés sur le serveur.",
|
||||
"labels.designerTitle": "Éditeur d’étiquettes",
|
||||
"labels.dpi": "Résolution",
|
||||
"labels.dpiHint": "Accordez-la à votre imprimante pour que les petites étiquettes restent nettes : 203 ppp pour la plupart des imprimantes thermiques, 300 ou plus pour le laser et le jet d’encre.",
|
||||
"labels.duplicate": "Dupliquer",
|
||||
"labels.exportDescAml": "Enregistre un fichier d’étiquette .aml par étiquette sélectionnée, chacune de {{w}} × {{h}} mm. S’ouvre dans les applications LPAPI fournies avec la plupart des imprimantes d’étiquettes thermiques, qui lisent la taille physique dans le fichier.",
|
||||
"labels.exportDescPng": "Enregistre une image PNG par étiquette sélectionnée, chacune de {{w}} × {{h}} mm.",
|
||||
"labels.exportFormat": "Format de fichier",
|
||||
"labels.exportFormatOptions.aml": "Fichier d’étiquette AML",
|
||||
"labels.exportFormatOptions.png": "Image PNG",
|
||||
"labels.fields.bedTemp": "Temp. plateau",
|
||||
"labels.fields.diameterMm": "Diamètre (mm)",
|
||||
"labels.fields.groupVendor": "Fabricant",
|
||||
"labels.fields.initialG": "Initial (g)",
|
||||
"labels.fields.netWeightG": "Poids net (g)",
|
||||
"labels.fields.remainingG": "Restant (g)",
|
||||
"labels.filamentsSelected_many": "{{count}} filaments sélectionnés",
|
||||
"labels.filamentsSelected_one": "{{count}} filament sélectionné",
|
||||
"labels.filamentsSelected_other": "{{count}} filaments sélectionnés",
|
||||
"labels.fill": "Remplissage",
|
||||
"labels.fitWidth": "Ajuster la largeur aux colonnes",
|
||||
"labels.fitWidthHint": "Règle la largeur de l’étiquette pour que le nombre de colonnes choisi remplisse la page.",
|
||||
"labels.fontMm": "Police",
|
||||
"labels.gridInfo": "{{cols}} × {{rows}} = {{perPage}} étiquettes par page",
|
||||
"labels.hMm": "H",
|
||||
"labels.heightMm": "Hauteur",
|
||||
"labels.hintNowrap": "Le texte reste sur une seule ligne ; ce qui dépasse la largeur est coupé.",
|
||||
"labels.hintSwatch": "Rempli avec la ou les couleurs du filament de la bobine au moment de l’impression.",
|
||||
"labels.hintWrap": "Encadrez un champ pour le masquer lorsqu’il est vide :",
|
||||
"labels.imageDesc": "",
|
||||
"labels.imageZipNote": "",
|
||||
"labels.insertField": "",
|
||||
"labels.kind.qr": "",
|
||||
"labels.kind.rect": "",
|
||||
"labels.kind.swatch": "",
|
||||
"labels.kind.text": "",
|
||||
"labels.labelSize": "",
|
||||
"labels.labelType": "",
|
||||
"labels.layout": "",
|
||||
"labels.modeImage": "",
|
||||
"labels.modeLabel": "",
|
||||
"labels.modeSheet": "",
|
||||
"labels.new": "",
|
||||
"labels.newText": "",
|
||||
"labels.noDesignsOption": "",
|
||||
"labels.noFilaments": "",
|
||||
"labels.noSpools": "",
|
||||
"labels.onePerLabel": "",
|
||||
"labels.preparing": "",
|
||||
"labels.preview": "",
|
||||
"labels.previewWith": "",
|
||||
"labels.radiusMm": "",
|
||||
"labels.sampleData": "",
|
||||
"labels.saved": "",
|
||||
"labels.saving": "",
|
||||
"labels.searchFilaments": "",
|
||||
"labels.searchSpools": "",
|
||||
"labels.selectAllShort": "",
|
||||
"labels.selectAtLeastOne": "",
|
||||
"labels.selectAtLeastOneFilament": "",
|
||||
"labels.selectDesign": "",
|
||||
"labels.selectElement": "",
|
||||
"labels.selectNoneShort": "",
|
||||
"labels.selectRecentHint": "",
|
||||
"labels.selectRecentShort": "",
|
||||
"labels.showingFilament": "",
|
||||
"labels.showingReal": "",
|
||||
"labels.showingSpool": "",
|
||||
"labels.showingTags": "",
|
||||
"labels.sizeMm": "",
|
||||
"labels.skipHint": "",
|
||||
"labels.sortHint": "",
|
||||
"labels.sortId": "",
|
||||
"labels.sortNewest": "",
|
||||
"labels.stroke": "",
|
||||
"labels.strokeMm": "",
|
||||
"labels.tabDesign": "",
|
||||
"labels.tabPrint": "",
|
||||
"labels.textTemplate": "",
|
||||
"labels.typeFilament": "",
|
||||
"labels.typeHint": "",
|
||||
"labels.typeSpool": "",
|
||||
"labels.unknownFilament": "",
|
||||
"labels.wMm": "",
|
||||
"labels.widthMm": "",
|
||||
"labels.wrapText": "",
|
||||
"labels.xMm": "",
|
||||
"labels.yMm": "",
|
||||
"library.apiError": "",
|
||||
"library.collapseGroup": "",
|
||||
"library.emptyBody": "",
|
||||
"library.emptyFiltered": "",
|
||||
"library.emptyTitle": "",
|
||||
"library.expandGroup": "",
|
||||
"library.filterBy": "",
|
||||
"library.groupBy": "",
|
||||
"library.groupNone": "",
|
||||
"library.nUnused": "",
|
||||
"library.noLocation": "",
|
||||
"library.noValues": "",
|
||||
"library.openGroup": "",
|
||||
"library.openManufacturer": "",
|
||||
"library.section.extra": "",
|
||||
"library.section.filament": "",
|
||||
"library.section.spool": "",
|
||||
"library.section.vendor": "",
|
||||
"library.showMore": "",
|
||||
"library.sort.nozzle": "",
|
||||
"library.sortBy": "",
|
||||
"library.unassigned": "",
|
||||
"library.unitGroups": "",
|
||||
"library.unitSpools": "",
|
||||
"library.unused": "",
|
||||
"library.unusedGroup": "",
|
||||
"library.unusedGroupMixed": "",
|
||||
"library.unusedSub": "",
|
||||
"library.unusedSubMixed": "",
|
||||
"library.used": "",
|
||||
"library.usedAgo": "",
|
||||
"labels.imageZipNote": "Au-delà de {{threshold}} étiquettes, le tout est livré dans un seul fichier zip.",
|
||||
"labels.insertField": "Insérer un champ",
|
||||
"labels.kind.qr": "QR code",
|
||||
"labels.kind.rect": "Rectangle",
|
||||
"labels.kind.swatch": "Pastille",
|
||||
"labels.kind.text": "Texte",
|
||||
"labels.labelSize": "Taille de l’étiquette",
|
||||
"labels.labelType": "Type d’étiquette",
|
||||
"labels.layout": "Disposition",
|
||||
"labels.modeImage": "Fichiers",
|
||||
"labels.modeLabel": "Imprimante d’étiquettes",
|
||||
"labels.modeSheet": "Planche (mosaïque)",
|
||||
"labels.new": "Nouveau",
|
||||
"labels.newText": "Nouveau texte",
|
||||
"labels.noDesignsOption": "Aucun modèle pour l’instant",
|
||||
"labels.noFilaments": "Aucun filament.",
|
||||
"labels.noSpools": "Aucune bobine.",
|
||||
"labels.onePerLabel": "Une page de {{w}} × {{h}} mm par étiquette.",
|
||||
"labels.preparing": "Préparation…",
|
||||
"labels.preview": "Aperçu",
|
||||
"labels.previewWith": "Aperçu avec",
|
||||
"labels.printN_many": "Imprimer {{count}} étiquettes",
|
||||
"labels.printN_one": "Imprimer {{count}} étiquette",
|
||||
"labels.printN_other": "Imprimer {{count}} étiquettes",
|
||||
"labels.radiusMm": "Rayon",
|
||||
"labels.sampleData": "Données d’exemple",
|
||||
"labels.saveAsFormat": "Enregistrer au format {{format}}",
|
||||
"labels.saved": "Enregistré",
|
||||
"labels.saving": "Enregistrement…",
|
||||
"labels.searchFilaments": "Rechercher des filaments…",
|
||||
"labels.searchSpools": "Rechercher des bobines…",
|
||||
"labels.selectAllShort": "Tout",
|
||||
"labels.selectAtLeastOne": "Sélectionnez au moins une bobine.",
|
||||
"labels.selectAtLeastOneFilament": "Sélectionnez au moins un filament.",
|
||||
"labels.selectDesign": "Choisir un modèle d’étiquette",
|
||||
"labels.selectElement": "Sélectionnez un élément pour le modifier, ou ajoutez-en un depuis la palette.",
|
||||
"labels.selectNoneShort": "Aucun",
|
||||
"labels.selectRecentHint": "Sélectionner les bobines ajoutées dans les dernières 24 heures",
|
||||
"labels.selectRecentShort": "Récentes ({{count}})",
|
||||
"labels.showingFilament": "Affichage du filament n° {{id}}",
|
||||
"labels.showingReal": "Affichage des données réelles de la bobine.",
|
||||
"labels.showingSpool": "Affichage de la bobine n° {{id}}",
|
||||
"labels.showingTags": "Les champs s’affichent sous forme d’étiquettes tant que vous n’avez pas choisi une bobine ci-dessus.",
|
||||
"labels.sizeMm": "Taille",
|
||||
"labels.skipHint": "« Ignorer des cases » laisse ce nombre de cases vides avant la première étiquette, ce qui permet de réutiliser une planche entamée : comptez les étiquettes déjà décollées, de gauche à droite en partant du haut.",
|
||||
"labels.sortHint": "Trier les bobines",
|
||||
"labels.sortId": "N°",
|
||||
"labels.sortNewest": "Plus récentes",
|
||||
"labels.stroke": "Contour",
|
||||
"labels.strokeMm": "Contour",
|
||||
"labels.tabDesign": "Conception",
|
||||
"labels.tabPrint": "Impression",
|
||||
"labels.textTemplate": "Texte / modèle",
|
||||
"labels.typeFilament": "Filament",
|
||||
"labels.typeHint": "Les étiquettes de filament omettent les champs propres aux bobines, et leur QR code renvoie vers le filament plutôt que vers une bobine.",
|
||||
"labels.typeSpool": "Bobine",
|
||||
"labels.unknownFilament": "Filament inconnu",
|
||||
"labels.wMm": "L",
|
||||
"labels.widthMm": "Largeur",
|
||||
"labels.wrapText": "Retour à la ligne",
|
||||
"labels.xMm": "X",
|
||||
"labels.yMm": "Y",
|
||||
"library.apiError": "Impossible de joindre l’API de Spoolman. Le serveur est-il démarré ?",
|
||||
"library.collapseGroup": "Masquer ces bobines",
|
||||
"library.dateFilter.ago": "il y a {{span}}",
|
||||
"library.dateFilter.between": "{{from}} — {{to}}",
|
||||
"library.dateFilter.customRange": "Période personnalisée",
|
||||
"library.dateFilter.from": "Du",
|
||||
"library.dateFilter.last": "{{span}} écoulés",
|
||||
"library.dateFilter.never": "Jamais",
|
||||
"library.dateFilter.olderThan": "Plus ancien que {{span}}",
|
||||
"library.dateFilter.since": "Depuis le {{date}}",
|
||||
"library.dateFilter.to": "Au (facultatif)",
|
||||
"library.dateFilter.until": "Jusqu’au {{date}}",
|
||||
"library.emptyBody": "Ajoutez votre première bobine pour commencer à suivre votre filament.",
|
||||
"library.emptyFiltered": "Aucune correspondance parmi les {{unit}} avec les filtres actuels.",
|
||||
"library.emptyTitle": "Aucune bobine pour l’instant",
|
||||
"library.expandGroup": "Afficher ces bobines",
|
||||
"library.filterBy": "Filtrer par",
|
||||
"library.groupBy": "Grouper",
|
||||
"library.groupNone": "Aucun (liste simple)",
|
||||
"library.groupSpools_many": "{{count}} bobines",
|
||||
"library.groupSpools_one": "{{count}} bobine",
|
||||
"library.groupSpools_other": "{{count}} bobines",
|
||||
"library.nUnused": "×{{count}} neuves",
|
||||
"library.noLocation": "sans emplacement",
|
||||
"library.noSpools": "Aucune bobine",
|
||||
"library.noValues": "Aucune valeur",
|
||||
"library.openGroup": "Ouvrir {{name}}",
|
||||
"library.openManufacturer": "Ouvrir le fabricant {{name}}",
|
||||
"library.resizeList": "Redimensionner la liste (double-clic pour réinitialiser)",
|
||||
"library.reverseSort": "Inverser l’ordre de tri",
|
||||
"library.section.extra": "Champs supplémentaires",
|
||||
"library.section.filament": "Filament",
|
||||
"library.section.spool": "Bobine",
|
||||
"library.section.vendor": "Fabricant",
|
||||
"library.showMore": "afficher {{count}} de plus",
|
||||
"library.sort.nozzle": "Temp. buse",
|
||||
"library.sortBy": "Trier",
|
||||
"library.unassigned": "non assignée",
|
||||
"library.unitGroups": "groupes",
|
||||
"library.unitSpools": "bobines",
|
||||
"library.unused": "neuve",
|
||||
"library.unusedGroup": "{{count}} neuves · {{weight}} g chacune",
|
||||
"library.unusedGroupMixed": "{{count}} neuves",
|
||||
"library.unusedSub": "{{weight}} g chacune · {{location}}",
|
||||
"library.unusedSubMixed": "{{location}}",
|
||||
"library.used": "entamée",
|
||||
"library.usedAgo": "utilisée il y a {{time}}",
|
||||
"loading": "Chargement",
|
||||
"locations.locations": "Emplacements",
|
||||
"locations.newLocation": "Créer un emplacement",
|
||||
"locations.noLocation": "Pas de localisation",
|
||||
"locations.noLocationsHelp": "Cette page vous permet d'organiser vos bobines par lieu, ajoutez une bobine pour commencer !",
|
||||
"nav.labels": "",
|
||||
"nav.library": "",
|
||||
"nav.labels": "Étiquettes",
|
||||
"nav.library": "Bibliothèque",
|
||||
"no": "Non",
|
||||
"notifications.error": "Erreur (status code : {{statusCode}})",
|
||||
"notifications.saveSuccessful": "Sauvegarde réussie !",
|
||||
"pagination.empty": "",
|
||||
"pagination.next": "",
|
||||
"pagination.pageSize": "",
|
||||
"pagination.perPage": "",
|
||||
"pagination.prev": "",
|
||||
"pagination.range": "",
|
||||
"pagination.empty": "Aucune {{unit}}",
|
||||
"pagination.next": "Page suivante",
|
||||
"pagination.pageSize": "Taille de page",
|
||||
"pagination.perPage": "{{size}} par page",
|
||||
"pagination.prev": "Page précédente",
|
||||
"pagination.range": "{{from}}–{{to}} sur {{total}} {{unit}}",
|
||||
"printing.generic.columns": "Colonnes",
|
||||
"printing.generic.customSize": "Personnaliser",
|
||||
"printing.generic.description": "Ajustez les paramètres ci-dessous pour obtenir la mise en page d'impression souhaitée. Gardez à l'esprit que les imprimantes et votre système d'exploitation peuvent appliquer leurs propres marges et mises à l'échelle, il se peut donc que vous deviez faire quelques essais avant que tout soit correct. Testez-le sur une feuille de papier ordinaire avant d'imprimer les étiquettes en elles mêmes.",
|
||||
"printing.generic.dimensions": "Dimensions",
|
||||
"printing.generic.gridTooWide": "",
|
||||
"printing.generic.gridTooWide": "Les étiquettes sont plus larges que la page. Réduisez le nombre de colonnes, l’espacement ou les marges.",
|
||||
"printing.generic.helpMargin": "Les marges doivent être configurées en fonction de votre papier à étiquettes et de votre imprimante, leur modification aura une incidence sur la taille de l'ensemble de la grille.",
|
||||
"printing.generic.helpPrinterMargin": "La zone de sécurité doit être définie en fonction de la proximité du bord du papier que votre imprimante peut imprimer, la modification de ces paramètres n'affectera pas l'ensemble de la grille.",
|
||||
"printing.generic.horizontalSpacing": "Espacement horizontal",
|
||||
@@ -329,11 +433,11 @@
|
||||
"printing.generic.skipItems": "Articles ignorés",
|
||||
"printing.generic.verticalSpacing": "Espacement vertical",
|
||||
"printing.qrcode.button": "Imprimer des étiquettes",
|
||||
"printing.qrcode.customUrl.caveat": "",
|
||||
"printing.qrcode.customUrl.hint": "",
|
||||
"printing.qrcode.customUrl.label": "",
|
||||
"printing.qrcode.customUrl.caveat": "Une URL personnalisée ne ramènera pas vers Spoolman à la lecture — utilisez-la pour pointer un QR code imprimé vers une application externe ou un autre serveur.",
|
||||
"printing.qrcode.customUrl.hint": "Insérez l’identifiant de la bobine où vous voulez à l’aide du marqueur",
|
||||
"printing.qrcode.customUrl.label": "Modèle d’URL personnalisée",
|
||||
"printing.qrcode.useHTTPUrl.label": "Lien du QR code",
|
||||
"printing.qrcode.useHTTPUrl.options.custom": "",
|
||||
"printing.qrcode.useHTTPUrl.options.custom": "URL personnalisée",
|
||||
"printing.qrcode.useHTTPUrl.options.default": "Défaut",
|
||||
"printing.qrcode.useHTTPUrl.options.url": "URL",
|
||||
"printing.qrcode.useHTTPUrl.preview": "Prévisualisation :",
|
||||
@@ -342,46 +446,47 @@
|
||||
"printing.spoolSelect.selectedTotal_one": "{{count}} bobine sélectionnée",
|
||||
"printing.spoolSelect.selectedTotal_other": "{{count}} bobines sélectionnées",
|
||||
"scanner.description": "Scannez un code QR de Spoolman pour afficher les détails de la bobine.",
|
||||
"scanner.error.inUse": "",
|
||||
"scanner.error.inUse": "La caméra est déjà utilisée par une autre application.",
|
||||
"scanner.error.insecureContext": "La page n'est pas servie par HTTPS.",
|
||||
"scanner.error.notAllowed": "Vous n'avez pas autorisé l'accès à l'appareil photo.",
|
||||
"scanner.error.notFound": "Aucun appareil photo n'a été trouvé.",
|
||||
"scanner.error.streamApiNotSupported": "Le navigateur ne prend pas en charge l'API MediaStream.",
|
||||
"scanner.error.unknown": "Une erreur inconnue s'est produite. ({{error}})",
|
||||
"scanner.starting": "",
|
||||
"scanner.starting": "Démarrage de la caméra…",
|
||||
"scanner.title": "Scanner de code QR",
|
||||
"search.colorHint": "",
|
||||
"search.noResults": "",
|
||||
"search.searching": "",
|
||||
"search.section.filaments": "",
|
||||
"search.section.spools": "",
|
||||
"search.section.vendors": "",
|
||||
"search.threshold": "",
|
||||
"search.unknownFilament": "",
|
||||
"settings.appearance.tab": "",
|
||||
"settings.appearance.theme.dark": "",
|
||||
"settings.appearance.theme.desc": "",
|
||||
"settings.appearance.theme.label": "",
|
||||
"settings.appearance.theme.light": "",
|
||||
"settings.appearance.theme.system": "",
|
||||
"settings.extraFields.addChoice": "",
|
||||
"settings.extraFields.addChoicesFirst": "",
|
||||
"settings.extraFields.addEntityField": "",
|
||||
"search.colorHint": "Les noms de couleurs doivent être saisis en anglais.",
|
||||
"search.moreSpools": "+{{count}} de plus",
|
||||
"search.noResults": "Aucun résultat",
|
||||
"search.searching": "Recherche en cours…",
|
||||
"search.section.filaments": "Filaments",
|
||||
"search.section.spools": "Bobines",
|
||||
"search.section.vendors": "Fabricants",
|
||||
"search.threshold": "Correspondance de couleur",
|
||||
"search.unknownFilament": "Filament inconnu",
|
||||
"settings.appearance.tab": "Apparence",
|
||||
"settings.appearance.theme.dark": "Sombre",
|
||||
"settings.appearance.theme.desc": "Thème de couleurs (ce navigateur uniquement)",
|
||||
"settings.appearance.theme.label": "Thème",
|
||||
"settings.appearance.theme.light": "Clair",
|
||||
"settings.appearance.theme.system": "Système",
|
||||
"settings.extraFields.addChoice": "ajouter un choix…",
|
||||
"settings.extraFields.addChoicesFirst": "Ajoutez d’abord des choix",
|
||||
"settings.extraFields.addEntityField": "Ajouter un champ {{entity}}",
|
||||
"settings.extraFields.booleanFalse": "Non",
|
||||
"settings.extraFields.booleanTrue": "Oui",
|
||||
"settings.extraFields.deleteConfirm": "Supprimer le champ {{name}} ?",
|
||||
"settings.extraFields.deleteConfirm": "Supprimer le champ {{name}} de {{entity}} ?",
|
||||
"settings.extraFields.description.constraints": "Une fois qu'un champ est ajouté, vous ne pouvez plus changer sa clé ou son type, et pour les champs de types \"Choix\", vous ne pouvez plus retirer un choix ou changer l'état des choix multiples. Si vous supprimez un champ, les données associées aux entités seront supprimées.",
|
||||
"settings.extraFields.description.intro": "Ici vous pouvez ajouter des champs personnalisés supplémentaires à vos entités.",
|
||||
"settings.extraFields.description.keyUsage": "La clé correspond à ce que les autres programmes lisent/écrivent, donc si votre champs doit s'intégrer avec un programme tiers, assurez-vous de le configurer correctement. La valeur par défaut n'est appliquées qu'aux nouveaux éléments.",
|
||||
"settings.extraFields.description.tableViews": "Les champs supplementaires ne peuvent pas être triés ou filtrés dans les vues de table.",
|
||||
"settings.extraFields.editorEdit": "",
|
||||
"settings.extraFields.editorNew": "",
|
||||
"settings.extraFields.errors.choiceNeeded": "",
|
||||
"settings.extraFields.errors.choicesRemoved": "",
|
||||
"settings.extraFields.errors.deleteFailed": "",
|
||||
"settings.extraFields.errors.keyFormat": "",
|
||||
"settings.extraFields.errors.nameRequired": "",
|
||||
"settings.extraFields.errors.saveFailed": "",
|
||||
"settings.extraFields.editorEdit": "Modifier le champ {{entity}}",
|
||||
"settings.extraFields.editorNew": "Nouveau champ {{entity}}",
|
||||
"settings.extraFields.errors.choiceNeeded": "Un champ à choix nécessite au moins un choix.",
|
||||
"settings.extraFields.errors.choicesRemoved": "Les choix existants ne peuvent pas être retirés : {{choices}}.",
|
||||
"settings.extraFields.errors.deleteFailed": "La suppression du champ a échoué.",
|
||||
"settings.extraFields.errors.keyFormat": "La clé ne doit contenir que des minuscules, des chiffres et des tirets bas.",
|
||||
"settings.extraFields.errors.nameRequired": "Le nom est obligatoire.",
|
||||
"settings.extraFields.errors.saveFailed": "L’enregistrement du champ a échoué.",
|
||||
"settings.extraFields.fieldType.boolean": "Booléen",
|
||||
"settings.extraFields.fieldType.choice": "Choix",
|
||||
"settings.extraFields.fieldType.datetime": "Date et heure",
|
||||
@@ -390,13 +495,13 @@
|
||||
"settings.extraFields.fieldType.integer": "Entier",
|
||||
"settings.extraFields.fieldType.integerRange": "Plage d'entier",
|
||||
"settings.extraFields.fieldType.text": "Texte",
|
||||
"settings.extraFields.max": "",
|
||||
"settings.extraFields.min": "",
|
||||
"settings.extraFields.multiSuffix": "",
|
||||
"settings.extraFields.multiple": "",
|
||||
"settings.extraFields.namePlaceholder": "",
|
||||
"settings.extraFields.max": "max",
|
||||
"settings.extraFields.min": "min",
|
||||
"settings.extraFields.multiSuffix": " · multiple",
|
||||
"settings.extraFields.multiple": "Multiple",
|
||||
"settings.extraFields.namePlaceholder": "Nom affiché",
|
||||
"settings.extraFields.nonUniqueKeyError": "La clé doit être unique.",
|
||||
"settings.extraFields.none": "",
|
||||
"settings.extraFields.none": "Aucun champ supplémentaire défini pour {{entity}}.",
|
||||
"settings.extraFields.params.choices": "Choix",
|
||||
"settings.extraFields.params.defaultValue": "Valeur par défaut",
|
||||
"settings.extraFields.params.fieldType": "Type",
|
||||
@@ -404,26 +509,42 @@
|
||||
"settings.extraFields.params.name": "Nom",
|
||||
"settings.extraFields.params.order": "Ordre",
|
||||
"settings.extraFields.params.unit": "Unité",
|
||||
"settings.extraFields.saveField": "",
|
||||
"settings.extraFields.reorderChoice": "Faites glisser, ou utilisez les flèches du clavier, pour réordonner ce choix.",
|
||||
"settings.extraFields.saveField": "Enregistrer le champ",
|
||||
"settings.extraFields.tab": "Champs additionnel",
|
||||
"settings.general.baseUrl.label": "Base de l'URL",
|
||||
"settings.general.baseUrl.tooltip": "La base de l'URL à utiliser lors de la génération de QR codes.",
|
||||
"settings.general.currency.desc": "",
|
||||
"settings.general.currency.desc": "Code ISO à trois lettres (par exemple EUR, USD) utilisé pour les prix",
|
||||
"settings.general.currency.label": "Devise",
|
||||
"settings.general.roundPrices.label": "",
|
||||
"settings.general.roundPrices.tooltip": "",
|
||||
"settings.general.roundPrices.label": "Arrondir les prix",
|
||||
"settings.general.roundPrices.tooltip": "Arrondir les prix au nombre entier le plus proche.",
|
||||
"settings.general.tab": "Général",
|
||||
"settings.header": "Paramètres",
|
||||
"settings.language.desc": "",
|
||||
"settings.language.label": "",
|
||||
"settings.library.lowThreshold.desc": "",
|
||||
"settings.library.lowThreshold.label": "",
|
||||
"settings.library.tab": "",
|
||||
"settings.language.desc": "Langue de l’interface (ce navigateur uniquement)",
|
||||
"settings.language.label": "Langue",
|
||||
"settings.library.lowThreshold.desc": "Les bobines à ce poids ou en dessous sont signalées en rouge (ce navigateur uniquement)",
|
||||
"settings.library.lowThreshold.label": "Seuil de stock bas",
|
||||
"settings.library.tab": "Comportement de la bibliothèque",
|
||||
"settings.scanner.autoNavigate.desc": "Ouvrir automatiquement, dans ce navigateur, la bobine ou le filament lu par un lecteur de puces.",
|
||||
"settings.scanner.autoNavigate.label": "Ouverture automatique",
|
||||
"settings.scanner.docs": "Comment configurer un lecteur de puces",
|
||||
"settings.scanner.lastSeen": "il y a {{when}}",
|
||||
"settings.scanner.pair": "Associer par lecture",
|
||||
"settings.scanner.reader.anyDesc": "Les lectures de n’importe quel lecteur ouvrent les bobines dans ce navigateur. Vous pouvez en associer un en particulier si vous souhaitez un contrôle plus fin. C’est aussi ce lecteur qui servira à ajouter de nouvelles puces aux bobines et aux filaments.",
|
||||
"settings.scanner.reader.label": "Lecteur de puces",
|
||||
"settings.scanner.reader.pairedDesc": "Seules les lectures de ce lecteur ouvrent les bobines dans ce navigateur.",
|
||||
"settings.scanner.recent": "Lecteurs récents",
|
||||
"settings.scanner.recentNone": "Aucun lecteur en mémoire. Configurez un lecteur de puces, puis lisez une puce pour le voir apparaître ici.",
|
||||
"settings.scanner.tab": "Lecture de puces",
|
||||
"settings.scanner.unpair": "Utiliser n’importe quel lecteur",
|
||||
"settings.scanner.use": "Utiliser celui-ci",
|
||||
"settings.scanner.waiting": "Présentez une puce sur le lecteur que vous souhaitez associer…",
|
||||
"sponsor": "Soutenez-moi sur GitHub",
|
||||
"spool.fields.archived": "Archivé",
|
||||
"spool.fields.comment": "Commentaire",
|
||||
"spool.fields.filament": "Filament",
|
||||
"spool.fields.firstUsed": "Première utilisation",
|
||||
"spool.fields.id": "ID",
|
||||
"spool.fields.id": "Identifiant",
|
||||
"spool.fields.lastUsed": "Dernière utilisation",
|
||||
"spool.fields.location": "Localisation",
|
||||
"spool.fields.lotNr": "N° de Lot",
|
||||
@@ -432,30 +553,85 @@
|
||||
"spool.fields.price": "Prix",
|
||||
"spool.fields.registered": "Enregistré",
|
||||
"spool.fields.remainingWeight": "Poids restant",
|
||||
"spool.fields.spoolWeight": "Poids de la bobine",
|
||||
"spool.fields.usedWeight": "Poids utilisé",
|
||||
"spool.fields.weight": "Poids",
|
||||
"spool.fieldsHelp.location": "L'emplacement de la bobine si vous stockez vos bobines à plusieurs endroits.",
|
||||
"spool.fieldsHelp.lotNr": "Numéro de lot du fabricant. Peut être utilisé pour s'assurer que la couleur d'une impression est homogène si plusieurs bobines sont utilisées.",
|
||||
"spool.fieldsHelp.measuredWeight": "Le poids du filament et de la bobine.",
|
||||
"spool.fieldsHelp.price": "Prix d'une bobine complète. S'il n'est pas défini, le prix du filament sera pris en compte.",
|
||||
"spool.fieldsHelp.remainingWeight": "La quantité de filament restant sur la bobine. Pour une nouvelle bobine, cela devrait correspondre au poids de la bobine.",
|
||||
"spool.fieldsHelp.spoolWeight": "Ce que pèse cette bobine à vide. Laissez le champ libre pour suivre le poids de bobine du filament, et renseignez-le lorsque cette bobine précise diffère — un rouleau rembobiné, ou un mandrin carton remplacé par du plastique. C’est ce qui est soustrait de la mesure lorsque vous ajustez au poids pesé : une valeur erronée fausse aussi la quantité de filament restante.",
|
||||
"spool.fieldsHelp.usedWeight": "Combien de filament a été utilisé à partir de la bobine. Une bobine neuve devrait avoir 0g utilisé.",
|
||||
"spool.fieldsHelp.weight": "Quantité de filament que contenait cette bobine pleine (poids net, sans compter la bobine elle-même). Laissez le champ libre pour suivre le poids du filament, et renseignez-le lorsque cette bobine précise diffère — un rouleau entamé, ou le même filament acheté sur une bobine plus grande.",
|
||||
"spool.form.measurementType.length": "Longueur",
|
||||
"spool.form.measurementType.weight": "Poids",
|
||||
"spool.spool": "Bobines",
|
||||
"topbar.addSpools": "",
|
||||
"topbar.searchPlaceholder": "",
|
||||
"tags.add": "Ajouter une puce",
|
||||
"tags.added": "Puce associée.",
|
||||
"tags.addedOn": "Ajoutée le {{date}}",
|
||||
"tags.modal.alreadyHere": "Cette puce identifie déjà cette bobine.",
|
||||
"tags.modal.alreadyHereFilament": "Cette puce identifie déjà ce filament.",
|
||||
"tags.modal.badUid": "L’UID d’une puce est hexadécimal, par exemple 04A2B3C4.",
|
||||
"tags.modal.checking": "Vérification…",
|
||||
"tags.modal.failed": "L’association de la puce a échoué.",
|
||||
"tags.modal.filamentNamed": "le filament {{name}}",
|
||||
"tags.modal.free": "Cette puce n’est encore associée à rien.",
|
||||
"tags.modal.intro": "Présenter cette puce sur un lecteur ouvrira désormais la bobine n° {{id}}.",
|
||||
"tags.modal.introFilament": "Présenter cette puce sur un lecteur ouvrira désormais ce filament.",
|
||||
"tags.modal.lastSeenOn": "{{reader}} a lu {{uid}} en dernier.",
|
||||
"tags.modal.link": "Associer la puce",
|
||||
"tags.modal.linking": "Association en cours…",
|
||||
"tags.modal.move": "La déplacer ici depuis la n° {{id}}",
|
||||
"tags.modal.moveFailed": "Le déplacement de la puce a échoué. Elle peut n’être désormais associée à aucune des deux bobines — essayez de l’associer à nouveau.",
|
||||
"tags.modal.moveFailedFilament": "Le déplacement de la puce a échoué. Elle peut n’être désormais associée à rien — essayez de l’associer à nouveau.",
|
||||
"tags.modal.moveHere": "La déplacer ici",
|
||||
"tags.modal.moving": "Déplacement en cours…",
|
||||
"tags.modal.phoneReading": "Maintenez la puce contre votre téléphone…",
|
||||
"tags.modal.phoneScan": "Lire une puce avec ce téléphone",
|
||||
"tags.modal.spoolNamed": "la bobine n° {{id}}, {{name}}",
|
||||
"tags.modal.spoolShort": "la bobine n° {{id}}",
|
||||
"tags.modal.takenBy": "Actuellement associée à {{spool}}.",
|
||||
"tags.modal.title": "Associer une puce",
|
||||
"tags.modal.uidHelp": "Les séparateurs et la casse n’ont aucune importance : 04:a2:b3:c4, 04-A2-B3-C4 et 04a2b3c4 désignent la même puce.",
|
||||
"tags.modal.uidLabel": "UID de la puce",
|
||||
"tags.modal.uidPlaceholder": "04A2B3C4",
|
||||
"tags.modal.useLastSeen": "L’utiliser",
|
||||
"tags.modal.waitingAny": "En attente d’une lecture sur n’importe quel lecteur.",
|
||||
"tags.modal.waitingOn": "En attente d’une lecture sur {{reader}}.",
|
||||
"tags.moved": "Puce déplacée ici depuis la bobine n° {{id}}.",
|
||||
"tags.movedFromFilament": "Puce déplacée ici depuis le filament {{name}}.",
|
||||
"tags.nfc.insecureContext": "La lecture des puces nécessite une connexion sécurisée. Ouvrez Spoolman en HTTPS pour utiliser le NFC de votre téléphone.",
|
||||
"tags.nfc.notAllowed": "Ce site n’est pas autorisé à utiliser le NFC. Accordez-lui la permission dans les réglages de site de votre navigateur, puis réessayez.",
|
||||
"tags.nfc.notReadable": "Le lecteur NFC est indisponible — une autre application l’utilise peut-être.",
|
||||
"tags.nfc.notSupported": "Ce téléphone n’a pas de NFC, ou le NFC est désactivé dans les réglages du système.",
|
||||
"tags.nfc.unknown": "La lecture de la puce a échoué.",
|
||||
"tags.none": "Aucune puce associée.",
|
||||
"tags.scan.openedFilament": "Puce lue : filament {{name}} ouvert.",
|
||||
"tags.scan.openedSpool": "Puce lue : bobine n° {{id}} ouverte, {{name}}.",
|
||||
"tags.scan.unknown": "Puce inconnue {{uid}}. Ouvrez la bobine à laquelle elle appartient et utilisez « Ajouter une puce » pour l’associer.",
|
||||
"tags.section": "Puces",
|
||||
"tags.unlink": "Dissocier",
|
||||
"tags.unlinkBody": "La puce {{uid}} cessera d’identifier cette bobine. Rien n’est écrit sur la puce elle-même : vous pourrez l’associer à nouveau — à cette bobine ou à une autre — quand vous le souhaiterez.",
|
||||
"tags.unlinkBodyFilament": "La puce {{uid}} cessera d’identifier ce filament. Rien n’est écrit sur la puce elle-même : vous pourrez l’associer à nouveau — ici ou ailleurs — quand vous le souhaiterez.",
|
||||
"tags.unlinkFailed": "La dissociation de la puce a échoué.",
|
||||
"tags.unlinkTitle": "Dissocier cette puce ?",
|
||||
"tags.unlinked": "Puce dissociée.",
|
||||
"tags.unlinking": "Dissociation en cours…",
|
||||
"topbar.addSpools": "Ajouter des bobines",
|
||||
"topbar.searchPlaceholder": "Rechercher — essayez « red » ou « pla »",
|
||||
"unknown": "Inconnu",
|
||||
"validation.hexDigits": "",
|
||||
"validation.maxChars": "",
|
||||
"validation.mustBeGt": "",
|
||||
"validation.mustBeMax": "",
|
||||
"validation.mustBeMin": "",
|
||||
"validation.mustBeNumber": "",
|
||||
"validation.required": "",
|
||||
"validation.hexDigits": "Utilisez 6 ou 8 chiffres hexadécimaux",
|
||||
"validation.maxChars": "{{max}} caractères au maximum",
|
||||
"validation.mustBeGt": "Doit être supérieur à {{value}}",
|
||||
"validation.mustBeMax": "Doit être inférieur ou égal à {{value}}",
|
||||
"validation.mustBeMin": "Doit être supérieur ou égal à {{value}}",
|
||||
"validation.mustBeNumber": "Doit être un nombre",
|
||||
"validation.required": "Obligatoire",
|
||||
"vendor.fields.comment": "Commentaire",
|
||||
"vendor.fields.emptySpoolWeight": "Poids de la bobine vide",
|
||||
"vendor.fields.externalId": "ID externe",
|
||||
"vendor.fields.id": "ID",
|
||||
"vendor.fields.id": "Identifiant",
|
||||
"vendor.fields.name": "Nom",
|
||||
"vendor.fields.registered": "Enregistré",
|
||||
"vendor.fieldsHelp.emptySpoolWeight": "Le poids d'une bobine vide de ce fabricant.",
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
{
|
||||
"add.addAndNew": "",
|
||||
"add.adding": "",
|
||||
"add.adding": "Pridedama...",
|
||||
"add.advanced": "",
|
||||
"add.advancedNote": "",
|
||||
"add.change": "",
|
||||
"add.advancedNote": "tankis, skersmuo, temperatūros (automatiškai užpildoma pagal medžiagą)",
|
||||
"add.change": "Keisti",
|
||||
"add.count": "",
|
||||
"add.createNew": "",
|
||||
"add.createNewSub": "",
|
||||
"add.dbUnavailable": "",
|
||||
"add.duplicate": "",
|
||||
"add.duplicateNote": "",
|
||||
"add.duplicateRename": "",
|
||||
"add.createNewSub": "nėra tavo kataloge ar {{name}}",
|
||||
"add.dbUnavailable": "{{name}} nepasiekiams",
|
||||
"add.duplicate": "dvigubinti",
|
||||
"add.duplicateNote": "Spesifikacijos, gamintojas ir kainos buvo per kopijuotos. Pervardinkite ir suteikite spalvą.",
|
||||
"add.duplicateRename": "Pavadinimas nepakeistas — pervardykite, kad galėtumėte atskirti.",
|
||||
"add.duplicateTitle": "",
|
||||
"add.filamentNamePlaceholder": "",
|
||||
"add.fill.full": "",
|
||||
"add.fill.full": "Pilnas",
|
||||
"add.fillLevel": "",
|
||||
"add.importNote": "",
|
||||
"add.inCatalog": "",
|
||||
"add.locationHint": "",
|
||||
"add.locationPlaceholder": "",
|
||||
"add.manufacturerPlaceholder": "",
|
||||
"add.locationHint": "Kur fiziškai laikote ritę – lentynoje, dėžėje ar ant spausdintuvo. Palikite lauką tuščią, jei visas jas laikote vienoje vietoje.",
|
||||
"add.locationPlaceholder": "pvz. Lentyna A, Džiovinimo dėžė 2, Spausdintuvas 1",
|
||||
"add.manufacturerPlaceholder": "pvz. Polymaker",
|
||||
"add.nameHint": "",
|
||||
"add.newFilament": "",
|
||||
"add.newFilamentTitle": "",
|
||||
"add.noCatalog": "",
|
||||
"add.noManufacturer": "",
|
||||
"add.pickColor": "",
|
||||
"add.searchPlaceholder": "",
|
||||
"add.searching": "",
|
||||
"add.noManufacturer": "Trūksta gamintojo",
|
||||
"add.pickColor": "Pasirinkite spalvą",
|
||||
"add.searchPlaceholder": "Ieškokite savo kataloge ir {{name}}...",
|
||||
"add.searching": "Ieškoma...",
|
||||
"add.spoolWeightPreset.cardboard": "",
|
||||
"add.spoolWeightPreset.plastic": "",
|
||||
"add.spoolWeightPresetsLead": "",
|
||||
"add.step2": "",
|
||||
"add.typeToSearchAll": "",
|
||||
"add.typeToSearchMatches": "",
|
||||
"add.useExisting": "",
|
||||
"add.vendorHint.existing": "",
|
||||
"add.vendorHint.new": "",
|
||||
"add.vendorHint.optional": "",
|
||||
"add.yourCatalog": "",
|
||||
"add.step2": "Žingsnis 2 · ritės duomenys",
|
||||
"add.typeToSearchAll": "Rašykite, norėdami ieškoti tarp tūkstančių gijų",
|
||||
"add.typeToSearchMatches": "Rašykite – atitikmenų nerasta",
|
||||
"add.useExisting": "Naudoti esamą",
|
||||
"add.vendorHint.existing": "Naudoti esamą gamintoją „{{name}}“",
|
||||
"add.vendorHint.new": "Naujas gamintojas „{{name}}“ bus sukurtas",
|
||||
"add.vendorHint.optional": "Nebūtina – palikite tuščią, jei gamintojo nėra",
|
||||
"add.yourCatalog": "Jūsų katalogas",
|
||||
"buttons.archive": "Archyvas",
|
||||
"buttons.cancel": "Atšaukti",
|
||||
"buttons.clear": "Išvalyti",
|
||||
"buttons.close": "",
|
||||
"buttons.close": "Uždaryti",
|
||||
"buttons.delete": "Šalinti",
|
||||
"buttons.edit": "Redaguoti",
|
||||
"buttons.filter": "Filtras",
|
||||
@@ -51,40 +51,40 @@
|
||||
"buttons.unArchive": "Išarchyvuoti",
|
||||
"common.decrement": "",
|
||||
"common.increment": "",
|
||||
"common.remove": "",
|
||||
"common.search": "",
|
||||
"dashboard.addFieldHint": "",
|
||||
"common.remove": "Panaikinti",
|
||||
"common.search": "Ieškoti",
|
||||
"dashboard.addFieldHint": "Nustatymuose prie ritinių pridėkite teksto arba pasirinkimo lauką, kad pagal jį taip pat būtų galima grupuoti",
|
||||
"dashboard.dashboard": "",
|
||||
"dashboard.deleteGroup": "",
|
||||
"dashboard.dragHint": "",
|
||||
"dashboard.dropHere": "",
|
||||
"dashboard.deleteGroup": "Ištrinti grupę",
|
||||
"dashboard.dragHint": "Nutempkite ritę prie kitos kortelės, kad pakeistumėte jos {{field}}, arba ant kortelės, kad pakeistumėte tvarką",
|
||||
"dashboard.dropHere": "Čia dėkite rites",
|
||||
"dashboard.emptyHelp": "",
|
||||
"dashboard.errorEmptyName": "",
|
||||
"dashboard.errorExists": "",
|
||||
"dashboard.errorRename": "",
|
||||
"dashboard.lastUsed": "",
|
||||
"dashboard.newGroup": "",
|
||||
"dashboard.newGroupName": "",
|
||||
"dashboard.reorderGroup": "",
|
||||
"dashboard.unassigned": "",
|
||||
"datetime.done": "",
|
||||
"datetime.hour": "",
|
||||
"datetime.minute": "",
|
||||
"datetime.nextMonth": "",
|
||||
"datetime.now": "",
|
||||
"datetime.pick": "",
|
||||
"datetime.prevMonth": "",
|
||||
"datetime.time": "",
|
||||
"dashboard.errorEmptyName": "Pavadinimo laukas negali būti tuščias",
|
||||
"dashboard.errorExists": "Tokio pavadinimo grupė jau egzistuoja",
|
||||
"dashboard.errorRename": "Nepavyko pervardyti grupės",
|
||||
"dashboard.lastUsed": "Paskutinį kartą naudota prieš {{time}}",
|
||||
"dashboard.newGroup": "Nauja grupė",
|
||||
"dashboard.newGroupName": "Nauja grupė {{n}}",
|
||||
"dashboard.reorderGroup": "Temkite, kad perrūšiuotumėte grupes",
|
||||
"dashboard.unassigned": "Nepriskita",
|
||||
"datetime.done": "Baigta",
|
||||
"datetime.hour": "Valanda",
|
||||
"datetime.minute": "Minutė",
|
||||
"datetime.nextMonth": "Kitas mėnuo",
|
||||
"datetime.now": "Dabar",
|
||||
"datetime.pick": "Pasirinkite datą ir laiką",
|
||||
"datetime.prevMonth": "Praeitas mėnuo",
|
||||
"datetime.time": "Laikas",
|
||||
"documentTitle.dashboard.list": "",
|
||||
"documentTitle.labels.list": "",
|
||||
"documentTitle.labels.list": "Etiketės | Spoolman",
|
||||
"documentTitle.library.list": "",
|
||||
"documentTitle.locations.list": "Vieta | Spoolman",
|
||||
"documentTitle.settings.list": "",
|
||||
"filament.fields.addColor": "",
|
||||
"filament.fields.addColor": "Pridėti spalvą",
|
||||
"filament.fields.articleNumber": "Gaminio numeris",
|
||||
"filament.fields.coaxial": "Koekstruduotas",
|
||||
"filament.fields.colorHex": "Spalva",
|
||||
"filament.fields.colorType": "",
|
||||
"filament.fields.colorType": "Spalvos tipas",
|
||||
"filament.fields.comment": "Komentaras",
|
||||
"filament.fields.density": "Tankis",
|
||||
"filament.fields.diameter": "Skersmuo",
|
||||
@@ -96,7 +96,7 @@
|
||||
"filament.fields.name": "Pavadinimas",
|
||||
"filament.fields.price": "Kaina",
|
||||
"filament.fields.registered": "Registruota",
|
||||
"filament.fields.removeColor": "",
|
||||
"filament.fields.removeColor": "Panaikinti spalvą",
|
||||
"filament.fields.settingsBedTemp": "Pado temperatūra",
|
||||
"filament.fields.settingsExtruderTemp": "Ekstruderio temperatūra",
|
||||
"filament.fields.singleColor": "Viena",
|
||||
@@ -104,12 +104,12 @@
|
||||
"filament.fields.vendor": "Gamintojas",
|
||||
"filament.fields.weight": "Svoris",
|
||||
"filament.fieldsHelp.articleNumber": "Pvz. EAN, UPC, ir tt.",
|
||||
"filament.fieldsHelp.multiColorDirection": "Gijos turėti kelias spalvas dviem būdais: arba dėl koekstruzijos, kaip dviejų spalvų gijos su nuosekliomis daugiaspalvėmis spalvomis, arba dėl išilginių spalvų pokyčių, kaip gradientinės gijos, kurios keičia spalvas išilgai ritės.",
|
||||
"filament.fieldsHelp.spoolWeight": "Tuščios ritės svoris. Naudojamas išmatuotam ritės svoriui nustatyti.",
|
||||
"filament.fieldsHelp.multiColorDirection": "Gijos gali turėti kelias spalvas dviem būdais: arba dėl koekstruzijos, kaip dviejų spalvų gijos su nuosekliomis daugiaspalvėmis spalvomis, arba dėl išilginių spalvų pokyčių, kaip gradientinės gijos, kurios keičia spalvas išilgai ritės.",
|
||||
"filament.fieldsHelp.spoolWeight": "Tuščios ritės svoris. Naudojamas išmatuotam ritės svoriui nustatyti. Nesi tikras? Kartoninės ritės dažniausiai sveria 130 – 150 g. ir plastikinės 180 – 250 g, — tiksliam svoriui nustatyti pasverkite ritę jai pasibaigus.",
|
||||
"filament.fieldsHelp.weight": "Pilnos ritės gijos svoris (grynasis svoris). Į šį skaičių neturėtų būti įtrauktas pačios ritės svoris, tik gijos svoris. Tai paprastai nurodoma ant pakuotės.",
|
||||
"filament.filament": "Gijos",
|
||||
"footer.documentation": "",
|
||||
"footer.reportIssue": "",
|
||||
"footer.reportIssue": "Praneškite apie problemą",
|
||||
"help.help": "Pagalba",
|
||||
"inspector.addSpoolsOfThis": "",
|
||||
"inspector.adjustFailed": "",
|
||||
|
||||
+227
-155
@@ -1,186 +1,257 @@
|
||||
{
|
||||
"add.addAndNew": "",
|
||||
"add.adding": "",
|
||||
"add.advanced": "",
|
||||
"add.advancedNote": "",
|
||||
"add.change": "",
|
||||
"add.count": "",
|
||||
"add.createNew": "",
|
||||
"add.createNewSub": "",
|
||||
"add.dbUnavailable": "",
|
||||
"add.duplicate": "",
|
||||
"add.duplicateNote": "",
|
||||
"add.duplicateRename": "",
|
||||
"add.duplicateTitle": "",
|
||||
"add.filamentNamePlaceholder": "",
|
||||
"add.fill.full": "",
|
||||
"add.fillLevel": "",
|
||||
"add.importNote": "",
|
||||
"add.inCatalog": "",
|
||||
"add.locationHint": "",
|
||||
"add.locationPlaceholder": "",
|
||||
"add.manufacturerPlaceholder": "",
|
||||
"add.nameHint": "",
|
||||
"add.newFilament": "",
|
||||
"add.newFilamentTitle": "",
|
||||
"add.noCatalog": "",
|
||||
"add.noManufacturer": "",
|
||||
"add.pickColor": "",
|
||||
"add.searchPlaceholder": "",
|
||||
"add.searching": "",
|
||||
"add.spoolWeightPreset.cardboard": "",
|
||||
"add.spoolWeightPreset.plastic": "",
|
||||
"add.spoolWeightPresetsLead": "",
|
||||
"add.step2": "",
|
||||
"add.typeToSearchAll": "",
|
||||
"add.typeToSearchMatches": "",
|
||||
"add.useExisting": "",
|
||||
"add.vendorHint.existing": "",
|
||||
"add.vendorHint.new": "",
|
||||
"add.vendorHint.optional": "",
|
||||
"add.yourCatalog": "",
|
||||
"add.addAndNew": "Toevoegen & nieuw",
|
||||
"add.addN_one": "Voeg {{count}} spoel toe",
|
||||
"add.addN_other": "Voeg {{count}} spoelen toe",
|
||||
"add.adding": "Toevoegen...",
|
||||
"add.advanced": "Geavanceerde specificaties",
|
||||
"add.advancedNote": "dichtheid, diameter, temperaturen (automatisch ingevuld via het material)",
|
||||
"add.change": "aanpassen",
|
||||
"add.count": "Aantal",
|
||||
"add.createNew": "Maak nieuw filament aan",
|
||||
"add.createNewSub": "niet in je catalogus of {{name}}",
|
||||
"add.dbUnavailable": "{{name}} niet beschikbaar",
|
||||
"add.duplicate": "duplicaat",
|
||||
"add.duplicateNote": "Specificaties, fabrikant en prijs zijn gekopieerd. Geef het een nieuwe naam en kleur.",
|
||||
"add.duplicateRename": "Nog steeds dezelfde naam als het origineel — geef het een andere naam om ze van elkaar te kunnen onderscheiden.",
|
||||
"add.duplicateTitle": "Nieuw filament · kopie van “{{name}}”",
|
||||
"add.filamentNamePlaceholder": "bijv. PolyTerra Matte Sage",
|
||||
"add.fill.full": "Vol",
|
||||
"add.fillLevel": "Vul niveau",
|
||||
"add.importNote": "Dit filament wordt aan je catalogus toegevoegd wanneer je de spoelen toevoegt.",
|
||||
"add.inCatalog": "in catalogus",
|
||||
"add.locationHint": "Waar je de spoel fysiek bewaart — op een plank, in een doos of in een printer. Laat dit veld leeg als je ze allemaal op één plek bewaart.",
|
||||
"add.locationPlaceholder": "bijv. plank A, container 2, printer 1",
|
||||
"add.manufacturerPlaceholder": "bijv. Polymaker",
|
||||
"add.nameHint": "Vermeld de kleur — daarmee kun je de filamenten van elkaar onderscheiden.",
|
||||
"add.newFilament": "nieuw filament",
|
||||
"add.newFilamentTitle": "Nieuw filament",
|
||||
"add.noCatalog": "Geen filamenten in je catalogus",
|
||||
"add.noManufacturer": "Geen fabrikant",
|
||||
"add.pickColor": "Kies kleur",
|
||||
"add.searchPlaceholder": "Zoek je catalogus en {{name}}...",
|
||||
"add.searching": "Zoeken...",
|
||||
"add.section.filament": "Filament",
|
||||
"add.section.filamentNote": "Het product op de spoel — een vermelding per kleur.",
|
||||
"add.section.manufacturer": "Fabrikant",
|
||||
"add.section.manufacturerNote": "Wie maakt het filament. Eenmalig toegevoegd en vervolgens voor al hun filamenten hergebruikt.",
|
||||
"add.section.spool": "Spoel",
|
||||
"add.section.spoolNote": "De fysieke spoelen die in je voorraad terechtkomen.",
|
||||
"add.section.spoolSharedNote": "Gewicht, spoelgewicht en prijs worden ook bewaard als standaardinstelling voor het nieuwe filament.",
|
||||
"add.spoolWeightPreset.cardboard": "Karton {{weight}} g",
|
||||
"add.spoolWeightPreset.plastic": "Plastic {{weight}} g",
|
||||
"add.spoolWeightPresetsLead": "Begin vanaf een typische spoel:",
|
||||
"add.step2": "Stap 2 · spoel details",
|
||||
"add.step2New": "Stap 2 · filament & spoel details",
|
||||
"add.summary_one": "{{count}} spoel",
|
||||
"add.summary_other": "{{count}} spoelen",
|
||||
"add.typeToSearchAll": "Typ om duizenden filamenten te doorzoeken",
|
||||
"add.typeToSearchMatches": "Typ om te zoeken — geen overeenkomsten",
|
||||
"add.useExisting": "gebruik een bestaande",
|
||||
"add.vendorHint.existing": "Gebruik een bestaande fabrikant \"{{name}}\"",
|
||||
"add.vendorHint.new": "Nieuwe fabrikant \"{{name}}\" wordt aangemaakt",
|
||||
"add.vendorHint.optional": "Optioneel — laat leeg voor geen fabrikant",
|
||||
"add.weightPresets": "Algemene spoel gewichten",
|
||||
"add.yourCatalog": "Je catalogus",
|
||||
"buttons.apply": "Toepassen",
|
||||
"buttons.archive": "Archiveren",
|
||||
"buttons.cancel": "Annuleren",
|
||||
"buttons.clear": "Wissen",
|
||||
"buttons.close": "",
|
||||
"buttons.close": "Sluiten",
|
||||
"buttons.delete": "Verwijderen",
|
||||
"buttons.edit": "Bewerken",
|
||||
"buttons.filter": "Filter",
|
||||
"buttons.save": "Opslaan",
|
||||
"buttons.showArchived": "Gearchiveerd tonen",
|
||||
"buttons.showNoSpools": "Laat filamenten zonder spoel zien",
|
||||
"buttons.unArchive": "Terugzetten",
|
||||
"common.decrement": "",
|
||||
"common.increment": "",
|
||||
"common.remove": "",
|
||||
"common.search": "",
|
||||
"dashboard.addFieldHint": "",
|
||||
"dashboard.dashboard": "",
|
||||
"dashboard.deleteGroup": "",
|
||||
"dashboard.dragHint": "",
|
||||
"dashboard.dropHere": "",
|
||||
"dashboard.emptyHelp": "",
|
||||
"dashboard.errorEmptyName": "",
|
||||
"dashboard.errorExists": "",
|
||||
"dashboard.errorRename": "",
|
||||
"dashboard.lastUsed": "",
|
||||
"dashboard.newGroup": "",
|
||||
"dashboard.newGroupName": "",
|
||||
"dashboard.reorderGroup": "",
|
||||
"dashboard.unassigned": "",
|
||||
"datetime.done": "",
|
||||
"datetime.hour": "",
|
||||
"datetime.minute": "",
|
||||
"datetime.nextMonth": "",
|
||||
"datetime.now": "",
|
||||
"datetime.pick": "",
|
||||
"datetime.prevMonth": "",
|
||||
"datetime.time": "",
|
||||
"documentTitle.dashboard.list": "",
|
||||
"documentTitle.labels.list": "",
|
||||
"documentTitle.library.list": "",
|
||||
"changeFilament.action": "Aanpassen",
|
||||
"changeFilament.adoptWeight": "Stel het volledige gewicht van deze spoel in op {{full}}",
|
||||
"changeFilament.adoptWeightHelp": "Het wordt geregistreerd als {{current}}, op basis van het filament dat het voorheen bevatte. Gebruikt gewicht ({{used}}) blijft hoe dan ook behouden, waardoor {{remaining}} overblijft.",
|
||||
"changeFilament.apply": "Pas filament aan",
|
||||
"changeFilament.applying": "Aanpassend...",
|
||||
"changeFilament.current": "huidig",
|
||||
"changeFilament.done": "Filament aangepast.",
|
||||
"changeFilament.failed": "Kon het filament niet veranderen.",
|
||||
"changeFilament.importNote": "Dit filament wordt aan je catalogus toegevoegd wanneer je de wijziging toepast.",
|
||||
"changeFilament.intro": "Spoel #{{id}} behoudt zijn nummer, locatie, data en gebruiksgeschiedenis — alleen het filament dat erin zit, verandert.",
|
||||
"changeFilament.newTag": "nieuw",
|
||||
"changeFilament.next": "Verander naar",
|
||||
"changeFilament.now": "Huidig",
|
||||
"changeFilament.pickBelow": "Kies een filament hieronder",
|
||||
"changeFilament.title": "Verander filament",
|
||||
"changeFilament.useExisting": "Kies een bestaand filament",
|
||||
"changeFilament.weightFollows": "Deze spoel heeft zelf geen volledig gewicht, dus er is nieuw filament nodig: {{full}}, waardoor {{remaining}} overblijft na de {{used}} die al is gebruikt.",
|
||||
"changeVendor.action": "Aanpassen",
|
||||
"changeVendor.apply": "Pas fabrikant aan",
|
||||
"changeVendor.applying": "Aanpassend...",
|
||||
"changeVendor.clearHint": "Verlaat dit filament zonder fabrikant",
|
||||
"changeVendor.create": "Maak \"{{name}}\" aan",
|
||||
"changeVendor.createHint": "Deze fabrikant bestaat nog niet in je lijst — het zal worden toegevoegd",
|
||||
"changeVendor.current": "huidig",
|
||||
"changeVendor.done": "Fabrikant gewijzigd.",
|
||||
"changeVendor.emptySpool": "Lege spoel {{value}}",
|
||||
"changeVendor.failed": "Kon de fabrikant niet aanpassen.",
|
||||
"changeVendor.intro": "“{{name}}” behoudt zijn spoelen, specificaties en geschiedenis — alleen de fabrikant waaronder het is bewaard wijzigt.",
|
||||
"changeVendor.next": "Verander naar",
|
||||
"changeVendor.noEmptySpool": "Geen leeg spoel gewicht",
|
||||
"changeVendor.noMatches": "Kan fabrikant niet vinden",
|
||||
"changeVendor.none": "Nog geen fabrikant",
|
||||
"changeVendor.noneNote": "Opgeslagen zonder merk",
|
||||
"changeVendor.now": "Huidig",
|
||||
"changeVendor.pickBelow": "Kies een fabrikant hieronder",
|
||||
"changeVendor.searchPlaceholder": "Zoek of noem een fabrikant",
|
||||
"changeVendor.tareFollows": "Dit filament heeft geen eigen leeg spoelgewicht, dus het is van de fabrikant. Na de verandering is dat {{value}}, wat een lege spoel weegt als je het meet.",
|
||||
"changeVendor.tareGone": "Dit filament heeft zelf geen leeg spoelgewicht en neemt de {{current}} van de fabrikant over. Na de aanpassing is er niks om in te vullen, totdat je het filament toevoegt.",
|
||||
"changeVendor.title": "Pas fabrikant aan",
|
||||
"changeVendor.willBeCreated": "Wordt aangemaakt",
|
||||
"common.decrement": "Verlagen",
|
||||
"common.increment": "Verhogen",
|
||||
"common.remove": "Verwijder",
|
||||
"common.search": "Zoek",
|
||||
"dashboard.addFieldHint": "Voeg een tekst- of keuzeveld toe aan de spoelen in Instellingen om er ook op te groeperen",
|
||||
"dashboard.dashboard": "Dashboard",
|
||||
"dashboard.deleteGroup": "Verwijder groep",
|
||||
"dashboard.dragHint": "Sleep een spoel naar een andere kaart om het {{field}} te wijzigen, of binnen een kaart om opnieuw te bestellen",
|
||||
"dashboard.dropHere": "laat spoelen hier los",
|
||||
"dashboard.emptyHelp": "Deze pagina groepeert je spoelen op een veld naar keuze, voeg wat spoelen toe om aan de slag te gaan!",
|
||||
"dashboard.errorEmptyName": "Naam mag niet leeg zijn",
|
||||
"dashboard.errorExists": "Een groep met die naam bestaat al",
|
||||
"dashboard.errorRename": "Hernoemen van groep is mislukt",
|
||||
"dashboard.lastUsed": "Laatst gebruikt {{time}} geleden",
|
||||
"dashboard.newGroup": "Nieuwe groep",
|
||||
"dashboard.newGroupName": "Nieuwe groep {{n}}",
|
||||
"dashboard.reorderGroup": "Sleep om de groep te herordenen",
|
||||
"dashboard.spoolCount_one": "{{count}} spoel",
|
||||
"dashboard.spoolCount_other": "{{count}} spoelen",
|
||||
"dashboard.unassigned": "Niet toegewezen",
|
||||
"datetime.done": "Klaar",
|
||||
"datetime.hour": "uur",
|
||||
"datetime.minute": "minuut",
|
||||
"datetime.nextMonth": "Volgende maand",
|
||||
"datetime.now": "Nu",
|
||||
"datetime.pick": "Kies datum en tijd",
|
||||
"datetime.prevMonth": "Vorige maand",
|
||||
"datetime.time": "Tijd",
|
||||
"documentTitle.dashboard.list": "Dashboard | Spoolman",
|
||||
"documentTitle.labels.list": "Labels | Spoolman",
|
||||
"documentTitle.library.list": "Bibliotheek | Spoolman",
|
||||
"documentTitle.locations.list": "Locaties | Spoolman",
|
||||
"documentTitle.settings.list": "",
|
||||
"filament.fields.addColor": "",
|
||||
"documentTitle.settings.list": "Instellingen | Spoolman",
|
||||
"filament.fields.addColor": "Voeg kleur toe",
|
||||
"filament.fields.articleNumber": "Artikel nummer",
|
||||
"filament.fields.coaxial": "Gecoëxtrudeerde",
|
||||
"filament.fields.coaxial": "Gecoëxtrudeerd",
|
||||
"filament.fields.colorHex": "Kleur",
|
||||
"filament.fields.colorType": "",
|
||||
"filament.fields.colorType": "Kleurtype",
|
||||
"filament.fields.comment": "Opmerking",
|
||||
"filament.fields.density": "Dichtheid",
|
||||
"filament.fields.diameter": "Diameter",
|
||||
"filament.fields.externalId": "Extern ID",
|
||||
"filament.fields.externalId": "Externe ID",
|
||||
"filament.fields.id": "ID",
|
||||
"filament.fields.longitudinal": "Longitudinaal",
|
||||
"filament.fields.material": "Materiaal",
|
||||
"filament.fields.multiColor": "Meervoudig",
|
||||
"filament.fields.name": "Naam",
|
||||
"filament.fields.opacity": "Ondoorzichtigheid",
|
||||
"filament.fields.price": "Prijs",
|
||||
"filament.fields.registered": "Geregistreerd",
|
||||
"filament.fields.removeColor": "",
|
||||
"filament.fields.settingsBedTemp": "Bed temperatuur",
|
||||
"filament.fields.removeColor": "Verwijder kleur",
|
||||
"filament.fields.settingsBedTemp": "Bed Temperatuur",
|
||||
"filament.fields.settingsExtruderTemp": "Extruder Temperatuur",
|
||||
"filament.fields.singleColor": "Enkel",
|
||||
"filament.fields.spoolWeight": "Gewicht spoel",
|
||||
"filament.fields.spoolWeight": "Gewicht Spoel",
|
||||
"filament.fields.vendor": "Fabrikant",
|
||||
"filament.fields.weight": "Gewicht",
|
||||
"filament.fieldsHelp.articleNumber": "Bv. EAN. UPC, enz.",
|
||||
"filament.fieldsHelp.multiColorDirection": "Filamenten kunnen op twee manieren meerdere kleuren hebben: door coëxtrusie, zoals tweekleurige filamenten met consistente meerkleuren, of door kleurveranderingen in de lengterichting, zoals verloopfilamenten die van kleur veranderen langs de spoel.",
|
||||
"filament.fieldsHelp.spoolWeight": "Het gewicht van een lege spoel. Wordt gebruikt om het gemeten gewicht van een spoel te bepalen.",
|
||||
"filament.fieldsHelp.articleNumber": "Bijv. EAN, UPC, enz.",
|
||||
"filament.fieldsHelp.multiColorDirection": "Filamenten kunnen op twee manieren meerdere kleuren hebben: door coëxtrusie, zoals tweekleurige filamenten met consistente meerkleurigheid, of door kleurveranderingen in de lengterichting, zoals verloopfilamenten die van kleur veranderen langs de spoel.",
|
||||
"filament.fieldsHelp.spoolWeight": "Het gewicht van een lege spoel. Wordt gebruikt om het gemeten gewicht van een spoel te bepalen. Onzeker? Kartonnen spoelen wegen meestal 130–150 g en plastic spoelen 180–250 g — voor een exact aantal, zet de spoel op een weegschaal zodra deze leeg is.",
|
||||
"filament.fieldsHelp.weight": "Het filamentgewicht van een volle spoel (nettogewicht). Dit mag niet het gewicht van de spoel zelf omvatten, alleen het filament. Dit staat meestal op de verpakking.",
|
||||
"filament.filament": "Filamenten",
|
||||
"footer.documentation": "",
|
||||
"footer.reportIssue": "",
|
||||
"footer.documentation": "Documentatie",
|
||||
"footer.reportIssue": "Meld een probleem",
|
||||
"footer.updateAvailable": "v{{version}} beschikbaar",
|
||||
"help.help": "Help",
|
||||
"inspector.addSpoolsOfThis": "",
|
||||
"inspector.adjustFailed": "",
|
||||
"inspector.adjustHelp": "",
|
||||
"inspector.adjustWeight": "",
|
||||
"inspector.apply": "",
|
||||
"inspector.applying": "",
|
||||
"inspector.colorHex": "",
|
||||
"inspector.consumeAmount": "",
|
||||
"inspector.delete.deleting": "",
|
||||
"inspector.delete.errorGone": "",
|
||||
"inspector.delete.errorSpoolsExist": "",
|
||||
"inspector.delete.errorUnknown": "",
|
||||
"inspector.delete.filament": "",
|
||||
"inspector.delete.filamentBlockedHint": "",
|
||||
"inspector.delete.filamentBlockedTitle": "",
|
||||
"inspector.delete.filamentBody": "",
|
||||
"inspector.delete.filamentDone": "",
|
||||
"inspector.delete.filamentTitle": "",
|
||||
"inspector.delete.spool": "",
|
||||
"inspector.delete.spoolBody": "",
|
||||
"inspector.delete.spoolDone": "",
|
||||
"inspector.delete.spoolRemaining": "",
|
||||
"inspector.delete.spoolTitle": "",
|
||||
"inspector.delete.vendor": "",
|
||||
"inspector.delete.vendorBody": "",
|
||||
"inspector.delete.vendorDone": "",
|
||||
"inspector.delete.vendorTitle": "",
|
||||
"inspector.duplicateFilament": "",
|
||||
"inspector.empty": "",
|
||||
"inspector.enterValidLength": "",
|
||||
"inspector.enterValidWeight": "",
|
||||
"inspector.loading": "",
|
||||
"inspector.manageExtraFields": "",
|
||||
"inspector.measuredHelp": "",
|
||||
"inspector.multi": "",
|
||||
"inspector.newGross": "",
|
||||
"inspector.noExtraFields": "",
|
||||
"inspector.notFound": "",
|
||||
"inspector.ofRemaining": "",
|
||||
"inspector.openFilament": "",
|
||||
"inspector.openLink": "",
|
||||
"inspector.openManufacturer": "",
|
||||
"inspector.specs": "",
|
||||
"inspector.spoolsCount": "",
|
||||
"inspector.spoolsTotalRemaining": "",
|
||||
"inspector.used": "",
|
||||
"inspector.addSpoolsOfThis": "Voeg spoelen hiervan toe",
|
||||
"inspector.adjustFailed": "Aanpassen van gewicht is mislukt.",
|
||||
"inspector.adjustHelp": "Positief verbruikt filament, negatief voegt het terug.",
|
||||
"inspector.adjustWeight": "Pas gewicht aan",
|
||||
"inspector.apply": "Toepassen",
|
||||
"inspector.applying": "Toepassen...",
|
||||
"inspector.colorHex": "Hex kleur",
|
||||
"inspector.consumeAmount": "Verbruikte hoeveelheid:",
|
||||
"inspector.defaultFrom.filament": "{{value}} · standaard filament",
|
||||
"inspector.defaultFrom.manufacturer": "{{value}} · standaard fabrikant",
|
||||
"inspector.delete.deleting": "Verwijderen...",
|
||||
"inspector.delete.errorGone": "Het was al verwijderd.",
|
||||
"inspector.delete.errorSpoolsExist": "Kan dit filament niet verwijderen — spoelen gebruiken het nog steeds.",
|
||||
"inspector.delete.errorUnknown": "Verwijderen mislukt. Zie de server logs voor details.",
|
||||
"inspector.delete.filament": "Verwijder filament",
|
||||
"inspector.delete.filamentBlockedHint": "Verwijder die spoelen eerst. Het archiveren ervan is niet voldoende.",
|
||||
"inspector.delete.filamentBlockedTitle": "Dit filament kan niet verwijderd woden",
|
||||
"inspector.delete.filamentBlocked_one": "{{count}} spoel gebruikt nog steeds “{{name}}”, inclusief de gearchiveerde.",
|
||||
"inspector.delete.filamentBlocked_other": "{{count}} spoelen gebruiken nog steeds “{{name}}”, inclusief de gearchiveerde.",
|
||||
"inspector.delete.filamentBody": "“{{name}}” wordt uit je catalogus verwijderd. Dit kan niet ongedaan worden gemaakt.",
|
||||
"inspector.delete.filamentDone": "Filament verwijderd.",
|
||||
"inspector.delete.filamentTitle": "Verwijder dit filament?",
|
||||
"inspector.delete.spool": "Verwijder spoel",
|
||||
"inspector.delete.spoolBody": "Spoel #{{id}} van “{{name}}” wordt verwijderd, samen met de gebruiksgeschiedenis. Dit kan niet ongedaan worden gemaakt.",
|
||||
"inspector.delete.spoolDone": "Spoel verwijderd.",
|
||||
"inspector.delete.spoolRemaining": "Er is nog {{weight}} filament over. Archiveer het in plaats daarvan als je het alleen maar wilt opruimen.",
|
||||
"inspector.delete.spoolTitle": "Verwijder deze spoel?",
|
||||
"inspector.delete.vendor": "Verwijder fabrikant",
|
||||
"inspector.delete.vendorBody": "“{{name}}” wordt verwijderd. Dit kan niet ongedaan worden gemaakt.",
|
||||
"inspector.delete.vendorDone": "Fabrikant verwijderd.",
|
||||
"inspector.delete.vendorOrphans_one": "Het filament {{count}} wordt bewaard, maar blijft zonder fabrikant achter.",
|
||||
"inspector.delete.vendorOrphans_other": "De {{count}} filamenten worden bewaard, maar blijven zonder fabrikant achter.",
|
||||
"inspector.delete.vendorTitle": "Verwijder deze fabrikant?",
|
||||
"inspector.duplicateFilament": "Dupliceer",
|
||||
"inspector.empty": "Selecteer een spoel, filament of fabrikant uit de lijst om de details hier te zien.",
|
||||
"inspector.enterValidLength": "Voer een geldige lengte in.",
|
||||
"inspector.enterValidWeight": "Voer een geldig gewicht in.",
|
||||
"inspector.loading": "Laden...",
|
||||
"inspector.manageExtraFields": "Velden beheren",
|
||||
"inspector.measuredHelp": "Totaal gewicht van de spoel + overblijvend filament, afgelezen van een weegschaal.",
|
||||
"inspector.multi": "multi ×{{count}}",
|
||||
"inspector.newGross": "Nieuw brutogewicht:",
|
||||
"inspector.noExtraFields": "Nog geen — voeg je eigen velden toe om iets bij te houden wat Spoolman niet zelf doet.",
|
||||
"inspector.notFound": "Dit item kon niet worden gevonden. Mogelijk is het verwijderd.",
|
||||
"inspector.ofRemaining": "van {{weight}} resterende · {{length}} m",
|
||||
"inspector.openFilament": "Open filament",
|
||||
"inspector.openLink": "Open {{url}} in een nieuw tabblad",
|
||||
"inspector.openManufacturer": "Open fabrikant",
|
||||
"inspector.override.byFilament": "Overschreven door dit filament · {{value}}",
|
||||
"inspector.override.bySpool": "Overschreven door deze spoel · {{value}}",
|
||||
"inspector.override.overridesFilament": "Overschrijft filament · {{value}}",
|
||||
"inspector.override.overridesManufacturer": "Overschrijft fabrikant · {{value}}",
|
||||
"inspector.specs": "Specs — bewerken op zijn plaats",
|
||||
"inspector.spoolsCount": "Spoelen · {{count}}",
|
||||
"inspector.spoolsTotalRemaining": "{{weight}} totaal",
|
||||
"inspector.used": "gebruikt",
|
||||
"inspector.vendorSub_one": "{{count}} filament",
|
||||
"inspector.vendorSub_other": "{{count}} filamenten",
|
||||
"kofi": "Tip mij op Ko-fi",
|
||||
"labels.addHead": "",
|
||||
"labels.addQr": "",
|
||||
"labels.addRect": "",
|
||||
"labels.addSwatch": "",
|
||||
"labels.addText": "",
|
||||
"labels.align": "",
|
||||
"labels.alignCenter": "",
|
||||
"labels.alignLeft": "",
|
||||
"labels.alignRight": "",
|
||||
"labels.blank": "",
|
||||
"labels.bold": "",
|
||||
"labels.canvasHint": "",
|
||||
"labels.centerLogo": "",
|
||||
"labels.chooseField": "",
|
||||
"labels.clearFill": "",
|
||||
"labels.copies": "",
|
||||
"labels.cornerRadiusMm": "",
|
||||
"labels.createFirst": "",
|
||||
"labels.cutGuides": "",
|
||||
"labels.deleteConfirm": "",
|
||||
"labels.deleteElement": "",
|
||||
"labels.designName": "",
|
||||
"labels.designerDesc": "",
|
||||
"labels.addHead": "Voeg toe",
|
||||
"labels.addQr": "QR code",
|
||||
"labels.addRect": "Rechthoek",
|
||||
"labels.addSwatch": "Kleurstaal",
|
||||
"labels.addText": "Tekst / veld",
|
||||
"labels.align": "Uitlijnen",
|
||||
"labels.alignCenter": "Centreren",
|
||||
"labels.alignLeft": "Links",
|
||||
"labels.alignRight": "Rechts",
|
||||
"labels.blank": "Je hebt nog geen labelontwerpen.",
|
||||
"labels.bold": "Vet",
|
||||
"labels.canvasHint": "Sleep om te verplaatsen · klik om te selecteren · handvatten om de grootte te wijzigen.",
|
||||
"labels.centerLogo": "Centreer Spoolman logo",
|
||||
"labels.chooseField": "Kies een veld...",
|
||||
"labels.clearFill": "Leeg vulling (alleen outline)",
|
||||
"labels.copies": "Kopieën",
|
||||
"labels.cornerRadiusMm": "Hoekradius",
|
||||
"labels.createFirst": "Maak je eerste label",
|
||||
"labels.cutGuides": "Snij gids",
|
||||
"labels.deleteConfirm": "Verwijder dit label ontwerp?",
|
||||
"labels.deleteElement": "Verwijder element",
|
||||
"labels.designName": "Ontwerp naam",
|
||||
"labels.designerDesc": "Ontwerp QR-codelabels met elk spoel-, filament- of leveranciersveld en print ze vervolgens op vellen of een labelprinter. Ontwerpen worden opgeslagen op de server.",
|
||||
"labels.designerTitle": "",
|
||||
"labels.dpi": "",
|
||||
"labels.dpiHint": "",
|
||||
@@ -232,7 +303,7 @@
|
||||
"labels.selectAllShort": "",
|
||||
"labels.selectAtLeastOne": "",
|
||||
"labels.selectAtLeastOneFilament": "",
|
||||
"labels.selectDesign": "",
|
||||
"labels.selectDesign": "Selecteer labelontwerp",
|
||||
"labels.selectElement": "",
|
||||
"labels.selectNoneShort": "",
|
||||
"labels.selectRecentHint": "",
|
||||
@@ -368,7 +439,7 @@
|
||||
"settings.extraFields.addEntityField": "",
|
||||
"settings.extraFields.booleanFalse": "Nee",
|
||||
"settings.extraFields.booleanTrue": "Ja",
|
||||
"settings.extraFields.deleteConfirm": "Veld {{name}} verwijderen?",
|
||||
"settings.extraFields.deleteConfirm": "Veld {{name}} van {{entity}} verwijderen?",
|
||||
"settings.extraFields.description.constraints": "Zodra een veld is toegevoegd, kunt u de key of het type ervan niet wijzigen, en voor velden met keuzetypes kunt u geen keuzes verwijderen of de meervoudige velden wijzigen. Als u een veld verwijdert, worden de bijbehorende gegevens voor alle entiteiten verwijderd.",
|
||||
"settings.extraFields.description.intro": "Hier kunt u extra aangepaste velden toevoegen aan uw entiteiten.",
|
||||
"settings.extraFields.description.keyUsage": "De key is hoe andere programma's de gegevens lezen/schrijven, dus of uw aangepaste veld moet worden geïntegreerd met een derde partij programma, zorg ervoor dat u het correct instelt. De standaardwaarde wordt alleen toegepast op nieuwe items.",
|
||||
@@ -399,7 +470,7 @@
|
||||
"settings.extraFields.params.choices": "Keuzes",
|
||||
"settings.extraFields.params.defaultValue": "Standaardwaarde",
|
||||
"settings.extraFields.params.fieldType": "Type",
|
||||
"settings.extraFields.params.key": "Key",
|
||||
"settings.extraFields.params.key": "Sleutel",
|
||||
"settings.extraFields.params.name": "Naam",
|
||||
"settings.extraFields.params.order": "Volgorde",
|
||||
"settings.extraFields.params.unit": "Eenheid",
|
||||
@@ -418,6 +489,7 @@
|
||||
"settings.library.lowThreshold.desc": "",
|
||||
"settings.library.lowThreshold.label": "",
|
||||
"settings.library.tab": "",
|
||||
"sponsor": "Steun me op GitHub",
|
||||
"spool.fields.archived": "Gearchiveerd",
|
||||
"spool.fields.comment": "Opmerking",
|
||||
"spool.fields.filament": "Filament",
|
||||
|
||||
+577
-377
File diff suppressed because it is too large.
Load diff
@@ -9,28 +9,31 @@
|
||||
"add.change": "alterar",
|
||||
"add.count": "Quantidade",
|
||||
"add.createNew": "Criar um novo filamento",
|
||||
"add.createNewSub": "",
|
||||
"add.dbUnavailable": "",
|
||||
"add.duplicate": "",
|
||||
"add.duplicateNote": "",
|
||||
"add.duplicateRename": "",
|
||||
"add.duplicateTitle": "",
|
||||
"add.filamentNamePlaceholder": "",
|
||||
"add.fill.full": "",
|
||||
"add.fillLevel": "",
|
||||
"add.importNote": "",
|
||||
"add.inCatalog": "",
|
||||
"add.locationHint": "",
|
||||
"add.locationPlaceholder": "",
|
||||
"add.manufacturerPlaceholder": "",
|
||||
"add.nameHint": "",
|
||||
"add.newFilament": "",
|
||||
"add.newFilamentTitle": "",
|
||||
"add.noCatalog": "",
|
||||
"add.noManufacturer": "",
|
||||
"add.pickColor": "",
|
||||
"add.searchPlaceholder": "",
|
||||
"add.searching": "",
|
||||
"add.createNewSub": "Ausente em seu catálogo ou no {{name}}",
|
||||
"add.dbUnavailable": "{{name}} indisponível",
|
||||
"add.duplicate": "duplicar",
|
||||
"add.duplicateNote": "As especificações, o fabricante e o preço foram copiados. Dê a ele um novo nome e uma nova cor.",
|
||||
"add.duplicateRename": "Ainda com o mesmo nome do original. Renomeie-o para diferenciá-los.",
|
||||
"add.duplicateTitle": "Novo filamento · cópia de “{{name}}”",
|
||||
"add.filamentNamePlaceholder": "por exemplo: PLA Dourado Silk",
|
||||
"add.fill.full": "Cheio",
|
||||
"add.fillLevel": "Quantidade de filamento",
|
||||
"add.importNote": "Este filamento será adicionado ao seu catálogo quando você adicionar os carretéis.",
|
||||
"add.inCatalog": "no catálogo",
|
||||
"add.locationHint": "Onde você guarda fisicamente o carretel: em uma prateleira, em uma caixa ou na impressora. Deixe em branco se você guardar todos eles no mesmo lugar.",
|
||||
"add.locationPlaceholder": "por ex.: Prateleira A, Secadora 2, Impressora 1",
|
||||
"add.manufacturerPlaceholder": "por exemplo: Polymaker",
|
||||
"add.nameHint": "Inclua a cor. É ela que diferencia os seus filamentos.",
|
||||
"add.newFilament": "novo filamento",
|
||||
"add.newFilamentTitle": "Novo filamento",
|
||||
"add.noCatalog": "Não há filamentos no seu catálogo",
|
||||
"add.noManufacturer": "Sem fabricante",
|
||||
"add.pickColor": "Escolher cor",
|
||||
"add.searchPlaceholder": "Pesquisar em seu catálogo e no {{name}}...",
|
||||
"add.searching": "Pesquisando...",
|
||||
"add.section.filament": "Filamento",
|
||||
"add.section.manufacturer": "Fabricante",
|
||||
"add.section.manufacturerNote": "Quem fabrica o filamento. É adicionado uma vez, depois é reutilizado em todos os filamentos do mesmo fabricante.",
|
||||
"add.spoolWeightPreset.cardboard": "",
|
||||
"add.spoolWeightPreset.plastic": "",
|
||||
"add.spoolWeightPresetsLead": "",
|
||||
@@ -108,7 +111,7 @@
|
||||
"filament.fields.weight": "Peso",
|
||||
"filament.fieldsHelp.articleNumber": "Por exemplo: EAN, UPC, etc.",
|
||||
"filament.fieldsHelp.multiColorDirection": "Os filamentos podem ter múltiplas cores de duas maneiras: por meio de coextrusão, como os filamentos bicolores com as cores consistentes ao longo do carretel, ou por meio de mudanças longitudinais de cor, como os filamentos gradientes que mudam as cores ao longo do carretel.",
|
||||
"filament.fieldsHelp.spoolWeight": "O peso de um carretel vazio. Usado para determinar o peso medido de um carretel.",
|
||||
"filament.fieldsHelp.spoolWeight": "O peso de um carretel vazio. Usado para determinar o peso medido de um carretel. Não tem certeza? Carretéis de papelão geralmente pesam entre 130 e 150 g, e os de plástico, entre 180 e 250 g. Para obter um valor exato, coloque o carretel em uma balança quando ele estiver vazio ou consulte o fabricante.",
|
||||
"filament.fieldsHelp.weight": "O peso do filamento de um carretel cheio (peso líquido). Isso não deve incluir o peso do carretel em si, apenas o filamento. É o que normalmente está escrito na embalagem.",
|
||||
"filament.filament": "Filamentos",
|
||||
"footer.documentation": "",
|
||||
@@ -372,11 +375,11 @@
|
||||
"settings.extraFields.addEntityField": "",
|
||||
"settings.extraFields.booleanFalse": "Não",
|
||||
"settings.extraFields.booleanTrue": "Sim",
|
||||
"settings.extraFields.deleteConfirm": "Apagar o campo {{name}}?",
|
||||
"settings.extraFields.description.constraints": "Depois que um campo é adicionado, você não pode alterar sua chave ou tipo, e para campos do tipo \"escolha\" você não pode remover as opções ou alterar o estado de \"múltipla de escolha\". Se você remover um campo, os dados associados a ele em todas as entidades serão excluídos.",
|
||||
"settings.extraFields.deleteConfirm": "Apagar o campo {{name}} em {{entity}}?",
|
||||
"settings.extraFields.description.constraints": "Depois que um campo for adicionado, você não poderá alterar sua chave ou tipo, e para campos do tipo \"opção\" você não pode remover as opções ou alterar o estado da opção de \"múltipla de escolha\". Se você remover um campo, os dados associados a ele em todas as entidades serão excluídos.",
|
||||
"settings.extraFields.description.intro": "Aqui você pode adicionar campos personalizados extras às suas entidades.",
|
||||
"settings.extraFields.description.keyUsage": "A chave é como outros programas leem/gravam os dados, portanto, se o seu campo personalizado for para uma integração com um programa de terceiros, certifique-se de configurá-lo corretamente. O valor padrão é aplicado somente a novos itens.",
|
||||
"settings.extraFields.description.tableViews": "Campos extras não podem ser ordenados ou filtrados nas visualizações de tabela.",
|
||||
"settings.extraFields.description.tableViews": "Campos extras não podem ser ordenados ou filtrados nas visualizações de tabela. Campos dos tipos \"opção\" e \"booleano\" também disponibilizam opções de filtragem nesses locais, e todos os tipos de campos extras oferecem suporte à filtragem de valores vazios.",
|
||||
"settings.extraFields.editorEdit": "",
|
||||
"settings.extraFields.editorNew": "",
|
||||
"settings.extraFields.errors.choiceNeeded": "",
|
||||
@@ -436,7 +439,7 @@
|
||||
"spool.fields.registered": "Registrado",
|
||||
"spool.fields.remainingWeight": "Peso Restante",
|
||||
"spool.fields.usedWeight": "Peso Utilizado",
|
||||
"spool.fieldsHelp.location": "Onde o carretel está localizado se você tiver vários locais onde armazena seus carretéis.",
|
||||
"spool.fieldsHelp.location": "O local onde você guarda fisicamente esse carretel: uma prateleira, uma gaveta, uma secadora de filamentos ou a própria impressora em que ele está carregado. Os carretéis são agrupados por localização na sua biblioteca, é assim que você os encontra novamente.",
|
||||
"spool.fieldsHelp.lotNr": "Número de lote do fabricante. Pode ser usado para garantir que uma impressão tenha cores consistentes se vários carretéis forem usados.",
|
||||
"spool.fieldsHelp.measuredWeight": "Quanto pesa o filamento e o carretel.",
|
||||
"spool.fieldsHelp.price": "Preço de um carretel cheio. Se não for definido, o preço do filamento será assumido.",
|
||||
|
||||
@@ -31,10 +31,18 @@
|
||||
"add.pickColor": "Выбрать цвет",
|
||||
"add.searchPlaceholder": "Поиск по каталогу и {{name}}...",
|
||||
"add.searching": "Поиск...",
|
||||
"add.section.filament": "Филамент",
|
||||
"add.section.filamentNote": "Продукт на катушке — одна запись на цвет.",
|
||||
"add.section.manufacturer": "Производитель",
|
||||
"add.section.manufacturerNote": "Кто производит филамент. Добавляется один раз, затем используется для всех их филаментов.",
|
||||
"add.section.spool": "Катушка",
|
||||
"add.section.spoolNote": "Физические катушки в вашем инвентаре.",
|
||||
"add.section.spoolSharedNote": "Вес, вес катушки и цена также сохраняются как значения по умолчанию для нового филамента.",
|
||||
"add.spoolWeightPreset.cardboard": "Картонная {{weight}} г",
|
||||
"add.spoolWeightPreset.plastic": "Пластиковая {{weight}} г",
|
||||
"add.spoolWeightPresetsLead": "Начните с типичной катушки:",
|
||||
"add.step2": "Шаг 2 · детали катушки",
|
||||
"add.step2New": "Шаг 2 · детали филамента и катушки",
|
||||
"add.summary_few": "{{count}} катушки",
|
||||
"add.summary_many": "{{count}} катушек",
|
||||
"add.summary_one": "{{count}} катушка",
|
||||
@@ -44,7 +52,9 @@
|
||||
"add.vendorHint.existing": "Используется существующий производитель «{{name}}",
|
||||
"add.vendorHint.new": "Будет создан новый производитель «{{name}}",
|
||||
"add.vendorHint.optional": "Необязательно — оставьте пустым, если производитель не нужен",
|
||||
"add.weightPresets": "Стандартные размеры катушек",
|
||||
"add.yourCatalog": "Ваш каталог",
|
||||
"buttons.apply": "Применить",
|
||||
"buttons.archive": "В архив",
|
||||
"buttons.cancel": "Отмена",
|
||||
"buttons.clear": "Очистить",
|
||||
@@ -54,15 +64,48 @@
|
||||
"buttons.filter": "Фильтр",
|
||||
"buttons.save": "Сохранить",
|
||||
"buttons.showArchived": "Показать архивные",
|
||||
"buttons.showNoSpools": "Показать филамент без катушек",
|
||||
"buttons.unArchive": "Из архива",
|
||||
"changeFilament.action": "Изменить",
|
||||
"changeFilament.adoptWeight": "Установить полный вес этой катушки на {{full}}",
|
||||
"changeFilament.adoptWeightHelp": "Записано как {{current}}, из предыдущего филамента. Использованный вес ({{used}}) сохранен, оставляя {{remaining}}.",
|
||||
"changeFilament.apply": "Сменить филамент",
|
||||
"changeFilament.applying": "Изменение...",
|
||||
"changeFilament.applying": "Смена...",
|
||||
"changeFilament.current": "текущий",
|
||||
"changeFilament.done": "Филамент изменён.",
|
||||
"changeFilament.failed": "Не получилось сменить филамент.",
|
||||
"changeFilament.importNote": "Этот филамент будет добавлен в ваш каталог после применения изменения.",
|
||||
"changeFilament.intro": "Катушка #{{id}} сохранит свой номер, расположение, даты и историю использования - только хранимый филамент будет изменён.",
|
||||
"changeFilament.newTag": "новый",
|
||||
"changeFilament.next": "Изменить на",
|
||||
"changeFilament.now": "Текущий",
|
||||
"changeFilament.pickBelow": "Выберите филамент ниже",
|
||||
"changeFilament.title": "Смена филамента",
|
||||
"changeFilament.useExisting": "Выбрать существующий филамент",
|
||||
"changeFilament.weightFollows": "У этой катушки нет собственного полного веса, поэтому она принимает вес нового филамента: {{full}}, оставляя {{remaining}} после {{used}} уже использованных.",
|
||||
"changeVendor.action": "Изменить",
|
||||
"changeVendor.apply": "Сменить производителя",
|
||||
"changeVendor.applying": "Смена...",
|
||||
"changeVendor.clearHint": "Оставить этот филамент без производителя",
|
||||
"changeVendor.create": "Создать “{{name}}”",
|
||||
"changeVendor.createHint": "Еще не в списке ваших производителей — будет добавлен",
|
||||
"changeVendor.current": "текущий",
|
||||
"changeVendor.done": "Производитель изменен.",
|
||||
"changeVendor.emptySpool": "Вес пустой катушки {{value}}",
|
||||
"changeVendor.failed": "Не удалось сменить производителя.",
|
||||
"changeVendor.intro": "“{{name}}” сохраняет свои катушки, характеристики и историю — меняется только производитель.",
|
||||
"changeVendor.next": "Изменить на",
|
||||
"changeVendor.noEmptySpool": "Нет веса пустой катушки",
|
||||
"changeVendor.noMatches": "Нет совпадений по производителям",
|
||||
"changeVendor.none": "Производителей еще нет",
|
||||
"changeVendor.noneNote": "Без бренда",
|
||||
"changeVendor.now": "Сейчас",
|
||||
"changeVendor.pickBelow": "Выберите производителя ниже",
|
||||
"changeVendor.searchPlaceholder": "Поиск или имя производителя",
|
||||
"changeVendor.tareFollows": "У этого филамента нет собственного веса пустой катушки, поэтому он принимает вес производителя. После изменения он составит {{value}}.",
|
||||
"changeVendor.tareGone": "У этого филамента нет собственного веса пустой катушки, он принимал значение {{current}} от производителя. После изменения значения нет, пока вы не зададите его филаменту.",
|
||||
"changeVendor.title": "Смена производителя",
|
||||
"changeVendor.willBeCreated": "Будет создан",
|
||||
"common.decrement": "Уменьшить",
|
||||
"common.increment": "Увеличить",
|
||||
"common.remove": "Удалить",
|
||||
@@ -111,6 +154,7 @@
|
||||
"filament.fields.material": "Материал",
|
||||
"filament.fields.multiColor": "Многоцветный",
|
||||
"filament.fields.name": "Имя",
|
||||
"filament.fields.opacity": "Непрозрачность",
|
||||
"filament.fields.price": "Цена",
|
||||
"filament.fields.registered": "Добавлен",
|
||||
"filament.fields.removeColor": "Удалить цвет",
|
||||
@@ -136,6 +180,8 @@
|
||||
"inspector.applying": "Применение...",
|
||||
"inspector.colorHex": "Шестнадцатеричный код цвета",
|
||||
"inspector.consumeAmount": "Количество для списания:",
|
||||
"inspector.defaultFrom.filament": "{{value}} · по умолчанию для филамента",
|
||||
"inspector.defaultFrom.manufacturer": "{{value}} · по умолчанию для производителя",
|
||||
"inspector.delete.deleting": "Удаление...",
|
||||
"inspector.delete.errorGone": "Он уже был удален.",
|
||||
"inspector.delete.errorSpoolsExist": "Невозможно удалить этот филамент — его используют катушки.",
|
||||
@@ -177,6 +223,10 @@
|
||||
"inspector.openFilament": "Открыть филамент",
|
||||
"inspector.openLink": "Открыть {{url}} в новой вкладке",
|
||||
"inspector.openManufacturer": "Открыть производителя",
|
||||
"inspector.override.byFilament": "Переопределено этим филаментом · {{value}}",
|
||||
"inspector.override.bySpool": "Переопределено этой катушкой · {{value}}",
|
||||
"inspector.override.overridesFilament": "Переопределяет филамент · {{value}}",
|
||||
"inspector.override.overridesManufacturer": "Переопределяет производителя · {{value}}",
|
||||
"inspector.specs": "Характеристики — редактирование на месте",
|
||||
"inspector.spoolsCount": "Катушки · {{count}}",
|
||||
"inspector.spoolsTotalRemaining": "всего {{weight}}",
|
||||
@@ -212,6 +262,11 @@
|
||||
"labels.dpi": "Разрешение",
|
||||
"labels.dpiHint": "Соответствуйте параметрам принтера, чтобы мелкие этикетки были четкими: 203 dpi для большинства термопринтеров, 300 или более для лазерных и струйных.",
|
||||
"labels.duplicate": "Дублировать",
|
||||
"labels.exportDescAml": "Сохраняет один файл .aml для каждой этикетки размером {{w}}×{{h}} мм. Открывается в приложениях на базе LPAPI (обычно поставляются с термопринтерами), которые считывают размер из файла.",
|
||||
"labels.exportDescPng": "Сохраняет одно PNG изображение для каждой этикетки размером {{w}}×{{h}} мм.",
|
||||
"labels.exportFormat": "Формат файла",
|
||||
"labels.exportFormatOptions.aml": "Файл этикетки AML",
|
||||
"labels.exportFormatOptions.png": "Изображение PNG",
|
||||
"labels.fields.bedTemp": "Температура стола",
|
||||
"labels.fields.diameterMm": "Диаметр (мм)",
|
||||
"labels.fields.groupVendor": "Производитель",
|
||||
@@ -241,7 +296,7 @@
|
||||
"labels.labelSize": "Размер этикетки",
|
||||
"labels.labelType": "Тип этикетки",
|
||||
"labels.layout": "Макет",
|
||||
"labels.modeImage": "Файлы изображений",
|
||||
"labels.modeImage": "Файлы",
|
||||
"labels.modeLabel": "Принтер этикеток",
|
||||
"labels.modeSheet": "Лист (плитка)",
|
||||
"labels.new": "Новый",
|
||||
@@ -258,6 +313,7 @@
|
||||
"labels.printN_one": "Печать {{count}} этикетки",
|
||||
"labels.radiusMm": "Радиус",
|
||||
"labels.sampleData": "Пример данных",
|
||||
"labels.saveAsFormat": "Сохранить как {{format}}",
|
||||
"labels.saved": "Сохранено",
|
||||
"labels.saving": "Сохранение...",
|
||||
"labels.searchFilaments": "Поиск филаментов...",
|
||||
@@ -275,6 +331,7 @@
|
||||
"labels.showingSpool": "Отображение катушки #{{id}}",
|
||||
"labels.showingTags": "Поля отображаются как теги, пока не выбрана катушка выше.",
|
||||
"labels.sizeMm": "Размер",
|
||||
"labels.skipHint": "Пропустить элементы — оставляет указанное количество клеток пустыми, чтобы использовать остатки листа.",
|
||||
"labels.sortHint": "Сортировать катушки",
|
||||
"labels.sortId": "#",
|
||||
"labels.sortNewest": "Самые новые",
|
||||
@@ -284,7 +341,7 @@
|
||||
"labels.tabPrint": "Печать",
|
||||
"labels.textTemplate": "Текст / шаблон",
|
||||
"labels.typeFilament": "Филамет",
|
||||
"labels.typeHint": "",
|
||||
"labels.typeHint": "Этикетки для филамента исключают поля, специфичные для катушки, а QR-код ведет на страницу филамента.",
|
||||
"labels.typeSpool": "Катушка",
|
||||
"labels.unknownFilament": "Неизвестный филамент",
|
||||
"labels.wMm": "Ш",
|
||||
@@ -293,52 +350,73 @@
|
||||
"labels.xMm": "X",
|
||||
"labels.yMm": "Y",
|
||||
"library.apiError": "Spoolman API недоступен. Сервер запущен?",
|
||||
"library.collapseGroup": "Скрыть эти катушки",
|
||||
"library.dateFilter.ago": "{{span}} назад",
|
||||
"library.dateFilter.between": "{{from}} – {{to}}",
|
||||
"library.dateFilter.customRange": "Пользовательский диапазон",
|
||||
"library.dateFilter.from": "От",
|
||||
"library.dateFilter.last": "Последние {{span}}",
|
||||
"library.dateFilter.never": "Никогда",
|
||||
"library.dateFilter.olderThan": "Старше чем {{span}}",
|
||||
"library.dateFilter.since": "С {{date}}",
|
||||
"library.dateFilter.to": "До (необязательно)",
|
||||
"library.dateFilter.until": "До {{date}}",
|
||||
"library.emptyBody": "Добавьте первую катушку, чтобы начать отслеживать филамент.",
|
||||
"library.emptyFiltered": "Нет {{unit}}, соответствующих фильтрам.",
|
||||
"library.emptyTitle": "Катушек пока нет",
|
||||
"library.expandGroup": "Показать эти катушки",
|
||||
"library.filterBy": "Фильтровать по",
|
||||
"library.groupBy": "Группировать",
|
||||
"library.groupNone": "Нет (списком)",
|
||||
"library.groupSpools_few": "{{count}} катушки",
|
||||
"library.groupSpools_many": "{{count}} катушек",
|
||||
"library.groupSpools_one": "{{count}} катушка",
|
||||
"library.nUnused": "",
|
||||
"library.noLocation": "",
|
||||
"library.noValues": "",
|
||||
"library.openGroup": "",
|
||||
"library.openManufacturer": "",
|
||||
"library.section.extra": "",
|
||||
"library.section.filament": "",
|
||||
"library.section.spool": "",
|
||||
"library.section.vendor": "",
|
||||
"library.showMore": "",
|
||||
"library.sort.nozzle": "",
|
||||
"library.sortBy": "",
|
||||
"library.unassigned": "",
|
||||
"library.unitGroups": "",
|
||||
"library.unitSpools": "",
|
||||
"library.unused": "",
|
||||
"library.unusedGroup": "",
|
||||
"library.unusedGroupMixed": "",
|
||||
"library.unusedSub": "",
|
||||
"library.unusedSubMixed": "",
|
||||
"library.used": "",
|
||||
"library.usedAgo": "",
|
||||
"library.nUnused": "×{{count}} не использовано",
|
||||
"library.noLocation": "нет местоположения",
|
||||
"library.noSpools": "Нет катушек",
|
||||
"library.noValues": "Нет значений",
|
||||
"library.openGroup": "Открыть {{name}}",
|
||||
"library.openManufacturer": "Открыть производителя {{name}}",
|
||||
"library.resizeList": "Изменить размер списка (двойной клик для сброса)",
|
||||
"library.reverseSort": "Обратить порядок сортировки",
|
||||
"library.section.extra": "Дополнительные поля",
|
||||
"library.section.filament": "Филамент",
|
||||
"library.section.spool": "Катушка",
|
||||
"library.section.vendor": "Производитель",
|
||||
"library.showMore": "показать еще {{count}}",
|
||||
"library.sort.nozzle": "Темп. сопла",
|
||||
"library.sortBy": "Сортировать",
|
||||
"library.unassigned": "без категории",
|
||||
"library.unitGroups": "группы",
|
||||
"library.unitSpools": "катушки",
|
||||
"library.unused": "не использовано",
|
||||
"library.unusedGroup": "{{count}} не использовано · {{weight}} г каждая",
|
||||
"library.unusedGroupMixed": "{{count}} не использовано",
|
||||
"library.unusedSub": "{{weight}} г каждая · {{location}}",
|
||||
"library.unusedSubMixed": "{{location}}",
|
||||
"library.used": "использовано",
|
||||
"library.usedAgo": "использовано {{time}} назад",
|
||||
"loading": "Загрузка",
|
||||
"locations.locations": "Местоположения",
|
||||
"locations.newLocation": "Новое местоположение",
|
||||
"locations.noLocation": "Нет местоположения",
|
||||
"locations.noLocationsHelp": "Эта страница позволяет организовать катушки по местоположениям. Добавьте несколько катушек, чтобы начать!",
|
||||
"nav.labels": "",
|
||||
"nav.library": "",
|
||||
"nav.labels": "Этикетки",
|
||||
"nav.library": "Библиотека",
|
||||
"no": "Нет",
|
||||
"notifications.error": "Ошибка (status code: {{statusCode}})",
|
||||
"notifications.saveSuccessful": "Успешное сохранение!",
|
||||
"pagination.empty": "",
|
||||
"pagination.next": "",
|
||||
"pagination.pageSize": "",
|
||||
"pagination.perPage": "",
|
||||
"pagination.prev": "",
|
||||
"pagination.range": "",
|
||||
"pagination.empty": "Нет {{unit}}",
|
||||
"pagination.next": "Следующая страница",
|
||||
"pagination.pageSize": "Размер страницы",
|
||||
"pagination.perPage": "{{size}} / страница",
|
||||
"pagination.prev": "Предыдущая страница",
|
||||
"pagination.range": "{{from}}–{{to}} из {{total}} {{unit}}",
|
||||
"printing.generic.columns": "Столбцы",
|
||||
"printing.generic.customSize": "Настроить",
|
||||
"printing.generic.description": "Настройте параметры ниже, чтобы получить желаемый макет печати. Имейте в виду, что принтеры и ваша ОС могут применять свои собственные поля и масштабирование, поэтому вам, возможно, придется выполнить несколько проб и ошибок, прежде чем все будет правильно. Прежде чем печатать на этикетках, проверьте это на листе обычной бумаги.",
|
||||
"printing.generic.dimensions": "Размеры",
|
||||
"printing.generic.gridTooWide": "",
|
||||
"printing.generic.gridTooWide": "Этикетки шире страницы. Уменьшите количество столбцов, интервалы или поля.",
|
||||
"printing.generic.helpMargin": "Поля должны быть настроены в соответствии с вашей этикеточной бумагой и принтером. Их изменение повлияет на размер всей сетки.",
|
||||
"printing.generic.helpPrinterMargin": "В безопасной зоне должно быть установлено, насколько близко к краю бумаги может печатать ваш принтер. Изменение этих значений не повлияет на всю сетку.",
|
||||
"printing.generic.horizontalSpacing": "Горизонтальный интервал",
|
||||
@@ -356,11 +434,11 @@
|
||||
"printing.generic.skipItems": "Пропустить элементы",
|
||||
"printing.generic.verticalSpacing": "Вертикальный интервал",
|
||||
"printing.qrcode.button": "Печать этикеток",
|
||||
"printing.qrcode.customUrl.caveat": "",
|
||||
"printing.qrcode.customUrl.hint": "",
|
||||
"printing.qrcode.customUrl.label": "",
|
||||
"printing.qrcode.customUrl.caveat": "Пользовательский URL не будет сканироваться обратно в Spoolman — используйте его для внешних приложений.",
|
||||
"printing.qrcode.customUrl.hint": "Вставьте ID катушки в любое место с помощью плейсхолдера",
|
||||
"printing.qrcode.customUrl.label": "Шаблон пользовательского URL",
|
||||
"printing.qrcode.useHTTPUrl.label": "Ссылка на QR-код",
|
||||
"printing.qrcode.useHTTPUrl.options.custom": "",
|
||||
"printing.qrcode.useHTTPUrl.options.custom": "Пользовательский URL",
|
||||
"printing.qrcode.useHTTPUrl.options.default": "По умолчанию",
|
||||
"printing.qrcode.useHTTPUrl.options.url": "URL",
|
||||
"printing.qrcode.useHTTPUrl.preview": "Предварительный просмотр:",
|
||||
@@ -369,45 +447,46 @@
|
||||
"printing.spoolSelect.selectedTotal_many": "{{count}} катушек выбрано",
|
||||
"printing.spoolSelect.selectedTotal_one": "{{count}} катушка выбрана",
|
||||
"scanner.description": "Отсканируйте QR-код Spoolman, чтобы просмотреть подробную информацию о катушке.",
|
||||
"scanner.error.inUse": "",
|
||||
"scanner.error.inUse": "Камера уже используется другим приложением.",
|
||||
"scanner.error.insecureContext": "Страница не обслуживается по протоколу HTTPS.",
|
||||
"scanner.error.notAllowed": "Вы запретили доступ к камере.",
|
||||
"scanner.error.notFound": "Ни одна камера не найдена.",
|
||||
"scanner.error.streamApiNotSupported": "Браузер не поддерживает API MediaStream.",
|
||||
"scanner.error.unknown": "Произошла неизвестная ошибка. ({{error}})",
|
||||
"scanner.starting": "",
|
||||
"scanner.starting": "Запуск камеры...",
|
||||
"scanner.title": "Сканер QR-кода",
|
||||
"search.colorHint": "",
|
||||
"search.noResults": "",
|
||||
"search.searching": "",
|
||||
"search.section.filaments": "",
|
||||
"search.section.spools": "",
|
||||
"search.section.vendors": "",
|
||||
"search.threshold": "",
|
||||
"search.unknownFilament": "",
|
||||
"settings.appearance.tab": "",
|
||||
"settings.appearance.theme.dark": "",
|
||||
"settings.appearance.theme.desc": "",
|
||||
"settings.appearance.theme.label": "",
|
||||
"settings.appearance.theme.light": "",
|
||||
"settings.appearance.theme.system": "",
|
||||
"settings.extraFields.addChoice": "",
|
||||
"settings.extraFields.addChoicesFirst": "",
|
||||
"settings.extraFields.addEntityField": "",
|
||||
"search.colorHint": "Имена цветов должны быть на английском.",
|
||||
"search.moreSpools": "+{{count}} еще",
|
||||
"search.noResults": "Совпадений не найдено",
|
||||
"search.searching": "Поиск...",
|
||||
"search.section.filaments": "Филаменты",
|
||||
"search.section.spools": "Катушки",
|
||||
"search.section.vendors": "Производители",
|
||||
"search.threshold": "Соответствие цвета",
|
||||
"search.unknownFilament": "Неизвестный филамент",
|
||||
"settings.appearance.tab": "Внешний вид",
|
||||
"settings.appearance.theme.dark": "Темная",
|
||||
"settings.appearance.theme.desc": "Цветовая тема (только для этого браузера)",
|
||||
"settings.appearance.theme.label": "Тема",
|
||||
"settings.appearance.theme.light": "Светлая",
|
||||
"settings.appearance.theme.system": "Системная",
|
||||
"settings.extraFields.addChoice": "добавить вариант...",
|
||||
"settings.extraFields.addChoicesFirst": "Сначала добавьте варианты",
|
||||
"settings.extraFields.addEntityField": "Добавить поле для {{entity}}",
|
||||
"settings.extraFields.booleanFalse": "Нет",
|
||||
"settings.extraFields.booleanTrue": "Да",
|
||||
"settings.extraFields.deleteConfirm": "Удалить поле {{name}} сущности {{entity}}?",
|
||||
"settings.extraFields.deleteConfirm": "Удалить поле {{name}} у {{entity}}?",
|
||||
"settings.extraFields.description.constraints": "Как только поле добавлено, вы не можете изменить его ключ или тип, а для полей типа выбора вы не можете удалить варианты или изменить состояние множественного выбора. Если вы удалите поле, связанные данные для всех сущностей будут удалены.",
|
||||
"settings.extraFields.description.intro": "Здесь вы можете добавлять дополнительные пользовательские поля к своим сущностям.",
|
||||
"settings.extraFields.description.keyUsage": "Ключевым моментом является то, как другие программы считывают/записывают данные, поэтому, если предполагается, что ваше пользовательское поле интегрируется со сторонней программой, убедитесь, что оно настроено правильно. Значение по умолчанию применяется только к новым элементам.",
|
||||
"settings.extraFields.description.tableViews": "Дополнительные поля нельзя отсортировать или отфильтровать в табличных представлениях.",
|
||||
"settings.extraFields.editorEdit": "",
|
||||
"settings.extraFields.editorNew": "",
|
||||
"settings.extraFields.errors.choiceNeeded": "",
|
||||
"settings.extraFields.errors.choicesRemoved": "",
|
||||
"settings.extraFields.errors.deleteFailed": "",
|
||||
"settings.extraFields.errors.keyFormat": "",
|
||||
"settings.extraFields.errors.nameRequired": "",
|
||||
"settings.extraFields.editorEdit": "Редактировать поле {{entity}}",
|
||||
"settings.extraFields.editorNew": "Новое поле {{entity}}",
|
||||
"settings.extraFields.errors.choiceNeeded": "Поля выбора требуют минимум один вариант.",
|
||||
"settings.extraFields.errors.choicesRemoved": "Существующие варианты нельзя удалить: {{choices}}.",
|
||||
"settings.extraFields.errors.deleteFailed": "Не удалось удалить поле.",
|
||||
"settings.extraFields.errors.keyFormat": "Ключ может содержать только строчные буквы, цифры и подчеркивания.",
|
||||
"settings.extraFields.errors.nameRequired": "Имя обязательно.",
|
||||
"settings.extraFields.errors.saveFailed": "Не удалось сохранить поле.",
|
||||
"settings.extraFields.fieldType.boolean": "Логический",
|
||||
"settings.extraFields.fieldType.choice": "Выбор",
|
||||
@@ -419,7 +498,7 @@
|
||||
"settings.extraFields.fieldType.text": "Текст",
|
||||
"settings.extraFields.max": "макс",
|
||||
"settings.extraFields.min": "мин",
|
||||
"settings.extraFields.multiSuffix": "",
|
||||
"settings.extraFields.multiSuffix": " · мульти",
|
||||
"settings.extraFields.multiple": "Множественный",
|
||||
"settings.extraFields.namePlaceholder": "Отображаемое название",
|
||||
"settings.extraFields.nonUniqueKeyError": "Ключ должен быть уникальным.",
|
||||
@@ -431,6 +510,7 @@
|
||||
"settings.extraFields.params.name": "Имя",
|
||||
"settings.extraFields.params.order": "Сортировка",
|
||||
"settings.extraFields.params.unit": "Единица измерения",
|
||||
"settings.extraFields.reorderChoice": "Перетащите или используйте стрелки для изменения порядка вариантов.",
|
||||
"settings.extraFields.saveField": "Сохранить поле",
|
||||
"settings.extraFields.tab": "Дополнительные поля",
|
||||
"settings.general.baseUrl.label": "Базовый URL-адрес",
|
||||
@@ -445,7 +525,22 @@
|
||||
"settings.language.label": "Язык",
|
||||
"settings.library.lowThreshold.desc": "Катушка с таким остатком или меньше, будет помечена красным (только этот браузер)",
|
||||
"settings.library.lowThreshold.label": "Порог низкого остатка",
|
||||
"settings.library.tab": "",
|
||||
"settings.library.tab": "Поведение библиотеки",
|
||||
"settings.scanner.autoNavigate.desc": "Автоматически переходить в этом веб-браузере к катушке или филаменту, считанным считывателем меток.",
|
||||
"settings.scanner.autoNavigate.label": "Автопереход",
|
||||
"settings.scanner.docs": "Как настроить считыватель меток",
|
||||
"settings.scanner.lastSeen": "{{when}} назад",
|
||||
"settings.scanner.pair": "Сопряжение касанием",
|
||||
"settings.scanner.reader.anyDesc": "Сканирование любым считывателем открывает катушки в этом веб-браузере. Вы можете выполнить сопряжение с конкретным считывателем, если вам нужен более точный контроль. Этот же считыватель будет использоваться для добавления новых меток к катушкам и филаментам.",
|
||||
"settings.scanner.reader.label": "Считыватель меток",
|
||||
"settings.scanner.reader.pairedDesc": "Только сканирование этим считывателем открывает катушки в этом веб-браузере.",
|
||||
"settings.scanner.recent": "Недавние считыватели",
|
||||
"settings.scanner.recentNone": "В памяти нет считывателей. Настройте считыватель меток, а затем считайте любую метку, чтобы он появился здесь.",
|
||||
"settings.scanner.tab": "Сканирование меток",
|
||||
"settings.scanner.unpair": "Использовать любой считыватель",
|
||||
"settings.scanner.use": "Использовать этот",
|
||||
"settings.scanner.waiting": "Приложите любую метку к нужному считывателю...",
|
||||
"sponsor": "Поддержать меня на GitHub",
|
||||
"spool.fields.archived": "В архиве",
|
||||
"spool.fields.comment": "Комментарий",
|
||||
"spool.fields.filament": "Филамент",
|
||||
@@ -467,15 +562,68 @@
|
||||
"spool.fieldsHelp.measuredWeight": "Сколько весит филамент и катушка.",
|
||||
"spool.fieldsHelp.price": "Цена полной катушки. Если не указано, будет использоваться цена филамента.",
|
||||
"spool.fieldsHelp.remainingWeight": "Сколько филамента осталось на катушке. Для новой катушки этот вес должен соответствовать весу катушки.",
|
||||
"spool.fieldsHelp.spoolWeight": "Вес пустой катушки. Оставьте поле пустым, чтобы использовать значение по умолчанию для филамента, или укажите, если эта конкретная катушка отличается (например, перемотанный ролик или картонная втулка вместо пластиковой). Это значение вычитается из измеренного веса при настройке, поэтому неправильное значение приведет к неверному расчету остатка филамента.",
|
||||
"spool.fieldsHelp.usedWeight": "Сколько филамента было использовано с катушки. На новой катушке должно быть использовано 0 г.",
|
||||
"spool.fieldsHelp.weight": "Сколько филамента было на катушке, когда она была полной (вес нетто, без учета самой катушки). Оставьте поле пустым, чтобы использовать вес филамента, или укажите значение, если эта конкретная катушка отличается — например, неполный рулон или тот же филамент на катушке большего размера.",
|
||||
"spool.form.measurementType.length": "Длина",
|
||||
"spool.form.measurementType.weight": "Вес",
|
||||
"spool.spool": "Катушки",
|
||||
"tags.add": "Добавить метку",
|
||||
"tags.added": "Метка привязана.",
|
||||
"tags.addedOn": "Добавлено {{date}}",
|
||||
"tags.modal.alreadyHere": "Эта метка уже идентифицирует эту катушку.",
|
||||
"tags.modal.alreadyHereFilament": "Эта метка уже идентифицирует этот филамент.",
|
||||
"tags.modal.badUid": "UID метки в HEX-формате, например 04A2B3C4.",
|
||||
"tags.modal.checking": "Проверка...",
|
||||
"tags.modal.failed": "Не удалось привязать метку.",
|
||||
"tags.modal.filamentNamed": "филамент {{name}}",
|
||||
"tags.modal.free": "Эта метка пока ни к чему не привязана.",
|
||||
"tags.modal.intro": "При поднесении этой метки к считывателю будет найдена катушка #{{id}}.",
|
||||
"tags.modal.introFilament": "При поднесении этой метки к считывателю будет найден этот филамент.",
|
||||
"tags.modal.lastSeenOn": "{{reader}} последний раз считал {{uid}}.",
|
||||
"tags.modal.link": "Привязать метку",
|
||||
"tags.modal.linking": "Привязка...",
|
||||
"tags.modal.move": "Переместить сюда с #{{id}}",
|
||||
"tags.modal.moveFailed": "Не удалось переместить метку. Возможно, теперь она не привязана ни к одной из катушек — попробуйте привязать её снова.",
|
||||
"tags.modal.moveFailedFilament": "Не удалось переместить метку. Возможно, теперь она ни к чему не привязана — попробуйте привязать её снова.",
|
||||
"tags.modal.moveHere": "Переместить сюда",
|
||||
"tags.modal.moving": "Перемещение...",
|
||||
"tags.modal.phoneReading": "Приложите метку к телефону...",
|
||||
"tags.modal.phoneScan": "Считать метку с помощью этого телефона",
|
||||
"tags.modal.spoolNamed": "катушка #{{id}}, {{name}}",
|
||||
"tags.modal.spoolShort": "катушка #{{id}}",
|
||||
"tags.modal.takenBy": "В настоящее время привязана к {{spool}}.",
|
||||
"tags.modal.title": "Привязать метку",
|
||||
"tags.modal.uidHelp": "Разделители и регистр не имеют значения: 04:a2:b3:c4, 04-A2-B3-C4 и 04a2b3c4 — это одна и та же метка.",
|
||||
"tags.modal.uidLabel": "UID метки",
|
||||
"tags.modal.uidPlaceholder": "04A2B3C4",
|
||||
"tags.modal.useLastSeen": "Использовать",
|
||||
"tags.modal.waitingAny": "Ожидание считывания на любом считывателе.",
|
||||
"tags.modal.waitingOn": "Ожидание считывания на {{reader}}.",
|
||||
"tags.moved": "Метка перемещена сюда с катушки #{{id}}.",
|
||||
"tags.movedFromFilament": "Метка перемещена сюда с филамента {{name}}.",
|
||||
"tags.nfc.insecureContext": "Для считывания меток требуется защищённое соединение. Откройте Spoolman по HTTPS, чтобы использовать NFC телефона.",
|
||||
"tags.nfc.notAllowed": "Этому сайту не разрешено использовать NFC. Предоставьте разрешение в настройках сайта в браузере и попробуйте снова.",
|
||||
"tags.nfc.notReadable": "Считыватель NFC недоступен — возможно, его использует другое приложение.",
|
||||
"tags.nfc.notSupported": "В этом телефоне нет NFC или NFC отключён в системных настройках.",
|
||||
"tags.nfc.unknown": "Не удалось считать метку.",
|
||||
"tags.none": "Нет привязанных меток.",
|
||||
"tags.scan.openedFilament": "Метка отсканирована: открыт филамент {{name}}.",
|
||||
"tags.scan.openedSpool": "Метка отсканирована: открыта катушка #{{id}}, {{name}}.",
|
||||
"tags.scan.unknown": "Неизвестная метка {{uid}}. Откройте катушку, которой она принадлежит, и используйте «Добавить метку», чтобы привязать её.",
|
||||
"tags.section": "Метки",
|
||||
"tags.unlink": "Отвязать",
|
||||
"tags.unlinkBody": "Метка {{uid}} больше не будет идентифицировать эту катушку. На саму метку ничего не записывается, поэтому вы можете привязать её снова — к этой или другой катушке — в любое время.",
|
||||
"tags.unlinkBodyFilament": "Метка {{uid}} больше не будет идентифицировать этот филамент. На саму метку ничего не записывается, поэтому вы можете привязать её снова — здесь или в другом месте — в любое время.",
|
||||
"tags.unlinkFailed": "Не удалось отвязать метку.",
|
||||
"tags.unlinkTitle": "Отвязать эту метку?",
|
||||
"tags.unlinked": "Метка отвязана.",
|
||||
"tags.unlinking": "Отвязка...",
|
||||
"topbar.addSpools": "Добавить катушки",
|
||||
"topbar.searchPlaceholder": "",
|
||||
"topbar.searchPlaceholder": "Поиск — попробуйте \"red\" или \"pla\"",
|
||||
"unknown": "Неизвестно",
|
||||
"validation.hexDigits": "",
|
||||
"validation.maxChars": "",
|
||||
"validation.hexDigits": "Используйте 6 или 8 шестнадцатеричных цифр",
|
||||
"validation.maxChars": "Максимум {{max}} символов",
|
||||
"validation.mustBeGt": "Должно быть больше {{value}}",
|
||||
"validation.mustBeMax": "Должно быть равно или меньше {{value}}",
|
||||
"validation.mustBeMin": "Должно быть равно или больше {{value}}",
|
||||
|
||||
+465
-316
@@ -1,90 +1,141 @@
|
||||
{
|
||||
"add.addAndNew": "",
|
||||
"add.adding": "",
|
||||
"add.advanced": "",
|
||||
"add.advancedNote": "",
|
||||
"add.change": "",
|
||||
"add.count": "",
|
||||
"add.createNew": "",
|
||||
"add.createNewSub": "",
|
||||
"add.dbUnavailable": "",
|
||||
"add.duplicate": "",
|
||||
"add.duplicateNote": "",
|
||||
"add.duplicateRename": "",
|
||||
"add.duplicateTitle": "",
|
||||
"add.filamentNamePlaceholder": "",
|
||||
"add.fill.full": "",
|
||||
"add.fillLevel": "",
|
||||
"add.importNote": "",
|
||||
"add.inCatalog": "",
|
||||
"add.locationHint": "",
|
||||
"add.locationPlaceholder": "",
|
||||
"add.manufacturerPlaceholder": "",
|
||||
"add.nameHint": "",
|
||||
"add.newFilament": "",
|
||||
"add.newFilamentTitle": "",
|
||||
"add.noCatalog": "",
|
||||
"add.noManufacturer": "",
|
||||
"add.pickColor": "",
|
||||
"add.searchPlaceholder": "",
|
||||
"add.searching": "",
|
||||
"add.spoolWeightPreset.cardboard": "",
|
||||
"add.spoolWeightPreset.plastic": "",
|
||||
"add.spoolWeightPresetsLead": "",
|
||||
"add.step2": "",
|
||||
"add.typeToSearchAll": "",
|
||||
"add.typeToSearchMatches": "",
|
||||
"add.useExisting": "",
|
||||
"add.vendorHint.existing": "",
|
||||
"add.vendorHint.new": "",
|
||||
"add.vendorHint.optional": "",
|
||||
"add.yourCatalog": "",
|
||||
"add.addAndNew": "Dodaj in novo",
|
||||
"add.adding": "Dodajanje ...",
|
||||
"add.advanced": "Napredne specifikacije",
|
||||
"add.advancedNote": "gostota, premer, temperature (samodejno izpolnjeno iz materiala)",
|
||||
"add.change": "spremeni",
|
||||
"add.count": "Število",
|
||||
"add.createNew": "Ustvari nov filament",
|
||||
"add.createNewSub": "ni v vašem katalogu ali {{name}}",
|
||||
"add.dbUnavailable": "{{name}} ni na voljo",
|
||||
"add.duplicate": "podvoji",
|
||||
"add.duplicateNote": "Specifikacije, proizvajalec in cena so bili prekopirani. Določite novo ime in barvo.",
|
||||
"add.duplicateRename": "Še vedno ima isto ime kot izvirnik — preimenujte ga, da ju boste razlikovali.",
|
||||
"add.duplicateTitle": "Nov filament · kopija „{{name}}“",
|
||||
"add.filamentNamePlaceholder": "npr. PolyTerra Matte Sage",
|
||||
"add.fill.full": "Polno",
|
||||
"add.fillLevel": "Raven napolnjenosti",
|
||||
"add.importNote": "Ta filament bo dodan v vaš katalog, ko dodate kolute.",
|
||||
"add.inCatalog": "v katalogu",
|
||||
"add.locationHint": "Kjer fizično hranite kolut — polica, škatla ali tiskalnik. Pustite prazno, če jih vse hranite na istem mestu.",
|
||||
"add.locationPlaceholder": "npr. Polica A, suha škatla 2, tiskalnik 1",
|
||||
"add.manufacturerPlaceholder": "npr. Polymaker",
|
||||
"add.nameHint": "Vključite barvo — po njej boste ločili svoje filamente.",
|
||||
"add.newFilament": "nov filament",
|
||||
"add.newFilamentTitle": "Nov filament",
|
||||
"add.noCatalog": "V vašem katalogu ni filamentov",
|
||||
"add.noManufacturer": "Ni proizvajalca",
|
||||
"add.pickColor": "Izberi barvo",
|
||||
"add.searchPlaceholder": "Iskanje po vašem katalogu in {{name}} ...",
|
||||
"add.searching": "Iskanje ...",
|
||||
"add.section.filament": "Filament",
|
||||
"add.section.filamentNote": "Izdelek na kolutu – en vnos na barvo.",
|
||||
"add.section.manufacturer": "Proizvajalec",
|
||||
"add.section.manufacturerNote": "Kdo izdeluje filament. Doda se enkrat, nato pa uporabi za vse njihove filamente.",
|
||||
"add.section.spool": "Kolut",
|
||||
"add.section.spoolNote": "Fizični koluti, ki gredo v vaš inventar.",
|
||||
"add.section.spoolSharedNote": "Teža, teža koluta in cena se shranijo tudi kot privzete vrednosti novega filamenta.",
|
||||
"add.spoolWeightPreset.cardboard": "Karton {{weight}} g",
|
||||
"add.spoolWeightPreset.plastic": "Plastika {{weight}} g",
|
||||
"add.spoolWeightPresetsLead": "Začnite s tipičnim kolutom:",
|
||||
"add.step2": "2. korak · podrobnosti o kolutu",
|
||||
"add.step2New": "2. korak · podrobnosti o filamentu in kolutu",
|
||||
"add.typeToSearchAll": "Tipkajte za iskanje med tisoči filamenti",
|
||||
"add.typeToSearchMatches": "Tipkajte za iskanje — ni zadetkov",
|
||||
"add.useExisting": "uporabi obstoječega",
|
||||
"add.vendorHint.existing": "Uporaba obstoječega proizvajalca „{{name}}“",
|
||||
"add.vendorHint.new": "Ustvarjen bo nov proizvajalec „{{name}}“",
|
||||
"add.vendorHint.optional": "Izbirno — pustite prazno, če ni proizvajalca",
|
||||
"add.weightPresets": "Običajne velikosti zvitkov",
|
||||
"add.yourCatalog": "Vaš katalog",
|
||||
"buttons.apply": "Uporabi",
|
||||
"buttons.archive": "Arhiviraj",
|
||||
"buttons.cancel": "Prekliči",
|
||||
"buttons.clear": "Počisti",
|
||||
"buttons.close": "",
|
||||
"buttons.close": "Zapri",
|
||||
"buttons.delete": "Izbriši",
|
||||
"buttons.edit": "Uredi",
|
||||
"buttons.filter": "Filter",
|
||||
"buttons.save": "Shrani",
|
||||
"buttons.showArchived": "Prikaži arhiv",
|
||||
"buttons.showNoSpools": "Prikaži filamente brez kolutov",
|
||||
"buttons.unArchive": "Odstrani iz arhiva",
|
||||
"common.decrement": "",
|
||||
"common.increment": "",
|
||||
"common.remove": "",
|
||||
"common.search": "",
|
||||
"dashboard.addFieldHint": "",
|
||||
"dashboard.dashboard": "",
|
||||
"dashboard.deleteGroup": "",
|
||||
"dashboard.dragHint": "",
|
||||
"dashboard.dropHere": "",
|
||||
"dashboard.emptyHelp": "",
|
||||
"dashboard.errorEmptyName": "",
|
||||
"dashboard.errorExists": "",
|
||||
"dashboard.errorRename": "",
|
||||
"dashboard.lastUsed": "",
|
||||
"dashboard.newGroup": "",
|
||||
"dashboard.newGroupName": "",
|
||||
"dashboard.reorderGroup": "",
|
||||
"dashboard.unassigned": "",
|
||||
"datetime.done": "",
|
||||
"datetime.hour": "",
|
||||
"datetime.minute": "",
|
||||
"datetime.nextMonth": "",
|
||||
"datetime.now": "",
|
||||
"datetime.pick": "",
|
||||
"datetime.prevMonth": "",
|
||||
"datetime.time": "",
|
||||
"documentTitle.dashboard.list": "",
|
||||
"documentTitle.labels.list": "",
|
||||
"documentTitle.library.list": "",
|
||||
"changeFilament.action": "Spremeni",
|
||||
"changeFilament.adoptWeight": "Nastavi polno težo tega koluta na {{full}}",
|
||||
"changeFilament.adoptWeightHelp": "Zabeležen je kot {{current}} na podlagi filamenta, ki je bil na njem prej. Porabljena teža ({{used}}) se ohrani v obeh primerih, kar pomeni {{remaining}} preostanka.",
|
||||
"changeFilament.apply": "Spremeni filament",
|
||||
"changeFilament.applying": "Spreminjanje ...",
|
||||
"changeFilament.current": "trenutno",
|
||||
"changeFilament.done": "Filament je spremenjen.",
|
||||
"changeFilament.failed": "Filamenta ni bilo mogoče spremeniti.",
|
||||
"changeFilament.importNote": "Ta filament bo dodan v vaš katalog, ko uveljavite spremembo.",
|
||||
"changeFilament.intro": "Kolut št. {{id}} ohrani svojo številko, lokacijo, datume in zgodovino uporabe – spremeni se le filament na njem.",
|
||||
"changeFilament.newTag": "novo",
|
||||
"changeFilament.next": "Spremeni v",
|
||||
"changeFilament.now": "Trenutno",
|
||||
"changeFilament.pickBelow": "Spodaj izberite filament",
|
||||
"changeFilament.title": "Spremeni filament",
|
||||
"changeFilament.useExisting": "Izberite obstoječ filament",
|
||||
"changeFilament.weightFollows": "Ta kolut nima lastne polne teže, zato prevzame težo novega filamenta: {{full}}, kar po že porabljenih {{used}} pusti {{remaining}}.",
|
||||
"changeVendor.action": "Spremeni",
|
||||
"changeVendor.apply": "Spremeni proizvajalca",
|
||||
"changeVendor.applying": "Spreminjanje ...",
|
||||
"changeVendor.clearHint": "Pusti ta filament brez proizvajalca",
|
||||
"changeVendor.create": "Ustvari „{{name}}“",
|
||||
"changeVendor.createHint": "Še ni med vašimi proizvajalci – dodan bo",
|
||||
"changeVendor.current": "trenutno",
|
||||
"changeVendor.done": "Proizvajalec je spremenjen.",
|
||||
"changeVendor.emptySpool": "Prazen kolut {{value}}",
|
||||
"changeVendor.failed": "Proizvajalca ni bilo mogoče spremeniti.",
|
||||
"changeVendor.intro": "„{{name}}“ ohrani svoje kolute, specifikacije in zgodovino – spremeni se le proizvajalec, pod katerega je uvrščen.",
|
||||
"changeVendor.next": "Spremeni v",
|
||||
"changeVendor.noEmptySpool": "Ni teže praznega koluta",
|
||||
"changeVendor.noMatches": "Noben proizvajalec se ne ujema",
|
||||
"changeVendor.none": "Ni še proizvajalcev",
|
||||
"changeVendor.noneNote": "Brez blagovne znamke",
|
||||
"changeVendor.now": "Trenutno",
|
||||
"changeVendor.pickBelow": "Spodaj izberite proizvajalca",
|
||||
"changeVendor.searchPlaceholder": "Iščite ali poimenujte proizvajalca",
|
||||
"changeVendor.tareFollows": "Ta filament nima lastne teže praznega koluta, zato prevzame težo proizvajalca. Po spremembi je to {{value}}, kar predstavlja težo praznega koluta, ko ga izmerite.",
|
||||
"changeVendor.tareGone": "Ta filament nima lastne teže praznega koluta in prevzame proizvajalčevo vrednost {{current}}. Po spremembi ni nobene vrednosti, ki bi jo lahko prevzel, dokler je ne določite filamentu.",
|
||||
"changeVendor.title": "Spremeni proizvajalca",
|
||||
"changeVendor.willBeCreated": "Bo ustvarjeno",
|
||||
"common.decrement": "Zmanjšaj",
|
||||
"common.increment": "Povečaj",
|
||||
"common.remove": "Odstrani",
|
||||
"common.search": "Išči",
|
||||
"dashboard.addFieldHint": "V nastavitvah dodajte besedilno polje ali polje z izbiro h kolutom, da boste lahko združevali tudi po tem",
|
||||
"dashboard.dashboard": "Nadzorna plošča",
|
||||
"dashboard.deleteGroup": "Izbriši skupino",
|
||||
"dashboard.dragHint": "Povlecite kolut na drugo kartico, da spremenite njegovo polje {{field}}, ali znotraj kartice, da spremenite vrstni red",
|
||||
"dashboard.dropHere": "spustite kolute sem",
|
||||
"dashboard.emptyHelp": "Ta stran združuje vaše kolute po polju po vaši izbiri; za začetek dodajte nekaj kolutov!",
|
||||
"dashboard.errorEmptyName": "Ime ne more biti prazno",
|
||||
"dashboard.errorExists": "Skupina s tem imenom že obstaja",
|
||||
"dashboard.errorRename": "Preimenovanje skupine ni uspelo",
|
||||
"dashboard.lastUsed": "Zadnjič uporabljeno pred {{time}}",
|
||||
"dashboard.newGroup": "Nova skupina",
|
||||
"dashboard.newGroupName": "Nova skupina {{n}}",
|
||||
"dashboard.reorderGroup": "Povlecite za spremembo vrstnega reda skupine",
|
||||
"dashboard.unassigned": "Nedodeljeno",
|
||||
"datetime.done": "Končano",
|
||||
"datetime.hour": "Ura",
|
||||
"datetime.minute": "Minuta",
|
||||
"datetime.nextMonth": "Naslednji mesec",
|
||||
"datetime.now": "Zdaj",
|
||||
"datetime.pick": "Izberite datum in čas",
|
||||
"datetime.prevMonth": "Prejšnji mesec",
|
||||
"datetime.time": "Čas",
|
||||
"documentTitle.dashboard.list": "Nadzorna plošča | Spoolman",
|
||||
"documentTitle.labels.list": "Nalepke | Spoolman",
|
||||
"documentTitle.library.list": "Knjižnica | Spoolman",
|
||||
"documentTitle.locations.list": "Lokacije | Kolutar",
|
||||
"documentTitle.settings.list": "",
|
||||
"filament.fields.addColor": "",
|
||||
"documentTitle.settings.list": "Nastavitve | Spoolman",
|
||||
"filament.fields.addColor": "Dodaj barvo",
|
||||
"filament.fields.articleNumber": "Številka izdelka",
|
||||
"filament.fields.coaxial": "Soosno",
|
||||
"filament.fields.colorHex": "Barva",
|
||||
"filament.fields.colorType": "",
|
||||
"filament.fields.colorType": "Vrsta barve",
|
||||
"filament.fields.comment": "Komentar",
|
||||
"filament.fields.density": "Gostota",
|
||||
"filament.fields.diameter": "Premer",
|
||||
@@ -94,9 +145,10 @@
|
||||
"filament.fields.material": "Material",
|
||||
"filament.fields.multiColor": "Večbarvno",
|
||||
"filament.fields.name": "Ime",
|
||||
"filament.fields.opacity": "Prosojnost",
|
||||
"filament.fields.price": "Cena",
|
||||
"filament.fields.registered": "Registrirano",
|
||||
"filament.fields.removeColor": "",
|
||||
"filament.fields.removeColor": "Odstrani barvo",
|
||||
"filament.fields.settingsBedTemp": "Temperatura mize",
|
||||
"filament.fields.settingsExtruderTemp": "Temperatura ekstruderja",
|
||||
"filament.fields.singleColor": "Enobarvno",
|
||||
@@ -108,210 +160,235 @@
|
||||
"filament.fieldsHelp.spoolWeight": "Masa praznega koluta. Uporablja se za določitev izmerjene mase koluta.",
|
||||
"filament.fieldsHelp.weight": "Masa filamenta na polnem kolutu (neto masa). V to se ne sme vključiti mase samega koluta, temveč le masa filamenta. To je običajno navedeno na embalaži.",
|
||||
"filament.filament": "Filamenti",
|
||||
"footer.documentation": "",
|
||||
"footer.reportIssue": "",
|
||||
"footer.documentation": "Dokumentacija",
|
||||
"footer.reportIssue": "Prijavi težavo",
|
||||
"help.help": "Pomoč",
|
||||
"inspector.addSpoolsOfThis": "",
|
||||
"inspector.adjustFailed": "",
|
||||
"inspector.adjustHelp": "",
|
||||
"inspector.adjustWeight": "",
|
||||
"inspector.apply": "",
|
||||
"inspector.applying": "",
|
||||
"inspector.colorHex": "",
|
||||
"inspector.consumeAmount": "",
|
||||
"inspector.delete.deleting": "",
|
||||
"inspector.delete.errorGone": "",
|
||||
"inspector.delete.errorSpoolsExist": "",
|
||||
"inspector.delete.errorUnknown": "",
|
||||
"inspector.delete.filament": "",
|
||||
"inspector.delete.filamentBlockedHint": "",
|
||||
"inspector.delete.filamentBlockedTitle": "",
|
||||
"inspector.delete.filamentBody": "",
|
||||
"inspector.delete.filamentDone": "",
|
||||
"inspector.delete.filamentTitle": "",
|
||||
"inspector.delete.spool": "",
|
||||
"inspector.delete.spoolBody": "",
|
||||
"inspector.delete.spoolDone": "",
|
||||
"inspector.delete.spoolRemaining": "",
|
||||
"inspector.delete.spoolTitle": "",
|
||||
"inspector.delete.vendor": "",
|
||||
"inspector.delete.vendorBody": "",
|
||||
"inspector.delete.vendorDone": "",
|
||||
"inspector.delete.vendorTitle": "",
|
||||
"inspector.duplicateFilament": "",
|
||||
"inspector.empty": "",
|
||||
"inspector.enterValidLength": "",
|
||||
"inspector.enterValidWeight": "",
|
||||
"inspector.loading": "",
|
||||
"inspector.manageExtraFields": "",
|
||||
"inspector.measuredHelp": "",
|
||||
"inspector.multi": "",
|
||||
"inspector.newGross": "",
|
||||
"inspector.noExtraFields": "",
|
||||
"inspector.notFound": "",
|
||||
"inspector.ofRemaining": "",
|
||||
"inspector.openFilament": "",
|
||||
"inspector.openLink": "",
|
||||
"inspector.openManufacturer": "",
|
||||
"inspector.specs": "",
|
||||
"inspector.spoolsCount": "",
|
||||
"inspector.spoolsTotalRemaining": "",
|
||||
"inspector.used": "",
|
||||
"inspector.addSpoolsOfThis": "Dodaj kolute tega filamenta",
|
||||
"inspector.adjustFailed": "Prilagoditev teže ni uspela.",
|
||||
"inspector.adjustHelp": "Pozitivna vrednost porablja filament, negativna ga doda nazaj.",
|
||||
"inspector.adjustWeight": "Prilagodi težo",
|
||||
"inspector.apply": "Uporabi",
|
||||
"inspector.applying": "Uporabljanje ...",
|
||||
"inspector.colorHex": "Heksadecimalna koda barve",
|
||||
"inspector.consumeAmount": "Količina porabe:",
|
||||
"inspector.defaultFrom.filament": "{{value}} · privzeto za filament",
|
||||
"inspector.defaultFrom.manufacturer": "{{value}} · privzeto za proizvajalca",
|
||||
"inspector.delete.deleting": "Brisanje ...",
|
||||
"inspector.delete.errorGone": "Že prej je bil izbrisan.",
|
||||
"inspector.delete.errorSpoolsExist": "Tega filamenta ni mogoče izbrisati – koluti ga še vedno uporabljajo.",
|
||||
"inspector.delete.errorUnknown": "Brisanje ni uspelo. Za podrobnosti si oglejte strežniške dnevnike.",
|
||||
"inspector.delete.filament": "Izbriši filament",
|
||||
"inspector.delete.filamentBlockedHint": "Najprej izbrišite te kolute. Arhiviranje ne zadostuje.",
|
||||
"inspector.delete.filamentBlockedTitle": "Tega filamenta ni mogoče izbrisati",
|
||||
"inspector.delete.filamentBody": "„{{name}}“ bo odstranjen iz vašega kataloga. Tega dejanja ni mogoče razveljaviti.",
|
||||
"inspector.delete.filamentDone": "Filament je izbrisan.",
|
||||
"inspector.delete.filamentTitle": "Želite izbrisati ta filament?",
|
||||
"inspector.delete.spool": "Izbriši kolut",
|
||||
"inspector.delete.spoolBody": "Kolut št. {{id}} izdelka „{{name}}“ bo odstranjen skupaj z zgodovino uporabe. Tega dejanja ni mogoče razveljaviti.",
|
||||
"inspector.delete.spoolDone": "Kolut je izbrisan.",
|
||||
"inspector.delete.spoolRemaining": "Preostalo je še {{weight}} filamenta. Če ga želite le umakniti, ga raje arhivirajte.",
|
||||
"inspector.delete.spoolTitle": "Želite izbrisati ta kolut?",
|
||||
"inspector.delete.vendor": "Izbriši proizvajalca",
|
||||
"inspector.delete.vendorBody": "„{{name}}“ bo odstranjen. Tega dejanja ni mogoče razveljaviti.",
|
||||
"inspector.delete.vendorDone": "Proizvajalec je izbrisan.",
|
||||
"inspector.delete.vendorTitle": "Želite izbrisati tega proizvajalca?",
|
||||
"inspector.duplicateFilament": "Podvoji",
|
||||
"inspector.empty": "Na seznamu izberite kolut, filament ali proizvajalca, da si tukaj ogledate podrobnosti.",
|
||||
"inspector.enterValidLength": "Vnesite veljavno dolžino.",
|
||||
"inspector.enterValidWeight": "Vnesite veljavno težo.",
|
||||
"inspector.loading": "Nalaganje ...",
|
||||
"inspector.manageExtraFields": "Upravljanje polj",
|
||||
"inspector.measuredHelp": "Skupna teža koluta + preostalega filamenta, kot je razvidno s tehtnice.",
|
||||
"inspector.multi": "več ×{{count}}",
|
||||
"inspector.newGross": "Nova bruto teža:",
|
||||
"inspector.noExtraFields": "Še nič — dodajte svoja polja za sledenje vsemu, čemur Spoolman ne sledi.",
|
||||
"inspector.notFound": "Tega predmeta ni bilo mogoče najti. Morda je bil izbrisan.",
|
||||
"inspector.ofRemaining": "od {{weight}} preostalo · {{length}} m",
|
||||
"inspector.openFilament": "Odpri filament",
|
||||
"inspector.openLink": "Odpri {{url}} v novem zavihku",
|
||||
"inspector.openManufacturer": "Odpri proizvajalca",
|
||||
"inspector.override.byFilament": "Povoženo s tem filamentom · {{value}}",
|
||||
"inspector.override.bySpool": "Povoženo s tem kolutom · {{value}}",
|
||||
"inspector.override.overridesFilament": "Povozi filament · {{value}}",
|
||||
"inspector.override.overridesManufacturer": "Povozi proizvajalca · {{value}}",
|
||||
"inspector.specs": "Specifikacije — uredi na mestu",
|
||||
"inspector.spoolsCount": "Koluti · {{count}}",
|
||||
"inspector.spoolsTotalRemaining": "{{weight}} skupaj",
|
||||
"inspector.used": "porabljeno",
|
||||
"kofi": "Prispevajte na Ko-fi",
|
||||
"labels.addHead": "",
|
||||
"labels.addQr": "",
|
||||
"labels.addRect": "",
|
||||
"labels.addSwatch": "",
|
||||
"labels.addText": "",
|
||||
"labels.align": "",
|
||||
"labels.alignCenter": "",
|
||||
"labels.alignLeft": "",
|
||||
"labels.alignRight": "",
|
||||
"labels.blank": "",
|
||||
"labels.bold": "",
|
||||
"labels.canvasHint": "",
|
||||
"labels.centerLogo": "",
|
||||
"labels.chooseField": "",
|
||||
"labels.clearFill": "",
|
||||
"labels.copies": "",
|
||||
"labels.cornerRadiusMm": "",
|
||||
"labels.createFirst": "",
|
||||
"labels.cutGuides": "",
|
||||
"labels.deleteConfirm": "",
|
||||
"labels.deleteElement": "",
|
||||
"labels.designName": "",
|
||||
"labels.designerDesc": "",
|
||||
"labels.designerTitle": "",
|
||||
"labels.dpi": "",
|
||||
"labels.dpiHint": "",
|
||||
"labels.duplicate": "",
|
||||
"labels.fields.bedTemp": "",
|
||||
"labels.fields.diameterMm": "",
|
||||
"labels.fields.groupVendor": "",
|
||||
"labels.fields.initialG": "",
|
||||
"labels.fields.netWeightG": "",
|
||||
"labels.fields.remainingG": "",
|
||||
"labels.fill": "",
|
||||
"labels.fitWidth": "",
|
||||
"labels.fitWidthHint": "",
|
||||
"labels.fontMm": "",
|
||||
"labels.gridInfo": "",
|
||||
"labels.hMm": "",
|
||||
"labels.heightMm": "",
|
||||
"labels.hintNowrap": "",
|
||||
"labels.hintSwatch": "",
|
||||
"labels.hintWrap": "",
|
||||
"labels.addHead": "Dodaj",
|
||||
"labels.addQr": "Koda QR",
|
||||
"labels.addRect": "Pravokotnik",
|
||||
"labels.addSwatch": "Barvni vzorec",
|
||||
"labels.addText": "Besedilo / polje",
|
||||
"labels.align": "Poravnaj",
|
||||
"labels.alignCenter": "Sredina",
|
||||
"labels.alignLeft": "Levo",
|
||||
"labels.alignRight": "Desno",
|
||||
"labels.blank": "Nimate še nobenih načrtov nalepk.",
|
||||
"labels.bold": "Krepko",
|
||||
"labels.canvasHint": "Povlecite za premikanje · kliknite za izbiro · ročaji za spreminjanje velikosti.",
|
||||
"labels.centerLogo": "Sredinski logotip Spoolman",
|
||||
"labels.chooseField": "Izberite polje ...",
|
||||
"labels.clearFill": "Brez polnila (samo obroba)",
|
||||
"labels.copies": "Kopije",
|
||||
"labels.cornerRadiusMm": "Polmer kota",
|
||||
"labels.createFirst": "Ustvarite svojo prvo nalepko",
|
||||
"labels.cutGuides": "Vodilne črte za rezanje",
|
||||
"labels.deleteConfirm": "Želite izbrisati ta načrt nalepke?",
|
||||
"labels.deleteElement": "Izbriši element",
|
||||
"labels.designName": "Ime načrta",
|
||||
"labels.designerDesc": "Oblikujte nalepke s kodami QR s poljubnim poljem za kolut, filament ali dobavitelja, nato pa jih natisnite na pole ali s tiskalnikom nalepk. Načrti so shranjeni na strežniku.",
|
||||
"labels.designerTitle": "Oblikovalnik nalepk",
|
||||
"labels.dpi": "Ločljivost",
|
||||
"labels.dpiHint": "Uskladite s tiskalnikom, da bodo majhne nalepke ostre: 203 dpi za večino termičnih tiskalnikov nalepk, 300 ali več za laserske in brizgalne tiskalnike.",
|
||||
"labels.duplicate": "Podvoji",
|
||||
"labels.exportDescAml": "Shrani eno datoteko nalepke .aml na izbrano nalepko, vsaka velikosti {{w}}×{{h}} mm. Odpre se v aplikacijah, temelječih na LPAPI, ki so priložene večini termičnih tiskalnikov nalepk in preberejo fizično velikost iz datoteke.",
|
||||
"labels.exportDescPng": "Shrani eno sliko PNG na izbrano nalepko, vsaka velikosti {{w}}×{{h}} mm.",
|
||||
"labels.exportFormat": "Format datoteke",
|
||||
"labels.exportFormatOptions.aml": "Datoteka nalepke AML",
|
||||
"labels.exportFormatOptions.png": "Slika PNG",
|
||||
"labels.fields.bedTemp": "Temp. delovne plošče",
|
||||
"labels.fields.diameterMm": "Premer (mm)",
|
||||
"labels.fields.groupVendor": "Dobavitelj",
|
||||
"labels.fields.initialG": "Začetna (g)",
|
||||
"labels.fields.netWeightG": "Neto teža (g)",
|
||||
"labels.fields.remainingG": "Preostalo (g)",
|
||||
"labels.fill": "Polnilo",
|
||||
"labels.fitWidth": "Prilagodi širino stolpcem",
|
||||
"labels.fitWidthHint": "Nastavite širino nalepke tako, da izbrano število stolpcev zapolni stran.",
|
||||
"labels.fontMm": "Pisava",
|
||||
"labels.gridInfo": "{{cols}} × {{rows}} = {{perPage}} nalepk / stran",
|
||||
"labels.hMm": "V",
|
||||
"labels.heightMm": "Višina",
|
||||
"labels.hintNowrap": "Besedilo ostane v eni vrstici; vse, kar presega širino, je odrezano.",
|
||||
"labels.hintSwatch": "Ob tiskanju se zapolni z barvami filamenta na kolutu.",
|
||||
"labels.hintWrap": "Obdajte polje, da ga skrijete, ko je prazno:",
|
||||
"labels.imageDesc": "",
|
||||
"labels.imageZipNote": "",
|
||||
"labels.insertField": "",
|
||||
"labels.kind.qr": "",
|
||||
"labels.kind.rect": "",
|
||||
"labels.kind.swatch": "",
|
||||
"labels.kind.text": "",
|
||||
"labels.labelSize": "",
|
||||
"labels.labelType": "",
|
||||
"labels.layout": "",
|
||||
"labels.modeImage": "",
|
||||
"labels.modeLabel": "",
|
||||
"labels.modeSheet": "",
|
||||
"labels.new": "",
|
||||
"labels.newText": "",
|
||||
"labels.noDesignsOption": "",
|
||||
"labels.noFilaments": "",
|
||||
"labels.noSpools": "",
|
||||
"labels.onePerLabel": "",
|
||||
"labels.preparing": "",
|
||||
"labels.preview": "",
|
||||
"labels.previewWith": "",
|
||||
"labels.radiusMm": "",
|
||||
"labels.sampleData": "",
|
||||
"labels.saved": "",
|
||||
"labels.saving": "",
|
||||
"labels.searchFilaments": "",
|
||||
"labels.searchSpools": "",
|
||||
"labels.selectAllShort": "",
|
||||
"labels.selectAtLeastOne": "",
|
||||
"labels.selectAtLeastOneFilament": "",
|
||||
"labels.selectDesign": "",
|
||||
"labels.selectElement": "",
|
||||
"labels.selectNoneShort": "",
|
||||
"labels.selectRecentHint": "",
|
||||
"labels.selectRecentShort": "",
|
||||
"labels.showingFilament": "",
|
||||
"labels.showingReal": "",
|
||||
"labels.showingSpool": "",
|
||||
"labels.showingTags": "",
|
||||
"labels.sizeMm": "",
|
||||
"labels.skipHint": "",
|
||||
"labels.sortHint": "",
|
||||
"labels.sortId": "",
|
||||
"labels.sortNewest": "",
|
||||
"labels.stroke": "",
|
||||
"labels.strokeMm": "",
|
||||
"labels.tabDesign": "",
|
||||
"labels.tabPrint": "",
|
||||
"labels.textTemplate": "",
|
||||
"labels.typeFilament": "",
|
||||
"labels.typeHint": "",
|
||||
"labels.typeSpool": "",
|
||||
"labels.unknownFilament": "",
|
||||
"labels.wMm": "",
|
||||
"labels.widthMm": "",
|
||||
"labels.wrapText": "",
|
||||
"labels.xMm": "",
|
||||
"labels.yMm": "",
|
||||
"library.apiError": "",
|
||||
"library.collapseGroup": "",
|
||||
"library.emptyBody": "",
|
||||
"library.emptyFiltered": "",
|
||||
"library.emptyTitle": "",
|
||||
"library.expandGroup": "",
|
||||
"library.filterBy": "",
|
||||
"library.groupBy": "",
|
||||
"library.groupNone": "",
|
||||
"library.nUnused": "",
|
||||
"library.noLocation": "",
|
||||
"library.noValues": "",
|
||||
"library.openGroup": "",
|
||||
"library.openManufacturer": "",
|
||||
"library.section.extra": "",
|
||||
"library.section.filament": "",
|
||||
"library.section.spool": "",
|
||||
"library.section.vendor": "",
|
||||
"library.showMore": "",
|
||||
"library.sort.nozzle": "",
|
||||
"library.sortBy": "",
|
||||
"library.unassigned": "",
|
||||
"library.unitGroups": "",
|
||||
"library.unitSpools": "",
|
||||
"library.unused": "",
|
||||
"library.unusedGroup": "",
|
||||
"library.unusedGroupMixed": "",
|
||||
"library.unusedSub": "",
|
||||
"library.unusedSubMixed": "",
|
||||
"library.used": "",
|
||||
"library.usedAgo": "",
|
||||
"labels.imageZipNote": "Več kot {{threshold}} nalepk se dostavi kot ena datoteka zip.",
|
||||
"labels.insertField": "Vstavi polje",
|
||||
"labels.kind.qr": "Koda QR",
|
||||
"labels.kind.rect": "Pravokotnik",
|
||||
"labels.kind.swatch": "Vzorec",
|
||||
"labels.kind.text": "Besedilo",
|
||||
"labels.labelSize": "Velikost nalepke",
|
||||
"labels.labelType": "Vrsta nalepke",
|
||||
"labels.layout": "Postavitev",
|
||||
"labels.modeImage": "Datoteke",
|
||||
"labels.modeLabel": "Tiskalnik nalepk",
|
||||
"labels.modeSheet": "Pola (ploščice)",
|
||||
"labels.new": "Novo",
|
||||
"labels.newText": "Novo besedilo",
|
||||
"labels.noDesignsOption": "Ni še načrtov",
|
||||
"labels.noFilaments": "Ni filamentov.",
|
||||
"labels.noSpools": "Ni kolutov.",
|
||||
"labels.onePerLabel": "Ena stran {{w}}×{{h}} mm na nalepko.",
|
||||
"labels.preparing": "Priprava ...",
|
||||
"labels.preview": "Predogled",
|
||||
"labels.previewWith": "Predogled z",
|
||||
"labels.radiusMm": "Polmer",
|
||||
"labels.sampleData": "Vzorčni podatki",
|
||||
"labels.saveAsFormat": "Shrani kot {{format}}",
|
||||
"labels.saved": "Shranjeno",
|
||||
"labels.saving": "Shranjevanje ...",
|
||||
"labels.searchFilaments": "Išči filamente ...",
|
||||
"labels.searchSpools": "Išči kolute ...",
|
||||
"labels.selectAllShort": "Vse",
|
||||
"labels.selectAtLeastOne": "Izberite vsaj en kolut.",
|
||||
"labels.selectAtLeastOneFilament": "Izberite vsaj en filament.",
|
||||
"labels.selectDesign": "Izberi načrt nalepke",
|
||||
"labels.selectElement": "Izberite element za urejanje ali pa ga dodajte iz palete.",
|
||||
"labels.selectNoneShort": "Brez",
|
||||
"labels.selectRecentHint": "Izberite kolute, dodane v zadnjih 24 urah",
|
||||
"labels.selectRecentShort": "Nedavno ({{count}})",
|
||||
"labels.showingFilament": "Prikaz filamenta št. {{id}}",
|
||||
"labels.showingReal": "Prikaz dejanskih podatkov o kolutu.",
|
||||
"labels.showingSpool": "Prikaz koluta št. {{id}}",
|
||||
"labels.showingTags": "Polja so prikazana kot oznake, dokler zgoraj ne izberete koluta.",
|
||||
"labels.sizeMm": "Velikost",
|
||||
"labels.skipHint": "Možnost Preskoči elemente pusti toliko praznih celic pred prvo nalepko, kar vam omogoča ponovno uporabo delno porabljene pole: preštejte že odlepljene nalepke od leve proti desni z vrha.",
|
||||
"labels.sortHint": "Razvrsti kolute",
|
||||
"labels.sortId": "#",
|
||||
"labels.sortNewest": "Najnovejše",
|
||||
"labels.stroke": "Črta",
|
||||
"labels.strokeMm": "Črta",
|
||||
"labels.tabDesign": "Načrt",
|
||||
"labels.tabPrint": "Natisni",
|
||||
"labels.textTemplate": "Besedilo / predloga",
|
||||
"labels.typeFilament": "Filament",
|
||||
"labels.typeHint": "Nalepke za filamente izpustijo polja, ki so specifična za kolute, njihova koda QR pa vodi do filamenta namesto do koluta.",
|
||||
"labels.typeSpool": "Kolut",
|
||||
"labels.unknownFilament": "Neznan filament",
|
||||
"labels.wMm": "Š",
|
||||
"labels.widthMm": "Širina",
|
||||
"labels.wrapText": "Prelomi besedilo",
|
||||
"labels.xMm": "X",
|
||||
"labels.yMm": "Y",
|
||||
"library.apiError": "Povezava z API-jem Spoolman ni uspela. Ali zaledje deluje?",
|
||||
"library.collapseGroup": "Skrij te kolute",
|
||||
"library.dateFilter.ago": "pred {{span}}",
|
||||
"library.dateFilter.between": "{{from}} – {{to}}",
|
||||
"library.dateFilter.customRange": "Obseg po meri",
|
||||
"library.dateFilter.from": "Od",
|
||||
"library.dateFilter.last": "Zadnjih {{span}}",
|
||||
"library.dateFilter.never": "Nikoli",
|
||||
"library.dateFilter.olderThan": "Starejše od {{span}}",
|
||||
"library.dateFilter.since": "Od {{date}}",
|
||||
"library.dateFilter.to": "Do (neobvezno)",
|
||||
"library.dateFilter.until": "Do {{date}}",
|
||||
"library.emptyBody": "Dodajte svoj prvi kolut, da začnete slediti svojemu filamentu.",
|
||||
"library.emptyFiltered": "Noben {{unit}} ne ustreza trenutnim filtrom.",
|
||||
"library.emptyTitle": "Ni še kolutov",
|
||||
"library.expandGroup": "Prikaži te kolute",
|
||||
"library.filterBy": "Filtriraj po",
|
||||
"library.groupBy": "Skupina",
|
||||
"library.groupNone": "Brez (ravno)",
|
||||
"library.nUnused": "×{{count}} neporabljeno",
|
||||
"library.noLocation": "brez lokacije",
|
||||
"library.noSpools": "Ni kolutov",
|
||||
"library.noValues": "Ni vrednosti",
|
||||
"library.openGroup": "Odpri {{name}}",
|
||||
"library.openManufacturer": "Odpri proizvajalca {{name}}",
|
||||
"library.resizeList": "Spremeni velikost seznama (dvojni klik za ponastavitev)",
|
||||
"library.reverseSort": "Obrni vrstni red razvrščanja",
|
||||
"library.section.extra": "Dodatna polja",
|
||||
"library.section.filament": "Filament",
|
||||
"library.section.spool": "Kolut",
|
||||
"library.section.vendor": "Proizvajalec",
|
||||
"library.showMore": "prikaži še {{count}}",
|
||||
"library.sort.nozzle": "Temp. šobe",
|
||||
"library.sortBy": "Razvrsti",
|
||||
"library.unassigned": "nedodeljeno",
|
||||
"library.unitGroups": "skupine",
|
||||
"library.unitSpools": "koluti",
|
||||
"library.unused": "neporabljeno",
|
||||
"library.unusedGroup": "{{count}} neporabljenih · {{weight}} g vsak",
|
||||
"library.unusedGroupMixed": "{{count}} neporabljenih",
|
||||
"library.unusedSub": "{{weight}} g vsak · {{location}}",
|
||||
"library.unusedSubMixed": "{{location}}",
|
||||
"library.used": "porabljeno",
|
||||
"library.usedAgo": "uporabljeno pred {{time}}",
|
||||
"loading": "Nalaganje",
|
||||
"locations.locations": "Lokacije",
|
||||
"locations.newLocation": "Nova lokacija",
|
||||
"locations.noLocation": "Brez lokacije",
|
||||
"locations.noLocationsHelp": "Na tej strani lahko kolute razvrstite po lokacijah. Za začetek dodajte nekaj kolutov.",
|
||||
"nav.labels": "",
|
||||
"nav.library": "",
|
||||
"nav.labels": "Nalepke",
|
||||
"nav.library": "Knjižnica",
|
||||
"no": "Ne",
|
||||
"notifications.error": "Napaka (status code: {{statusCode}})",
|
||||
"notifications.saveSuccessful": "Uspešno shranjeno!",
|
||||
"pagination.empty": "",
|
||||
"pagination.next": "",
|
||||
"pagination.pageSize": "",
|
||||
"pagination.perPage": "",
|
||||
"pagination.prev": "",
|
||||
"pagination.range": "",
|
||||
"pagination.empty": "Ni {{unit}}",
|
||||
"pagination.next": "Naslednja stran",
|
||||
"pagination.pageSize": "Velikost strani",
|
||||
"pagination.perPage": "{{size}} / stran",
|
||||
"pagination.prev": "Prejšnja stran",
|
||||
"pagination.range": "{{from}}–{{to}} od {{total}} {{unit}}",
|
||||
"printing.generic.columns": "Stolpci",
|
||||
"printing.generic.customSize": "Po meri",
|
||||
"printing.generic.description": "Prilagodite spodnje nastavitve, da dobite želeno postavitev tiskanja. Upoštevajte, da lahko tiskalniki in vaš operacijski sistem uporabijo svoje robove in merilo, zato boste morda morali nekajkrat poskusiti, preden bo vse pravilno. Preizkusite to na navadnem listu papirja, preden natisnete na dejanske nalepke.",
|
||||
"printing.generic.dimensions": "Dimenzije",
|
||||
"printing.generic.gridTooWide": "",
|
||||
"printing.generic.gridTooWide": "Nalepke so širše od strani. Zmanjšajte število stolpcev, razmik ali robove.",
|
||||
"printing.generic.helpMargin": "Robove je treba nastaviti tako, da ustrezajo papirju za nalepke in tiskalniku; njihova sprememba bo vplivala na velikost celotne mreže.",
|
||||
"printing.generic.helpPrinterMargin": "Varnostno območje je treba nastaviti glede na to, kako blizu roba papirja lahko tiskalnik tiska; sprememba teh nastavitev ne bo vplivala na celotno mrežo.",
|
||||
"printing.generic.horizontalSpacing": "Vodoraven razmik",
|
||||
@@ -329,11 +406,11 @@
|
||||
"printing.generic.skipItems": "Preskoči elemente",
|
||||
"printing.generic.verticalSpacing": "Navpičen razmik",
|
||||
"printing.qrcode.button": "Natisni nalepke",
|
||||
"printing.qrcode.customUrl.caveat": "",
|
||||
"printing.qrcode.customUrl.hint": "",
|
||||
"printing.qrcode.customUrl.label": "",
|
||||
"printing.qrcode.customUrl.caveat": "URL po meri se ne bo skeniral nazaj v Spoolman — uporabite ga za usmerjanje natisnjene kode QR na zunanjo aplikacijo ali drugega gostitelja.",
|
||||
"printing.qrcode.customUrl.hint": "Z uporabo mesta za vstavljanje vstavite ID koluta kamor koli",
|
||||
"printing.qrcode.customUrl.label": "Predloga URL-ja po meri",
|
||||
"printing.qrcode.useHTTPUrl.label": "Povezava QR kode",
|
||||
"printing.qrcode.useHTTPUrl.options.custom": "",
|
||||
"printing.qrcode.useHTTPUrl.options.custom": "URL po meri",
|
||||
"printing.qrcode.useHTTPUrl.options.default": "Privzeto",
|
||||
"printing.qrcode.useHTTPUrl.options.url": "URL",
|
||||
"printing.qrcode.useHTTPUrl.preview": "Predogled:",
|
||||
@@ -343,31 +420,32 @@
|
||||
"printing.spoolSelect.selectedTotal_other": "Izbranih {{count}} kolutov",
|
||||
"printing.spoolSelect.selectedTotal_two": "Izbrana {{count}} koluta",
|
||||
"scanner.description": "Preberite Kolutarjevo QR kodo za ogled podrobnosti o kolutu.",
|
||||
"scanner.error.inUse": "",
|
||||
"scanner.error.inUse": "Kamero že uporablja druga aplikacija.",
|
||||
"scanner.error.insecureContext": "Stran ni dostopna prek protokola HTTPS.",
|
||||
"scanner.error.notAllowed": "Niste omogočili dostopa do kamere.",
|
||||
"scanner.error.notFound": "Kamere ni bilo mogoče najti.",
|
||||
"scanner.error.streamApiNotSupported": "Brskalnik ne podpira MediaStream API.",
|
||||
"scanner.error.unknown": "Prišlo je do neznane napake. ({{error}})",
|
||||
"scanner.starting": "",
|
||||
"scanner.starting": "Zagon kamere ...",
|
||||
"scanner.title": "Čitalnik QR kode",
|
||||
"search.colorHint": "",
|
||||
"search.noResults": "",
|
||||
"search.searching": "",
|
||||
"search.section.filaments": "",
|
||||
"search.section.spools": "",
|
||||
"search.section.vendors": "",
|
||||
"search.threshold": "",
|
||||
"search.unknownFilament": "",
|
||||
"settings.appearance.tab": "",
|
||||
"settings.appearance.theme.dark": "",
|
||||
"settings.appearance.theme.desc": "",
|
||||
"settings.appearance.theme.label": "",
|
||||
"settings.appearance.theme.light": "",
|
||||
"settings.appearance.theme.system": "",
|
||||
"settings.extraFields.addChoice": "",
|
||||
"settings.extraFields.addChoicesFirst": "",
|
||||
"settings.extraFields.addEntityField": "",
|
||||
"search.colorHint": "Imena barv morajo biti vnesena v angleščini.",
|
||||
"search.moreSpools": "še +{{count}}",
|
||||
"search.noResults": "Ni najdenih zadetkov",
|
||||
"search.searching": "Iskanje ...",
|
||||
"search.section.filaments": "Filamenti",
|
||||
"search.section.spools": "Koluti",
|
||||
"search.section.vendors": "Proizvajalci",
|
||||
"search.threshold": "Ujemanje barv",
|
||||
"search.unknownFilament": "Neznan filament",
|
||||
"settings.appearance.tab": "Videz",
|
||||
"settings.appearance.theme.dark": "Temno",
|
||||
"settings.appearance.theme.desc": "Barvna tema (samo za ta brskalnik)",
|
||||
"settings.appearance.theme.label": "Tema",
|
||||
"settings.appearance.theme.light": "Svetlo",
|
||||
"settings.appearance.theme.system": "Sistemsko",
|
||||
"settings.extraFields.addChoice": "dodaj izbiro ...",
|
||||
"settings.extraFields.addChoicesFirst": "Najprej dodajte izbire",
|
||||
"settings.extraFields.addEntityField": "Dodaj polje za {{entity}}",
|
||||
"settings.extraFields.booleanFalse": "Ne",
|
||||
"settings.extraFields.booleanTrue": "Da",
|
||||
"settings.extraFields.deleteConfirm": "Zbrišem polje {{name}}?",
|
||||
@@ -375,14 +453,14 @@
|
||||
"settings.extraFields.description.intro": "Tukaj lahko svojim entitetam dodate dodatna polja po meri.",
|
||||
"settings.extraFields.description.keyUsage": "Ključ je tisto, kar drugi programi uporabljajo za branje/pisanje podatkov, zato če naj bi se vaše polje po meri integriralo s programom tretje osebe, poskrbite, da ga nastavite pravilno. Privzeta vrednost se uporablja le za nove elemente.",
|
||||
"settings.extraFields.description.tableViews": "Dodatna polja ni mogoče razvrstiti ali filtrirati v pogledih tabel.",
|
||||
"settings.extraFields.editorEdit": "",
|
||||
"settings.extraFields.editorNew": "",
|
||||
"settings.extraFields.errors.choiceNeeded": "",
|
||||
"settings.extraFields.errors.choicesRemoved": "",
|
||||
"settings.extraFields.errors.deleteFailed": "",
|
||||
"settings.extraFields.errors.keyFormat": "",
|
||||
"settings.extraFields.errors.nameRequired": "",
|
||||
"settings.extraFields.errors.saveFailed": "",
|
||||
"settings.extraFields.editorEdit": "Uredi polje za {{entity}}",
|
||||
"settings.extraFields.editorNew": "Novo polje za {{entity}}",
|
||||
"settings.extraFields.errors.choiceNeeded": "Polja z izbirami potrebujejo vsaj eno izbiro.",
|
||||
"settings.extraFields.errors.choicesRemoved": "Obstoječih izbir ni mogoče odstraniti: {{choices}}.",
|
||||
"settings.extraFields.errors.deleteFailed": "Brisanje polja ni uspelo.",
|
||||
"settings.extraFields.errors.keyFormat": "Ključ lahko vsebuje samo male črke, številke in podčrtaje.",
|
||||
"settings.extraFields.errors.nameRequired": "Ime je obvezno.",
|
||||
"settings.extraFields.errors.saveFailed": "Shranjevanje polja ni uspelo.",
|
||||
"settings.extraFields.fieldType.boolean": "Logična vrednost",
|
||||
"settings.extraFields.fieldType.choice": "Izbira",
|
||||
"settings.extraFields.fieldType.datetime": "Datum in čas",
|
||||
@@ -391,13 +469,13 @@
|
||||
"settings.extraFields.fieldType.integer": "Celo število",
|
||||
"settings.extraFields.fieldType.integerRange": "Območje celih števil",
|
||||
"settings.extraFields.fieldType.text": "Besedilo",
|
||||
"settings.extraFields.max": "",
|
||||
"settings.extraFields.min": "",
|
||||
"settings.extraFields.multiSuffix": "",
|
||||
"settings.extraFields.multiple": "",
|
||||
"settings.extraFields.namePlaceholder": "",
|
||||
"settings.extraFields.max": "največ",
|
||||
"settings.extraFields.min": "najmanj",
|
||||
"settings.extraFields.multiSuffix": " · več",
|
||||
"settings.extraFields.multiple": "Več",
|
||||
"settings.extraFields.namePlaceholder": "Prikazno ime",
|
||||
"settings.extraFields.nonUniqueKeyError": "Ključ mora biti edinstven.",
|
||||
"settings.extraFields.none": "",
|
||||
"settings.extraFields.none": "Za {{entity}} še ni definiranih dodatnih polj.",
|
||||
"settings.extraFields.params.choices": "Izbire",
|
||||
"settings.extraFields.params.defaultValue": "Privzeta vrednost",
|
||||
"settings.extraFields.params.fieldType": "Tip",
|
||||
@@ -405,21 +483,37 @@
|
||||
"settings.extraFields.params.name": "Ime",
|
||||
"settings.extraFields.params.order": "Zaporedje",
|
||||
"settings.extraFields.params.unit": "Enota",
|
||||
"settings.extraFields.saveField": "",
|
||||
"settings.extraFields.reorderChoice": "Povlecite ali uporabite puščice za spremembo vrstnega reda te izbire.",
|
||||
"settings.extraFields.saveField": "Shrani polje",
|
||||
"settings.extraFields.tab": "Dodatna polja",
|
||||
"settings.general.baseUrl.label": "Osnovni URL",
|
||||
"settings.general.baseUrl.tooltip": "Osnoven URL, ki se uporablja pri ustvarjanju elementov, kot npr. QR-kode.",
|
||||
"settings.general.currency.desc": "",
|
||||
"settings.general.currency.desc": "Trimestna koda ISO (npr. EUR, USD), ki se uporablja za cene",
|
||||
"settings.general.currency.label": "Valuta",
|
||||
"settings.general.roundPrices.label": "Zaokrožene cene",
|
||||
"settings.general.roundPrices.tooltip": "Zaokroži cene na najbližje celo število.",
|
||||
"settings.general.tab": "Splošno",
|
||||
"settings.header": "Nastavitve",
|
||||
"settings.language.desc": "",
|
||||
"settings.language.label": "",
|
||||
"settings.library.lowThreshold.desc": "",
|
||||
"settings.library.lowThreshold.label": "",
|
||||
"settings.library.tab": "",
|
||||
"settings.language.desc": "Jezik vmesnika (samo za ta brskalnik)",
|
||||
"settings.language.label": "Jezik",
|
||||
"settings.library.lowThreshold.desc": "Koluti s to težo ali manj so označeni rdeče (samo za ta brskalnik)",
|
||||
"settings.library.lowThreshold.label": "Prag za nizko zalogo",
|
||||
"settings.library.tab": "Vedenje knjižnice",
|
||||
"settings.scanner.autoNavigate.desc": "V tem spletnem brskalniku samodejno navigiraj do koluta ali filamenta, ki ga zazna čitalnik oznak.",
|
||||
"settings.scanner.autoNavigate.label": "Samodejna navigacija",
|
||||
"settings.scanner.docs": "Kako nastaviti čitalnik oznak",
|
||||
"settings.scanner.lastSeen": "pred {{when}}",
|
||||
"settings.scanner.pair": "Poveži s tapkanjem",
|
||||
"settings.scanner.reader.anyDesc": "Skeniranja s katerega koli čitalnika odprejo kolute v tem spletnem brskalniku. Za natančnejši nadzor se lahko povežete z določenim čitalnikom. To je tudi čitalnik, ki bo uporabljen za dodajanje novih oznak kolutom in filamentom.",
|
||||
"settings.scanner.reader.label": "Čitalnik oznak",
|
||||
"settings.scanner.reader.pairedDesc": "Samo skeniranja s tega čitalnika odprejo kolute v tem spletnem brskalniku.",
|
||||
"settings.scanner.recent": "Nedavni čitalniki",
|
||||
"settings.scanner.recentNone": "V pomnilniku ni čitalnika; nastavite čitalnik oznak in nato preberite katero koli oznako, da se pojavi tukaj.",
|
||||
"settings.scanner.tab": "Skeniranje oznak",
|
||||
"settings.scanner.unpair": "Uporabi kateri koli čitalnik",
|
||||
"settings.scanner.use": "Uporabi tega",
|
||||
"settings.scanner.waiting": "Tapnite katero koli oznako na čitalniku, ki ga želite ...",
|
||||
"sponsor": "Sponzorirajte me na GitHubu",
|
||||
"spool.fields.archived": "Arhivirano",
|
||||
"spool.fields.comment": "Komentar",
|
||||
"spool.fields.filament": "Filament",
|
||||
@@ -433,26 +527,81 @@
|
||||
"spool.fields.price": "Cena",
|
||||
"spool.fields.registered": "Registrirano",
|
||||
"spool.fields.remainingWeight": "Preostala masa",
|
||||
"spool.fields.spoolWeight": "Teža koluta",
|
||||
"spool.fields.usedWeight": "Porabljena masa",
|
||||
"spool.fields.weight": "Teža",
|
||||
"spool.fieldsHelp.location": "Kje se nahaja kolut, če imate več mest za shranjevanje.",
|
||||
"spool.fieldsHelp.lotNr": "Številka serije proizvajalca. Uporablja se za zagotavljanje barvne doslednosti tiska, če se uporablja več kolutov.",
|
||||
"spool.fieldsHelp.measuredWeight": "Koliko tehtata filament in kolut.",
|
||||
"spool.fieldsHelp.price": "Cena polnega koluta. Če ta vrednost ni nastavljena, se namesto nje upošteva cena filamenta.",
|
||||
"spool.fieldsHelp.remainingWeight": "Koliko filamenta je še na kolutu. Pri novem kolutu se ta količina ujema z maso koluta.",
|
||||
"spool.fieldsHelp.spoolWeight": "Teža tega koluta, ko je prazen. Pustite prazno, da upoštevate težo koluta za ta filament, in jo nastavite le, ko se ta specifični kolut razlikuje – npr. ponovno navit kolut ali zamenjava kartonskega jedra s plastičnim. To je vrednost, ki se odšteje od meritve pri prilagajanju glede na izmerjeno težo, zato napačna vrednost napačno prikaže preostanek filamenta.",
|
||||
"spool.fieldsHelp.usedWeight": "Koliko filamenta je bilo porabljenega s koluta. Pri novem kolutu je vrednost 0 g.",
|
||||
"spool.fieldsHelp.weight": "Koliko filamenta je bilo na tem kolutu, ko je bil poln (neto teža, brez samega koluta). Pustite prazno, da upoštevate težo filamenta, in jo nastavite le, ko se ta specifični kolut razlikuje – npr. delno porabljen kolut ali isti filament na večjem kolutu.",
|
||||
"spool.form.measurementType.length": "Dolžina",
|
||||
"spool.form.measurementType.weight": "Masa",
|
||||
"spool.spool": "Koluti",
|
||||
"topbar.addSpools": "",
|
||||
"topbar.searchPlaceholder": "",
|
||||
"tags.add": "Dodaj oznako",
|
||||
"tags.added": "Oznaka je povezana.",
|
||||
"tags.addedOn": "Dodano {{date}}",
|
||||
"tags.modal.alreadyHere": "Ta oznaka že identificira ta kolut.",
|
||||
"tags.modal.alreadyHereFilament": "Ta oznaka že identificira ta filament.",
|
||||
"tags.modal.badUid": "UID oznake je heksadecimalen, npr. 04A2B3C4.",
|
||||
"tags.modal.checking": "Preverjanje ...",
|
||||
"tags.modal.failed": "Oznake ni bilo mogoče povezati.",
|
||||
"tags.modal.filamentNamed": "filament {{name}}",
|
||||
"tags.modal.free": "Ta oznaka še ni povezana z ničemer.",
|
||||
"tags.modal.intro": "S tapkanjem te oznake na čitalniku boste nato našli kolut št. {{id}}.",
|
||||
"tags.modal.introFilament": "S tapkanjem te oznake na čitalniku boste nato našli ta filament.",
|
||||
"tags.modal.lastSeenOn": "{{reader}} je nazadnje prebral {{uid}}.",
|
||||
"tags.modal.link": "Poveži oznako",
|
||||
"tags.modal.linking": "Povezovanje ...",
|
||||
"tags.modal.move": "Premakni sem s št. {{id}}",
|
||||
"tags.modal.moveFailed": "Oznake ni bilo mogoče premakniti. Morda zdaj ni povezana z nobenim kolutom – poskusite jo znova povezati.",
|
||||
"tags.modal.moveFailedFilament": "Oznake ni bilo mogoče premakniti. Morda zdaj ni povezana z ničemer – poskusite jo znova povezati.",
|
||||
"tags.modal.moveHere": "Premakni sem",
|
||||
"tags.modal.moving": "Premikanje ...",
|
||||
"tags.modal.phoneReading": "Pridržite oznako ob telefonu ...",
|
||||
"tags.modal.phoneScan": "Preberi oznako s tem telefonom",
|
||||
"tags.modal.spoolNamed": "kolut št. {{id}}, {{name}}",
|
||||
"tags.modal.spoolShort": "kolut št. {{id}}",
|
||||
"tags.modal.takenBy": "Trenutno povezano s kolutom {{spool}}.",
|
||||
"tags.modal.title": "Poveži oznako",
|
||||
"tags.modal.uidHelp": "Ločila in velike začetnice ne igrajo vloge: 04:a2:b3:c4, 04-A2-B3-C4 in 04a2b3c4 so vse ista oznaka.",
|
||||
"tags.modal.uidLabel": "UID oznake",
|
||||
"tags.modal.uidPlaceholder": "04A2B3C4",
|
||||
"tags.modal.useLastSeen": "Uporabi jo",
|
||||
"tags.modal.waitingAny": "Čakanje na tapkanje na katerem koli čitalniku.",
|
||||
"tags.modal.waitingOn": "Čakanje na tapkanje na {{reader}}.",
|
||||
"tags.moved": "Oznaka je bila premaknjena sem s koluta št. {{id}}.",
|
||||
"tags.movedFromFilament": "Oznaka je bila premaknjena sem s filamenta {{name}}.",
|
||||
"tags.nfc.insecureContext": "Branje oznak zahteva varno povezavo. Odprite Spoolman prek HTTPS, da uporabite NFC svojega telefona.",
|
||||
"tags.nfc.notAllowed": "To spletno mesto nima dovoljenja za uporabo NFC. Podelite dovoljenje v nastavitvah spletnega mesta v brskalniku in poskusite znova.",
|
||||
"tags.nfc.notReadable": "Čitalnik NFC ni na voljo – morda ga uporablja druga aplikacija.",
|
||||
"tags.nfc.notSupported": "Ta telefon nima NFC ali pa je NFC izklopljen v sistemskih nastavitvah.",
|
||||
"tags.nfc.unknown": "Oznake ni bilo mogoče prebrati.",
|
||||
"tags.none": "Ni povezanih oznak.",
|
||||
"tags.scan.openedFilament": "Oznaka skenirana: odprt filament {{name}}.",
|
||||
"tags.scan.openedSpool": "Oznaka skenirana: odprt kolut št. {{id}}, {{name}}.",
|
||||
"tags.scan.unknown": "Neznana oznaka {{uid}}. Odprite kolut, ki mu pripada, in uporabite možnost Dodaj oznako, da jo povežete.",
|
||||
"tags.section": "Oznake",
|
||||
"tags.unlink": "Prekini povezavo",
|
||||
"tags.unlinkBody": "Oznaka {{uid}} ne bo več identificirala tega koluta. Na samo oznako se nič ne zapiše, zato jo lahko kadar koli znova povežete – s tem kolutom ali drugim.",
|
||||
"tags.unlinkBodyFilament": "Oznaka {{uid}} ne bo več identificirala tega filamenta. Na samo oznako se nič ne zapiše, zato jo lahko kadar koli znova povežete – tukaj ali drugje.",
|
||||
"tags.unlinkFailed": "Povezave z oznako ni bilo mogoče prekiniti.",
|
||||
"tags.unlinkTitle": "Želite prekiniti povezavo s to oznako?",
|
||||
"tags.unlinked": "Povezava z oznako je prekinjena.",
|
||||
"tags.unlinking": "Prekinjanje povezave ...",
|
||||
"topbar.addSpools": "Dodaj kolute",
|
||||
"topbar.searchPlaceholder": "Iskanje — poskusite »rdeča« ali »pla«",
|
||||
"unknown": "Neznano",
|
||||
"validation.hexDigits": "",
|
||||
"validation.maxChars": "",
|
||||
"validation.mustBeGt": "",
|
||||
"validation.mustBeMax": "",
|
||||
"validation.mustBeMin": "",
|
||||
"validation.mustBeNumber": "",
|
||||
"validation.required": "",
|
||||
"validation.hexDigits": "Uporabite 6 ali 8 heksadecimalnih številk",
|
||||
"validation.maxChars": "Največ {{max}} znakov",
|
||||
"validation.mustBeGt": "Mora biti večje od {{value}}",
|
||||
"validation.mustBeMax": "Mora biti {{value}} ali manj",
|
||||
"validation.mustBeMin": "Mora biti {{value}} ali več",
|
||||
"validation.mustBeNumber": "Mora biti številka",
|
||||
"validation.required": "Obvezno",
|
||||
"vendor.fields.comment": "Komentar",
|
||||
"vendor.fields.emptySpoolWeight": "Masa praznega koluta",
|
||||
"vendor.fields.externalId": "Zunanji ID",
|
||||
|
||||
+424
-344
@@ -1,93 +1,143 @@
|
||||
{
|
||||
"add.addAndNew": "",
|
||||
"add.addN_other": "",
|
||||
"add.adding": "",
|
||||
"add.advanced": "",
|
||||
"add.advancedNote": "",
|
||||
"add.change": "",
|
||||
"add.count": "",
|
||||
"add.createNew": "",
|
||||
"add.createNewSub": "",
|
||||
"add.dbUnavailable": "",
|
||||
"add.duplicate": "",
|
||||
"add.duplicateNote": "",
|
||||
"add.duplicateRename": "",
|
||||
"add.duplicateTitle": "",
|
||||
"add.filamentNamePlaceholder": "",
|
||||
"add.fill.full": "",
|
||||
"add.fillLevel": "",
|
||||
"add.importNote": "",
|
||||
"add.inCatalog": "",
|
||||
"add.locationHint": "",
|
||||
"add.locationPlaceholder": "",
|
||||
"add.manufacturerPlaceholder": "",
|
||||
"add.nameHint": "",
|
||||
"add.newFilament": "",
|
||||
"add.newFilamentTitle": "",
|
||||
"add.noCatalog": "",
|
||||
"add.noManufacturer": "",
|
||||
"add.pickColor": "",
|
||||
"add.searchPlaceholder": "",
|
||||
"add.searching": "",
|
||||
"add.spoolWeightPreset.cardboard": "",
|
||||
"add.spoolWeightPreset.plastic": "",
|
||||
"add.spoolWeightPresetsLead": "",
|
||||
"add.step2": "",
|
||||
"add.summary_other": "",
|
||||
"add.typeToSearchAll": "",
|
||||
"add.typeToSearchMatches": "",
|
||||
"add.useExisting": "",
|
||||
"add.vendorHint.existing": "",
|
||||
"add.vendorHint.new": "",
|
||||
"add.vendorHint.optional": "",
|
||||
"add.yourCatalog": "",
|
||||
"add.addAndNew": "บันทึกและเพิ่มรายการใหม่",
|
||||
"add.addN_other": "เพิ่ม {{count}} ม้วน",
|
||||
"add.adding": "กำลังเพิ่ม...",
|
||||
"add.advanced": "สเปกขั้นสูง",
|
||||
"add.advancedNote": "ความหนาแน่น เส้นผ่านศูนย์กลาง อุณหภูมิ (กรอกอัตโนมัติตามวัสดุ)",
|
||||
"add.change": "เปลี่ยน",
|
||||
"add.count": "จำนวน",
|
||||
"add.createNew": "สร้างเส้นพลาสติกใหม่",
|
||||
"add.createNewSub": "ไม่อยู่ในแค็ตตาล็อกของคุณหรือ {{name}}",
|
||||
"add.dbUnavailable": "ไม่สามารถใช้งาน {{name}} ได้",
|
||||
"add.duplicate": "ทำซ้ำ",
|
||||
"add.duplicateNote": "คัดลอกสเปก ผู้ผลิต และราคามาแล้ว กรุณาตั้งชื่อและเลือกสีใหม่",
|
||||
"add.duplicateRename": "ชื่อยังเหมือนกับต้นฉบับ — เปลี่ยนชื่อเพื่อแยกความต่าง",
|
||||
"add.duplicateTitle": "เส้นพลาสติกใหม่ · สำเนาของ “{{name}}”",
|
||||
"add.filamentNamePlaceholder": "เช่น PolyTerra Matte Sage",
|
||||
"add.fill.full": "เต็มม้วน",
|
||||
"add.fillLevel": "ระดับปริมาณคงเหลือ",
|
||||
"add.importNote": "เส้นพลาสติกนี้จะถูกเพิ่มลงในแค็ตตาล็อกของคุณเมื่อคุณเพิ่มม้วนพลาสติก",
|
||||
"add.inCatalog": "ในแค็ตตาล็อก",
|
||||
"add.locationHint": "ตำแหน่งที่คุณจัดเก็บม้วนพลาสติกจริง — เช่น ชั้นวาง กล่อง หรือเครื่องพิมพ์ เว้นว่างไว้หากเก็บไว้ที่เดียวกันทั้งหมด",
|
||||
"add.locationPlaceholder": "เช่น ชั้นวาง A, กล่องกันชื้น 2, เครื่องพิมพ์ 1",
|
||||
"add.manufacturerPlaceholder": "เช่น Polymaker",
|
||||
"add.nameHint": "ระบุชื่อสีด้วย — เพื่อช่วยแยกความต่างของเส้นพลาสติก",
|
||||
"add.newFilament": "เส้นพลาสติกใหม่",
|
||||
"add.newFilamentTitle": "เส้นพลาสติกใหม่",
|
||||
"add.noCatalog": "ไม่มีเส้นพลาสติกในแค็ตตาล็อกของคุณ",
|
||||
"add.noManufacturer": "ไม่มีผู้ผลิต",
|
||||
"add.pickColor": "เลือกสี",
|
||||
"add.searchPlaceholder": "ค้นหาในแค็ตตาล็อกของคุณและ {{name}}...",
|
||||
"add.searching": "กำลังค้นหา...",
|
||||
"add.section.filament": "เส้นพลาสติก",
|
||||
"add.section.filamentNote": "ผลิตภัณฑ์บนม้วนพลาสติก — หนึ่งรายการต่อหนึ่งสี",
|
||||
"add.section.manufacturer": "ผู้ผลิต",
|
||||
"add.section.manufacturerNote": "ผู้ผลิตเส้นพลาสติก เพิ่มเพียงครั้งเดียวแล้วนำไปใช้ซ้ำกับเส้นพลาสติกทั้งหมดของแบรนด์นี้",
|
||||
"add.section.spool": "ม้วนพลาสติก",
|
||||
"add.section.spoolNote": "ม้วนพลาสติกจริงที่จะบันทึกลงในคลังของคุณ",
|
||||
"add.section.spoolSharedNote": "น้ำหนัก น้ำหนักม้วนเปล่า และราคา จะถูกบันทึกเป็นค่าเริ่มต้นของเส้นพลาสติกใหม่ด้วย",
|
||||
"add.spoolWeightPreset.cardboard": "กระดาษแข็ง {{weight}} ก.",
|
||||
"add.spoolWeightPreset.plastic": "พลาสติก {{weight}} ก.",
|
||||
"add.spoolWeightPresetsLead": "เริ่มต้นจากม้วนทั่วไป:",
|
||||
"add.step2": "ขั้นตอนที่ 2 · รายละเอียดม้วนพลาสติก",
|
||||
"add.step2New": "ขั้นตอนที่ 2 · รายละเอียดเส้นและม้วนพลาสติก",
|
||||
"add.summary_other": "{{count}} ม้วน",
|
||||
"add.typeToSearchAll": "พิมพ์เพื่อค้นหาเส้นพลาสติกนับพันรายการ",
|
||||
"add.typeToSearchMatches": "พิมพ์เพื่อค้นหา — ไม่พบรายการที่ตรงกัน",
|
||||
"add.useExisting": "ใช้รายการที่มีอยู่แล้ว",
|
||||
"add.vendorHint.existing": "ใช้ผู้ผลิตที่มีอยู่แล้ว “{{name}}”",
|
||||
"add.vendorHint.new": "จะสร้างผู้ผลิตใหม่ “{{name}}”",
|
||||
"add.vendorHint.optional": "ไม่บังคับ — เว้นว่างไว้หากไม่มีผู้ผลิต",
|
||||
"add.weightPresets": "ขนาดยอดนิยม",
|
||||
"add.yourCatalog": "แค็ตตาล็อกของคุณ",
|
||||
"buttons.apply": "นำไปใช้",
|
||||
"buttons.archive": "เก็บถาวร",
|
||||
"buttons.cancel": "ยกเลิก",
|
||||
"buttons.clear": "ล้าง",
|
||||
"buttons.close": "",
|
||||
"buttons.close": "ปิด",
|
||||
"buttons.delete": "ลบ",
|
||||
"buttons.edit": "แก้ไข",
|
||||
"buttons.filter": "กรอง",
|
||||
"buttons.save": "บันทึก",
|
||||
"buttons.showArchived": "แสดงที่เก็บถาวร",
|
||||
"buttons.unArchive": "ยกเลิกเก็บถาวร",
|
||||
"common.decrement": "",
|
||||
"common.increment": "",
|
||||
"common.remove": "",
|
||||
"common.search": "",
|
||||
"dashboard.addFieldHint": "",
|
||||
"dashboard.dashboard": "",
|
||||
"dashboard.deleteGroup": "",
|
||||
"dashboard.dragHint": "",
|
||||
"dashboard.dropHere": "",
|
||||
"dashboard.emptyHelp": "",
|
||||
"dashboard.errorEmptyName": "",
|
||||
"dashboard.errorExists": "",
|
||||
"dashboard.errorRename": "",
|
||||
"dashboard.lastUsed": "",
|
||||
"dashboard.newGroup": "",
|
||||
"dashboard.newGroupName": "",
|
||||
"dashboard.reorderGroup": "",
|
||||
"dashboard.spoolCount_other": "",
|
||||
"dashboard.unassigned": "",
|
||||
"datetime.done": "",
|
||||
"datetime.hour": "",
|
||||
"datetime.minute": "",
|
||||
"datetime.nextMonth": "",
|
||||
"datetime.now": "",
|
||||
"datetime.pick": "",
|
||||
"datetime.prevMonth": "",
|
||||
"datetime.time": "",
|
||||
"documentTitle.dashboard.list": "",
|
||||
"documentTitle.labels.list": "",
|
||||
"documentTitle.library.list": "",
|
||||
"changeFilament.action": "เปลี่ยน",
|
||||
"changeFilament.adoptWeight": "ตั้งค่าน้ำหนักเต็มม้วนนี้เป็น {{full}}",
|
||||
"changeFilament.adoptWeightHelp": "บันทึกเดิมคือ {{current}} จากเส้นพลาสติกเดิม น้ำหนักที่ใช้ไป ({{used}}) จะยังคงเดิม ทำให้เหลือ {{remaining}}",
|
||||
"changeFilament.apply": "เปลี่ยนเส้นพลาสติก",
|
||||
"changeFilament.applying": "กำลังเปลี่ยน...",
|
||||
"changeFilament.current": "ปัจจุบัน",
|
||||
"changeFilament.done": "เปลี่ยนเส้นพลาสติกเรียบร้อยแล้ว",
|
||||
"changeFilament.failed": "ไม่สามารถเปลี่ยนเส้นพลาสติกได้",
|
||||
"changeFilament.importNote": "เส้นพลาสติกนี้จะถูกเพิ่มลงในแค็ตตาล็อกของคุณเมื่อคุณบันทึกการเปลี่ยนแปลง",
|
||||
"changeFilament.intro": "ม้วนพลาสติก #{{id}} จะยังคงรักษาหมายเลข สถานที่จัดเก็บ วันที่ และประวัติการใช้งานเดิมไว้ — เปลี่ยนเฉพาะเส้นพลาสติกที่บรรจุอยู่เท่านั้น",
|
||||
"changeFilament.newTag": "ใหม่",
|
||||
"changeFilament.next": "เปลี่ยนเป็น",
|
||||
"changeFilament.now": "ปัจจุบัน",
|
||||
"changeFilament.pickBelow": "เลือกเส้นพลาสติกด้านล่าง",
|
||||
"changeFilament.title": "เปลี่ยนเส้นพลาสติก",
|
||||
"changeFilament.useExisting": "เลือกเส้นพลาสติกที่มีอยู่แล้ว",
|
||||
"changeFilament.weightFollows": "ม้วนพลาสติกนี้ไม่ได้ระบุน้ำหนักเต็มม้วนไว้ จึงใช้น้ำหนักของเส้นพลาสติกใหม่: {{full}} คงเหลือ {{remaining}} หลังจากหักลบ {{used}} ที่ใช้ไปแล้ว",
|
||||
"changeVendor.action": "เปลี่ยน",
|
||||
"changeVendor.apply": "เปลี่ยนผู้ผลิต",
|
||||
"changeVendor.applying": "กำลังเปลี่ยน...",
|
||||
"changeVendor.clearHint": "กำหนดให้เส้นพลาสติกนี้ไม่มีผู้ผลิต",
|
||||
"changeVendor.create": "สร้าง “{{name}}”",
|
||||
"changeVendor.createHint": "ยังไม่มีในรายชื่อผู้ผลิตของคุณ — ระบบจะเพิ่มให้อัตโนมัติ",
|
||||
"changeVendor.current": "ปัจจุบัน",
|
||||
"changeVendor.done": "เปลี่ยนผู้ผลิตเรียบร้อยแล้ว",
|
||||
"changeVendor.emptySpool": "ม้วนเปล่า {{value}}",
|
||||
"changeVendor.failed": "ไม่สามารถเปลี่ยนผู้ผลิตได้",
|
||||
"changeVendor.intro": "“{{name}}” จะยังคงรักษาม้วนพลาสติก สเปก และประวัติเดิมไว้ — เปลี่ยนเฉพาะผู้ผลิตที่จัดหมวดหมู่ไว้เท่านั้น",
|
||||
"changeVendor.next": "เปลี่ยนเป็น",
|
||||
"changeVendor.noEmptySpool": "ไม่มีน้ำหนักม้วนพลาสติกเปล่า",
|
||||
"changeVendor.noMatches": "ไม่พบผู้ผลิตที่ตรงกัน",
|
||||
"changeVendor.none": "ยังไม่มีผู้ผลิต",
|
||||
"changeVendor.noneNote": "ไม่ได้ระบุแบรนด์",
|
||||
"changeVendor.now": "ปัจจุบัน",
|
||||
"changeVendor.pickBelow": "เลือกผู้ผลิตด้านล่าง",
|
||||
"changeVendor.searchPlaceholder": "ค้นหาหรือพิมพ์ชื่อผู้ผลิต",
|
||||
"changeVendor.tareFollows": "เส้นพลาสติกนี้ไม่ได้ระบุน้ำหนักม้วนเปล่าไว้ จึงใช้น้ำหนักของผู้ผลิต หลังเปลี่ยนจะเป็น {{value}} ซึ่งเป็นน้ำหนักม้วนเปล่าเมื่อคุณนำไปชั่ง",
|
||||
"changeVendor.tareGone": "เส้นพลาสติกนี้ไม่ได้ระบุน้ำหนักม้วนเปล่าไว้และเดิมใช้ {{current}} ของผู้ผลิต หลังเปลี่ยนจะไม่มีค่าน้ำหนักม้วนเปล่า จนกว่าคุณจะกำหนดให้กับเส้นพลาสติกนี้",
|
||||
"changeVendor.title": "เปลี่ยนผู้ผลิต",
|
||||
"changeVendor.willBeCreated": "จะถูกสร้างใหม่",
|
||||
"common.decrement": "ลดลง",
|
||||
"common.increment": "เพิ่มขึ้น",
|
||||
"common.remove": "ลบออก",
|
||||
"common.search": "ค้นหา",
|
||||
"dashboard.addFieldHint": "เพิ่มฟิลด์ข้อความหรือตัวเลือกในม้วนพลาสติกที่หน้าการตั้งค่าเพื่อจัดกลุ่มตามฟิลด์นั้นได้ด้วย",
|
||||
"dashboard.dashboard": "แดชบอร์ด",
|
||||
"dashboard.deleteGroup": "ลบกลุ่ม",
|
||||
"dashboard.dragHint": "ลากม้วนพลาสติกไปยังการ์ดอื่นเพื่อเปลี่ยน {{field}} หรือลากภายในการ์ดเพื่อจัดเรียงลำดับ",
|
||||
"dashboard.dropHere": "วางม้วนพลาสติกที่นี่",
|
||||
"dashboard.emptyHelp": "หน้านี้จะจัดกลุ่มม้วนพลาสติกของคุณตามฟิลด์ที่คุณเลือก เพิ่มม้วนพลาสติกเพื่อเริ่มต้น!",
|
||||
"dashboard.errorEmptyName": "ชื่อต้องไม่ว่างเปล่า",
|
||||
"dashboard.errorExists": "มีกลุ่มที่ใช้ชื่อนี้อยู่แล้ว",
|
||||
"dashboard.errorRename": "ไม่สามารถเปลี่ยนชื่อกลุ่มได้",
|
||||
"dashboard.lastUsed": "ใช้งานล่าสุดเมื่อ {{time}}",
|
||||
"dashboard.newGroup": "กลุ่มใหม่",
|
||||
"dashboard.newGroupName": "กลุ่มใหม่ {{n}}",
|
||||
"dashboard.reorderGroup": "ลากเพื่อจัดเรียงกลุ่ม",
|
||||
"dashboard.spoolCount_other": "{{count}} ม้วน",
|
||||
"dashboard.unassigned": "ยังไม่ได้จัดกลุ่ม",
|
||||
"datetime.done": "เสร็จสิ้น",
|
||||
"datetime.hour": "ชั่วโมง",
|
||||
"datetime.minute": "นาที",
|
||||
"datetime.nextMonth": "เดือนถัดไป",
|
||||
"datetime.now": "ตอนนี้",
|
||||
"datetime.pick": "เลือกวันเวลา",
|
||||
"datetime.prevMonth": "เดือนก่อนหน้า",
|
||||
"datetime.time": "เวลา",
|
||||
"documentTitle.dashboard.list": "แดชบอร์ด | Spoolman",
|
||||
"documentTitle.labels.list": "ฉลาก | Spoolman",
|
||||
"documentTitle.library.list": "คลังข้อมูล | Spoolman",
|
||||
"documentTitle.locations.list": "สถานที่เก็บ | Spoolman",
|
||||
"documentTitle.settings.list": "",
|
||||
"filament.fields.addColor": "",
|
||||
"filament.fields.articleNumber": "เลขบทความ",
|
||||
"filament.fields.coaxial": "เส้นหลายสี",
|
||||
"documentTitle.settings.list": "การตั้งค่า | Spoolman",
|
||||
"filament.fields.addColor": "เพิ่มสี",
|
||||
"filament.fields.articleNumber": "รหัสสินค้า",
|
||||
"filament.fields.coaxial": "เส้นประกบหลายสี",
|
||||
"filament.fields.colorHex": "สี",
|
||||
"filament.fields.colorType": "",
|
||||
"filament.fields.colorType": "ประเภทสี",
|
||||
"filament.fields.comment": "หมายเหตุ",
|
||||
"filament.fields.density": "ความหนาแน่น",
|
||||
"filament.fields.diameter": "เส้นผ่านศูนย์กลาง",
|
||||
@@ -97,9 +147,10 @@
|
||||
"filament.fields.material": "วัสดุ",
|
||||
"filament.fields.multiColor": "หลายสี",
|
||||
"filament.fields.name": "ชื่อ",
|
||||
"filament.fields.opacity": "ความทึบแสง",
|
||||
"filament.fields.price": "ราคา",
|
||||
"filament.fields.registered": "ลงทะเบียน",
|
||||
"filament.fields.removeColor": "",
|
||||
"filament.fields.removeColor": "ลบสีออก",
|
||||
"filament.fields.settingsBedTemp": "อุณหภูมิฐานพิมพ์",
|
||||
"filament.fields.settingsExtruderTemp": "อุณหภูมิหัวพิมพ์",
|
||||
"filament.fields.singleColor": "สีเดียว",
|
||||
@@ -108,219 +159,242 @@
|
||||
"filament.fields.weight": "น้ำหนัก",
|
||||
"filament.fieldsHelp.articleNumber": "เช่น EAN, UPC, เป็นต้น",
|
||||
"filament.fieldsHelp.multiColorDirection": "เส้นพลาสติกหลายสีสามารถมีหลายสีได้ 2 รูปแบบ: แบบ coextrusion (เช่น เส้นพลาสติกสองสีพร้อมกัน) หรือแบบไล่สีตามความยาว (เช่น เส้นพลาสติกเปลี่ยนสีไปเรื่อยๆ)",
|
||||
"filament.fieldsHelp.spoolWeight": "น้ำหนักม้วนพลาสติกเปล่า ใช้เพื่อคำนวณน้ำหนักรวมของม้วนพลาสติก",
|
||||
"filament.fieldsHelp.spoolWeight": "น้ำหนักของม้วนพลาสติกเปล่า ใช้เพื่อคำนวณน้ำหนักที่วัดได้ของม้วนพลาสติก หากไม่แน่ใจ ม้วนกระดาษมักหนัก 130–150 ก. และม้วนพลาสติกหนัก 180–250 ก. — หากต้องการตัวเลขที่แม่นยำ ให้นำม้วนเปล่าไปชั่งเมื่อใช้หมด",
|
||||
"filament.fieldsHelp.weight": "น้ำหนักของเส้นพลาสติกในม้วนพลาสติก (สุทธิ) ไม่รวมม้วนพลาสติกเปล่า เป็นค่าน้ำหนักที่มักระบุบนบรรจุภัณฑ์",
|
||||
"filament.filament": "เส้นพลาสติก",
|
||||
"footer.documentation": "",
|
||||
"footer.reportIssue": "",
|
||||
"footer.documentation": "เอกสารคู่มือ",
|
||||
"footer.reportIssue": "รายงานปัญหา",
|
||||
"help.help": "วิธีใช้",
|
||||
"inspector.addSpoolsOfThis": "",
|
||||
"inspector.adjustFailed": "",
|
||||
"inspector.adjustHelp": "",
|
||||
"inspector.adjustWeight": "",
|
||||
"inspector.apply": "",
|
||||
"inspector.applying": "",
|
||||
"inspector.colorHex": "",
|
||||
"inspector.consumeAmount": "",
|
||||
"inspector.delete.deleting": "",
|
||||
"inspector.delete.errorGone": "",
|
||||
"inspector.delete.errorSpoolsExist": "",
|
||||
"inspector.delete.errorUnknown": "",
|
||||
"inspector.delete.filament": "",
|
||||
"inspector.delete.filamentBlockedHint": "",
|
||||
"inspector.delete.filamentBlockedTitle": "",
|
||||
"inspector.delete.filamentBlocked_other": "",
|
||||
"inspector.delete.filamentBody": "",
|
||||
"inspector.delete.filamentDone": "",
|
||||
"inspector.delete.filamentTitle": "",
|
||||
"inspector.delete.spool": "",
|
||||
"inspector.delete.spoolBody": "",
|
||||
"inspector.delete.spoolDone": "",
|
||||
"inspector.delete.spoolRemaining": "",
|
||||
"inspector.delete.spoolTitle": "",
|
||||
"inspector.delete.vendor": "",
|
||||
"inspector.delete.vendorBody": "",
|
||||
"inspector.delete.vendorDone": "",
|
||||
"inspector.delete.vendorOrphans_other": "",
|
||||
"inspector.delete.vendorTitle": "",
|
||||
"inspector.duplicateFilament": "",
|
||||
"inspector.empty": "",
|
||||
"inspector.enterValidLength": "",
|
||||
"inspector.enterValidWeight": "",
|
||||
"inspector.loading": "",
|
||||
"inspector.manageExtraFields": "",
|
||||
"inspector.measuredHelp": "",
|
||||
"inspector.multi": "",
|
||||
"inspector.newGross": "",
|
||||
"inspector.noExtraFields": "",
|
||||
"inspector.notFound": "",
|
||||
"inspector.ofRemaining": "",
|
||||
"inspector.openFilament": "",
|
||||
"inspector.openLink": "",
|
||||
"inspector.openManufacturer": "",
|
||||
"inspector.specs": "",
|
||||
"inspector.spoolsCount": "",
|
||||
"inspector.spoolsTotalRemaining": "",
|
||||
"inspector.used": "",
|
||||
"inspector.vendorSub_other": "",
|
||||
"inspector.addSpoolsOfThis": "เพิ่มม้วนพลาสติกจากเส้นพลาสติกนี้",
|
||||
"inspector.adjustFailed": "ปรับค่าน้ำหนักไม่สำเร็จ",
|
||||
"inspector.adjustHelp": "ค่าบวกคือใช้เส้นพลาสติก ค่าลบคือเพิ่มเส้นพลาสติกกลับเข้าไป",
|
||||
"inspector.adjustWeight": "ปรับน้ำหนัก",
|
||||
"inspector.apply": "นำไปใช้",
|
||||
"inspector.applying": "กำลังนำไปใช้...",
|
||||
"inspector.colorHex": "รหัสสี (HEX)",
|
||||
"inspector.consumeAmount": "ปริมาณที่ใช้ไป:",
|
||||
"inspector.defaultFrom.filament": "{{value}} · ค่าเริ่มต้นของเส้นพลาสติก",
|
||||
"inspector.defaultFrom.manufacturer": "{{value}} · ค่าเริ่มต้นของผู้ผลิต",
|
||||
"inspector.delete.deleting": "กำลังลบ...",
|
||||
"inspector.delete.errorGone": "รายการนี้ถูกลบไปแล้ว",
|
||||
"inspector.delete.errorSpoolsExist": "ไม่สามารถลบเส้นพลาสติกนี้ได้ — ยังมีม้วนพลาสติกที่ใช้งานอยู่",
|
||||
"inspector.delete.errorUnknown": "การลบล้มเหลว ตรวจสอบรายละเอียดได้ที่ server logs",
|
||||
"inspector.delete.filament": "ลบเส้นพลาสติก",
|
||||
"inspector.delete.filamentBlockedHint": "ลบม้วนพลาสติกเหล่านั้นออกก่อน การเก็บถาวรยังไม่เพียงพอ",
|
||||
"inspector.delete.filamentBlockedTitle": "ไม่สามารถลบเส้นพลาสติกนี้ได้",
|
||||
"inspector.delete.filamentBlocked_other": "ยังมี {{count}} ม้วนพลาสติกที่ใช้ “{{name}}” อยู่ (รวมถึงที่เก็บถาวร)",
|
||||
"inspector.delete.filamentBody": "“{{name}}” จะถูกลบออกจากแค็ตตาล็อกของคุณ และไม่สามารถยกเลิกการกระทำนี้ได้",
|
||||
"inspector.delete.filamentDone": "ลบเส้นพลาสติกเรียบร้อยแล้ว",
|
||||
"inspector.delete.filamentTitle": "ลบเส้นพลาสติกนี้หรือไม่?",
|
||||
"inspector.delete.spool": "ลบม้วนพลาสติก",
|
||||
"inspector.delete.spoolBody": "ม้วนพลาสติก #{{id}} ของ “{{name}}” จะถูกลบออกพร้อมกับประวัติการใช้งาน และไม่สามารถยกเลิกการกระทำนี้ได้",
|
||||
"inspector.delete.spoolDone": "ลบม้วนพลาสติกเรียบร้อยแล้ว",
|
||||
"inspector.delete.spoolRemaining": "ยังมีเส้นพลาสติกเหลืออยู่ {{weight}} ให้เลือกเก็บถาวรแทนหากต้องการซ่อนรายการไว้",
|
||||
"inspector.delete.spoolTitle": "ลบม้วนพลาสติกนี้หรือไม่?",
|
||||
"inspector.delete.vendor": "ลบผู้ผลิต",
|
||||
"inspector.delete.vendorBody": "“{{name}}” จะถูกลบออก และไม่สามารถยกเลิกการกระทำนี้ได้",
|
||||
"inspector.delete.vendorDone": "ลบผู้ผลิตเรียบร้อยแล้ว",
|
||||
"inspector.delete.vendorOrphans_other": "เส้นพลาสติกทั้ง {{count}} รายการจะยังคงอยู่ แต่จะไม่มีผู้ผลิตระบุไว้",
|
||||
"inspector.delete.vendorTitle": "ลบผู้ผลิตนี้หรือไม่?",
|
||||
"inspector.duplicateFilament": "ทำซ้ำ",
|
||||
"inspector.empty": "เลือกม้วนพลาสติก เส้นพลาสติก หรือผู้ผลิตจากรายการเพื่อดูรายละเอียดที่นี่",
|
||||
"inspector.enterValidLength": "กรุณากรอกความยาวที่ถูกต้อง",
|
||||
"inspector.enterValidWeight": "กรุณากรอกน้ำหนักที่ถูกต้อง",
|
||||
"inspector.loading": "กำลังโหลด...",
|
||||
"inspector.manageExtraFields": "จัดการฟิลด์",
|
||||
"inspector.measuredHelp": "น้ำหนักรวมของม้วนพลาสติก + เส้นพลาสติกคงเหลือ ตามที่อ่านได้จากตาชั่ง",
|
||||
"inspector.multi": "หลายรายการ ×{{count}}",
|
||||
"inspector.newGross": "น้ำหนักรวมใหม่ (Gross Weight):",
|
||||
"inspector.noExtraFields": "ยังไม่มี — เพิ่มฟิลด์ที่คุณกำหนดเองเพื่อติดตามข้อมูลอื่นๆ นอกเหนือจากที่ Spoolman มีให้",
|
||||
"inspector.notFound": "ไม่พบรายการนี้ รายการอาจถูกลบไปแล้ว",
|
||||
"inspector.ofRemaining": "จากคงเหลือ {{weight}} · {{length}} ม.",
|
||||
"inspector.openFilament": "เปิดข้อมูลเส้นพลาสติก",
|
||||
"inspector.openLink": "เปิด {{url}} ในแท็บใหม่",
|
||||
"inspector.openManufacturer": "เปิดข้อมูลผู้ผลิต",
|
||||
"inspector.override.byFilament": "ถูกแทนที่โดยเส้นพลาสติกนี้ · {{value}}",
|
||||
"inspector.override.bySpool": "ถูกแทนที่โดยม้วนพลาสติกนี้ · {{value}}",
|
||||
"inspector.override.overridesFilament": "แทนที่เส้นพลาสติก · {{value}}",
|
||||
"inspector.override.overridesManufacturer": "แทนที่ผู้ผลิต · {{value}}",
|
||||
"inspector.specs": "สเปก — แก้ไขได้โดยตรง",
|
||||
"inspector.spoolsCount": "ม้วนพลาสติก · {{count}}",
|
||||
"inspector.spoolsTotalRemaining": "รวมทั้งหมด {{weight}}",
|
||||
"inspector.used": "ใช้ไปแล้ว",
|
||||
"inspector.vendorSub_other": "{{count}} เส้นพลาสติก",
|
||||
"kofi": "สนับสนุนเราได้ที่ Ko-fi",
|
||||
"labels.addHead": "",
|
||||
"labels.addQr": "",
|
||||
"labels.addRect": "",
|
||||
"labels.addSwatch": "",
|
||||
"labels.addText": "",
|
||||
"labels.align": "",
|
||||
"labels.alignCenter": "",
|
||||
"labels.alignLeft": "",
|
||||
"labels.alignRight": "",
|
||||
"labels.blank": "",
|
||||
"labels.bold": "",
|
||||
"labels.canvasHint": "",
|
||||
"labels.centerLogo": "",
|
||||
"labels.chooseField": "",
|
||||
"labels.clearFill": "",
|
||||
"labels.copies": "",
|
||||
"labels.cornerRadiusMm": "",
|
||||
"labels.createFirst": "",
|
||||
"labels.cutGuides": "",
|
||||
"labels.deleteConfirm": "",
|
||||
"labels.deleteElement": "",
|
||||
"labels.designName": "",
|
||||
"labels.designerDesc": "",
|
||||
"labels.designerTitle": "",
|
||||
"labels.dpi": "",
|
||||
"labels.dpiHint": "",
|
||||
"labels.duplicate": "",
|
||||
"labels.fields.bedTemp": "",
|
||||
"labels.fields.diameterMm": "",
|
||||
"labels.fields.groupVendor": "",
|
||||
"labels.fields.initialG": "",
|
||||
"labels.fields.netWeightG": "",
|
||||
"labels.fields.remainingG": "",
|
||||
"labels.filamentsSelected_other": "",
|
||||
"labels.fill": "",
|
||||
"labels.fitWidth": "",
|
||||
"labels.fitWidthHint": "",
|
||||
"labels.fontMm": "",
|
||||
"labels.gridInfo": "",
|
||||
"labels.hMm": "",
|
||||
"labels.heightMm": "",
|
||||
"labels.hintNowrap": "",
|
||||
"labels.hintSwatch": "",
|
||||
"labels.hintWrap": "",
|
||||
"labels.addHead": "เพิ่ม",
|
||||
"labels.addQr": "QR code",
|
||||
"labels.addRect": "สี่เหลี่ยม",
|
||||
"labels.addSwatch": "ตัวอย่างสี (Swatch)",
|
||||
"labels.addText": "ข้อความ / ฟิลด์",
|
||||
"labels.align": "จัดแนว",
|
||||
"labels.alignCenter": "กึ่งกลาง",
|
||||
"labels.alignLeft": "ชิดซ้าย",
|
||||
"labels.alignRight": "ชิดขวา",
|
||||
"labels.blank": "คุณยังไม่มีรูปแบบฉลากใดๆ",
|
||||
"labels.bold": "ตัวหนา",
|
||||
"labels.canvasHint": "ลากเพื่อย้าย · คลิกเพื่อเลือก · ดึงจุดจับเพื่อปรับขนาด",
|
||||
"labels.centerLogo": "จัดโลโก้ Spoolman กึ่งกลาง",
|
||||
"labels.chooseField": "เลือกฟิลด์...",
|
||||
"labels.clearFill": "ล้างสีพื้นหลัง (เส้นขอบเท่านั้น)",
|
||||
"labels.copies": "สำเนา",
|
||||
"labels.cornerRadiusMm": "รัศมีมุมโค้ง",
|
||||
"labels.createFirst": "สร้างฉลากแรกของคุณ",
|
||||
"labels.cutGuides": "เส้นนำการตัด",
|
||||
"labels.deleteConfirm": "ลบรูปแบบฉลากนี้หรือไม่?",
|
||||
"labels.deleteElement": "ลบองค์ประกอบ",
|
||||
"labels.designName": "ชื่อรูปแบบฉลาก",
|
||||
"labels.designerDesc": "ออกแบบฉลาก QR code พร้อมฟิลด์ของม้วนพลาสติก เส้นพลาสติก หรือผู้ผลิต แล้วพิมพ์ลงบนแผ่นสติกเกอร์หรือเครื่องพิมพ์ฉลาก รูปแบบจะถูกบันทึกไว้บนเซิร์ฟเวอร์",
|
||||
"labels.designerTitle": "ตัวออกแบบฉลาก",
|
||||
"labels.dpi": "ความละเอียด",
|
||||
"labels.dpiHint": "กำหนดให้ตรงกับเครื่องพิมพ์เพื่อให้ฉลากขนาดเล็กคมชัด: 203 dpi สำหรับเครื่องพิมพ์ฉลากความร้อนส่วนใหญ่, 300 ขึ้นไปสำหรับเลเซอร์และอิงค์เจ็ท",
|
||||
"labels.duplicate": "ทำซ้ำ",
|
||||
"labels.exportDescAml": "บันทึกไฟล์ฉลาก .aml หนึ่งไฟล์ต่อฉลากที่เลือก แต่ละฉลากขนาด {{w}}×{{h}} มม. เปิดใช้งานได้ในแอปที่ใช้ LPAPI ซึ่งมาพร้อมกับเครื่องพิมพ์ฉลากความร้อนส่วนใหญ่ โดยจะอ่านขนาดจริงจากไฟล์",
|
||||
"labels.exportDescPng": "บันทึกภาพ PNG หนึ่งไฟล์ต่อฉลากที่เลือก แต่ละฉลากขนาด {{w}}×{{h}} มม.",
|
||||
"labels.exportFormat": "รูปแบบไฟล์",
|
||||
"labels.exportFormatOptions.aml": "ไฟล์ฉลาก AML",
|
||||
"labels.exportFormatOptions.png": "รูปภาพ PNG",
|
||||
"labels.fields.bedTemp": "อุณหภูมิฐานพิมพ์",
|
||||
"labels.fields.diameterMm": "เส้นผ่านศูนย์กลาง (มม.)",
|
||||
"labels.fields.groupVendor": "ผู้ผลิต",
|
||||
"labels.fields.initialG": "เริ่มต้น (ก.)",
|
||||
"labels.fields.netWeightG": "น้ำหนักสุทธิ (ก.)",
|
||||
"labels.fields.remainingG": "คงเหลือ (ก.)",
|
||||
"labels.filamentsSelected_other": "เลือก {{count}} เส้นพลาสติก",
|
||||
"labels.fill": "สีพื้นหลัง",
|
||||
"labels.fitWidth": "ปรับความกว้างให้พอดีกับคอลัมน์",
|
||||
"labels.fitWidthHint": "ตั้งค่าความกว้างฉลากเพื่อให้จำนวนคอลัมน์ที่เลือกพอดีกับหน้ากระดาษ",
|
||||
"labels.fontMm": "ฟอนต์",
|
||||
"labels.gridInfo": "{{cols}} × {{rows}} = {{perPage}} ฉลาก / หน้า",
|
||||
"labels.hMm": "สูง",
|
||||
"labels.heightMm": "ความสูง",
|
||||
"labels.hintNowrap": "ข้อความจะอยู่บรรทัดเดียว ส่วนที่เกินความกว้างจะถูกตัดออก",
|
||||
"labels.hintSwatch": "จะเติมด้วยสีเส้นพลาสติกของม้วนนั้นในขณะพิมพ์",
|
||||
"labels.hintWrap": "ครอบฟิลด์เพื่อซ่อนเมื่อไม่มีข้อมูล:",
|
||||
"labels.imageDesc": "",
|
||||
"labels.imageZipNote": "",
|
||||
"labels.insertField": "",
|
||||
"labels.kind.qr": "",
|
||||
"labels.kind.rect": "",
|
||||
"labels.kind.swatch": "",
|
||||
"labels.kind.text": "",
|
||||
"labels.labelSize": "",
|
||||
"labels.labelType": "",
|
||||
"labels.layout": "",
|
||||
"labels.modeImage": "",
|
||||
"labels.modeLabel": "",
|
||||
"labels.modeSheet": "",
|
||||
"labels.new": "",
|
||||
"labels.newText": "",
|
||||
"labels.noDesignsOption": "",
|
||||
"labels.noFilaments": "",
|
||||
"labels.noSpools": "",
|
||||
"labels.onePerLabel": "",
|
||||
"labels.preparing": "",
|
||||
"labels.preview": "",
|
||||
"labels.previewWith": "",
|
||||
"labels.printN_other": "",
|
||||
"labels.radiusMm": "",
|
||||
"labels.sampleData": "",
|
||||
"labels.saved": "",
|
||||
"labels.saving": "",
|
||||
"labels.searchFilaments": "",
|
||||
"labels.searchSpools": "",
|
||||
"labels.selectAllShort": "",
|
||||
"labels.selectAtLeastOne": "",
|
||||
"labels.selectAtLeastOneFilament": "",
|
||||
"labels.selectDesign": "",
|
||||
"labels.selectElement": "",
|
||||
"labels.selectNoneShort": "",
|
||||
"labels.selectRecentHint": "",
|
||||
"labels.selectRecentShort": "",
|
||||
"labels.showingFilament": "",
|
||||
"labels.showingReal": "",
|
||||
"labels.showingSpool": "",
|
||||
"labels.showingTags": "",
|
||||
"labels.sizeMm": "",
|
||||
"labels.skipHint": "",
|
||||
"labels.sortHint": "",
|
||||
"labels.sortId": "",
|
||||
"labels.sortNewest": "",
|
||||
"labels.stroke": "",
|
||||
"labels.strokeMm": "",
|
||||
"labels.tabDesign": "",
|
||||
"labels.tabPrint": "",
|
||||
"labels.textTemplate": "",
|
||||
"labels.typeFilament": "",
|
||||
"labels.typeHint": "",
|
||||
"labels.typeSpool": "",
|
||||
"labels.unknownFilament": "",
|
||||
"labels.wMm": "",
|
||||
"labels.widthMm": "",
|
||||
"labels.wrapText": "",
|
||||
"labels.xMm": "",
|
||||
"labels.yMm": "",
|
||||
"library.apiError": "",
|
||||
"library.collapseGroup": "",
|
||||
"library.emptyBody": "",
|
||||
"library.emptyFiltered": "",
|
||||
"library.emptyTitle": "",
|
||||
"library.expandGroup": "",
|
||||
"library.filterBy": "",
|
||||
"library.groupBy": "",
|
||||
"library.groupNone": "",
|
||||
"library.groupSpools_other": "",
|
||||
"library.nUnused": "",
|
||||
"library.noLocation": "",
|
||||
"library.noValues": "",
|
||||
"library.openGroup": "",
|
||||
"library.openManufacturer": "",
|
||||
"library.section.extra": "",
|
||||
"library.section.filament": "",
|
||||
"library.section.spool": "",
|
||||
"library.section.vendor": "",
|
||||
"library.showMore": "",
|
||||
"library.sort.nozzle": "",
|
||||
"library.sortBy": "",
|
||||
"library.unassigned": "",
|
||||
"library.unitGroups": "",
|
||||
"library.unitSpools": "",
|
||||
"library.unused": "",
|
||||
"library.unusedGroup": "",
|
||||
"library.unusedGroupMixed": "",
|
||||
"library.unusedSub": "",
|
||||
"library.unusedSubMixed": "",
|
||||
"library.used": "",
|
||||
"library.usedAgo": "",
|
||||
"labels.imageZipNote": "ฉลากที่มากกว่า {{threshold}} รายการจะถูกส่งออกเป็นไฟล์ zipไฟล์เดียว",
|
||||
"labels.insertField": "แทรกฟิลด์",
|
||||
"labels.kind.qr": "QR code",
|
||||
"labels.kind.rect": "สี่เหลี่ยม",
|
||||
"labels.kind.swatch": "ตัวอย่างสี",
|
||||
"labels.kind.text": "ข้อความ",
|
||||
"labels.labelSize": "ขนาดฉลาก",
|
||||
"labels.labelType": "ประเภทฉลาก",
|
||||
"labels.layout": "เค้าโครง",
|
||||
"labels.modeImage": "ไฟล์",
|
||||
"labels.modeLabel": "เครื่องพิมพ์ฉลาก",
|
||||
"labels.modeSheet": "แผ่นสติกเกอร์ (เรียงแถว)",
|
||||
"labels.new": "ใหม่",
|
||||
"labels.newText": "ข้อความใหม่",
|
||||
"labels.noDesignsOption": "ยังไม่มีรูปแบบฉลาก",
|
||||
"labels.noFilaments": "ไม่มีเส้นพลาสติก",
|
||||
"labels.noSpools": "ไม่มีม้วนพลาสติก",
|
||||
"labels.onePerLabel": "หนึ่งหน้าขนาด {{w}}×{{h}} มม. ต่อหนึ่งฉลาก",
|
||||
"labels.preparing": "กำลังเตรียมพิมพ์...",
|
||||
"labels.preview": "ตัวอย่าง",
|
||||
"labels.previewWith": "ดูตัวอย่างด้วย",
|
||||
"labels.printN_other": "พิมพ์ {{count}} ฉลาก",
|
||||
"labels.radiusMm": "รัศมีมุม",
|
||||
"labels.sampleData": "ข้อมูลตัวอย่าง",
|
||||
"labels.saveAsFormat": "บันทึกเป็น {{format}}",
|
||||
"labels.saved": "บันทึกแล้ว",
|
||||
"labels.saving": "กำลังบันทึก...",
|
||||
"labels.searchFilaments": "ค้นหาเส้นพลาสติก...",
|
||||
"labels.searchSpools": "ค้นหาม้วนพลาสติก...",
|
||||
"labels.selectAllShort": "ทั้งหมด",
|
||||
"labels.selectAtLeastOne": "กรุณาเลือกม้วนพลาสติกอย่างน้อย 1 ม้วน",
|
||||
"labels.selectAtLeastOneFilament": "กรุณาเลือกเส้นพลาสติกอย่างน้อย 1 รายการ",
|
||||
"labels.selectDesign": "เลือกรูปแบบฉลาก",
|
||||
"labels.selectElement": "เลือกองค์ประกอบเพื่อแก้ไข หรือเพิ่มองค์ประกอบจากพาเล็ตต์",
|
||||
"labels.selectNoneShort": "ไม่เลือก",
|
||||
"labels.selectRecentHint": "เลือกม้วนพลาสติกที่เพิ่มเข้ามาในช่วง 24 ชั่วโมงที่ผ่านมา",
|
||||
"labels.selectRecentShort": "ล่าสุด ({{count}})",
|
||||
"labels.showingFilament": "กำลังแสดงเส้นพลาสติก #{{id}}",
|
||||
"labels.showingReal": "กำลังแสดงข้อมูลจริงของม้วนพลาสติก",
|
||||
"labels.showingSpool": "กำลังแสดงม้วนพลาสติก #{{id}}",
|
||||
"labels.showingTags": "ฟิลด์จะแสดงเป็นแท็กจนกว่าคุณจะเลือกม้วนพลาสติกด้านบน",
|
||||
"labels.sizeMm": "ขนาด",
|
||||
"labels.skipHint": "ข้ามรายการจะเว้นช่องว่างตามจำนวนนั้นไว้ก่อนฉลากแรก ช่วยให้คุณนำแผ่นสติกเกอร์ที่ใช้ไปบางส่วนกลับมาใช้ใหม่ได้: ให้นับฉลากที่ลอกออกไปแล้วจากซ้ายไปขวาจากบนลงล่าง",
|
||||
"labels.sortHint": "จัดเรียงม้วนพลาสติก",
|
||||
"labels.sortId": "#",
|
||||
"labels.sortNewest": "ใหม่สุด",
|
||||
"labels.stroke": "เส้นขอบ",
|
||||
"labels.strokeMm": "ความหนาเส้นขอบ",
|
||||
"labels.tabDesign": "ออกแบบ",
|
||||
"labels.tabPrint": "พิมพ์",
|
||||
"labels.textTemplate": "ข้อความ / เทมเพลต",
|
||||
"labels.typeFilament": "เส้นพลาสติก",
|
||||
"labels.typeHint": "ฉลากเส้นพลาสติกจะละเว้นฟิลด์เฉพาะของม้วนพลาสติก และ QR code จะลิงก์ไปยังเส้นพลาสติกแทนม้วนพลาสติก",
|
||||
"labels.typeSpool": "ม้วนพลาสติก",
|
||||
"labels.unknownFilament": "ไม่ทราบเส้นพลาสติก",
|
||||
"labels.wMm": "กว้าง",
|
||||
"labels.widthMm": "ความกว้าง",
|
||||
"labels.wrapText": "ตัดข้อความอัตโนมัติ",
|
||||
"labels.xMm": "X",
|
||||
"labels.yMm": "Y",
|
||||
"library.apiError": "ไม่สามารถเชื่อมต่อกับ Spoolman API ได้ Backend กำลังทำงานอยู่หรือไม่?",
|
||||
"library.collapseGroup": "ซ่อนม้วนพลาสติกเหล่านี้",
|
||||
"library.dateFilter.ago": "{{span}} ที่แล้ว",
|
||||
"library.dateFilter.between": "{{from}} – {{to}}",
|
||||
"library.dateFilter.customRange": "กำหนดช่วงเอง",
|
||||
"library.dateFilter.from": "จาก",
|
||||
"library.dateFilter.last": "{{span}} ล่าสุด",
|
||||
"library.dateFilter.never": "ไม่เคย",
|
||||
"library.dateFilter.olderThan": "เก่ากว่า {{span}}",
|
||||
"library.dateFilter.since": "ตั้งแต่ {{date}}",
|
||||
"library.dateFilter.to": "ถึง (ไม่บังคับ)",
|
||||
"library.dateFilter.until": "จนถึง {{date}}",
|
||||
"library.emptyBody": "เพิ่มม้วนพลาสติกแรกของคุณเพื่อเริ่มติดตามเส้นพลาสติก",
|
||||
"library.emptyFiltered": "ไม่พบ {{unit}} ที่ตรงกับตัวกรองปัจจุบัน",
|
||||
"library.emptyTitle": "ยังไม่มีม้วนพลาสติก",
|
||||
"library.expandGroup": "แสดงม้วนพลาสติกเหล่านี้",
|
||||
"library.filterBy": "กรองโดย",
|
||||
"library.groupBy": "จัดกลุ่ม",
|
||||
"library.groupNone": "ไม่จัดกลุ่ม (แบบเรียบ)",
|
||||
"library.groupSpools_other": "{{count}} ม้วน",
|
||||
"library.nUnused": "×{{count}} ยังไม่ได้ใช้",
|
||||
"library.noLocation": "ไม่มีสถานที่เก็บ",
|
||||
"library.noValues": "ไม่มีข้อมูล",
|
||||
"library.openGroup": "เปิด {{name}}",
|
||||
"library.openManufacturer": "เปิดผู้ผลิต {{name}}",
|
||||
"library.resizeList": "ปรับขนาดรายการ (ดับเบิลคลิกเพื่อรีเซ็ต)",
|
||||
"library.section.extra": "ฟิลด์เพิ่มเติม",
|
||||
"library.section.filament": "เส้นพลาสติก",
|
||||
"library.section.spool": "ม้วนพลาสติก",
|
||||
"library.section.vendor": "ผู้ผลิต",
|
||||
"library.showMore": "แสดงเพิ่มอีก {{count}} รายการ",
|
||||
"library.sort.nozzle": "อุณหภูมิหัวพิมพ์",
|
||||
"library.sortBy": "จัดเรียง",
|
||||
"library.unassigned": "ยังไม่ได้ระบุ",
|
||||
"library.unitGroups": "กลุ่ม",
|
||||
"library.unitSpools": "ม้วน",
|
||||
"library.unused": "ยังไม่ได้ใช้",
|
||||
"library.unusedGroup": "{{count}} ยังไม่ได้ใช้ · ม้วนละ {{weight}} ก.",
|
||||
"library.unusedGroupMixed": "{{count}} ยังไม่ได้ใช้",
|
||||
"library.unusedSub": "ม้วนละ {{weight}} ก. · {{location}}",
|
||||
"library.unusedSubMixed": "{{location}}",
|
||||
"library.used": "ใช้งานแล้ว",
|
||||
"library.usedAgo": "ใช้งานไปเมื่อ {{time}}",
|
||||
"loading": "กำลังโหลด",
|
||||
"locations.locations": "สถานที่เก็บ",
|
||||
"locations.newLocation": "สถานที่เก็บใหม่",
|
||||
"locations.noLocation": "ไม่มีสถานที่เก็บ",
|
||||
"locations.noLocationsHelp": "หน้านี้ให้คุณจัดการม้วนพลาสติกตามสถานที่เก็บ เพิ่มม้วนพลาสติกเพื่อเริ่มต้น!",
|
||||
"nav.labels": "",
|
||||
"nav.library": "",
|
||||
"nav.labels": "ฉลาก",
|
||||
"nav.library": "คลังข้อมูล",
|
||||
"no": "ไม่",
|
||||
"notifications.error": "เกิดข้อผิดพลาด (รหัสสถานะ: {{statusCode}})",
|
||||
"notifications.saveSuccessful": "บันทึกสำเร็จ!",
|
||||
"pagination.empty": "",
|
||||
"pagination.next": "",
|
||||
"pagination.pageSize": "",
|
||||
"pagination.perPage": "",
|
||||
"pagination.prev": "",
|
||||
"pagination.range": "",
|
||||
"pagination.empty": "ไม่มี {{unit}}",
|
||||
"pagination.next": "หน้าถัดไป",
|
||||
"pagination.pageSize": "ขนาดหน้า",
|
||||
"pagination.perPage": "{{size}} / หน้า",
|
||||
"pagination.prev": "หน้าก่อนหน้า",
|
||||
"pagination.range": "{{from}}–{{to}} จาก {{total}} {{unit}}",
|
||||
"printing.generic.columns": "คอลัมน์",
|
||||
"printing.generic.customSize": "กำหนดเอง",
|
||||
"printing.generic.description": "ปรับการตั้งค่าด้านล่างเพื่อให้ได้รูปแบบการพิมพ์ที่ต้องการ โปรดทราบว่าเครื่องพิมพ์และระบบปฏิบัติการของคุณอาจมีการตั้งค่าขอบกระดาษและการปรับขนาดของตัวเอง ดังนั้นคุณอาจต้องลองผิดลองถูกเล็กน้อยก่อนที่จะได้ผลลัพธ์ที่ถูกต้อง แนะนำให้ทดสอบบนกระดาษธรรมดาก่อนที่จะพิมพ์บนกระดาษสติกเกอร์จริง",
|
||||
"printing.generic.dimensions": "ขนาด",
|
||||
"printing.generic.gridTooWide": "",
|
||||
"printing.generic.gridTooWide": "ฉลากมีความกว้างเกินหน้ากระดาษ กรุณาลดจำนวนคอลัมน์ ระยะห่าง หรือระยะขอบ",
|
||||
"printing.generic.helpMargin": "การตั้งค่าขอบกระดาษควรกำหนดให้ตรงกับขนาดกระดาษและเครื่องพิมพ์ของคุณ การเปลี่ยนค่านี้จะมีผลต่อขนาดตารางทั้งหมด",
|
||||
"printing.generic.helpPrinterMargin": "Safe-Zone ควรกำหนดเป็นค่าระยะห่างจากขอบกระดาษที่เครื่องพิมพ์สามารถพิมพ์ได้ การเปลี่ยนค่านี้จะไม่กระทบต่อขนาดตารางทั้งหมด",
|
||||
"printing.generic.horizontalSpacing": "ระยะห่างแนวนอน",
|
||||
@@ -338,72 +412,73 @@
|
||||
"printing.generic.skipItems": "ข้ามรายการ",
|
||||
"printing.generic.verticalSpacing": "ระยะห่างแนวตั้ง",
|
||||
"printing.qrcode.button": "พิมพ์ฉลาก",
|
||||
"printing.qrcode.customUrl.caveat": "",
|
||||
"printing.qrcode.customUrl.hint": "",
|
||||
"printing.qrcode.customUrl.label": "",
|
||||
"printing.qrcode.customUrl.caveat": "URL ที่กำหนดเองจะไม่สามารถสแกนกลับเข้ามาใน Spoolman ได้ — ใช้เพื่อชี้ QR code ไปยังแอปภายนอกหรือโฮสต์อื่น",
|
||||
"printing.qrcode.customUrl.hint": "แทรก spool id ตรงจุดใดก็ได้โดยใช้ตัวยึดตำแหน่ง",
|
||||
"printing.qrcode.customUrl.label": "เทมเพลต URL กำหนดเอง",
|
||||
"printing.qrcode.useHTTPUrl.label": "ลิงก์ QR code",
|
||||
"printing.qrcode.useHTTPUrl.options.custom": "",
|
||||
"printing.qrcode.useHTTPUrl.options.custom": "URL กำหนดเอง",
|
||||
"printing.qrcode.useHTTPUrl.options.default": "ค่าเริ่มต้น",
|
||||
"printing.qrcode.useHTTPUrl.options.url": "URL",
|
||||
"printing.qrcode.useHTTPUrl.preview": "ตัวอย่าง:",
|
||||
"printing.qrcode.useHTTPUrl.tooltip": "จะใช้ลิงก์ภายในที่จะทำงานได้เฉพาะเมื่อสแกนจากฟีเจอร์สแกนของ Spoolman (ค่าเริ่มต้น) URL จะใช้จาก base URL ที่กำหนดไว้ใน settings หรือใช้ URL ของหน้าปัจจุบันหากไม่ได้ตั้งค่า",
|
||||
"printing.spoolSelect.selectedTotal_other": "เลือก {{count}} ม้วนพลาสติก",
|
||||
"scanner.description": "สแกน Spoolman QR code เพื่อดูข้อมูลเกี่ยวกับม้วนพลาสติก",
|
||||
"scanner.error.inUse": "",
|
||||
"scanner.error.inUse": "กล้องกำลังถูกใช้งานโดยแอปพลิเคชันอื่น",
|
||||
"scanner.error.insecureContext": "หน้านี้ไม่ได้ถูกให้บริการผ่าน HTTPS",
|
||||
"scanner.error.notAllowed": "คุณไม่ได้อนุญาตให้เข้าถึงกล้อง",
|
||||
"scanner.error.notFound": "ไม่พบกล้อง",
|
||||
"scanner.error.streamApiNotSupported": "เบราว์เซอร์ไม่รองรับ MediaStream API",
|
||||
"scanner.error.unknown": "เกิดข้อผิดพลาดที่ไม่ทราบสาเหตุ ({{error}})",
|
||||
"scanner.starting": "",
|
||||
"scanner.starting": "กำลังเปิดใช้งานกล้อง...",
|
||||
"scanner.title": "เครื่องสแกน QR Code",
|
||||
"search.colorHint": "",
|
||||
"search.noResults": "",
|
||||
"search.searching": "",
|
||||
"search.section.filaments": "",
|
||||
"search.section.spools": "",
|
||||
"search.section.vendors": "",
|
||||
"search.threshold": "",
|
||||
"search.unknownFilament": "",
|
||||
"settings.appearance.tab": "",
|
||||
"settings.appearance.theme.dark": "",
|
||||
"settings.appearance.theme.desc": "",
|
||||
"settings.appearance.theme.label": "",
|
||||
"settings.appearance.theme.light": "",
|
||||
"settings.appearance.theme.system": "",
|
||||
"settings.extraFields.addChoice": "",
|
||||
"settings.extraFields.addChoicesFirst": "",
|
||||
"settings.extraFields.addEntityField": "",
|
||||
"search.colorHint": "ชื่อสีต้องกรอกเป็นภาษาอังกฤษ",
|
||||
"search.moreSpools": "อีก +{{count}} ม้วน",
|
||||
"search.noResults": "ไม่พบรายการที่ตรงกัน",
|
||||
"search.searching": "กำลังค้นหา...",
|
||||
"search.section.filaments": "เส้นพลาสติก",
|
||||
"search.section.spools": "ม้วนพลาสติก",
|
||||
"search.section.vendors": "ผู้ผลิต",
|
||||
"search.threshold": "การจับคู่สี",
|
||||
"search.unknownFilament": "ไม่ทราบเส้นพลาสติก",
|
||||
"settings.appearance.tab": "รูปลักษณ์",
|
||||
"settings.appearance.theme.dark": "โหมดมืด",
|
||||
"settings.appearance.theme.desc": "ธีมสี (เฉพาะเบราว์เซอร์นี้)",
|
||||
"settings.appearance.theme.label": "ธีม",
|
||||
"settings.appearance.theme.light": "โหมดสว่าง",
|
||||
"settings.appearance.theme.system": "ตามระบบ",
|
||||
"settings.extraFields.addChoice": "เพิ่มตัวเลือก...",
|
||||
"settings.extraFields.addChoicesFirst": "กรุณาเพิ่มตัวเลือกก่อน",
|
||||
"settings.extraFields.addEntityField": "เพิ่มฟิลด์ {{entity}}",
|
||||
"settings.extraFields.booleanFalse": "ไม่",
|
||||
"settings.extraFields.booleanTrue": "ใช่",
|
||||
"settings.extraFields.deleteConfirm": "ลบฟิลด์ {{name}} หรือไม่?",
|
||||
"settings.extraFields.deleteConfirm": "ลบฟิลด์ {{name}} บน {{entity}} หรือไม่?",
|
||||
"settings.extraFields.description.constraints": "เมื่อเพิ่มฟิลด์แล้ว คุณจะไม่สามารถเปลี่ยนคีย์หรือประเภทของฟิลด์ได้ และสำหรับฟิลด์ประเภทตัวเลือกคุณจะไม่สามารถลบตัวเลือกหรือเปลี่ยนโหมด multi choice ได้ หากคุณลบฟิลด์ ข้อมูลของเอนทิตีทั้งหมดจะถูกลบด้วย",
|
||||
"settings.extraFields.description.intro": "คุณสามารถเพิ่มฟิลด์ที่กำหนดเองเพิ่มเติมสำหรับเอนทิตีของคุณได้ที่นี่",
|
||||
"settings.extraFields.description.keyUsage": "คีย์คือสิ่งที่โปรแกรมอื่นจะใช้เพื่ออ่าน/เขียนข้อมูล ดังนั้นหากฟิลด์ที่คุณสร้างเพื่อเชื่อมต่อกับโปรแกรมอื่น กรุณาตั้งค่าให้ถูกต้อง ค่าดีฟอลต์จะถูกนำไปใช้เฉพาะกับรายการใหม่เท่านั้น",
|
||||
"settings.extraFields.description.tableViews": "ฟิลด์เพิ่มเติมไม่สามารถเรียงหรือกรองได้ในตาราง",
|
||||
"settings.extraFields.editorEdit": "",
|
||||
"settings.extraFields.editorNew": "",
|
||||
"settings.extraFields.errors.choiceNeeded": "",
|
||||
"settings.extraFields.errors.choicesRemoved": "",
|
||||
"settings.extraFields.errors.deleteFailed": "",
|
||||
"settings.extraFields.errors.keyFormat": "",
|
||||
"settings.extraFields.errors.nameRequired": "",
|
||||
"settings.extraFields.errors.saveFailed": "",
|
||||
"settings.extraFields.fieldType.boolean": "บูลีน(ใส่เฉพาะ ใช่/ไม่)",
|
||||
"settings.extraFields.fieldType.choice": "ตัวเลือก",
|
||||
"settings.extraFields.fieldType.datetime": "วันเวลา",
|
||||
"settings.extraFields.fieldType.float": "ทศนิยม",
|
||||
"settings.extraFields.fieldType.floatRange": "ช่วงทศนิยม",
|
||||
"settings.extraFields.fieldType.integer": "จำนวนเต็ม",
|
||||
"settings.extraFields.fieldType.integerRange": "ช่วงจำนวนเต็ม",
|
||||
"settings.extraFields.fieldType.text": "ข้อความ",
|
||||
"settings.extraFields.max": "",
|
||||
"settings.extraFields.min": "",
|
||||
"settings.extraFields.multiSuffix": "",
|
||||
"settings.extraFields.multiple": "",
|
||||
"settings.extraFields.namePlaceholder": "",
|
||||
"settings.extraFields.description.tableViews": "ฟิลด์เพิ่มเติมสามารถแสดงและจัดเรียงในมุมมองตารางได้ ฟิลด์ประเภทตัวเลือกและบูลีนยังมีตัวเลือกการกรองที่นั่นด้วย และฟิลด์เพิ่มเติมทุกประเภทรองรับการกรองหาค่าว่าง",
|
||||
"settings.extraFields.editorEdit": "แก้ไขฟิลด์ {{entity}}",
|
||||
"settings.extraFields.editorNew": "เพิ่มฟิลด์ {{entity}} ใหม่",
|
||||
"settings.extraFields.errors.choiceNeeded": "ฟิลด์ประเภทตัวเลือกต้องมีตัวเลือกอย่างน้อย 1 รายการ",
|
||||
"settings.extraFields.errors.choicesRemoved": "ไม่สามารถลบตัวเลือกที่มีอยู่เดิมได้: {{choices}}",
|
||||
"settings.extraFields.errors.deleteFailed": "ลบฟิลด์ไม่สำเร็จ",
|
||||
"settings.extraFields.errors.keyFormat": "คีย์ต้องประกอบด้วยตัวพิมพ์เล็ก ตัวเลข และขีดล่างเท่านั้น",
|
||||
"settings.extraFields.errors.nameRequired": "จำเป็นต้องระบุชื่อ",
|
||||
"settings.extraFields.errors.saveFailed": "บันทึกฟิลด์ไม่สำเร็จ",
|
||||
"settings.extraFields.fieldType.boolean": "บูลีน (Boolean)",
|
||||
"settings.extraFields.fieldType.choice": "ตัวเลือก (Choice)",
|
||||
"settings.extraFields.fieldType.datetime": "วันเวลา (Datetime)",
|
||||
"settings.extraFields.fieldType.float": "ทศนิยม (Float)",
|
||||
"settings.extraFields.fieldType.floatRange": "ช่วงทศนิยม (Float Range)",
|
||||
"settings.extraFields.fieldType.integer": "จำนวนเต็ม (Integer)",
|
||||
"settings.extraFields.fieldType.integerRange": "ช่วงจำนวนเต็ม (Integer Range)",
|
||||
"settings.extraFields.fieldType.text": "ข้อความ (Text)",
|
||||
"settings.extraFields.max": "สูงสุด",
|
||||
"settings.extraFields.min": "ต่ำสุด",
|
||||
"settings.extraFields.multiSuffix": " · หลายรายการ",
|
||||
"settings.extraFields.multiple": "เลือกได้หลายรายการ",
|
||||
"settings.extraFields.namePlaceholder": "ชื่อที่แสดง",
|
||||
"settings.extraFields.nonUniqueKeyError": "คีย์ต้องไม่ซ้ำ",
|
||||
"settings.extraFields.none": "",
|
||||
"settings.extraFields.none": "ยังไม่มีการกำหนดฟิลด์เพิ่มเติมของ {{entity}}",
|
||||
"settings.extraFields.params.choices": "ตัวเลือก",
|
||||
"settings.extraFields.params.defaultValue": "ค่าเริ่มต้น",
|
||||
"settings.extraFields.params.fieldType": "ประเภท",
|
||||
@@ -411,21 +486,22 @@
|
||||
"settings.extraFields.params.name": "ชื่อ",
|
||||
"settings.extraFields.params.order": "ลำดับ",
|
||||
"settings.extraFields.params.unit": "หน่วย",
|
||||
"settings.extraFields.saveField": "",
|
||||
"settings.extraFields.reorderChoice": "ลาก หรือใช้ปุ่มลูกศร เพื่อจัดเรียงตัวเลือกนี้",
|
||||
"settings.extraFields.saveField": "บันทึกฟิลด์",
|
||||
"settings.extraFields.tab": "ฟิลด์เพิ่มเติม",
|
||||
"settings.general.baseUrl.label": "URLพื้นฐาน",
|
||||
"settings.general.baseUrl.tooltip": "URLพื้นฐาน ที่ใช้สำหรับสร้างฟีเจอร์ต่างๆ เช่น QR code",
|
||||
"settings.general.currency.desc": "",
|
||||
"settings.general.baseUrl.label": "URL พื้นฐาน",
|
||||
"settings.general.baseUrl.tooltip": "URL พื้นฐานที่ใช้สำหรับสร้างฟีเจอร์ต่างๆ เช่น QR code",
|
||||
"settings.general.currency.desc": "รหัส ISO 3 ตัวอักษร (เช่น THB, EUR, USD) สำหรับแสดงราคา",
|
||||
"settings.general.currency.label": "สกุลเงิน",
|
||||
"settings.general.roundPrices.label": "",
|
||||
"settings.general.roundPrices.tooltip": "",
|
||||
"settings.general.roundPrices.label": "ปัดเศษราคา",
|
||||
"settings.general.roundPrices.tooltip": "ปัดเศษราคาเป็นจำนวนเต็มที่ใกล้ที่สุด",
|
||||
"settings.general.tab": "ทั่วไป",
|
||||
"settings.header": "การตั้งค่า",
|
||||
"settings.language.desc": "",
|
||||
"settings.language.label": "",
|
||||
"settings.library.lowThreshold.desc": "",
|
||||
"settings.library.lowThreshold.label": "",
|
||||
"settings.library.tab": "",
|
||||
"settings.language.desc": "ภาษาของอินเทอร์เฟซ (เฉพาะเบราว์เซอร์นี้)",
|
||||
"settings.language.label": "ภาษา",
|
||||
"settings.library.lowThreshold.desc": "ม้วนพลาสติกที่มีน้ำหนักเท่ากับหรือต่ำกว่านี้จะถูกไฮไลต์สีแดง (เฉพาะเบราว์เซอร์นี้)",
|
||||
"settings.library.lowThreshold.label": "เกณฑ์เตือนพลาสติกใกล้หมด",
|
||||
"settings.library.tab": "พฤติกรรมคลังข้อมูล",
|
||||
"spool.fields.archived": "เก็บถาวร",
|
||||
"spool.fields.comment": "หมายเหตุ",
|
||||
"spool.fields.filament": "เส้นพลาสติก",
|
||||
@@ -439,26 +515,30 @@
|
||||
"spool.fields.price": "ราคา",
|
||||
"spool.fields.registered": "ลงทะเบียน",
|
||||
"spool.fields.remainingWeight": "น้ำหนักคงเหลือ",
|
||||
"spool.fields.spoolWeight": "น้ำหนักม้วนพลาสติกเปล่า",
|
||||
"spool.fields.usedWeight": "น้ำหนักที่ใช้ไป",
|
||||
"spool.fieldsHelp.location": "ตำแหน่งที่เก็บม้วนพลาสติก หากคุณมีหลายที่เก็บ นี่คือที่ที่คุณจัดเก็บม้วนพลาสติก",
|
||||
"spool.fields.weight": "น้ำหนัก",
|
||||
"spool.fieldsHelp.location": "ตำแหน่งที่คุณจัดเก็บม้วนพลาสติกจริง — เช่น ชั้นวาง ลิ้นชัก กล่องกันชื้น หรือเครื่องพิมพ์ที่กำลังใส่อยู่ ม้วนพลาสติกจะถูกจัดกลุ่มตามสถานที่ในคลัง เพื่อให้คุณค้นหากลับมาได้ง่าย",
|
||||
"spool.fieldsHelp.lotNr": "เลขล็อตของผู้ผลิต ใช้เพื่อให้มั่นใจว่า การพิมพ์จะมีสีสม่ำเสมอ หากต้องใช้พลาสติกหลายม้วน",
|
||||
"spool.fieldsHelp.measuredWeight": "น้ำหนักรวมของเส้นพลาสติกและม้วนพลาสติก",
|
||||
"spool.fieldsHelp.price": "ราคาของม้วนพลาสติกเต็ม หากไม่ได้ตั้งค่า จะใช้ราคาของเส้นพลาสติกแทน",
|
||||
"spool.fieldsHelp.remainingWeight": "ปริมาณเส้นพลาสติกที่เหลืออยู่ในม้วนพลาสติก สำหรับม้วนพลาสติกใหม่ควรมีค่าเท่ากับน้ำหนักของม้วนพลาสติกเต็ม",
|
||||
"spool.fieldsHelp.remainingWeight": "ปริมาณเส้นพลาสติกที่เหลืออยู่ในม้วน สำหรับม้วนพลาสติกใหม่ควรมีค่าเท่ากับน้ำหนักของม้วนพลาสติกเต็ม",
|
||||
"spool.fieldsHelp.spoolWeight": "น้ำหนักของม้วนพลาสติกนี้เมื่อไม่มีเส้นพลาสติก (ม้วนเปล่า) เว้นว่างไว้เพื่อใช้น้ำหนักม้วนเปล่าตามเส้นพลาสติก และระบุเมื่อม้วนนี้มีความแตกต่าง — เช่น ม้วนที่กรอใหม่ หรือเปลี่ยนแกนกระดาษเป็นพลาสติก ค่าน้ำหนักนี้จะถูกนำไปลบเมื่อคุณปรับค่าน้ำหนักที่วัดได้ หากระบุผิดจะทำให้การคำนวณเส้นพลาสติกคงเหลือคลาดเคลื่อน",
|
||||
"spool.fieldsHelp.usedWeight": "ปริมาณเส้นพลาสติกที่ถูกใช้ไปจากม้วนพลาสติก ม้วนพลาสติกใหม่ควรเริ่มต้นที่ 0 กรัม",
|
||||
"spool.fieldsHelp.weight": "ปริมาณเส้นพลาสติกที่ม้วนนี้บรรจุได้เมื่อเต็ม (น้ำหนักสุทธิ ไม่รวมม้วนเปล่า) เว้นว่างไว้เพื่อใช้น้ำหนักตามเส้นพลาสติก และระบุเมื่อม้วนนี้แตกต่างจากปกติ — เช่น ม้วนแบ่ง หรือเส้นพลาสติกเดียวกันแต่ซื้อแบบม้วนขนาดใหญ่กว่า",
|
||||
"spool.form.measurementType.length": "ความยาว",
|
||||
"spool.form.measurementType.weight": "น้ำหนัก",
|
||||
"spool.spool": "ม้วนพลาสติก",
|
||||
"topbar.addSpools": "",
|
||||
"topbar.searchPlaceholder": "",
|
||||
"topbar.addSpools": "เพิ่มม้วนพลาสติก",
|
||||
"topbar.searchPlaceholder": "ค้นหา — เช่น \"red\" หรือ \"pla\"",
|
||||
"unknown": "ไม่ทราบ",
|
||||
"validation.hexDigits": "",
|
||||
"validation.maxChars": "",
|
||||
"validation.mustBeGt": "",
|
||||
"validation.mustBeMax": "",
|
||||
"validation.mustBeMin": "",
|
||||
"validation.mustBeNumber": "",
|
||||
"validation.required": "",
|
||||
"validation.hexDigits": "ต้องเป็นรหัสสี Hex 6 หรือ 8 หลัก",
|
||||
"validation.maxChars": "ความยาวต้องไม่เกิน {{max}} ตัวอักษร",
|
||||
"validation.mustBeGt": "ค่าต้องมากกว่า {{value}}",
|
||||
"validation.mustBeMax": "ค่าต้องไม่เกิน {{value}}",
|
||||
"validation.mustBeMin": "ค่าต้องไม่น้อยกว่า {{value}}",
|
||||
"validation.mustBeNumber": "ต้องเป็นตัวเลขเท่านั้น",
|
||||
"validation.required": "จำเป็นต้องระบุ",
|
||||
"vendor.fields.comment": "หมายเหตุ",
|
||||
"vendor.fields.emptySpoolWeight": "น้ำหนักม้วนพลาสติกเปล่า",
|
||||
"vendor.fields.externalId": "รหัสภายนอก",
|
||||
|
||||
@@ -3,24 +3,24 @@
|
||||
"add.adding": "",
|
||||
"add.advanced": "",
|
||||
"add.advancedNote": "",
|
||||
"add.change": "",
|
||||
"add.change": "змінити",
|
||||
"add.count": "",
|
||||
"add.createNew": "",
|
||||
"add.createNewSub": "",
|
||||
"add.dbUnavailable": "",
|
||||
"add.duplicate": "",
|
||||
"add.duplicateNote": "",
|
||||
"add.duplicateRename": "",
|
||||
"add.duplicateTitle": "",
|
||||
"add.filamentNamePlaceholder": "",
|
||||
"add.dbUnavailable": "{{name}} недоступний",
|
||||
"add.duplicate": "клонувати",
|
||||
"add.duplicateNote": "Специфікації, виробник і ціна були скопійовані. Надайте нове ім'я та колір.",
|
||||
"add.duplicateRename": "Досі названо як оригінал — перейменуйте, щоб відрізняти їх.",
|
||||
"add.duplicateTitle": "Новий філамент · копія “{{name}}”",
|
||||
"add.filamentNamePlaceholder": "наприклад, PolyTerra Matte Sage",
|
||||
"add.fill.full": "",
|
||||
"add.fillLevel": "",
|
||||
"add.importNote": "",
|
||||
"add.inCatalog": "",
|
||||
"add.locationHint": "",
|
||||
"add.locationPlaceholder": "",
|
||||
"add.manufacturerPlaceholder": "",
|
||||
"add.nameHint": "",
|
||||
"add.manufacturerPlaceholder": "наприклад, Polymaker",
|
||||
"add.nameHint": "Вкажіть колір — саме за ним відрізняються ваші філаменти.",
|
||||
"add.newFilament": "",
|
||||
"add.newFilamentTitle": "",
|
||||
"add.noCatalog": "",
|
||||
|
||||
@@ -1,330 +1,408 @@
|
||||
{
|
||||
"add.addAndNew": "",
|
||||
"add.addN_other": "",
|
||||
"add.adding": "",
|
||||
"add.advanced": "",
|
||||
"add.advancedNote": "",
|
||||
"add.change": "",
|
||||
"add.count": "",
|
||||
"add.createNew": "",
|
||||
"add.createNewSub": "",
|
||||
"add.dbUnavailable": "",
|
||||
"add.duplicate": "",
|
||||
"add.duplicateNote": "",
|
||||
"add.duplicateRename": "",
|
||||
"add.duplicateTitle": "",
|
||||
"add.filamentNamePlaceholder": "",
|
||||
"add.fill.full": "",
|
||||
"add.fillLevel": "",
|
||||
"add.importNote": "",
|
||||
"add.inCatalog": "",
|
||||
"add.locationHint": "",
|
||||
"add.locationPlaceholder": "",
|
||||
"add.manufacturerPlaceholder": "",
|
||||
"add.nameHint": "",
|
||||
"add.newFilament": "",
|
||||
"add.newFilamentTitle": "",
|
||||
"add.noCatalog": "",
|
||||
"add.noManufacturer": "",
|
||||
"add.pickColor": "",
|
||||
"add.searchPlaceholder": "",
|
||||
"add.searching": "",
|
||||
"add.spoolWeightPreset.cardboard": "",
|
||||
"add.spoolWeightPreset.plastic": "",
|
||||
"add.spoolWeightPresetsLead": "",
|
||||
"add.step2": "",
|
||||
"add.summary_other": "",
|
||||
"add.typeToSearchAll": "",
|
||||
"add.typeToSearchMatches": "",
|
||||
"add.useExisting": "",
|
||||
"add.vendorHint.existing": "",
|
||||
"add.vendorHint.new": "",
|
||||
"add.vendorHint.optional": "",
|
||||
"add.yourCatalog": "",
|
||||
"add.addAndNew": "新增後再新增",
|
||||
"add.addN_other": "新增 {{count}} 個線軸",
|
||||
"add.adding": "正在新增...",
|
||||
"add.advanced": "高階參數",
|
||||
"add.advancedNote": "密度、直徑、溫度(依材料自動帶入)",
|
||||
"add.change": "更改",
|
||||
"add.count": "數量",
|
||||
"add.createNew": "建立新線材",
|
||||
"add.createNewSub": "不在您的目錄或 {{name}} 中",
|
||||
"add.dbUnavailable": "{{name}} 不可用",
|
||||
"add.duplicate": "建立副本",
|
||||
"add.duplicateNote": "規格、製造商和價格已複製。請為其指定新的名稱和顏色。",
|
||||
"add.duplicateRename": "名稱仍與原線材相同 — 請重新命名以便區分。",
|
||||
"add.duplicateTitle": "新線材 · {{name}} 的副本",
|
||||
"add.filamentNamePlaceholder": "例如:PolyTerra Matte Sage",
|
||||
"add.fill.full": "全滿",
|
||||
"add.fillLevel": "裝填程度",
|
||||
"add.importNote": "當您新增線軸時,此線材將被自動新增到您的目錄中。",
|
||||
"add.inCatalog": "已在目錄中",
|
||||
"add.locationHint": "線軸的實際存放位置 — 例如架子、乾燥箱或印表機。如果您將所有線材放在同一位置,可留空。",
|
||||
"add.locationPlaceholder": "例如:架子 A、乾燥箱 2、印表機 1",
|
||||
"add.manufacturerPlaceholder": "例如:Polymaker",
|
||||
"add.nameHint": "建議包含顏色資訊 — 這是區分不同線材的關鍵。",
|
||||
"add.newFilament": "新線材",
|
||||
"add.newFilamentTitle": "新增線材",
|
||||
"add.noCatalog": "目錄中沒有線材",
|
||||
"add.noManufacturer": "無製造商",
|
||||
"add.pickColor": "選擇顏色",
|
||||
"add.searchPlaceholder": "搜尋您的目錄和 {{name}}...",
|
||||
"add.searching": "正在搜尋...",
|
||||
"add.section.filament": "線材",
|
||||
"add.section.filamentNote": "線軸上的產品,每種顏色各建立一筆。",
|
||||
"add.section.manufacturer": "製造商",
|
||||
"add.section.manufacturerNote": "線材生產商。只需新增一次,即可重複用於其旗下的所有線材。",
|
||||
"add.section.spool": "線軸",
|
||||
"add.section.spoolNote": "要加入庫存的實體線軸。",
|
||||
"add.section.spoolSharedNote": "線材重量、線軸重量和價格也會儲存為新線材的預設值。",
|
||||
"add.spoolWeightPreset.cardboard": "紙軸 {{weight}} 克",
|
||||
"add.spoolWeightPreset.plastic": "塑膠軸 {{weight}} 克",
|
||||
"add.spoolWeightPresetsLead": "從常見線軸開始:",
|
||||
"add.step2": "步驟 2 · 線軸詳細資料",
|
||||
"add.step2New": "步驟 2 · 線材與線軸詳細資料",
|
||||
"add.summary_other": "{{count}} 個線軸",
|
||||
"add.typeToSearchAll": "輸入文字,搜尋數千種線材",
|
||||
"add.typeToSearchMatches": "輸入文字以搜尋(目前沒有相符結果)",
|
||||
"add.useExisting": "使用現有線材",
|
||||
"add.vendorHint.existing": "使用現有製造商「{{name}}」",
|
||||
"add.vendorHint.new": "將建立新製造商「{{name}}」",
|
||||
"add.vendorHint.optional": "選填 — 留空表示不指定製造商",
|
||||
"add.weightPresets": "常見線材重量",
|
||||
"add.yourCatalog": "您的線材目錄",
|
||||
"buttons.apply": "套用",
|
||||
"buttons.archive": "封存",
|
||||
"buttons.cancel": "取消",
|
||||
"buttons.clear": "清除",
|
||||
"buttons.close": "",
|
||||
"buttons.close": "關閉",
|
||||
"buttons.delete": "刪除",
|
||||
"buttons.edit": "編輯",
|
||||
"buttons.filter": "過濾器",
|
||||
"buttons.filter": "篩選",
|
||||
"buttons.save": "儲存",
|
||||
"buttons.showArchived": "檢視已封存",
|
||||
"buttons.showNoSpools": "顯示沒有線軸的線材規格",
|
||||
"buttons.unArchive": "取消封存",
|
||||
"common.decrement": "",
|
||||
"common.increment": "",
|
||||
"common.remove": "",
|
||||
"common.search": "",
|
||||
"dashboard.addFieldHint": "",
|
||||
"dashboard.dashboard": "",
|
||||
"dashboard.deleteGroup": "",
|
||||
"dashboard.dragHint": "",
|
||||
"dashboard.dropHere": "",
|
||||
"dashboard.emptyHelp": "",
|
||||
"dashboard.errorEmptyName": "",
|
||||
"dashboard.errorExists": "",
|
||||
"dashboard.errorRename": "",
|
||||
"dashboard.lastUsed": "",
|
||||
"dashboard.newGroup": "",
|
||||
"dashboard.newGroupName": "",
|
||||
"dashboard.reorderGroup": "",
|
||||
"dashboard.spoolCount_other": "",
|
||||
"dashboard.unassigned": "",
|
||||
"datetime.done": "",
|
||||
"datetime.hour": "",
|
||||
"datetime.minute": "",
|
||||
"datetime.nextMonth": "",
|
||||
"datetime.now": "",
|
||||
"datetime.pick": "",
|
||||
"datetime.prevMonth": "",
|
||||
"datetime.time": "",
|
||||
"documentTitle.dashboard.list": "",
|
||||
"documentTitle.labels.list": "",
|
||||
"documentTitle.library.list": "",
|
||||
"changeFilament.action": "更改",
|
||||
"changeFilament.adoptWeight": "將此線軸的滿載重量設為 {{full}}",
|
||||
"changeFilament.adoptWeightHelp": "先前記錄的滿載重量為 {{current}}(來自更改前的線材)。無論如何都會保留已使用重量({{used}}),剩餘重量為 {{remaining}}。",
|
||||
"changeFilament.apply": "更改線材",
|
||||
"changeFilament.applying": "正在更改...",
|
||||
"changeFilament.current": "目前",
|
||||
"changeFilament.done": "線材已更改。",
|
||||
"changeFilament.failed": "無法更改線材。",
|
||||
"changeFilament.importNote": "當您套用此更改時,此線材將被新增到您的目錄中。",
|
||||
"changeFilament.intro": "線軸 #{{id}} 會保留編號、位置、日期和使用記錄,只有所裝的線材會變更。",
|
||||
"changeFilament.newTag": "新",
|
||||
"changeFilament.next": "更改為",
|
||||
"changeFilament.now": "目前為",
|
||||
"changeFilament.pickBelow": "請在下方選擇線材",
|
||||
"changeFilament.title": "更改線材類型",
|
||||
"changeFilament.useExisting": "選擇現有線材",
|
||||
"changeFilament.weightFollows": "此線軸沒有自訂滿載重量,因此將使用新線材的設定:{{full}}。在扣除已使用的 {{used}} 後,剩餘 {{remaining}}。",
|
||||
"changeVendor.action": "更改",
|
||||
"changeVendor.apply": "更改製造商",
|
||||
"changeVendor.applying": "正在更改...",
|
||||
"changeVendor.clearHint": "保持此線材無製造商狀態",
|
||||
"changeVendor.create": "建立「{{name}}」",
|
||||
"changeVendor.createHint": "尚不在您的製造商清單中 — 將自動新增",
|
||||
"changeVendor.current": "目前",
|
||||
"changeVendor.done": "製造商已更改。",
|
||||
"changeVendor.emptySpool": "空線軸重量 {{value}}",
|
||||
"changeVendor.failed": "無法更改製造商。",
|
||||
"changeVendor.intro": "「{{name}}」的線軸、規格和歷史記錄都會保留,只有所屬製造商會變更。",
|
||||
"changeVendor.next": "更改為",
|
||||
"changeVendor.noEmptySpool": "未設定空線軸重量",
|
||||
"changeVendor.noMatches": "無相符的製造商",
|
||||
"changeVendor.none": "尚無製造商",
|
||||
"changeVendor.noneNote": "未歸類到任何品牌",
|
||||
"changeVendor.now": "目前為",
|
||||
"changeVendor.pickBelow": "請在下方選擇製造商",
|
||||
"changeVendor.searchPlaceholder": "搜尋或輸入製造商名稱",
|
||||
"changeVendor.tareFollows": "此線材沒有自訂空線軸重量,因此將繼承其製造商的空線軸重量。更改後空線軸重量為 {{value}},即空線軸實測的重量。",
|
||||
"changeVendor.tareGone": "此線材原本繼承了製造商的空線軸重量({{current}})。更改後將不再有可繼承的空線軸重量,直到您為此線材單獨指定一個。",
|
||||
"changeVendor.title": "更改製造商",
|
||||
"changeVendor.willBeCreated": "將被建立",
|
||||
"common.decrement": "減少",
|
||||
"common.increment": "增加",
|
||||
"common.remove": "移除",
|
||||
"common.search": "搜尋",
|
||||
"dashboard.addFieldHint": "在「設定」中為線軸新增文字或選項欄位,即可依該欄位分組",
|
||||
"dashboard.dashboard": "儀表板",
|
||||
"dashboard.deleteGroup": "刪除群組",
|
||||
"dashboard.dragHint": "將線軸拖到其他卡片以變更其 {{field}},或在卡片內拖曳以重新排序",
|
||||
"dashboard.dropHere": "將線軸拖放到此處",
|
||||
"dashboard.emptyHelp": "本頁會依您選擇的欄位將線軸分組。請先新增一些線軸!",
|
||||
"dashboard.errorEmptyName": "名稱不能為空",
|
||||
"dashboard.errorExists": "已存在同名群組",
|
||||
"dashboard.errorRename": "重新命名群組失敗",
|
||||
"dashboard.lastUsed": "{{time}} 前使用",
|
||||
"dashboard.newGroup": "新增群組",
|
||||
"dashboard.newGroupName": "新增群組 {{n}}",
|
||||
"dashboard.reorderGroup": "拖曳以調整群組順序",
|
||||
"dashboard.spoolCount_other": "{{count}} 個線軸",
|
||||
"dashboard.unassigned": "未分配",
|
||||
"datetime.done": "完成",
|
||||
"datetime.hour": "時",
|
||||
"datetime.minute": "分",
|
||||
"datetime.nextMonth": "下個月",
|
||||
"datetime.now": "現在",
|
||||
"datetime.pick": "選擇日期與時間",
|
||||
"datetime.prevMonth": "上個月",
|
||||
"datetime.time": "時間",
|
||||
"documentTitle.dashboard.list": "儀表板 | Spoolman",
|
||||
"documentTitle.labels.list": "標籤列印 | Spoolman",
|
||||
"documentTitle.library.list": "線材庫 | Spoolman",
|
||||
"documentTitle.locations.list": "位置| Spoolman",
|
||||
"documentTitle.settings.list": "",
|
||||
"filament.fields.addColor": "",
|
||||
"documentTitle.settings.list": "設定 | Spoolman",
|
||||
"filament.fields.addColor": "新增顏色",
|
||||
"filament.fields.articleNumber": "產品編號",
|
||||
"filament.fields.coaxial": "混色",
|
||||
"filament.fields.colorHex": "顏色",
|
||||
"filament.fields.colorType": "",
|
||||
"filament.fields.colorType": "顏色類型",
|
||||
"filament.fields.comment": "備註",
|
||||
"filament.fields.density": "密度",
|
||||
"filament.fields.diameter": "線徑",
|
||||
"filament.fields.externalId": "內部ID",
|
||||
"filament.fields.externalId": "外部 ID",
|
||||
"filament.fields.id": "ID",
|
||||
"filament.fields.longitudinal": "縱向",
|
||||
"filament.fields.material": "材料",
|
||||
"filament.fields.multiColor": "多色",
|
||||
"filament.fields.name": "名稱",
|
||||
"filament.fields.opacity": "透明度",
|
||||
"filament.fields.price": "價格",
|
||||
"filament.fields.registered": "已登記",
|
||||
"filament.fields.removeColor": "",
|
||||
"filament.fields.removeColor": "移除顏色",
|
||||
"filament.fields.settingsBedTemp": "床台溫度",
|
||||
"filament.fields.settingsExtruderTemp": "擠出機溫度",
|
||||
"filament.fields.singleColor": "單色",
|
||||
"filament.fields.spoolWeight": "料盤重量",
|
||||
"filament.fields.spoolWeight": "線軸重量",
|
||||
"filament.fields.vendor": "製造商",
|
||||
"filament.fields.weight": "重量",
|
||||
"filament.fieldsHelp.articleNumber": "例如EAN、UPC等。",
|
||||
"filament.fieldsHelp.multiColorDirection": "線材的顏色可通過兩種方式呈現多樣性:一種是共擠技術,例如雙色線材,具有穩定的多色效果;另一種是沿線材長度的顏色變化,例如漸變線材,顏色會隨著線材捲的長度逐漸轉變。",
|
||||
"filament.fieldsHelp.spoolWeight": "空線盤的重量,用於計算整捲線的實際重量。",
|
||||
"filament.fieldsHelp.weight": "整捲線材的重量(淨重)。此重量不應包含線盤本身的重量,僅限於線材的重量,通常會標示在包裝上。",
|
||||
"filament.fieldsHelp.multiColorDirection": "線材可透過兩種方式呈現多種顏色:共擠(如雙色線材,各色沿整段保持一致)或縱向漸層(顏色沿線材長度變化)。",
|
||||
"filament.fieldsHelp.spoolWeight": "空線軸的重量,用於計算整捲線的實際重量。",
|
||||
"filament.fieldsHelp.weight": "整捲線材的重量(淨重)。此重量不應包含線軸本身的重量,僅限於線材的重量,通常會標示在包裝上。",
|
||||
"filament.filament": "線材",
|
||||
"footer.documentation": "",
|
||||
"footer.reportIssue": "",
|
||||
"footer.documentation": "說明文件",
|
||||
"footer.reportIssue": "回報問題",
|
||||
"footer.updateAvailable": "可更新至 v{{version}}",
|
||||
"help.help": "說明",
|
||||
"inspector.addSpoolsOfThis": "",
|
||||
"inspector.adjustFailed": "",
|
||||
"inspector.adjustHelp": "",
|
||||
"inspector.adjustWeight": "",
|
||||
"inspector.apply": "",
|
||||
"inspector.applying": "",
|
||||
"inspector.colorHex": "",
|
||||
"inspector.consumeAmount": "",
|
||||
"inspector.delete.deleting": "",
|
||||
"inspector.delete.errorGone": "",
|
||||
"inspector.delete.errorSpoolsExist": "",
|
||||
"inspector.delete.errorUnknown": "",
|
||||
"inspector.delete.filament": "",
|
||||
"inspector.delete.filamentBlockedHint": "",
|
||||
"inspector.delete.filamentBlockedTitle": "",
|
||||
"inspector.delete.filamentBlocked_other": "",
|
||||
"inspector.delete.filamentBody": "",
|
||||
"inspector.delete.filamentDone": "",
|
||||
"inspector.delete.filamentTitle": "",
|
||||
"inspector.delete.spool": "",
|
||||
"inspector.delete.spoolBody": "",
|
||||
"inspector.delete.spoolDone": "",
|
||||
"inspector.delete.spoolRemaining": "",
|
||||
"inspector.delete.spoolTitle": "",
|
||||
"inspector.delete.vendor": "",
|
||||
"inspector.delete.vendorBody": "",
|
||||
"inspector.delete.vendorDone": "",
|
||||
"inspector.delete.vendorOrphans_other": "",
|
||||
"inspector.delete.vendorTitle": "",
|
||||
"inspector.duplicateFilament": "",
|
||||
"inspector.empty": "",
|
||||
"inspector.enterValidLength": "",
|
||||
"inspector.enterValidWeight": "",
|
||||
"inspector.loading": "",
|
||||
"inspector.manageExtraFields": "",
|
||||
"inspector.measuredHelp": "",
|
||||
"inspector.multi": "",
|
||||
"inspector.newGross": "",
|
||||
"inspector.noExtraFields": "",
|
||||
"inspector.notFound": "",
|
||||
"inspector.ofRemaining": "",
|
||||
"inspector.openFilament": "",
|
||||
"inspector.openLink": "",
|
||||
"inspector.openManufacturer": "",
|
||||
"inspector.specs": "",
|
||||
"inspector.spoolsCount": "",
|
||||
"inspector.spoolsTotalRemaining": "",
|
||||
"inspector.used": "",
|
||||
"inspector.vendorSub_other": "",
|
||||
"inspector.addSpoolsOfThis": "新增此規格的線軸",
|
||||
"inspector.adjustFailed": "調整重量失敗。",
|
||||
"inspector.adjustHelp": "正數表示消耗線材,負數表示退回/增加線材。",
|
||||
"inspector.adjustWeight": "調整用量/重量",
|
||||
"inspector.apply": "套用",
|
||||
"inspector.applying": "正在套用...",
|
||||
"inspector.colorHex": "十六進位色碼",
|
||||
"inspector.consumeAmount": "消耗數量:",
|
||||
"inspector.defaultFrom.filament": "{{value}} · 線材預設值",
|
||||
"inspector.defaultFrom.manufacturer": "{{value}} · 製造商預設值",
|
||||
"inspector.delete.deleting": "正在刪除...",
|
||||
"inspector.delete.errorGone": "此項目已刪除。",
|
||||
"inspector.delete.errorSpoolsExist": "無法刪除此線材 — 仍有線軸在使用它。",
|
||||
"inspector.delete.errorUnknown": "刪除失敗。詳細資料請檢視伺服器日誌。",
|
||||
"inspector.delete.filament": "刪除線材規格",
|
||||
"inspector.delete.filamentBlockedHint": "請先刪除這些線軸;只有封存並不足夠。",
|
||||
"inspector.delete.filamentBlockedTitle": "無法刪除此線材規格",
|
||||
"inspector.delete.filamentBlocked_other": "仍有 {{count}} 個線軸正在使用「{{name}}」(包含已封存項)。",
|
||||
"inspector.delete.filamentBody": "「{{name}}」 將從您的目錄中完全移除。此操作不可撤銷。",
|
||||
"inspector.delete.filamentDone": "線材規格已刪除。",
|
||||
"inspector.delete.filamentTitle": "確認刪除此線材規格?",
|
||||
"inspector.delete.spool": "刪除線軸",
|
||||
"inspector.delete.spoolBody": "線軸 #{{id}}(「{{name}}」)及其歷史使用記錄將被完全移除。此操作不可撤銷。",
|
||||
"inspector.delete.spoolDone": "線軸已刪除。",
|
||||
"inspector.delete.spoolRemaining": "該捲仍剩餘 {{weight}} 線材。如果您只是想清理介面,可以將其封存。",
|
||||
"inspector.delete.spoolTitle": "確認刪除此線軸?",
|
||||
"inspector.delete.vendor": "刪除製造商",
|
||||
"inspector.delete.vendorBody": "製造商「{{name}}」將被移除。此操作不可撤銷。",
|
||||
"inspector.delete.vendorDone": "製造商已刪除。",
|
||||
"inspector.delete.vendorOrphans_other": "其下的 {{count}} 種線材將被保留,但會變為無製造商狀態。",
|
||||
"inspector.delete.vendorTitle": "確認刪除此製造商?",
|
||||
"inspector.duplicateFilament": "建立副本",
|
||||
"inspector.empty": "請從清單中選擇線軸、線材規格或製造商以在此檢視詳細資料。",
|
||||
"inspector.enterValidLength": "請輸入有效的長度。",
|
||||
"inspector.enterValidWeight": "請輸入有效的重量。",
|
||||
"inspector.loading": "正在載入...",
|
||||
"inspector.manageExtraFields": "管理自訂欄位",
|
||||
"inspector.measuredHelp": "線軸 + 剩餘線材的電子秤實測總毛重。",
|
||||
"inspector.multi": "多色 ×{{count}}",
|
||||
"inspector.newGross": "新的實測總毛重:",
|
||||
"inspector.noExtraFields": "尚無額外欄位。您可以新增自訂欄位,追蹤 Spoolman 尚未涵蓋的屬性。",
|
||||
"inspector.notFound": "找不到此項目,可能已被刪除。",
|
||||
"inspector.ofRemaining": "剩餘 {{weight}} · 約 {{length}} 公尺",
|
||||
"inspector.openFilament": "開啟線材規格",
|
||||
"inspector.openLink": "在新標籤頁中開啟 {{url}}",
|
||||
"inspector.openManufacturer": "開啟製造商",
|
||||
"inspector.override.byFilament": "已被此線材覆蓋 · {{value}}",
|
||||
"inspector.override.bySpool": "已被此線軸覆蓋 · {{value}}",
|
||||
"inspector.override.overridesFilament": "覆蓋了線材預設 · {{value}}",
|
||||
"inspector.override.overridesManufacturer": "覆蓋了製造商預設 · {{value}}",
|
||||
"inspector.specs": "規格 — 直接編輯",
|
||||
"inspector.spoolsCount": "線軸數 · {{count}}",
|
||||
"inspector.spoolsTotalRemaining": "總剩餘 {{weight}}",
|
||||
"inspector.used": "已使用",
|
||||
"inspector.vendorSub_other": "{{count}} 種線材",
|
||||
"kofi": "在Ko-fi上打賞我",
|
||||
"labels.addHead": "",
|
||||
"labels.addQr": "",
|
||||
"labels.addRect": "",
|
||||
"labels.addSwatch": "",
|
||||
"labels.addText": "",
|
||||
"labels.align": "",
|
||||
"labels.alignCenter": "",
|
||||
"labels.alignLeft": "",
|
||||
"labels.alignRight": "",
|
||||
"labels.blank": "",
|
||||
"labels.bold": "",
|
||||
"labels.canvasHint": "",
|
||||
"labels.centerLogo": "",
|
||||
"labels.chooseField": "",
|
||||
"labels.clearFill": "",
|
||||
"labels.copies": "",
|
||||
"labels.cornerRadiusMm": "",
|
||||
"labels.createFirst": "",
|
||||
"labels.cutGuides": "",
|
||||
"labels.deleteConfirm": "",
|
||||
"labels.deleteElement": "",
|
||||
"labels.designName": "",
|
||||
"labels.designerDesc": "",
|
||||
"labels.designerTitle": "",
|
||||
"labels.dpi": "",
|
||||
"labels.dpiHint": "",
|
||||
"labels.duplicate": "",
|
||||
"labels.fields.bedTemp": "",
|
||||
"labels.fields.diameterMm": "",
|
||||
"labels.fields.groupVendor": "",
|
||||
"labels.fields.initialG": "",
|
||||
"labels.fields.netWeightG": "",
|
||||
"labels.fields.remainingG": "",
|
||||
"labels.filamentsSelected_other": "",
|
||||
"labels.fill": "",
|
||||
"labels.fitWidth": "",
|
||||
"labels.fitWidthHint": "",
|
||||
"labels.fontMm": "",
|
||||
"labels.gridInfo": "",
|
||||
"labels.hMm": "",
|
||||
"labels.heightMm": "",
|
||||
"labels.hintNowrap": "",
|
||||
"labels.hintSwatch": "",
|
||||
"labels.hintWrap": "",
|
||||
"labels.addHead": "新增元素",
|
||||
"labels.addQr": "QR 碼",
|
||||
"labels.addRect": "矩形框",
|
||||
"labels.addSwatch": "顏色色塊",
|
||||
"labels.addText": "文字 / 欄位",
|
||||
"labels.align": "對齊方式",
|
||||
"labels.alignCenter": "置中",
|
||||
"labels.alignLeft": "左對齊",
|
||||
"labels.alignRight": "右對齊",
|
||||
"labels.blank": "您還沒有任何標籤設計樣式。",
|
||||
"labels.bold": "粗體",
|
||||
"labels.canvasHint": "拖曳以移動 · 點擊以選取 · 拖曳控制點以調整大小。",
|
||||
"labels.centerLogo": "將 Spoolman 標誌置中",
|
||||
"labels.chooseField": "選擇屬性欄位...",
|
||||
"labels.clearFill": "清除填色(僅保留外框)",
|
||||
"labels.copies": "列印份數",
|
||||
"labels.cornerRadiusMm": "圓角半徑",
|
||||
"labels.createFirst": "建立您的第一個標籤樣式",
|
||||
"labels.cutGuides": "剪裁參考線",
|
||||
"labels.deleteConfirm": "確認刪除此標籤設計?",
|
||||
"labels.deleteElement": "刪除元素",
|
||||
"labels.designName": "設計樣式名稱",
|
||||
"labels.designerDesc": "使用任意線軸、線材或製造商欄位設計 QR 碼標籤,再列印到標籤紙或標籤印表機。設計會儲存在伺服器上。",
|
||||
"labels.designerTitle": "標籤設計器",
|
||||
"labels.dpi": "列印解析度",
|
||||
"labels.dpiHint": "請配合印表機解析度,讓小型標籤保持清晰:多數熱感式標籤印表機為 203 dpi,雷射與噴墨印表機則為 300 dpi 以上。",
|
||||
"labels.duplicate": "建立副本",
|
||||
"labels.exportDescAml": "為選中的每個標籤產生一個 .aml 檔案(尺寸 {{w}}×{{h}} mm)。大多數熱敏印表機附帶的 LPAPI 軟體支援讀取此類檔案中的物理尺寸。",
|
||||
"labels.exportDescPng": "為選中的每個標籤產生一張 PNG 圖片(尺寸 {{w}}×{{h}} mm)。",
|
||||
"labels.exportFormat": "匯出檔案格式",
|
||||
"labels.exportFormatOptions.aml": "AML 標籤檔案",
|
||||
"labels.exportFormatOptions.png": "PNG 圖片",
|
||||
"labels.fields.bedTemp": "熱床溫度",
|
||||
"labels.fields.diameterMm": "線徑 (mm)",
|
||||
"labels.fields.groupVendor": "製造商",
|
||||
"labels.fields.initialG": "初始總重 (g)",
|
||||
"labels.fields.netWeightG": "線材淨重 (g)",
|
||||
"labels.fields.remainingG": "剩餘重量 (g)",
|
||||
"labels.filamentsSelected_other": "已選擇 {{count}} 種線材",
|
||||
"labels.fill": "填色",
|
||||
"labels.fitWidth": "依欄數調整寬度",
|
||||
"labels.fitWidthHint": "自動設定標籤寬度,使所選的列數正好填滿整張頁面。",
|
||||
"labels.fontMm": "字號",
|
||||
"labels.gridInfo": "{{cols}} 列 × {{rows}} 行 = 每頁 {{perPage}} 個標籤",
|
||||
"labels.hMm": "高",
|
||||
"labels.heightMm": "高度",
|
||||
"labels.hintNowrap": "文字保持單行顯示,超出寬度的部分將被截斷。",
|
||||
"labels.hintSwatch": "列印時會使用線軸所裝線材的顏色。",
|
||||
"labels.hintWrap": "將欄位包在條件式中,使其在空白時隱藏:",
|
||||
"labels.imageDesc": "",
|
||||
"labels.imageZipNote": "",
|
||||
"labels.insertField": "",
|
||||
"labels.kind.qr": "",
|
||||
"labels.kind.rect": "",
|
||||
"labels.kind.swatch": "",
|
||||
"labels.kind.text": "",
|
||||
"labels.labelSize": "",
|
||||
"labels.labelType": "",
|
||||
"labels.layout": "",
|
||||
"labels.modeImage": "",
|
||||
"labels.modeLabel": "",
|
||||
"labels.modeSheet": "",
|
||||
"labels.new": "",
|
||||
"labels.newText": "",
|
||||
"labels.noDesignsOption": "",
|
||||
"labels.noFilaments": "",
|
||||
"labels.noSpools": "",
|
||||
"labels.onePerLabel": "",
|
||||
"labels.preparing": "",
|
||||
"labels.preview": "",
|
||||
"labels.previewWith": "",
|
||||
"labels.printN_other": "",
|
||||
"labels.radiusMm": "",
|
||||
"labels.sampleData": "",
|
||||
"labels.saved": "",
|
||||
"labels.saving": "",
|
||||
"labels.searchFilaments": "",
|
||||
"labels.searchSpools": "",
|
||||
"labels.selectAllShort": "",
|
||||
"labels.selectAtLeastOne": "",
|
||||
"labels.selectAtLeastOneFilament": "",
|
||||
"labels.selectDesign": "",
|
||||
"labels.selectElement": "",
|
||||
"labels.selectNoneShort": "",
|
||||
"labels.selectRecentHint": "",
|
||||
"labels.selectRecentShort": "",
|
||||
"labels.showingFilament": "",
|
||||
"labels.showingReal": "",
|
||||
"labels.showingSpool": "",
|
||||
"labels.showingTags": "",
|
||||
"labels.sizeMm": "",
|
||||
"labels.skipHint": "",
|
||||
"labels.sortHint": "",
|
||||
"labels.sortId": "",
|
||||
"labels.sortNewest": "",
|
||||
"labels.stroke": "",
|
||||
"labels.strokeMm": "",
|
||||
"labels.tabDesign": "",
|
||||
"labels.tabPrint": "",
|
||||
"labels.textTemplate": "",
|
||||
"labels.typeFilament": "",
|
||||
"labels.typeHint": "",
|
||||
"labels.typeSpool": "",
|
||||
"labels.unknownFilament": "",
|
||||
"labels.wMm": "",
|
||||
"labels.widthMm": "",
|
||||
"labels.wrapText": "",
|
||||
"labels.xMm": "",
|
||||
"labels.yMm": "",
|
||||
"library.apiError": "",
|
||||
"library.collapseGroup": "",
|
||||
"library.emptyBody": "",
|
||||
"library.emptyFiltered": "",
|
||||
"library.emptyTitle": "",
|
||||
"library.expandGroup": "",
|
||||
"library.filterBy": "",
|
||||
"library.groupBy": "",
|
||||
"library.groupNone": "",
|
||||
"library.groupSpools_other": "",
|
||||
"library.nUnused": "",
|
||||
"library.noLocation": "",
|
||||
"library.noValues": "",
|
||||
"library.openGroup": "",
|
||||
"library.openManufacturer": "",
|
||||
"library.section.extra": "",
|
||||
"library.section.filament": "",
|
||||
"library.section.spool": "",
|
||||
"library.section.vendor": "",
|
||||
"library.showMore": "",
|
||||
"library.sort.nozzle": "",
|
||||
"library.sortBy": "",
|
||||
"library.unassigned": "",
|
||||
"library.unitGroups": "",
|
||||
"library.unitSpools": "",
|
||||
"library.unused": "",
|
||||
"library.unusedGroup": "",
|
||||
"library.unusedGroupMixed": "",
|
||||
"library.unusedSub": "",
|
||||
"library.unusedSubMixed": "",
|
||||
"library.used": "",
|
||||
"library.usedAgo": "",
|
||||
"labels.imageZipNote": "超過 {{threshold}} 個標籤時,所有標籤會合併成單一壓縮檔供下載。",
|
||||
"labels.insertField": "插入變數欄位",
|
||||
"labels.kind.qr": "QR 碼",
|
||||
"labels.kind.rect": "矩形框",
|
||||
"labels.kind.swatch": "色塊",
|
||||
"labels.kind.text": "文字",
|
||||
"labels.labelSize": "標籤尺寸",
|
||||
"labels.labelType": "標籤類型",
|
||||
"labels.layout": "版面配置",
|
||||
"labels.modeImage": "檔案",
|
||||
"labels.modeLabel": "標籤印表機",
|
||||
"labels.modeSheet": "標籤紙(平鋪)",
|
||||
"labels.new": "新增",
|
||||
"labels.newText": "新文字框",
|
||||
"labels.noDesignsOption": "尚無設計",
|
||||
"labels.noFilaments": "無線材規格。",
|
||||
"labels.noSpools": "無線軸。",
|
||||
"labels.onePerLabel": "每個標籤使用一張 {{w}}×{{h}} mm 的頁面。",
|
||||
"labels.preparing": "正在準備...",
|
||||
"labels.preview": "預覽",
|
||||
"labels.previewWith": "預覽資料來源",
|
||||
"labels.printN_other": "列印 {{count}} 張標籤",
|
||||
"labels.radiusMm": "圓角半徑",
|
||||
"labels.sampleData": "範例資料",
|
||||
"labels.saveAsFormat": "另存為 {{format}}",
|
||||
"labels.saved": "已儲存",
|
||||
"labels.saving": "正在儲存...",
|
||||
"labels.searchFilaments": "搜尋線材規格...",
|
||||
"labels.searchSpools": "搜尋線軸...",
|
||||
"labels.selectAllShort": "全選",
|
||||
"labels.selectAtLeastOne": "請至少選擇一個線軸。",
|
||||
"labels.selectAtLeastOneFilament": "請至少選擇一種線材。",
|
||||
"labels.selectDesign": "選擇標籤樣式",
|
||||
"labels.selectElement": "選擇要編輯的元素,或從工具欄新增新元素。",
|
||||
"labels.selectNoneShort": "取消全選",
|
||||
"labels.selectRecentHint": "選擇過去 24 小時內新增的線軸",
|
||||
"labels.selectRecentShort": "近期新增 ({{count}})",
|
||||
"labels.showingFilament": "正在顯示線材規格 #{{id}}",
|
||||
"labels.showingReal": "正在顯示真實線軸資料。",
|
||||
"labels.showingSpool": "正在顯示線軸 #{{id}}",
|
||||
"labels.showingTags": "選擇上方的線軸前,欄位會以佔位符顯示。",
|
||||
"labels.sizeMm": "尺寸",
|
||||
"labels.skipHint": "跳過格子可以在第一張標籤前保留空位,方便您繼續使用已打過一部分的標籤紙:從左到右、從上到下數出已被撕掉的標籤數量即可。",
|
||||
"labels.sortHint": "排序方式",
|
||||
"labels.sortId": "編號",
|
||||
"labels.sortNewest": "最新加入",
|
||||
"labels.stroke": "描邊顏色",
|
||||
"labels.strokeMm": "描邊粗細",
|
||||
"labels.tabDesign": "畫布設計",
|
||||
"labels.tabPrint": "列印設定",
|
||||
"labels.textTemplate": "文字 / 模板",
|
||||
"labels.typeFilament": "線材規格標籤",
|
||||
"labels.typeHint": "線材標籤不包含線軸專用欄位,其 QR 碼會連結到線材而非特定線軸。",
|
||||
"labels.typeSpool": "實體線軸標籤",
|
||||
"labels.unknownFilament": "未知線材",
|
||||
"labels.wMm": "寬",
|
||||
"labels.widthMm": "寬度",
|
||||
"labels.wrapText": "自動換行",
|
||||
"labels.xMm": "X 座標",
|
||||
"labels.yMm": "Y 座標",
|
||||
"library.apiError": "無法連線至 Spoolman API。後端服務是否正在執行?",
|
||||
"library.collapseGroup": "摺疊這些線軸",
|
||||
"library.dateFilter.ago": "{{span}} 前",
|
||||
"library.dateFilter.between": "{{from}} – {{to}}",
|
||||
"library.dateFilter.customRange": "自訂時間段",
|
||||
"library.dateFilter.from": "起始時間",
|
||||
"library.dateFilter.last": "最近 {{span}}",
|
||||
"library.dateFilter.never": "從未",
|
||||
"library.dateFilter.olderThan": "早於 {{span}}",
|
||||
"library.dateFilter.since": "自 {{date}} 起",
|
||||
"library.dateFilter.to": "截止時間(選填)",
|
||||
"library.dateFilter.until": "截至 {{date}}",
|
||||
"library.emptyBody": "新增第一個線軸,開始追蹤線材。",
|
||||
"library.emptyFiltered": "沒有符合目前篩選條件的 {{unit}}。",
|
||||
"library.emptyTitle": "暫無線軸",
|
||||
"library.expandGroup": "展開這些線軸",
|
||||
"library.filterBy": "篩選依據",
|
||||
"library.groupBy": "群組依據",
|
||||
"library.groupNone": "無(平面清單)",
|
||||
"library.groupSpools_other": "{{count}} 個線軸",
|
||||
"library.nUnused": "×{{count}} 捲未拆封",
|
||||
"library.noLocation": "未指定存放位置",
|
||||
"library.noSpools": "無線軸",
|
||||
"library.noValues": "無資料",
|
||||
"library.openGroup": "開啟 {{name}}",
|
||||
"library.openManufacturer": "開啟製造商 {{name}}",
|
||||
"library.resizeList": "調整清單寬度(雙擊以重設)",
|
||||
"library.reverseSort": "切換升序/降序排序",
|
||||
"library.section.extra": "額外欄位",
|
||||
"library.section.filament": "線材規格",
|
||||
"library.section.spool": "線軸",
|
||||
"library.section.vendor": "製造商",
|
||||
"library.showMore": "展開剩餘 {{count}} 項",
|
||||
"library.sort.nozzle": "噴頭溫度",
|
||||
"library.sortBy": "排序依據",
|
||||
"library.unassigned": "未指派",
|
||||
"library.unitGroups": "組",
|
||||
"library.unitSpools": "捲",
|
||||
"library.unused": "未拆封/未使用",
|
||||
"library.unusedGroup": "{{count}} 捲未拆封 · 每捲 {{weight}} 克",
|
||||
"library.unusedGroupMixed": "{{count}} 捲未拆封",
|
||||
"library.unusedSub": "每捲 {{weight}} 克 · 位於 {{location}}",
|
||||
"library.unusedSubMixed": "位於 {{location}}",
|
||||
"library.used": "已拆封/使用中",
|
||||
"library.usedAgo": "上次使用於 {{time}} 前",
|
||||
"loading": "讀取中",
|
||||
"locations.locations": "位置",
|
||||
"locations.newLocation": "新增位置",
|
||||
"locations.noLocation": "未設定位置",
|
||||
"locations.noLocationsHelp": "這個頁面讓您將料盤按位置進行整理,請從增加一些線盤來開始!",
|
||||
"nav.labels": "",
|
||||
"nav.library": "",
|
||||
"nav.labels": "標籤列印",
|
||||
"nav.library": "線材庫存",
|
||||
"no": "否",
|
||||
"notifications.error": "錯誤 ( 狀態碼:{{statusCode}} )",
|
||||
"notifications.saveSuccessful": "儲存成功!",
|
||||
"pagination.empty": "",
|
||||
"pagination.next": "",
|
||||
"pagination.pageSize": "",
|
||||
"pagination.perPage": "",
|
||||
"pagination.prev": "",
|
||||
"pagination.range": "",
|
||||
"pagination.empty": "沒有 {{unit}}",
|
||||
"pagination.next": "下一頁",
|
||||
"pagination.pageSize": "每頁顯示",
|
||||
"pagination.perPage": "每頁 {{size}} 筆",
|
||||
"pagination.prev": "上一頁",
|
||||
"pagination.range": "第 {{from}}–{{to}} 筆,共 {{total}} 筆 {{unit}}",
|
||||
"printing.generic.columns": "欄",
|
||||
"printing.generic.customSize": "自訂",
|
||||
"printing.generic.description": "調整以下設定以獲得所需的列印佈局。請注意,列表機和您的操作系統可能會套用自己的邊距和縮放比例,因此可能需要進行一些反覆測試才能達到理想效果。在實際標籤上列印之前,先在普通紙上測試。",
|
||||
"printing.generic.description": "調整以下設定以取得所需的列印版面。請注意,印表機與作業系統可能會套用自己的邊距和縮放比例,因此可能需要多次測試。列印實際標籤前,請先使用一般紙張測試。",
|
||||
"printing.generic.dimensions": "尺寸",
|
||||
"printing.generic.gridTooWide": "",
|
||||
"printing.generic.helpMargin": "邊距應配置為與您的標籤紙和列表機相符,調整這些將影響整個網格的大小。",
|
||||
"printing.generic.helpPrinterMargin": "安全區域應設定為列表機能列印的距離紙張的邊界,更改此選項不影響整個網格。",
|
||||
"printing.generic.gridTooWide": "標籤排版寬度已超出頁面總寬。請減少列數、縮小間距或調整頁邊距。",
|
||||
"printing.generic.helpMargin": "邊距應設定為與您的標籤紙和印表機相符,調整這些將影響整個網格的大小。",
|
||||
"printing.generic.helpPrinterMargin": "安全區域應設定為印表機能列印的距離紙張的邊界,更改此選項不影響整個網格。",
|
||||
"printing.generic.horizontalSpacing": "水平間距",
|
||||
"printing.generic.itemCopies": "項目複製",
|
||||
"printing.generic.itemCopies": "每項份數",
|
||||
"printing.generic.marginBottom": "下邊界",
|
||||
"printing.generic.marginLeft": "左邊界",
|
||||
"printing.generic.marginRight": "右邊界",
|
||||
@@ -338,57 +416,61 @@
|
||||
"printing.generic.skipItems": "跳過項目",
|
||||
"printing.generic.verticalSpacing": "垂直間距",
|
||||
"printing.qrcode.button": "列印標籤",
|
||||
"printing.qrcode.customUrl.caveat": "",
|
||||
"printing.qrcode.customUrl.hint": "",
|
||||
"printing.qrcode.customUrl.label": "",
|
||||
"printing.qrcode.useHTTPUrl.label": "QR碼連結",
|
||||
"printing.qrcode.useHTTPUrl.options.custom": "",
|
||||
"printing.qrcode.customUrl.caveat": "Spoolman 內建掃描器無法直接讀取使用自訂 URL 的 QR 碼;這通常用於連結外部軟體或第三方網頁。",
|
||||
"printing.qrcode.customUrl.fieldsHint": "也可使用標籤欄位,例如",
|
||||
"printing.qrcode.customUrl.hint": "可使用佔位符,將線軸識別碼插入連結中的任意位置",
|
||||
"printing.qrcode.customUrl.label": "自訂 URL 模板",
|
||||
"printing.qrcode.useHTTPUrl.label": "QR 碼連結",
|
||||
"printing.qrcode.useHTTPUrl.options.custom": "自訂網頁 URL",
|
||||
"printing.qrcode.useHTTPUrl.options.default": "預設",
|
||||
"printing.qrcode.useHTTPUrl.options.url": "URL",
|
||||
"printing.qrcode.useHTTPUrl.preview": "預覽:",
|
||||
"printing.qrcode.useHTTPUrl.tooltip": "將使用專屬連結,僅在使用 Spoolman 的掃描功能掃描時有效(預設)。URL 使用設定中指定的基礎 URL,若未設置則使用目前頁面的 URL。",
|
||||
"printing.spoolSelect.selectedTotal_other": "已選擇 {{count}} 個料盤",
|
||||
"scanner.description": "掃描Spoolman的QR碼以檢視關於料盤的詳細資料。",
|
||||
"scanner.error.inUse": "",
|
||||
"printing.qrcode.useHTTPUrl.tooltip": "「預設」會產生只有 Spoolman 內建掃描器可讀取的專用代碼;「網址」則會產生任何掃描器都能開啟的一般網頁連結。連結會使用設定中的基礎網址;若未設定,則使用目前網站的網址。",
|
||||
"printing.spoolSelect.selectedTotal_other": "已選擇 {{count}} 個線軸",
|
||||
"scanner.camera": "相機",
|
||||
"scanner.cameraNumbered": "相機 {{number}}",
|
||||
"scanner.description": "掃描 Spoolman QR 碼以檢視線軸的詳細資料。",
|
||||
"scanner.error.inUse": "攝影機正由其他應用程式使用。",
|
||||
"scanner.error.insecureContext": "此頁面未通過 HTTPS 提供服務。",
|
||||
"scanner.error.notAllowed": "您未被允許存取攝影機。",
|
||||
"scanner.error.notFound": "未找到攝影機。",
|
||||
"scanner.error.streamApiNotSupported": "瀏覽器不支援MediaStream API。",
|
||||
"scanner.error.unknown": "發生未知的錯誤。({{error}})",
|
||||
"scanner.starting": "",
|
||||
"scanner.title": "掃描QR碼",
|
||||
"search.colorHint": "",
|
||||
"search.noResults": "",
|
||||
"search.searching": "",
|
||||
"search.section.filaments": "",
|
||||
"search.section.spools": "",
|
||||
"search.section.vendors": "",
|
||||
"search.threshold": "",
|
||||
"search.unknownFilament": "",
|
||||
"settings.appearance.tab": "",
|
||||
"settings.appearance.theme.dark": "",
|
||||
"settings.appearance.theme.desc": "",
|
||||
"settings.appearance.theme.label": "",
|
||||
"settings.appearance.theme.light": "",
|
||||
"settings.appearance.theme.system": "",
|
||||
"settings.extraFields.addChoice": "",
|
||||
"settings.extraFields.addChoicesFirst": "",
|
||||
"settings.extraFields.addEntityField": "",
|
||||
"scanner.starting": "正在啟動攝影機...",
|
||||
"scanner.title": "QR 碼掃描器",
|
||||
"search.colorHint": "顏色名稱必須輸入英文(如 red、blue)。",
|
||||
"search.moreSpools": "+ 其餘 {{count}} 個線軸",
|
||||
"search.noResults": "找不到相符結果",
|
||||
"search.searching": "正在搜尋...",
|
||||
"search.section.filaments": "線材規格",
|
||||
"search.section.spools": "線軸",
|
||||
"search.section.vendors": "製造商",
|
||||
"search.threshold": "顏色相符容差",
|
||||
"search.unknownFilament": "未知線材",
|
||||
"settings.appearance.tab": "外觀",
|
||||
"settings.appearance.theme.dark": "深色模式",
|
||||
"settings.appearance.theme.desc": "介面配色主題(僅對目前瀏覽器生效)",
|
||||
"settings.appearance.theme.label": "主題設定",
|
||||
"settings.appearance.theme.light": "淺色模式",
|
||||
"settings.appearance.theme.system": "跟隨系統",
|
||||
"settings.extraFields.addChoice": "新增選項...",
|
||||
"settings.extraFields.addChoicesFirst": "請先新增預設選項",
|
||||
"settings.extraFields.addEntityField": "新增{{entity}}欄位",
|
||||
"settings.extraFields.booleanFalse": "否",
|
||||
"settings.extraFields.booleanTrue": "是",
|
||||
"settings.extraFields.deleteConfirm": "刪除欄位 {{name}}?",
|
||||
"settings.extraFields.deleteConfirm": "要刪除 {{entity}} 的欄位「{{name}}」嗎?",
|
||||
"settings.extraFields.description.constraints": "一旦加入欄位,您將無法更改其鍵值或類型,對於選擇類型欄位,您也無法刪除選項或更改多選狀態。如果刪除欄位,所有實體的相關資料將被刪除。",
|
||||
"settings.extraFields.description.intro": "在此,您可以為您的實體加入額外的自訂欄位。",
|
||||
"settings.extraFields.description.keyUsage": "鍵值是其他程式讀取/寫入資料的方式,因此如果您的自訂欄位需要與第三方程式整合,請確保正確設置。預設值僅適用於新品項。",
|
||||
"settings.extraFields.description.tableViews": "額外的欄位無法在表格視圖中排序或過濾。",
|
||||
"settings.extraFields.editorEdit": "",
|
||||
"settings.extraFields.editorNew": "",
|
||||
"settings.extraFields.errors.choiceNeeded": "",
|
||||
"settings.extraFields.errors.choicesRemoved": "",
|
||||
"settings.extraFields.errors.deleteFailed": "",
|
||||
"settings.extraFields.errors.keyFormat": "",
|
||||
"settings.extraFields.errors.nameRequired": "",
|
||||
"settings.extraFields.errors.saveFailed": "",
|
||||
"settings.extraFields.description.keyUsage": "鍵值是其他程式讀取/寫入資料的方式,因此如果您的自訂欄位需要與第三方程式整合,請確保正確設定。預設值僅適用於新品項。",
|
||||
"settings.extraFields.description.tableViews": "額外欄位可顯示於表格中並用於排序。選項與布林欄位也會提供篩選選項,所有額外欄位類型都可篩選空值。",
|
||||
"settings.extraFields.editorEdit": "編輯{{entity}}欄位",
|
||||
"settings.extraFields.editorNew": "新增{{entity}}欄位",
|
||||
"settings.extraFields.errors.choiceNeeded": "選項類欄位必須至少包含一個預設選項。",
|
||||
"settings.extraFields.errors.choicesRemoved": "不能刪除已存在的選項:{{choices}}。",
|
||||
"settings.extraFields.errors.deleteFailed": "刪除欄位失敗。",
|
||||
"settings.extraFields.errors.keyFormat": "鍵值只能包含小寫字母、數字和底線。",
|
||||
"settings.extraFields.errors.nameRequired": "名稱為必填項。",
|
||||
"settings.extraFields.errors.saveFailed": "儲存欄位失敗。",
|
||||
"settings.extraFields.fieldType.boolean": "布林",
|
||||
"settings.extraFields.fieldType.choice": "選項",
|
||||
"settings.extraFields.fieldType.datetime": "日期時間",
|
||||
@@ -397,13 +479,13 @@
|
||||
"settings.extraFields.fieldType.integer": "整數",
|
||||
"settings.extraFields.fieldType.integerRange": "整數範圍",
|
||||
"settings.extraFields.fieldType.text": "文字",
|
||||
"settings.extraFields.max": "",
|
||||
"settings.extraFields.min": "",
|
||||
"settings.extraFields.multiSuffix": "",
|
||||
"settings.extraFields.multiple": "",
|
||||
"settings.extraFields.namePlaceholder": "",
|
||||
"settings.extraFields.max": "最大值",
|
||||
"settings.extraFields.min": "最小值",
|
||||
"settings.extraFields.multiSuffix": " · 多選",
|
||||
"settings.extraFields.multiple": "允許多選",
|
||||
"settings.extraFields.namePlaceholder": "介面顯示名稱",
|
||||
"settings.extraFields.nonUniqueKeyError": "鍵值必須為唯一。",
|
||||
"settings.extraFields.none": "",
|
||||
"settings.extraFields.none": "尚未定義{{entity}}的額外欄位。",
|
||||
"settings.extraFields.params.choices": "選項",
|
||||
"settings.extraFields.params.defaultValue": "預設值",
|
||||
"settings.extraFields.params.fieldType": "類型",
|
||||
@@ -411,21 +493,37 @@
|
||||
"settings.extraFields.params.name": "名稱",
|
||||
"settings.extraFields.params.order": "順序",
|
||||
"settings.extraFields.params.unit": "單位",
|
||||
"settings.extraFields.saveField": "",
|
||||
"settings.extraFields.reorderChoice": "拖曳或使用方向鍵調整此選項的順序。",
|
||||
"settings.extraFields.saveField": "儲存欄位",
|
||||
"settings.extraFields.tab": "額外欄位",
|
||||
"settings.general.baseUrl.label": "基本URL",
|
||||
"settings.general.baseUrl.tooltip": "生成 QR 碼等功能時使用的基本 URL。",
|
||||
"settings.general.currency.desc": "",
|
||||
"settings.general.currency.label": "貨幣設定標籤",
|
||||
"settings.general.baseUrl.label": "基礎 URL",
|
||||
"settings.general.baseUrl.tooltip": "產生 QR 碼等功能時使用的基本 URL。",
|
||||
"settings.general.currency.desc": "顯示價格時使用的三字母 ISO 代碼(例如 EUR、USD)",
|
||||
"settings.general.currency.label": "貨幣",
|
||||
"settings.general.roundPrices.label": "價格四捨五入",
|
||||
"settings.general.roundPrices.tooltip": "將價格四捨五入至最接近的整數。",
|
||||
"settings.general.tab": "一般",
|
||||
"settings.header": "設定",
|
||||
"settings.language.desc": "",
|
||||
"settings.language.label": "",
|
||||
"settings.library.lowThreshold.desc": "",
|
||||
"settings.library.lowThreshold.label": "",
|
||||
"settings.library.tab": "",
|
||||
"settings.language.desc": "介面顯示語言(僅對目前瀏覽器生效)",
|
||||
"settings.language.label": "顯示語言",
|
||||
"settings.library.lowThreshold.desc": "當線材剩餘重量等於或低於此設定值時,將在介面標紅警示(僅對目前瀏覽器生效)",
|
||||
"settings.library.lowThreshold.label": "低庫存臨界值",
|
||||
"settings.library.tab": "線材庫偏好",
|
||||
"settings.scanner.autoNavigate.desc": "標籤讀取器讀到標籤時,自動在此瀏覽器開啟對應的線軸或線材。",
|
||||
"settings.scanner.autoNavigate.label": "讀卡自動跳轉",
|
||||
"settings.scanner.docs": "如何設定標籤讀取器",
|
||||
"settings.scanner.lastSeen": "{{when}} 前",
|
||||
"settings.scanner.pair": "感應標籤以配對",
|
||||
"settings.scanner.reader.anyDesc": "任何讀取器的掃描都可在此瀏覽器開啟線軸。若需要更精確的控制,可配對特定讀取器;新增線軸或線材標籤時也會使用這個讀取器。",
|
||||
"settings.scanner.reader.label": "標籤讀取器",
|
||||
"settings.scanner.reader.pairedDesc": "只有此讀取器的掃描會在本瀏覽器開啟線軸。",
|
||||
"settings.scanner.recent": "最近使用的讀取器",
|
||||
"settings.scanner.recentNone": "目前沒有已記錄的讀取器。設定標籤讀取器並讀取任意標籤後,它就會顯示在這裡。",
|
||||
"settings.scanner.tab": "標籤掃描",
|
||||
"settings.scanner.unpair": "使用任意讀取器",
|
||||
"settings.scanner.use": "使用這個讀取器",
|
||||
"settings.scanner.waiting": "請在要使用的讀取器上感應任意標籤...",
|
||||
"sponsor": "在 GitHub 上贊助專案",
|
||||
"spool.fields.archived": "封存",
|
||||
"spool.fields.comment": "備註",
|
||||
"spool.fields.filament": "線材",
|
||||
@@ -439,32 +537,87 @@
|
||||
"spool.fields.price": "價格",
|
||||
"spool.fields.registered": "登記於",
|
||||
"spool.fields.remainingWeight": "剩餘重量",
|
||||
"spool.fields.spoolWeight": "線軸重量",
|
||||
"spool.fields.usedWeight": "已用重量",
|
||||
"spool.fieldsHelp.location": "如果您的料盤存於多個位置,請填寫料盤所在位置。",
|
||||
"spool.fieldsHelp.lotNr": "製造商的批號。如果使用多個線盤,可用於確保列印的顏色一致。",
|
||||
"spool.fieldsHelp.measuredWeight": "線材和料盤的重量為多少。",
|
||||
"spool.fieldsHelp.price": "完整線盤的價格。如果未設定,則會假設為線材的價格。",
|
||||
"spool.fieldsHelp.remainingWeight": "料盤所剩餘的線材。對於新的料盤來說應等於料盤重量。",
|
||||
"spool.fieldsHelp.usedWeight": "料盤所使用的線材重量。一個新料盤應該為0克。",
|
||||
"spool.fields.weight": "初始線材淨重",
|
||||
"spool.fieldsHelp.location": "如果您的線軸存於多個位置,請填寫線軸所在位置。",
|
||||
"spool.fieldsHelp.lotNr": "製造商的批號。如果使用多個線軸,可用於確保列印的顏色一致。",
|
||||
"spool.fieldsHelp.measuredWeight": "線材和線軸的重量為多少。",
|
||||
"spool.fieldsHelp.price": "完整線軸的價格。如果未設定,則會假設為線材的價格。",
|
||||
"spool.fieldsHelp.remainingWeight": "線軸所剩餘的線材。對於新的線軸來說應等於線軸重量。",
|
||||
"spool.fieldsHelp.spoolWeight": "此線軸空載時的重量。留空會沿用線材的預設線軸重量;若此線軸不同,例如重新繞線或更換軸心材質,請在此單獨設定。依實測重量調整時會扣除此重量,數值不正確會影響剩餘線材的計算。",
|
||||
"spool.fieldsHelp.usedWeight": "此線軸已使用的線材重量。新線軸應為 0 g。",
|
||||
"spool.fieldsHelp.weight": "此線軸裝滿時的線材淨重,不含線軸本身。留空會沿用線材的預設重量;若此線軸不同,例如只有部分線材或容量較大,請在此單獨設定。",
|
||||
"spool.form.measurementType.length": "長度",
|
||||
"spool.form.measurementType.weight": "重量",
|
||||
"spool.spool": "料盤",
|
||||
"topbar.addSpools": "",
|
||||
"topbar.searchPlaceholder": "",
|
||||
"spool.spool": "線軸",
|
||||
"tags.add": "新增標籤",
|
||||
"tags.added": "標籤已連結。",
|
||||
"tags.addedOn": "連結於 {{date}}",
|
||||
"tags.modal.alreadyHere": "此標籤已識別這個線軸。",
|
||||
"tags.modal.alreadyHereFilament": "此標籤已識別這個線材。",
|
||||
"tags.modal.badUid": "標籤 UID 應為十六進位格式,如 04A2B3C4。",
|
||||
"tags.modal.checking": "正在檢查...",
|
||||
"tags.modal.failed": "無法連結標籤。",
|
||||
"tags.modal.filamentNamed": "線材 {{name}}",
|
||||
"tags.modal.free": "此標籤尚未連結任何項目。",
|
||||
"tags.modal.intro": "日後在讀取器上感應此標籤,即可找到線軸 #{{id}}。",
|
||||
"tags.modal.introFilament": "日後在讀取器上感應此標籤,即可找到這個線材。",
|
||||
"tags.modal.lastSeenOn": "{{reader}} 上次讀取了 {{uid}}。",
|
||||
"tags.modal.link": "連結標籤",
|
||||
"tags.modal.linking": "正在連結...",
|
||||
"tags.modal.move": "從 #{{id}} 移到這裡",
|
||||
"tags.modal.moveFailed": "無法移動標籤。標籤目前可能未連結任何線軸,請重新連結。",
|
||||
"tags.modal.moveFailedFilament": "無法移動標籤。標籤目前可能未連結任何項目,請重新連結。",
|
||||
"tags.modal.moveHere": "移到這裡",
|
||||
"tags.modal.moving": "正在移動...",
|
||||
"tags.modal.phoneReading": "請將標籤貼近手機感應區…",
|
||||
"tags.modal.phoneScan": "使用目前手機讀取標籤",
|
||||
"tags.modal.spoolNamed": "線軸 #{{id}}({{name}})",
|
||||
"tags.modal.spoolShort": "線軸 #{{id}}",
|
||||
"tags.modal.takenBy": "目前連結至 {{spool}}。",
|
||||
"tags.modal.title": "連結標籤",
|
||||
"tags.modal.uidHelp": "分隔符和大小寫不影響識別:04:a2:b3:c4、04-A2-B3-C4 與 04a2b3c4 代表同一張標籤。",
|
||||
"tags.modal.uidLabel": "標籤 UID",
|
||||
"tags.modal.uidPlaceholder": "例如:04A2B3C4",
|
||||
"tags.modal.useLastSeen": "使用它",
|
||||
"tags.modal.waitingAny": "等待任意讀取器感應標籤...",
|
||||
"tags.modal.waitingOn": "等待讀取器 {{reader}} 感應標籤...",
|
||||
"tags.moved": "標籤已從線軸 #{{id}} 轉移至此處。",
|
||||
"tags.movedFromFilament": "已將標籤從線材 {{name}} 移到這裡。",
|
||||
"tags.nfc.insecureContext": "讀取 NFC 標籤需要安全連線。請在 HTTPS 環境下開啟 Spoolman 以呼叫手機 NFC 功能。",
|
||||
"tags.nfc.notAllowed": "目前頁面未獲得 NFC 使用權限。請在瀏覽器網站設定中授予權限後重試。",
|
||||
"tags.nfc.notReadable": "NFC 模組不可用 — 可能有其他套用正在佔用。",
|
||||
"tags.nfc.notSupported": "此手機不支援 NFC,或系統設定中未開啟 NFC 功能。",
|
||||
"tags.nfc.unknown": "無法讀取標籤。",
|
||||
"tags.none": "尚未連結標籤。",
|
||||
"tags.scan.openedFilament": "已掃描標籤:已開啟線材 {{name}}。",
|
||||
"tags.scan.openedSpool": "已掃描標籤:已開啟線軸 #{{id}}({{name}})。",
|
||||
"tags.scan.unknown": "未知標籤 {{uid}}。請開啟其所屬線軸,使用「新增標籤」加以連結。",
|
||||
"tags.section": "電子標籤",
|
||||
"tags.unlink": "解除連結",
|
||||
"tags.unlinkBody": "解除後,標籤 {{uid}} 將不再識別這個線軸。標籤本身不會寫入任何資料,因此之後仍可將它重新連結到此線軸或其他線軸。",
|
||||
"tags.unlinkBodyFilament": "解除後,標籤 {{uid}} 將不再識別這個線材。標籤本身不會寫入任何資料,因此之後仍可將它重新連結到此線材或其他項目。",
|
||||
"tags.unlinkFailed": "無法解除標籤連結。",
|
||||
"tags.unlinkTitle": "要解除此標籤的連結嗎?",
|
||||
"tags.unlinked": "已解除標籤連結。",
|
||||
"tags.unlinking": "正在解除連結...",
|
||||
"topbar.addSpools": "新增線軸",
|
||||
"topbar.searchPlaceholder": "搜尋,例如「red」或「pla」",
|
||||
"unknown": "未知",
|
||||
"validation.hexDigits": "",
|
||||
"validation.maxChars": "",
|
||||
"validation.mustBeGt": "",
|
||||
"validation.mustBeMax": "",
|
||||
"validation.mustBeMin": "",
|
||||
"validation.mustBeNumber": "",
|
||||
"validation.required": "",
|
||||
"validation.hexDigits": "請使用 6 位或 8 位十六進位字元",
|
||||
"validation.maxChars": "最多允許 {{max}} 個字元",
|
||||
"validation.mustBeGt": "必須大於 {{value}}",
|
||||
"validation.mustBeMax": "必須小於或等於 {{value}}",
|
||||
"validation.mustBeMin": "必須大於或等於 {{value}}",
|
||||
"validation.mustBeNumber": "必須為數字",
|
||||
"validation.required": "此項為必填項",
|
||||
"vendor.fields.comment": "備註",
|
||||
"vendor.fields.emptySpoolWeight": "空料盤重量",
|
||||
"vendor.fields.externalId": "外部ID",
|
||||
"vendor.fields.emptySpoolWeight": "空線軸重量",
|
||||
"vendor.fields.externalId": "外部 ID",
|
||||
"vendor.fields.id": "ID",
|
||||
"vendor.fields.name": "名稱",
|
||||
"vendor.fields.registered": "已登記",
|
||||
"vendor.fieldsHelp.emptySpoolWeight": "此製造商空線盤的重量。",
|
||||
"vendor.fieldsHelp.emptySpoolWeight": "此製造商空線軸的重量。",
|
||||
"yes": "是"
|
||||
}
|
||||
+568
-428
File diff suppressed because it is too large.
Load diff
Generated
+489
-461
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "spoolman-client-v2",
|
||||
"private": true,
|
||||
"version": "0.26.1",
|
||||
"version": "0.27.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"strip-empty-locales": "node scripts/strip-empty-locales.mjs",
|
||||
@@ -41,7 +41,7 @@
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.28.0",
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
"ta",
|
||||
"th",
|
||||
"lt",
|
||||
"tr"
|
||||
"tr",
|
||||
"sl",
|
||||
"ko"
|
||||
]
|
||||
}
|
||||
@@ -273,7 +273,7 @@ input[type='number'] {
|
||||
.seg-btn,
|
||||
.tab,
|
||||
.logo,
|
||||
.kofi,
|
||||
.sponsor,
|
||||
.crumb,
|
||||
.arch-toggle,
|
||||
.spool-row,
|
||||
@@ -289,9 +289,9 @@ input[type='number'] {
|
||||
.grabber {
|
||||
align-items: center;
|
||||
}
|
||||
/* The Ko-fi button collapses to an icon-only square under 560px; keep it a
|
||||
/* The sponsor button collapses to an icon-only square under 560px; keep it a
|
||||
full tap target in both dimensions. */
|
||||
.kofi {
|
||||
.sponsor {
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Reveal text that has been cut off with an ellipsis (#1093).
|
||||
*
|
||||
* Truncated text is unreadable, and the value behind it — a filament name, a
|
||||
* location, a manufacturer — is usually the whole reason the row is on screen.
|
||||
* The fix is the one every platform uses: hover it and see the full string.
|
||||
*
|
||||
* The point of doing it here rather than with a plain `title={...}` is the word
|
||||
* *cut off*. A title on text that already fits is noise: it pops a redundant
|
||||
* tooltip over every row the pointer crosses. So the element is measured, and
|
||||
* the tooltip appears and disappears as the layout changes — which it does
|
||||
* constantly here, since the list pane is resizable (#1034) and the same row is
|
||||
* truncated at one width and complete at the next.
|
||||
*
|
||||
* Scope: this is the pointer story. Touch has no hover, and the answer there is
|
||||
* the one already in the app — tapping a row opens the inspector, which shows
|
||||
* every value in full and never truncates it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fractional layout puts scrollWidth a hair over clientWidth on text that
|
||||
* visibly fits, so a bare `>` would tag half the rows in a list. A pixel of
|
||||
* slack is well under the width of the ellipsis it takes to truncate.
|
||||
*/
|
||||
function isCut(node: HTMLElement): boolean {
|
||||
return node.scrollWidth - node.clientWidth > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-measure whenever either half of the question can have changed: the width of
|
||||
* the box, or the text asked to fit in it.
|
||||
*
|
||||
* Both are needed. Resizing is the obvious one — the splitter, the window, a
|
||||
* webfont arriving. But the box can also keep its size while its contents
|
||||
* change, which is the whole of what a live update does to a row: same column,
|
||||
* longer name. A ResizeObserver never hears about that.
|
||||
*/
|
||||
function watch(node: HTMLElement, measure: () => void) {
|
||||
// Fires once on observe, after the first layout: that is the initial reading.
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(node);
|
||||
// Attributes are deliberately not observed — truncTitle writes one.
|
||||
const mo = new MutationObserver(measure);
|
||||
mo.observe(node, { subtree: true, childList: true, characterData: true });
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
mo.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Give an element a `title` for as long as its own text is cut off.
|
||||
*
|
||||
* The element normally speaks for itself. Its `textContent` is put through the
|
||||
* same collapse the renderer applies, so an element built from several spans
|
||||
* reads back as the one line it draws rather than carrying the markup's
|
||||
* newlines and indentation into the tooltip. Pass the text explicitly only where
|
||||
* the visible string genuinely isn't the element's own text.
|
||||
*/
|
||||
export function truncTitle(node: HTMLElement, text?: string) {
|
||||
let full = text;
|
||||
const measure = () => {
|
||||
const value = (full ?? node.textContent ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (value && isCut(node)) node.setAttribute('title', value);
|
||||
else node.removeAttribute('title');
|
||||
};
|
||||
const stop = watch(node, measure);
|
||||
return {
|
||||
update(next?: string) {
|
||||
full = next;
|
||||
measure();
|
||||
},
|
||||
destroy: stop
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Report whether an element's text is cut off, instead of titling it.
|
||||
*
|
||||
* For text the pointer can't reach: a group header stretches its link across the
|
||||
* whole row, so the element under the cursor is that overlay and never the title
|
||||
* beneath it, and a `title` there would never show. The caller takes the answer
|
||||
* and hangs the tooltip on an ancestor the hover does land on.
|
||||
*/
|
||||
export function onTruncated(node: HTMLElement, onchange: (cut: boolean) => void) {
|
||||
let cb = onchange;
|
||||
let last: boolean | null = null;
|
||||
const stop = watch(node, () => {
|
||||
const cut = isCut(node);
|
||||
if (cut === last) return;
|
||||
last = cut;
|
||||
cb(cut);
|
||||
});
|
||||
return {
|
||||
update(next: (cut: boolean) => void) {
|
||||
cb = next;
|
||||
},
|
||||
destroy: stop
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { spoolSource } from './spoolSource';
|
||||
import type { ExternalFilament } from './external';
|
||||
|
||||
/** Rows per request. Enough to overflow a picker's result list, so there is something to scroll. */
|
||||
const PAGE = 20;
|
||||
/** Distance from the bottom of the result list at which the next page is fetched. */
|
||||
const SCROLL_MARGIN = 120;
|
||||
|
||||
/**
|
||||
* The external-catalog half of a filament picker's search (#1136): `search` loads the
|
||||
* first page of matches, and scrolling the result list towards its end appends the next.
|
||||
* Shared by the add-spools and change-filament dialogs.
|
||||
*/
|
||||
export class ExternalSearch {
|
||||
items = $state<ExternalFilament[]>([]);
|
||||
error = $state(false);
|
||||
loadingMore = $state(false);
|
||||
#total = 0;
|
||||
#query = '';
|
||||
#loading = false;
|
||||
// Bumped by every new search, so a page that lands after the query changed is dropped.
|
||||
#req = 0;
|
||||
#ctrl: AbortController | undefined;
|
||||
// The result list, remembered from its first scroll event.
|
||||
#list: HTMLElement | undefined;
|
||||
|
||||
async search(query: string): Promise<void> {
|
||||
const req = this.#restart();
|
||||
this.#query = query.trim();
|
||||
this.#loading = true;
|
||||
this.error = false;
|
||||
try {
|
||||
const page = await spoolSource.searchExternalFilaments(this.#query, PAGE, 0, this.#ctrl!.signal);
|
||||
if (req !== this.#req) return;
|
||||
this.items = page.items;
|
||||
this.#total = page.total;
|
||||
// A new query starts at the top: left scrolled down, the list would fetch
|
||||
// its second page straight away.
|
||||
this.#list?.scrollTo(0, 0);
|
||||
} catch {
|
||||
if (req !== this.#req) return;
|
||||
this.items = [];
|
||||
this.#total = 0;
|
||||
this.error = true;
|
||||
} finally {
|
||||
if (req === this.#req) this.#loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async more(): Promise<void> {
|
||||
if (this.#loading || this.loadingMore || this.error || this.items.length >= this.#total) return;
|
||||
const req = this.#req;
|
||||
this.loadingMore = true;
|
||||
try {
|
||||
const page = await spoolSource.searchExternalFilaments(
|
||||
this.#query,
|
||||
PAGE,
|
||||
this.items.length,
|
||||
this.#ctrl!.signal
|
||||
);
|
||||
if (req !== this.#req) return;
|
||||
// A catalog sync between two pages shifts the offsets and can hand back a row
|
||||
// that is already listed; a repeated id would break the keyed list.
|
||||
const fresh = page.items.filter((f) => !this.items.some((e) => e.id === f.id));
|
||||
this.items = [...this.items, ...fresh];
|
||||
this.#total = page.total;
|
||||
} catch (e) {
|
||||
if (req === this.#req) console.error('Failed to load more external filaments', e);
|
||||
} finally {
|
||||
if (req === this.#req) this.loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget the results, dropping any request still in flight. */
|
||||
clear(): void {
|
||||
this.#restart();
|
||||
this.items = [];
|
||||
this.#total = 0;
|
||||
}
|
||||
|
||||
/** Scroll handler for the result list. An arrow function so it can be passed as-is. */
|
||||
onscroll = (e: Event) => {
|
||||
const el = (this.#list = e.currentTarget as HTMLElement);
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_MARGIN) void this.more();
|
||||
};
|
||||
|
||||
#restart(): number {
|
||||
this.#ctrl?.abort();
|
||||
this.#ctrl = new AbortController();
|
||||
this.#loading = false;
|
||||
this.loadingMore = false;
|
||||
return ++this.#req;
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,15 @@ export function isAbortError(e: unknown, signal?: AbortSignal): boolean {
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number
|
||||
readonly status: number,
|
||||
/**
|
||||
* The parsed error body, when there was one. Most failures need nothing
|
||||
* beyond the status, but a few carry a field the UI can act on rather than
|
||||
* merely report — linking a tag another spool already holds answers 409
|
||||
* with the `spool_id` that holds it, which is what lets the client offer to
|
||||
* move it without first going and looking the tag up.
|
||||
*/
|
||||
readonly body?: Record<string, unknown>
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
@@ -53,13 +61,14 @@ const RELOAD_ON_401 = new Set(['GET', 'HEAD']);
|
||||
async function ensureOk(res: Response, method: string, path: string): Promise<Response> {
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 && RELOAD_ON_401.has(method)) recoverFromUnauthorized();
|
||||
let detail = '';
|
||||
let body: Record<string, unknown> | undefined;
|
||||
try {
|
||||
detail = (await res.json())?.message ?? '';
|
||||
body = (await res.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
/* ignore */
|
||||
/* no body, or not JSON */
|
||||
}
|
||||
throw new HttpError(`${method} ${path} → ${res.status}${detail ? `: ${detail}` : ''}`, res.status);
|
||||
const detail = typeof body?.message === 'string' ? body.message : '';
|
||||
throw new HttpError(`${method} ${path} → ${res.status}${detail ? `: ${detail}` : ''}`, res.status, body);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
@@ -67,6 +76,15 @@ async function ensureOk(res: Response, method: string, path: string): Promise<Re
|
||||
export interface RawPage {
|
||||
items: unknown[];
|
||||
total: number;
|
||||
/** The group endpoint's totals across every page, when it sends them. */
|
||||
totalSpools?: number;
|
||||
totalRemaining?: number;
|
||||
}
|
||||
|
||||
function numberHeader(res: Response, name: string): number | undefined {
|
||||
const header = res.headers.get(name);
|
||||
const n = header != null && header !== '' ? Number(header) : NaN;
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
// Reads take an optional AbortSignal so a view can cancel what it no longer needs
|
||||
@@ -82,9 +100,12 @@ export async function getList(
|
||||
): Promise<RawPage> {
|
||||
const res = await ensureOk(await fetch(API_BASE + path + queryString(params), { signal }), 'GET', path);
|
||||
const items = (await res.json()) as unknown[];
|
||||
const header = res.headers.get('x-total-count');
|
||||
const total = header != null && header !== '' ? Number(header) : items.length;
|
||||
return { items, total: Number.isNaN(total) ? items.length : total };
|
||||
return {
|
||||
items,
|
||||
total: numberHeader(res, 'x-total-count') ?? items.length,
|
||||
totalSpools: numberHeader(res, 'x-total-spools'),
|
||||
totalRemaining: numberHeader(res, 'x-total-remaining-weight')
|
||||
};
|
||||
}
|
||||
|
||||
export async function getJson<T = unknown>(
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface Info {
|
||||
external_db_name: string;
|
||||
git_commit?: string;
|
||||
build_date?: string;
|
||||
latest_version?: string | null;
|
||||
update_available?: boolean;
|
||||
}
|
||||
|
||||
export function getInfo(): Promise<Info> {
|
||||
|
||||
@@ -123,3 +123,31 @@ describe('mapSpool — inherited vs. own values', () => {
|
||||
expect(s.initial).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
// `tags` is always on the wire, but `format` is omitted rather than nulled when
|
||||
// unset (the API excludes nulls), so the mapping has to read absence — not null —
|
||||
// as "no format". Getting this backwards would render the string "null" as a
|
||||
// badge on every tag a reader didn't name.
|
||||
describe('mapSpool — tags', () => {
|
||||
const api = (tags: unknown) => ({ id: 1, used_weight: 0, filament: { id: 9 }, tags });
|
||||
|
||||
it('maps a linked tag, keeping the canonical UID the server returned', () => {
|
||||
const s = mapSpool(api([{ uid: '04A2B3C4', format: 'ntag', added: '2026-08-13T10:00:00Z' }]));
|
||||
expect(s.tags).toEqual([{ uid: '04A2B3C4', format: 'ntag', added: '2026-08-13T10:00:00Z' }]);
|
||||
});
|
||||
|
||||
it('reads an omitted format as no format rather than as a value', () => {
|
||||
const s = mapSpool(api([{ uid: '04A2B3C4', added: '2026-08-13T10:00:00Z' }]));
|
||||
expect(s.tags[0].format).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives an untagged spool an empty list', () => {
|
||||
expect(mapSpool(api([])).tags).toEqual([]);
|
||||
});
|
||||
|
||||
// Fixtures and a server upgraded under a long-lived tab can both produce a
|
||||
// spool with no `tags` key at all; every consumer indexes into it.
|
||||
it('gives a spool without the key an empty list rather than undefined', () => {
|
||||
expect(mapSpool({ id: 1, used_weight: 0, filament: { id: 9 } }).tags).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
Filament,
|
||||
FilamentPatch,
|
||||
Tag,
|
||||
MultiColorDirection,
|
||||
Spool,
|
||||
SpoolPatch,
|
||||
@@ -41,6 +42,20 @@ export function mapVendor(v: Json): Vendor {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A spool's or filament's tags. The API always sends `tags`, but an entity that
|
||||
* predates a server upgrade mid-session (or a fixture) may not, so an absent one
|
||||
* reads as none. `format` is omitted rather than nulled when unset — the API
|
||||
* excludes nulls — which is why it maps to undefined instead of being tested for null.
|
||||
*/
|
||||
function mapTags(tags: unknown): Tag[] {
|
||||
return ((tags ?? []) as Json[]).map((t) => ({
|
||||
uid: t.uid,
|
||||
format: t.format ?? undefined,
|
||||
added: t.added
|
||||
}));
|
||||
}
|
||||
|
||||
export function mapFilament(f: Json): Filament {
|
||||
return {
|
||||
id: String(f.id),
|
||||
@@ -60,6 +75,7 @@ export function mapFilament(f: Json): Filament {
|
||||
comment: f.comment ?? '',
|
||||
externalId: f.external_id ?? undefined,
|
||||
registeredLabel: formatShortDate(f.registered),
|
||||
tags: mapTags(f.tags),
|
||||
extra: f.extra ?? {}
|
||||
};
|
||||
}
|
||||
@@ -96,6 +112,7 @@ export function mapSpool(s: Json): Spool {
|
||||
registeredLabel: formatShortDate(s.registered),
|
||||
archived: s.archived ?? false,
|
||||
comment: s.comment ?? '',
|
||||
tags: mapTags(s.tags),
|
||||
extra: s.extra ?? {}
|
||||
};
|
||||
}
|
||||
@@ -155,7 +172,18 @@ export function mapGroup(g: Json): GroupSummary {
|
||||
totalRemaining: g.total_remaining_weight ?? 0,
|
||||
hasStock: (g.spool_count ?? 0) > 0,
|
||||
lastUsedLabel: formatDurationShort(g.last_used),
|
||||
lastUsedSort: 0
|
||||
lastUsedSort: 0,
|
||||
filamentCount: g.filament_count ?? undefined,
|
||||
previewSpools: (g.spools as Json[] | undefined)?.map((s) => ({
|
||||
id: String(s.id),
|
||||
remaining: s.remaining_weight ?? null,
|
||||
initial: s.initial_weight ?? null
|
||||
})),
|
||||
previewFilaments: (g.filaments as Json[] | undefined)?.map((f) => ({
|
||||
id: String(f.id),
|
||||
colors: colorsFromApi(f),
|
||||
direction: f.multi_color_direction ?? undefined
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { allPages, buildCatalogQuery, buildGroupQuery, buildVendorTotalsQuery } from './query';
|
||||
import type { LibraryState } from '$lib/library/params';
|
||||
|
||||
// A filament with no spools is the one you need to re-order, and grouping by
|
||||
// filament used to drop it entirely — the library then looked fully stocked
|
||||
// (#1092). Listing them is opt-in (showEmpty), and on top of that the query
|
||||
// builder has a rule worth pinning down: a filter about SPOOLS (where they are,
|
||||
// when they were used) is a question an empty filament cannot answer, so the API
|
||||
// refuses that combination and the client must not send it.
|
||||
//
|
||||
// These cases run with showEmpty on unless they say otherwise; the default-off
|
||||
// behaviour is its own case below.
|
||||
|
||||
function state(over: Partial<LibraryState> = {}): LibraryState {
|
||||
return {
|
||||
view: 'spools',
|
||||
selection: null,
|
||||
group: 'filament',
|
||||
sortKey: 'last_used',
|
||||
sortAsc: false,
|
||||
filters: [],
|
||||
showArchived: false,
|
||||
showEmpty: true,
|
||||
showLotNumbers: false,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildGroupQuery — includeEmpty', () => {
|
||||
it('asks for empty groups when grouping by filament with no filters', () => {
|
||||
expect(buildGroupQuery(state()).includeEmpty).toBe(true);
|
||||
});
|
||||
|
||||
it('does not ask unless the user turned it on', () => {
|
||||
// Off by default: a filament you own none of cannot serve the daily job of
|
||||
// finding something to print with, and how many of them there are depends
|
||||
// on how big a catalogue the user keeps.
|
||||
expect(buildGroupQuery(state({ showEmpty: false })).includeEmpty).toBe(false);
|
||||
});
|
||||
|
||||
it('does not ask on any other grouping, which has no empty groups to list', () => {
|
||||
for (const group of ['vendor', 'material', 'location'] as const) {
|
||||
expect(buildGroupQuery(state({ group })).includeEmpty).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps asking under filters the filament itself answers', () => {
|
||||
const filters = [
|
||||
{ prop: 'filament', value: '4' },
|
||||
{ prop: 'material', value: 'PLA' },
|
||||
{ prop: 'vendor', value: 'Acme' },
|
||||
{ prop: 'direction', value: 'coaxial' },
|
||||
{ prop: 'filament.extra.shelf', value: '"A"' },
|
||||
{ prop: 'filament.vendor.extra.tier', value: '"gold"' }
|
||||
];
|
||||
for (const f of filters) {
|
||||
expect(buildGroupQuery(state({ filters: [f] })).includeEmpty).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('stops asking under a filter about the spools themselves', () => {
|
||||
const filters = [
|
||||
{ prop: 'location', value: 'Shelf A' },
|
||||
{ prop: 'lot', value: 'B12' },
|
||||
{ prop: 'last_used', value: '7d' },
|
||||
{ prop: 'first_used', value: '7d' },
|
||||
{ prop: 'registered', value: '7d' },
|
||||
{ prop: 'extra.opened', value: 'true' }
|
||||
];
|
||||
for (const f of filters) {
|
||||
expect(buildGroupQuery(state({ filters: [f] })).includeEmpty).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('lets one spool-scoped filter veto a set of filament-scoped ones', () => {
|
||||
const filters = [
|
||||
{ prop: 'material', value: 'PLA' },
|
||||
{ prop: 'location', value: 'Shelf A' }
|
||||
];
|
||||
expect(buildGroupQuery(state({ filters })).includeEmpty).toBe(false);
|
||||
});
|
||||
|
||||
it('still asks when only archived spools are hidden, which is the default view', () => {
|
||||
// A filament whose every spool is archived has none to print with, so it
|
||||
// belongs in the list for the same reason a filament with no spools does.
|
||||
expect(buildGroupQuery(state({ showArchived: false })).includeEmpty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// The catalog sub-views page the group endpoint too, always including the empty
|
||||
// groups. The order is what holds the filament list's manufacturer sections
|
||||
// together across pages, so it is pinned down here.
|
||||
describe('buildCatalogQuery', () => {
|
||||
const catalog = (over: Partial<LibraryState> = {}) =>
|
||||
state({ view: 'filaments', group: 'vendor', sortKey: 'name', sortAsc: true, showEmpty: false, ...over });
|
||||
|
||||
it('lists every filament, manufacturer first, then by name', () => {
|
||||
const q = buildCatalogQuery(catalog({ page: 3, pageSize: 50 }));
|
||||
expect(q).toMatchObject({ field: 'filament', includeEmpty: true, preview: true, limit: 50, offset: 100 });
|
||||
expect(q.sort).toEqual([
|
||||
{ field: 'group.vendor_name', dir: 'asc' },
|
||||
{ field: 'group.title', dir: 'asc' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the manufacturer lead when the list is flat', () => {
|
||||
expect(buildCatalogQuery(catalog({ group: 'none', sortAsc: false })).sort).toEqual([
|
||||
{ field: 'group.title', dir: 'desc' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('breaks weight and recency ties by name', () => {
|
||||
expect(buildCatalogQuery(catalog({ sortKey: 'remaining_weight', sortAsc: false })).sort).toEqual([
|
||||
{ field: 'group.vendor_name', dir: 'asc' },
|
||||
{ field: 'group.total_remaining', dir: 'desc' },
|
||||
{ field: 'group.title', dir: 'asc' }
|
||||
]);
|
||||
expect(buildCatalogQuery(catalog({ group: 'none', sortKey: 'last_used' })).sort).toEqual([
|
||||
{ field: 'group.last_used', dir: 'asc' },
|
||||
{ field: 'group.title', dir: 'asc' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('pages manufacturers in the manufacturer view', () => {
|
||||
const q = buildCatalogQuery(catalog({ view: 'manufacturers', group: 'none' }));
|
||||
expect(q).toMatchObject({ field: 'vendor', includeEmpty: true, preview: true });
|
||||
expect(q.sort).toEqual([{ field: 'group.title', dir: 'asc' }]);
|
||||
});
|
||||
|
||||
it('orders by the fields of the filament and manufacturer, custom ones too', () => {
|
||||
const sortOf = (sortKey: string, view: LibraryState['view'] = 'filaments') =>
|
||||
buildCatalogQuery(catalog({ view, group: 'none', sortKey, sortAsc: false })).sort[0];
|
||||
expect(sortOf('filament.settings_extruder_temp')).toEqual({
|
||||
field: 'filament.settings_extruder_temp',
|
||||
dir: 'desc'
|
||||
});
|
||||
expect(sortOf('filament.extra.shelf')).toEqual({ field: 'filament.extra.shelf', dir: 'desc' });
|
||||
expect(sortOf('filament.vendor.extra.country', 'manufacturers')).toEqual({
|
||||
field: 'filament.vendor.extra.country',
|
||||
dir: 'desc'
|
||||
});
|
||||
expect(sortOf('filament_count', 'manufacturers')).toEqual({ field: 'group.filament_count', dir: 'desc' });
|
||||
// A filament's custom field means nothing to a manufacturer, nor a spool's to either.
|
||||
expect(sortOf('filament.extra.shelf', 'manufacturers').field).toBe('group.title');
|
||||
expect(sortOf('location').field).toBe('group.title');
|
||||
});
|
||||
|
||||
it('takes any filter, leaving out the empty ones under a filter about spools', () => {
|
||||
// "Which filaments do I have on Shelf A" is a fair question of the catalog,
|
||||
// but a filament with no spools isn't anywhere, and the API refuses to say.
|
||||
const filters = [{ prop: 'location', value: 'Shelf A' }];
|
||||
expect(buildCatalogQuery(catalog({ filters }))).toMatchObject({ includeEmpty: false });
|
||||
expect(buildVendorTotalsQuery(catalog({ filters }), ['1'])).toMatchObject({ includeEmpty: false });
|
||||
expect(buildCatalogQuery(catalog({ filters: [{ prop: 'material', value: 'PLA' }] }))).toMatchObject({
|
||||
includeEmpty: true
|
||||
});
|
||||
});
|
||||
|
||||
it('narrows the section totals to the manufacturers on the page', () => {
|
||||
const filters = [{ prop: 'material', value: 'PLA' }];
|
||||
const q = buildVendorTotalsQuery(catalog({ filters, showArchived: true }), ['1', '4']);
|
||||
expect(q).toMatchObject({
|
||||
field: 'vendor',
|
||||
includeEmpty: true,
|
||||
allowArchived: true,
|
||||
limit: 2,
|
||||
offset: 0
|
||||
});
|
||||
expect(q.filters).toEqual({ material: ['PLA'], vendorId: ['1', '4'] });
|
||||
});
|
||||
});
|
||||
|
||||
// The dashboard and the filament inspector used to stop at their first page
|
||||
// (1000 groups, 100 spools) and show the rest as if it didn't exist.
|
||||
describe('allPages', () => {
|
||||
const rows = Array.from({ length: 25 }, (_, i) => i);
|
||||
const pager = (size: number) => {
|
||||
const offsets: number[] = [];
|
||||
const fetchPage = async (offset: number) => {
|
||||
offsets.push(offset);
|
||||
return { items: rows.slice(offset, offset + size), total: rows.length };
|
||||
};
|
||||
return { offsets, fetchPage };
|
||||
};
|
||||
|
||||
it('keeps fetching until it has every row', async () => {
|
||||
const { offsets, fetchPage } = pager(10);
|
||||
expect(await allPages(10, fetchPage)).toEqual(rows);
|
||||
expect(offsets).toEqual([0, 10, 20]);
|
||||
});
|
||||
|
||||
it('stops after one request when the first page holds everything', async () => {
|
||||
const { offsets, fetchPage } = pager(100);
|
||||
expect(await allPages(100, fetchPage)).toEqual(rows);
|
||||
expect(offsets).toEqual([0]);
|
||||
});
|
||||
|
||||
it('stops on an exact multiple without asking for an empty page', async () => {
|
||||
const { offsets, fetchPage } = pager(5);
|
||||
expect(await allPages(5, fetchPage)).toHaveLength(25);
|
||||
expect(offsets).toEqual([0, 5, 10, 15, 20]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GroupField, GroupQuery, GroupSummary, SortField, SpoolQuery } from './types';
|
||||
import type { LibraryState } from '$lib/library/params';
|
||||
import { resolveSortField, resolveGroupSortField } from '$lib/utils/library';
|
||||
import type { GroupField, GroupQuery, GroupSummary, Page, SortField, SpoolQuery } from './types';
|
||||
import { isSpoolScopedFilter, type CatalogView, type LibraryState } from '$lib/library/params';
|
||||
import { resolveSortField, resolveGroupSortField, catalogSortField } from '$lib/utils/library';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
|
||||
// Translates the URL-borne LibraryState into concrete /spool and /spool/group
|
||||
@@ -19,6 +19,34 @@ function currentFilters(state: LibraryState): Record<string, string[]> {
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to ask for filaments that have no spools at all (#1092).
|
||||
*
|
||||
* Off unless the user asked for it (see params.showEmpty). Finding a spool to
|
||||
* print with is the daily job, and a filament you own none of cannot serve it;
|
||||
* how many such rows there would be depends on how big a catalogue you keep, so
|
||||
* it is not a default that can be right for everyone. Asked for once, it is
|
||||
* remembered (see viewPrefs).
|
||||
*
|
||||
* Even then it is only sent while every active filter is one the *filament* can
|
||||
* answer.
|
||||
*
|
||||
* Ask "which of these are at Shelf A" and a filament with no spools has no
|
||||
* answer: it isn't anywhere. Including it would flood that view with every
|
||||
* filament in the catalogue instead of showing the handful actually on the
|
||||
* shelf. The API says the same and rejects that combination outright, so this
|
||||
* is the client keeping off a 400 rather than a preference of its own; the two
|
||||
* lists of spool-scoped filters have to stay in step.
|
||||
*
|
||||
* Archived is not one of them: hiding archived spools is the default view, and
|
||||
* a filament whose only spools are archived genuinely has none to print with.
|
||||
*/
|
||||
function wantsEmptyGroups(state: LibraryState): boolean {
|
||||
return (
|
||||
state.showEmpty && state.group === 'filament' && !state.filters.some((f) => isSpoolScopedFilter(f.prop))
|
||||
);
|
||||
}
|
||||
|
||||
/** Page-of-groups query (grouped mode). */
|
||||
export function buildGroupQuery(state: LibraryState, signal?: AbortSignal): GroupQuery {
|
||||
const field = state.group as GroupField;
|
||||
@@ -29,6 +57,7 @@ export function buildGroupQuery(state: LibraryState, signal?: AbortSignal): Grou
|
||||
filters: currentFilters(state),
|
||||
sort: [{ field: groupField, dir }],
|
||||
allowArchived: state.showArchived,
|
||||
includeEmpty: wantsEmptyGroups(state),
|
||||
limit: state.pageSize,
|
||||
offset: (state.page - 1) * state.pageSize,
|
||||
lowThreshold: settings.lowThreshold,
|
||||
@@ -36,6 +65,70 @@ export function buildGroupQuery(state: LibraryState, signal?: AbortSignal): Grou
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a catalog page lists the filaments (or manufacturers) with no spools
|
||||
* too. It does unless a filter asks about the spools themselves: a filament with
|
||||
* none can't be on Shelf A, and the API refuses the pairing (see wantsEmptyGroups).
|
||||
*/
|
||||
function catalogIncludesEmpty(state: LibraryState): boolean {
|
||||
return !state.filters.some((f) => isSpoolScopedFilter(f.prop));
|
||||
}
|
||||
|
||||
/**
|
||||
* Page of a catalog sub-view: one group per filament (or manufacturer), those
|
||||
* with no spools included (see catalogIncludesEmpty), with the preview the row
|
||||
* draws from.
|
||||
*/
|
||||
export function buildCatalogQuery(state: LibraryState, signal?: AbortSignal): GroupQuery {
|
||||
const field = catalogSortField(state.view as CatalogView, state.sortKey) ?? 'group.title';
|
||||
const sort: SortField[] = [{ field, dir: state.sortAsc ? 'asc' : 'desc' }];
|
||||
// Manufacturer sections are runs of consecutive rows (see library/catalog), so
|
||||
// the manufacturer has to lead the order or a section would break apart.
|
||||
if (state.view === 'filaments' && state.group === 'vendor') {
|
||||
sort.unshift({ field: 'group.vendor_name', dir: 'asc' });
|
||||
}
|
||||
// Most orders tie a lot (every filament with no spools has 0 g and was never
|
||||
// used; many share a material), so the name settles ties instead of the
|
||||
// database's whim.
|
||||
if (field !== 'group.title') sort.push({ field: 'group.title', dir: 'asc' });
|
||||
return {
|
||||
field: state.view === 'manufacturers' ? 'vendor' : 'filament',
|
||||
filters: currentFilters(state),
|
||||
sort,
|
||||
allowArchived: state.showArchived,
|
||||
includeEmpty: catalogIncludesEmpty(state),
|
||||
preview: true,
|
||||
limit: state.pageSize,
|
||||
offset: (state.page - 1) * state.pageSize,
|
||||
lowThreshold: settings.lowThreshold,
|
||||
signal
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The aggregates behind the manufacturer section headers on one page of the
|
||||
* filament catalog: the same filters, narrowed to the manufacturers on screen.
|
||||
* Including the empty ones whenever the page does is what makes the filament
|
||||
* count cover the same filaments as the rows under the header.
|
||||
*/
|
||||
export function buildVendorTotalsQuery(
|
||||
state: LibraryState,
|
||||
vendorIds: string[],
|
||||
signal?: AbortSignal
|
||||
): GroupQuery {
|
||||
return {
|
||||
field: 'vendor',
|
||||
filters: { ...currentFilters(state), vendorId: vendorIds },
|
||||
sort: [],
|
||||
allowArchived: state.showArchived,
|
||||
includeEmpty: catalogIncludesEmpty(state),
|
||||
limit: vendorIds.length,
|
||||
offset: 0,
|
||||
lowThreshold: settings.lowThreshold,
|
||||
signal
|
||||
};
|
||||
}
|
||||
|
||||
/** Spools of one group, ordered by the chosen Sort. */
|
||||
export function buildScopedSpoolQuery(
|
||||
state: LibraryState,
|
||||
@@ -77,3 +170,20 @@ export function buildFlatSpoolQuery(state: LibraryState, signal?: AbortSignal):
|
||||
signal
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every row of a paged list, fetched `size` at a time: for views that need the
|
||||
* whole set (every dashboard card, every spool of one filament) without asking
|
||||
* the server for all of it in one response.
|
||||
*/
|
||||
export async function allPages<T>(
|
||||
size: number,
|
||||
fetchPage: (offset: number) => Promise<Page<T>>
|
||||
): Promise<T[]> {
|
||||
const all: T[] = [];
|
||||
for (;;) {
|
||||
const page = await fetchPage(all.length);
|
||||
all.push(...page.items);
|
||||
if (page.items.length < size || all.length >= page.total) return all;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { wsUrl } from './config';
|
||||
import { getJson } from './http';
|
||||
import { mapSpool, mapFilament, mapVendor } from './map';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import type { Filament, Spool } from '$lib/types';
|
||||
|
||||
// The NFC/RFID scan relay: a reader taps a tag, POSTs it to /api/v1/tag/scan,
|
||||
// and the server fans that out over a websocket to whichever browsers are
|
||||
// listening. This is the client for that socket.
|
||||
//
|
||||
// It is deliberately NOT part of live.ts. That module is one socket per entity
|
||||
// resource (`spool`, `filament`, `vendor`), each feeding inventory.ingest — and a
|
||||
// scan is not an entity. It has no id, nothing to cache it under, and must never
|
||||
// enter the inventory cache as though it were a record. Its URL also carries a
|
||||
// reader id where live.ts's carries a resource name. What IS worth sharing is the
|
||||
// hard-won connection behaviour, so the backoff, the wake-on-online/visible
|
||||
// listeners, the keepalive and the 401 probe below are all deliberate copies of
|
||||
// live.ts rather than an attempt to generalize one module over both.
|
||||
//
|
||||
// Which pool you get comes from the path and nothing else:
|
||||
// /tag/scan every reader
|
||||
// /tag/scan/{reader_id} that reader only
|
||||
// That is the whole of pairing — subscribe to the root, wait for one tap, read
|
||||
// its reader_id, then resubscribe to that reader. No server state is involved and
|
||||
// nothing needs to be told you paired.
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type Json = Record<string, any>;
|
||||
|
||||
/** One tap, as broadcast to every browser listening to the reader that saw it. */
|
||||
export interface TagScan {
|
||||
uid: string;
|
||||
/** Always present. Readers that send none are given one derived from their
|
||||
* address (`ip-192-168-1-50`), which is what makes pairing work with an
|
||||
* unmodified off-the-shelf agent. */
|
||||
readerId: string;
|
||||
/** The reader's friendly name, when it sends one. Prefer it over `readerId`. */
|
||||
name?: string;
|
||||
/** Tag hardware type, e.g. "ntag". Informational. */
|
||||
format?: string;
|
||||
/** Raw tag contents, base64. Phase 1 does not decode it. */
|
||||
payloadB64?: string;
|
||||
/** The spool this tag is linked to, absent unless a spool holds it. Embedded
|
||||
* in the event in full, so acting on a scan needs no follow-up request. */
|
||||
spool?: Spool;
|
||||
/** The filament this tag is linked to, absent unless a filament holds it. At
|
||||
* most one of `spool` and `filament` is set; neither means an unknown tag. */
|
||||
filament?: Filament;
|
||||
}
|
||||
|
||||
export type ScanHandler = (scan: TagScan) => void;
|
||||
|
||||
/** `null` means the root pool: every reader. */
|
||||
export type ReaderPool = string | null;
|
||||
|
||||
// See live.ts — same reasoning, same numbers, so a relay socket and an entity
|
||||
// socket behave identically when the backend goes away and comes back.
|
||||
const RECONNECT_BASE_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30000;
|
||||
const KEEPALIVE_MS = 25000;
|
||||
|
||||
const AUTH_PROBE_MIN_INTERVAL_MS = 10000;
|
||||
let lastAuthProbeAt = 0;
|
||||
|
||||
function probeAuth(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastAuthProbeAt < AUTH_PROBE_MIN_INTERVAL_MS) return;
|
||||
lastAuthProbeAt = now;
|
||||
getJson('/info').catch(() => {
|
||||
/* backend down, or a 401 that has already triggered the reload */
|
||||
});
|
||||
}
|
||||
|
||||
/** Pool → socket path. The reader id is a path segment, and the server constrains
|
||||
* it to `[A-Za-z0-9._:-]` precisely so it can travel as one. */
|
||||
function pathFor(pool: ReaderPool): string {
|
||||
return pool === null ? '/tag/scan' : `/tag/scan/${encodeURIComponent(pool)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a broadcast payload to a `TagScan`.
|
||||
*
|
||||
* The embedded spool or filament is cached on the way past, exactly as one
|
||||
* arriving from any other endpoint would be: a scan that is about to open an
|
||||
* inspector should find the filament and manufacturer already there. Note that
|
||||
* this is the only thing a scan puts in the cache — the scan itself is an event,
|
||||
* not a record.
|
||||
*/
|
||||
function toScan(payload: Json): TagScan {
|
||||
const raw = payload.spool as Json | undefined;
|
||||
let spool: Spool | undefined;
|
||||
if (raw) {
|
||||
if (raw.filament) {
|
||||
inventory.upsertFilament(mapFilament(raw.filament));
|
||||
if (raw.filament.vendor) inventory.upsertVendor(mapVendor(raw.filament.vendor));
|
||||
}
|
||||
spool = mapSpool(raw);
|
||||
inventory.upsertSpool(spool);
|
||||
}
|
||||
const rawFilament = payload.filament as Json | undefined;
|
||||
let filament: Filament | undefined;
|
||||
if (rawFilament) {
|
||||
if (rawFilament.vendor) inventory.upsertVendor(mapVendor(rawFilament.vendor));
|
||||
filament = mapFilament(rawFilament);
|
||||
inventory.upsertFilament(filament);
|
||||
}
|
||||
return {
|
||||
uid: payload.uid,
|
||||
readerId: payload.reader_id,
|
||||
name: payload.name ?? undefined,
|
||||
format: payload.format ?? undefined,
|
||||
payloadB64: payload.payload_b64 ?? undefined,
|
||||
spool,
|
||||
filament
|
||||
};
|
||||
}
|
||||
|
||||
interface PoolSocket {
|
||||
ws: WebSocket | null;
|
||||
subs: Set<ScanHandler>;
|
||||
reconnectTimer: ReturnType<typeof setTimeout> | null;
|
||||
closed: boolean;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
class ScanRelay {
|
||||
#sockets = new Map<string, PoolSocket>();
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== 'undefined') {
|
||||
const wake = () => this.#reconnectAllNow();
|
||||
window.addEventListener('online', wake);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) wake();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to one reader, or to every reader with `null`. Returns an unsubscribe;
|
||||
* the socket opens on the first subscriber to a pool and closes after the last
|
||||
* one leaves, so a browser that is not using NFC holds no connection at all.
|
||||
*/
|
||||
subscribe(pool: ReaderPool, handler: ScanHandler): () => void {
|
||||
const key = pathFor(pool);
|
||||
const sock = this.#ensure(key);
|
||||
sock.subs.add(handler);
|
||||
return () => {
|
||||
sock.subs.delete(handler);
|
||||
if (sock.subs.size === 0) this.#teardown(key);
|
||||
};
|
||||
}
|
||||
|
||||
#ensure(key: string): PoolSocket {
|
||||
let sock = this.#sockets.get(key);
|
||||
if (!sock) {
|
||||
sock = { ws: null, subs: new Set(), reconnectTimer: null, closed: false, attempts: 0 };
|
||||
this.#sockets.set(key, sock);
|
||||
this.#open(key, sock);
|
||||
}
|
||||
return sock;
|
||||
}
|
||||
|
||||
#open(key: string, sock: PoolSocket) {
|
||||
if (typeof WebSocket === 'undefined') return; // SSR / no WS support
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(wsUrl(key));
|
||||
} catch {
|
||||
this.#scheduleReconnect(key, sock);
|
||||
return;
|
||||
}
|
||||
sock.ws = ws;
|
||||
// Kept local as well as on `sock` so a superseded socket can clean up after
|
||||
// itself without touching the shared state — see the identity guards below.
|
||||
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let opened = false;
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let msg: Json;
|
||||
try {
|
||||
msg = JSON.parse(ev.data as string);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!msg || msg.status) return; // ignore health/ping replies
|
||||
if (!msg.payload?.uid) return;
|
||||
const scan = toScan(msg.payload);
|
||||
// Copied so a handler that unsubscribes (the pairing flow does, on its
|
||||
// first scan) doesn't mutate the set we are iterating.
|
||||
for (const handler of [...sock.subs]) handler(scan);
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
opened = true;
|
||||
if (sock.ws !== ws) {
|
||||
ws.close(); // superseded while connecting — drop it
|
||||
return;
|
||||
}
|
||||
sock.attempts = 0;
|
||||
pingTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send('ping');
|
||||
}, KEEPALIVE_MS);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (pingTimer) clearInterval(pingTimer);
|
||||
pingTimer = null;
|
||||
if (sock.ws !== ws) return; // a replacement is live — leave it alone
|
||||
sock.ws = null;
|
||||
if (!sock.closed && sock.subs.size > 0) {
|
||||
if (!opened) probeAuth();
|
||||
this.#scheduleReconnect(key, sock);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
|
||||
#scheduleReconnect(key: string, sock: PoolSocket) {
|
||||
if (sock.reconnectTimer) return;
|
||||
const exp = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** sock.attempts);
|
||||
const delay = exp / 2 + Math.random() * (exp / 2); // full jitter
|
||||
sock.attempts++;
|
||||
sock.reconnectTimer = setTimeout(() => {
|
||||
sock.reconnectTimer = null;
|
||||
if (!sock.closed && sock.subs.size > 0) this.#open(key, sock);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
#reconnectAllNow() {
|
||||
for (const [key, sock] of this.#sockets) {
|
||||
if (sock.closed || sock.subs.size === 0) continue;
|
||||
const rs = sock.ws?.readyState;
|
||||
if (rs === WebSocket.OPEN || rs === WebSocket.CONNECTING) continue;
|
||||
sock.attempts = 0;
|
||||
if (sock.reconnectTimer) {
|
||||
clearTimeout(sock.reconnectTimer);
|
||||
sock.reconnectTimer = null;
|
||||
}
|
||||
this.#open(key, sock);
|
||||
}
|
||||
}
|
||||
|
||||
#teardown(key: string) {
|
||||
const sock = this.#sockets.get(key);
|
||||
if (!sock) return;
|
||||
sock.closed = true;
|
||||
if (sock.reconnectTimer) clearTimeout(sock.reconnectTimer);
|
||||
sock.ws?.close();
|
||||
this.#sockets.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export const scanRelay = new ScanRelay();
|
||||
|
||||
/** Readers the server has heard from recently. */
|
||||
export interface KnownReader {
|
||||
readerId: string;
|
||||
name?: string;
|
||||
/** ISO timestamp of the last scan this reader sent. */
|
||||
lastSeen: string;
|
||||
/**
|
||||
* The last UID this reader reported, or undefined if it has not scanned since
|
||||
* the server started. Lets a dialog offer what is already on the reader rather
|
||||
* than waiting for another tap.
|
||||
*/
|
||||
lastUid?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The readers the server has seen recently, most recent first.
|
||||
*
|
||||
* This registry is in-memory and per-process: it is **empty after a restart**
|
||||
* until something scans again, which is normal and not an error. Treat it as a
|
||||
* convenience for picking a reader in another room, never as the list of readers
|
||||
* that exist.
|
||||
*/
|
||||
export async function listReaders(signal?: AbortSignal): Promise<KnownReader[]> {
|
||||
const rows = await getJson<Json[]>('/tag/reader', {}, signal);
|
||||
return rows.map((r) => ({
|
||||
readerId: r.reader_id,
|
||||
name: r.name ?? undefined,
|
||||
lastSeen: r.last_seen,
|
||||
lastUid: r.last_uid ?? undefined
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./config', () => ({ API_BASE: 'http://spoolman.test/api/v1' }));
|
||||
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { spoolSource } from './spoolSource';
|
||||
|
||||
// GET /location only knows locations that hold a spool, so an idle toolhead set up
|
||||
// as a dashboard card could not be picked for a spool (#1261).
|
||||
|
||||
const setting = (value: unknown) => ({ value: JSON.stringify(value), is_set: true, type: 'object' });
|
||||
|
||||
function serve(locations: string[], settings: Record<string, unknown> | null) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
if (url.endsWith('/location')) return Response.json(locations);
|
||||
if (settings === null) return new Response('{}', { status: 500 });
|
||||
return Response.json(settings);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('locationChoices', () => {
|
||||
it('adds the empty locations kept as dashboard cards', async () => {
|
||||
serve(['Shelf', 'Printer 1'], {
|
||||
dashboard_groups: setting({ location: ['Toolhead 1', 'Shelf', ''], material: ['PLA'] })
|
||||
});
|
||||
expect(await spoolSource.locationChoices()).toEqual(['Printer 1', 'Shelf', 'Toolhead 1']);
|
||||
});
|
||||
|
||||
it("reads the old client's saved locations when the dashboard has none", async () => {
|
||||
serve(['Shelf'], { locations: setting(['Toolhead 2']) });
|
||||
expect(await spoolSource.locationChoices()).toEqual(['Shelf', 'Toolhead 2']);
|
||||
});
|
||||
|
||||
it('still offers the locations in use when settings fail to load', async () => {
|
||||
serve(['Shelf'], null);
|
||||
expect(await spoolSource.locationChoices()).toEqual(['Shelf']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importExternalFilament', () => {
|
||||
const ext = {
|
||||
id: 'Prusa_PLA',
|
||||
manufacturer: 'Prusa',
|
||||
name: 'PLA',
|
||||
material: 'PLA',
|
||||
density: 1.24,
|
||||
diameter: 1.75,
|
||||
weight: 1000
|
||||
};
|
||||
type ApiVendor = { id: number; name: string; external_id?: string };
|
||||
|
||||
function serveImport(vendors: ApiVendor[], filamentIds: string[] = []) {
|
||||
const fetch = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const parsed = new URL(url);
|
||||
const resource = parsed.pathname.split('/').pop();
|
||||
if (init?.method === 'POST') {
|
||||
const body = JSON.parse(init.body as string);
|
||||
if (resource === 'vendor') {
|
||||
const vendor = { id: 100, ...body };
|
||||
vendors.push(vendor);
|
||||
return Response.json(vendor);
|
||||
}
|
||||
return Response.json({ id: 200, ...body, vendor: vendors.find((v) => v.id === body.vendor_id) });
|
||||
}
|
||||
const rows =
|
||||
resource === 'vendor' ? vendors : filamentIds.map((external_id, i) => ({ id: i + 1, external_id }));
|
||||
const term = parsed.searchParams.get('external_id');
|
||||
if (term === null) return Response.json(rows);
|
||||
return Response.json(
|
||||
rows.filter((row) =>
|
||||
term.startsWith('"') && term.endsWith('"')
|
||||
? row.external_id === term.slice(1, -1)
|
||||
: row.external_id?.toLowerCase().includes(term.toLowerCase())
|
||||
)
|
||||
);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
return fetch;
|
||||
}
|
||||
|
||||
it('reuses a hand-made vendor with the same name in different case without editing it', async () => {
|
||||
const vendor = { id: 10, name: 'pRUSA' };
|
||||
const fetch = serveImport([vendor]);
|
||||
expect((await spoolSource.importExternalFilament(ext)).vendorId).toBe('10');
|
||||
expect(vendor).toEqual({ id: 10, name: 'pRUSA' });
|
||||
expect(fetch.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1);
|
||||
expect(fetch.mock.calls.every(([, init]) => !['PATCH', 'PUT'].includes(init?.method ?? ''))).toBe(true);
|
||||
});
|
||||
|
||||
it('prefers an exact external_id over a matching name', async () => {
|
||||
const fetch = serveImport([
|
||||
{ id: 10, name: 'Prusa' },
|
||||
{ id: 20, name: 'Renamed manufacturer', external_id: 'Prusa' }
|
||||
]);
|
||||
expect((await spoolSource.importExternalFilament(ext)).vendorId).toBe('20');
|
||||
expect(fetch.mock.calls.map(([url]) => new URL(url).searchParams.get('external_id'))).toEqual([
|
||||
'"Prusa_PLA"',
|
||||
'"Prusa"',
|
||||
null
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not reuse substring external_ids for vendors or filaments', async () => {
|
||||
const vendors = [{ id: 10, name: 'Prusament', external_id: 'Prusament' }];
|
||||
const fetch = serveImport(vendors, ['Prusa_PLA_Plus']);
|
||||
const imported = await spoolSource.importExternalFilament(ext);
|
||||
expect(imported.id).toBe('200');
|
||||
expect(imported.vendorId).toBe('100');
|
||||
expect(vendors[1]).toEqual({ id: 100, name: 'Prusa', external_id: 'Prusa' });
|
||||
expect(fetch.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns an already imported filament without creating anything', async () => {
|
||||
const fetch = serveImport([], [ext.id]);
|
||||
expect((await spoolSource.importExternalFilament(ext)).id).toBe('1');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(new URL(fetch.mock.calls[0][0]).searchParams.get('external_id')).toBe('"Prusa_PLA"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFilament', () => {
|
||||
it.each([undefined, 0, 0.5])('sends optional numeric values unchanged: %s', async (value) => {
|
||||
const fetch = vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.spool_weight).toBe(value);
|
||||
expect(body.price).toBe(value);
|
||||
expect(body.settings_extruder_temp).toBe(value);
|
||||
expect(body.settings_bed_temp).toBe(value);
|
||||
return Response.json({ id: 1, ...body });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
await spoolSource.createFilament({
|
||||
name: 'Test',
|
||||
vendorName: '',
|
||||
material: 'PLA',
|
||||
density: 1.24,
|
||||
diameter: 1.75,
|
||||
colors: [],
|
||||
spoolWeight: value,
|
||||
price: value,
|
||||
nozzleTemp: value,
|
||||
bedTemp: value
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listFilamentPage', () => {
|
||||
it.each(['asc', 'desc'] as const)(
|
||||
'requests a bounded page in %s order and caches its relations',
|
||||
async (dir) => {
|
||||
const fetch = vi.fn(async () =>
|
||||
Response.json([{ id: 1201, name: 'Page filament', vendor: { id: 1201, name: 'Page vendor' } }], {
|
||||
headers: { 'X-Total-Count': '1500' }
|
||||
})
|
||||
);
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const signal = new AbortController().signal;
|
||||
const page = await spoolSource.listFilamentPage({
|
||||
sort: [{ field: 'id', dir }],
|
||||
limit: 100,
|
||||
offset: 100,
|
||||
signal
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledExactlyOnceWith(
|
||||
`http://spoolman.test/api/v1/filament?sort=id%3A${dir}&limit=100&offset=100`,
|
||||
{ signal }
|
||||
);
|
||||
expect(page.total).toBe(1500);
|
||||
expect(page.items.map((f) => f.id)).toEqual(['1201']);
|
||||
expect(inventory.filamentById('1201')).toEqual(page.items[0]);
|
||||
expect(inventory.vendorById('1201')?.name).toBe('Page vendor');
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps the preview batch API returning an array', async () => {
|
||||
const fetch = vi.fn(async () => Response.json([{ id: 1, name: 'Preview' }]));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
expect((await spoolSource.listFilaments(8)).map((f) => f.id)).toEqual(['1']);
|
||||
expect(fetch).toHaveBeenCalledExactlyOnceWith(
|
||||
'http://spoolman.test/api/v1/filament?sort=id%3Adesc&limit=8&offset=0',
|
||||
{ signal: undefined }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -23,10 +23,11 @@ import {
|
||||
} from './map';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { isDateFilterProp, parseDateFilter, resolveDateFilter } from '$lib/library/dateFilter';
|
||||
import { filamentLabel } from '$lib/utils/library';
|
||||
import { filamentLabel, type FilterOption } from '$lib/utils/library';
|
||||
import type { EntityType } from './fields';
|
||||
import { type ExternalFilament } from './external';
|
||||
import { searchAll } from './search';
|
||||
import { loadLayout } from '$lib/dashboard/layout';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type Json = Record<string, any>;
|
||||
@@ -64,7 +65,11 @@ const FILTER_PARAM: Record<string, string> = {
|
||||
vendor: 'filament.vendor.name',
|
||||
direction: 'filament.multi_color_direction',
|
||||
location: 'location',
|
||||
lot: 'lot_nr'
|
||||
lot: 'lot_nr',
|
||||
// Not in the filter menu, which picks manufacturers by name: the manufacturer
|
||||
// inspector's "Show as a list" filters by id, so two of the same name stay
|
||||
// apart, and the filament list narrows its section totals the same way.
|
||||
vendorId: 'filament.vendor.id'
|
||||
};
|
||||
|
||||
// Filters whose values are passed through verbatim rather than double-quoted for
|
||||
@@ -72,7 +77,8 @@ const FILTER_PARAM: Record<string, string> = {
|
||||
// an int and would reject if quoted. `direction` is passed verbatim so its empty
|
||||
// value (single-color = no direction) hits the backend's NULL-match branch, which
|
||||
// quoting would turn into an exact match on the literal empty string instead.
|
||||
const UNQUOTED_FILTERS = new Set(['filament', 'direction']);
|
||||
// `vendorId` is numeric like `filament`.
|
||||
const UNQUOTED_FILTERS = new Set(['filament', 'direction', 'vendorId']);
|
||||
|
||||
// A filter prop is either one of the fixed categories above or an extra-field
|
||||
// filter whose prop is already the query param the backend expects — `extra.<key>`
|
||||
@@ -166,11 +172,13 @@ class HttpSpoolSource {
|
||||
sort: sortParam(query.sort),
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
allow_archived: query.allowArchived ? 'true' : undefined
|
||||
allow_archived: query.allowArchived ? 'true' : undefined,
|
||||
include_empty: query.includeEmpty ? 'true' : undefined,
|
||||
preview: query.preview ? 'true' : undefined
|
||||
};
|
||||
applyFilters(params, query.filters);
|
||||
|
||||
const { items, total } = await getList('/spool/group', params, query.signal);
|
||||
const { items, total, totalSpools, totalRemaining } = await getList('/spool/group', params, query.signal);
|
||||
for (const g of items as Json[]) {
|
||||
if (g.filament) {
|
||||
inventory.upsertFilament(mapFilament(g.filament));
|
||||
@@ -178,7 +186,7 @@ class HttpSpoolSource {
|
||||
}
|
||||
if (g.vendor) inventory.upsertVendor(mapVendor(g.vendor));
|
||||
}
|
||||
return { items: (items as Json[]).map(mapGroup), total };
|
||||
return { items: (items as Json[]).map(mapGroup), total, totalSpools, totalRemaining };
|
||||
}
|
||||
|
||||
async listSpools(query: SpoolQuery): Promise<Page<Spool>> {
|
||||
@@ -195,20 +203,32 @@ class HttpSpoolSource {
|
||||
return { items: cacheSpools(items), total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a batch of filaments for the label picker/preview, newest first, and
|
||||
* cache them. Filament catalogs are far smaller than spool inventories, so a
|
||||
* single generous page covers virtually every library; the label picker also
|
||||
* has a search box for anything past the cap.
|
||||
*/
|
||||
async listFilaments(limit = 1000, signal?: AbortSignal): Promise<Filament[]> {
|
||||
const items = await getJson<Json[]>('/filament', { limit, sort: 'id:desc' }, signal);
|
||||
const filaments = items.map((f) => {
|
||||
/** Load and cache one page of filaments, including the catalog total. */
|
||||
async listFilamentPage(
|
||||
query: Pick<SpoolQuery, 'sort' | 'limit' | 'offset' | 'signal'>
|
||||
): Promise<Page<Filament>> {
|
||||
const { items, total } = await getList(
|
||||
'/filament',
|
||||
{ sort: sortParam(query.sort), limit: query.limit, offset: query.offset },
|
||||
query.signal
|
||||
);
|
||||
const filaments = (items as Json[]).map((f) => {
|
||||
if (f.vendor) inventory.upsertVendor(mapVendor(f.vendor));
|
||||
return mapFilament(f);
|
||||
});
|
||||
inventory.upsertFilaments(filaments);
|
||||
return filaments;
|
||||
return { items: filaments, total };
|
||||
}
|
||||
|
||||
/** Load a batch for label previews, newest first. */
|
||||
async listFilaments(limit = 1000, signal?: AbortSignal): Promise<Filament[]> {
|
||||
const { items } = await this.listFilamentPage({
|
||||
sort: [{ field: 'id', dir: 'desc' }],
|
||||
limit,
|
||||
offset: 0,
|
||||
signal
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
async searchFilaments(query: string, limit = 8): Promise<Filament[]> {
|
||||
@@ -233,12 +253,18 @@ class HttpSpoolSource {
|
||||
return filaments.map((m) => m.entity).slice(0, limit);
|
||||
}
|
||||
|
||||
async searchExternalFilaments(query: string, limit = 8): Promise<ExternalFilament[]> {
|
||||
async searchExternalFilaments(
|
||||
query: string,
|
||||
limit: number,
|
||||
offset = 0,
|
||||
signal?: AbortSignal
|
||||
): Promise<Page<ExternalFilament>> {
|
||||
// Filtering happens server-side (/external/filament/search) so the whole catalog
|
||||
// — thousands of entries — never has to be downloaded to the client.
|
||||
const q = query.trim();
|
||||
if (!q) return [];
|
||||
return getJson<ExternalFilament[]>('/external/filament/search', { query: q, limit });
|
||||
if (!q) return { items: [], total: 0 };
|
||||
const { items, total } = await getList('/external/filament/search', { query: q, limit, offset }, signal);
|
||||
return { items: items as ExternalFilament[], total };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,7 +273,7 @@ class HttpSpoolSource {
|
||||
*/
|
||||
async importExternalFilament(ext: ExternalFilament): Promise<Filament> {
|
||||
// Already imported?
|
||||
const existing = await getJson<Json[]>('/filament', { external_id: ext.id });
|
||||
const existing = await getJson<Json[]>('/filament', { external_id: quote(ext.id) });
|
||||
if (existing.length) {
|
||||
if (existing[0].vendor) inventory.upsertVendor(mapVendor(existing[0].vendor));
|
||||
const f = mapFilament(existing[0]);
|
||||
@@ -255,19 +281,14 @@ class HttpSpoolSource {
|
||||
return f;
|
||||
}
|
||||
|
||||
// Get-or-create the vendor (matched by external_id = manufacturer name).
|
||||
let vendorId: number;
|
||||
const vendors = await getJson<Json[]>('/vendor', { external_id: ext.manufacturer });
|
||||
// Match the vendor by external_id first, then by name.
|
||||
let vendorId: number | undefined;
|
||||
const vendors = await getJson<Json[]>('/vendor', { external_id: quote(ext.manufacturer) });
|
||||
if (vendors.length) {
|
||||
inventory.upsertVendor(mapVendor(vendors[0]));
|
||||
vendorId = vendors[0].id;
|
||||
} else {
|
||||
const created = await postJson<Json>('/vendor', {
|
||||
name: ext.manufacturer,
|
||||
external_id: ext.manufacturer
|
||||
});
|
||||
inventory.upsertVendor(mapVendor(created));
|
||||
vendorId = created.id;
|
||||
vendorId = await this.getOrCreateVendor(ext.manufacturer, undefined, ext.manufacturer);
|
||||
}
|
||||
|
||||
const body: Json = {
|
||||
@@ -298,10 +319,10 @@ class HttpSpoolSource {
|
||||
/**
|
||||
* Find a vendor by (case-insensitive) name or create it; returns its id.
|
||||
*
|
||||
* `extra` only applies to a vendor this call creates — matching an existing
|
||||
* `extra` and `externalId` only apply to a vendor this call creates — matching an existing
|
||||
* name links that record, it never edits it.
|
||||
*/
|
||||
async getOrCreateVendor(name: string, extra?: Extra): Promise<number | undefined> {
|
||||
async getOrCreateVendor(name: string, extra?: Extra, externalId?: string): Promise<number | undefined> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const all = await getJson<Json[]>('/vendor');
|
||||
@@ -312,6 +333,7 @@ class HttpSpoolSource {
|
||||
}
|
||||
const created = await postJson<Json>('/vendor', {
|
||||
name: trimmed,
|
||||
external_id: externalId,
|
||||
extra: extra && Object.keys(extra).length ? extra : undefined
|
||||
});
|
||||
inventory.upsertVendor(mapVendor(created));
|
||||
@@ -328,10 +350,10 @@ class HttpSpoolSource {
|
||||
density: draft.density,
|
||||
diameter: draft.diameter,
|
||||
weight: draft.weight || undefined,
|
||||
spool_weight: draft.spoolWeight || undefined,
|
||||
settings_extruder_temp: draft.nozzleTemp || undefined,
|
||||
settings_bed_temp: draft.bedTemp || undefined,
|
||||
price: draft.price || undefined,
|
||||
spool_weight: draft.spoolWeight,
|
||||
settings_extruder_temp: draft.nozzleTemp,
|
||||
settings_bed_temp: draft.bedTemp,
|
||||
price: draft.price,
|
||||
article_number: draft.articleNumber || undefined,
|
||||
comment: draft.comment || undefined,
|
||||
extra: draft.extra && Object.keys(draft.extra).length ? draft.extra : undefined,
|
||||
@@ -492,20 +514,30 @@ class HttpSpoolSource {
|
||||
return getJson<string[]>('/material');
|
||||
}
|
||||
/**
|
||||
* Every filament as a `{ value: id, label }` option for the "filament" filter,
|
||||
* so a spool listing can be narrowed to one specific filament (e.g. a given
|
||||
* colour from a given manufacturer). Filaments are cached so the chosen chip
|
||||
* can render its label from the reactive store.
|
||||
* Every filament as an option for the "filament" filter, so a spool listing can
|
||||
* be narrowed to one specific filament (e.g. a given colour from a given
|
||||
* manufacturer). Each option carries the filament's colours and material as
|
||||
* well as its label, since the menu shows all three. Filaments are cached so
|
||||
* the chosen chip can render its label from the reactive store.
|
||||
*/
|
||||
async filamentOptions(): Promise<{ value: string; label: string }[]> {
|
||||
async filamentOptions(): Promise<FilterOption[]> {
|
||||
const items = await getJson<Json[]>('/filament');
|
||||
const filaments = items.map((f) => {
|
||||
if (f.vendor) inventory.upsertVendor(mapVendor(f.vendor));
|
||||
return mapFilament(f);
|
||||
});
|
||||
inventory.upsertFilaments(filaments);
|
||||
// The label already carries the manufacturer, so the material is what's left
|
||||
// to tell two same-named filaments apart; the colors are what the eye goes
|
||||
// to first. Both are free here — this fetches whole filaments anyway.
|
||||
return filaments
|
||||
.map((f) => ({ value: f.id, label: filamentLabel(f, inventory.vendorOf(f)) }))
|
||||
.map((f) => ({
|
||||
value: f.id,
|
||||
label: filamentLabel(f, inventory.vendorOf(f)),
|
||||
meta: f.material,
|
||||
colors: f.colors,
|
||||
direction: f.multiColorDirection
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
/** Distinct values currently stored for a scalar extra field (text/single-choice). */
|
||||
@@ -515,6 +547,16 @@ class HttpSpoolSource {
|
||||
async locations(): Promise<string[]> {
|
||||
return getJson<string[]>('/location');
|
||||
}
|
||||
/**
|
||||
* Locations to offer when placing a spool. GET /location only knows the ones that
|
||||
* hold a spool right now, so the empty ones kept as dashboard cards (an idle
|
||||
* toolhead, a new shelf) are added from the saved layout, as the old client did
|
||||
* with its `locations` setting (#1261).
|
||||
*/
|
||||
async locationChoices(): Promise<string[]> {
|
||||
const [inUse, layout] = await Promise.all([this.locations(), loadLayout().catch(() => null)]);
|
||||
return [...new Set([...inUse, ...(layout?.groups.location ?? [])])].filter(Boolean).sort();
|
||||
}
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { asTagConflict, isBadUid } from './tags';
|
||||
import { HttpError } from './http';
|
||||
|
||||
// Linking a tag something else already holds is the one failure the UI has to do
|
||||
// something with rather than merely report: the 409 carries the id of the spool
|
||||
// or filament that holds it, which is what lets the dialog offer to move the tag
|
||||
// instead of making the user go and find it. These read that body.
|
||||
describe('asTagConflict', () => {
|
||||
it('reads the holding spool out of a 409', () => {
|
||||
const err = new HttpError('POST /spool/2/tag → 409', 409, {
|
||||
message: 'Tag 04A2B3C4 is already linked to spool 1.',
|
||||
spool_id: 1,
|
||||
filament_id: null
|
||||
});
|
||||
expect(asTagConflict(err)).toEqual({
|
||||
holder: { kind: 'spool', id: 1 },
|
||||
message: 'Tag 04A2B3C4 is already linked to spool 1.'
|
||||
});
|
||||
});
|
||||
|
||||
// Filament ids are strings everywhere else in the client, so the holder's is too.
|
||||
it('reads the holding filament out of a 409', () => {
|
||||
const err = new HttpError('POST /spool/2/tag → 409', 409, {
|
||||
message: 'Tag 04A2B3C4 is already linked to filament 7.',
|
||||
spool_id: null,
|
||||
filament_id: 7
|
||||
});
|
||||
expect(asTagConflict(err)).toEqual({
|
||||
holder: { kind: 'filament', id: '7' },
|
||||
message: 'Tag 04A2B3C4 is already linked to filament 7.'
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores failures that are not conflicts', () => {
|
||||
expect(asTagConflict(new HttpError('POST /spool/2/tag → 404', 404, {}))).toBeNull();
|
||||
expect(asTagConflict(new Error('network'))).toBeNull();
|
||||
});
|
||||
|
||||
// A 409 we can't act on is worse than no offer: "move it here" with nowhere to
|
||||
// move it from would fail on the click. It falls back to an ordinary error.
|
||||
it('declines a 409 with no usable holder id', () => {
|
||||
expect(asTagConflict(new HttpError('x', 409, { message: 'nope' }))).toBeNull();
|
||||
expect(asTagConflict(new HttpError('x', 409, { spool_id: '1' }))).toBeNull();
|
||||
expect(asTagConflict(new HttpError('x', 409, { filament_id: '7' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// A UID the server won't accept comes back two different ways — 400 from the
|
||||
// handler for non-hex, 422 from the model for an empty string — and both mean the
|
||||
// same thing to someone who typed it, so the UI treats them as one case.
|
||||
describe('isBadUid', () => {
|
||||
it('accepts both shapes of "that UID is not usable"', () => {
|
||||
expect(isBadUid(new HttpError('x', 400, {}))).toBe(true);
|
||||
expect(isBadUid(new HttpError('x', 422, {}))).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves other failures alone', () => {
|
||||
expect(isBadUid(new HttpError('x', 404, {}))).toBe(false);
|
||||
expect(isBadUid(new HttpError('x', 409, {}))).toBe(false);
|
||||
expect(isBadUid(new Error('network'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { Filament, Spool, Tag } from '$lib/types';
|
||||
import { getList, postJson, deleteResource, HttpError } from './http';
|
||||
import { mapSpool, mapFilament, mapVendor } from './map';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
|
||||
// Linking physical NFC/RFID tags to spools and filaments.
|
||||
//
|
||||
// A tag identifies exactly one spool or filament and is keyed by its hardware
|
||||
// UID, which the server normalizes: `04:a2:b3`, `04-A2-B3` and `04a2b3` are one
|
||||
// tag. Nothing here normalizes anything itself — the canonical spelling comes
|
||||
// back in the response, and rendering that rather than what was typed is what
|
||||
// keeps the UI honest about which tag was actually touched.
|
||||
//
|
||||
// Neither link nor unlink updates the cache. Both emit the holder's ordinary
|
||||
// `updated` event, so liveSync ingests the new tag list and every open inspector
|
||||
// re-renders on its own; patching local state here as well would just race it.
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type Json = Record<string, any>;
|
||||
|
||||
/** What a tag can identify. */
|
||||
export type TagKind = 'spool' | 'filament';
|
||||
|
||||
/**
|
||||
* A spool or filament as the tag endpoints address it. Spool ids are numbers and
|
||||
* filament ids strings in the client; both go into the path as they are.
|
||||
*/
|
||||
export interface TagTarget {
|
||||
kind: TagKind;
|
||||
id: number | string;
|
||||
}
|
||||
|
||||
/** Whatever holds a tag, loaded, so the UI can name it. */
|
||||
export type TagHolder = { kind: 'spool'; spool: Spool } | { kind: 'filament'; filament: Filament };
|
||||
|
||||
export function holderTarget(holder: TagHolder): TagTarget {
|
||||
return holder.kind === 'spool'
|
||||
? { kind: 'spool', id: holder.spool.id }
|
||||
: { kind: 'filament', id: holder.filament.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag formats the server knows the name of (`spoolman/tags.py`). Informational
|
||||
* and not enforced — new tag types appear faster than releases do — so this is a
|
||||
* vocabulary to spell the common ones consistently, never a validation list.
|
||||
*/
|
||||
export const KNOWN_FORMATS = [
|
||||
'openprinttag',
|
||||
'opentag3d',
|
||||
'ntag',
|
||||
'bambu',
|
||||
'tigertag',
|
||||
'qidi',
|
||||
'creality',
|
||||
'prusa'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* What already holds a UID, carried by the 409 from `linkTag`. It is on the
|
||||
* error body so the UI can offer to move the tag without looking it up.
|
||||
*/
|
||||
export interface TagConflict {
|
||||
holder: TagTarget;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a 409 from `linkTag` as the conflict it describes, or null for any other
|
||||
* failure. A 409 that names neither a spool nor a filament by id counts as "not a
|
||||
* conflict we can act on" and falls back to being reported as an ordinary error.
|
||||
*/
|
||||
export function asTagConflict(err: unknown): TagConflict | null {
|
||||
if (!(err instanceof HttpError) || err.status !== 409) return null;
|
||||
const message = typeof err.body?.message === 'string' ? err.body.message : err.message;
|
||||
const spoolId = err.body?.spool_id;
|
||||
if (typeof spoolId === 'number') return { holder: { kind: 'spool', id: spoolId }, message };
|
||||
const filamentId = err.body?.filament_id;
|
||||
if (typeof filamentId === 'number') {
|
||||
return { holder: { kind: 'filament', id: String(filamentId) }, message };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a failure means the UID itself was unusable rather than the request
|
||||
* being wrong about anything else. The server answers a non-hex UID with 400 and
|
||||
* an empty one with 422 (it fails `min_length` before the handler sees it); both
|
||||
* mean the same thing to a user who typed it, so they read as one case.
|
||||
*/
|
||||
export function isBadUid(err: unknown): boolean {
|
||||
return err instanceof HttpError && (err.status === 400 || err.status === 422);
|
||||
}
|
||||
|
||||
function tagPath(target: TagTarget): string {
|
||||
return `/${target.kind}/${target.id}/tag`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a tag to a spool or filament. Re-linking a UID it already holds succeeds
|
||||
* and changes nothing (passing a `format` refines the stored one), so this is
|
||||
* safe to call without checking first.
|
||||
*
|
||||
* Rejects with a 409 when anything else holds the UID — see `asTagConflict`.
|
||||
*/
|
||||
export async function linkTag(target: TagTarget, uid: string, format?: string): Promise<Tag> {
|
||||
const body: Json = { uid };
|
||||
if (format) body.format = format;
|
||||
const tag = await postJson<Json>(tagPath(target), body);
|
||||
return { uid: tag.uid, format: tag.format ?? undefined, added: tag.added };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a tag from the spool or filament holding it. The UID may be in any
|
||||
* spelling; the server normalizes before matching.
|
||||
*/
|
||||
export async function unlinkTag(target: TagTarget, uid: string): Promise<void> {
|
||||
await deleteResource(`${tagPath(target)}/${encodeURIComponent(uid)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The spool or filament a UID is linked to, or undefined when nothing holds it.
|
||||
*
|
||||
* Both kinds are asked at once. A UID is unique across them, so at most one
|
||||
* answers, and asking in parallel keeps the lookup as quick as asking one.
|
||||
*
|
||||
* Archived spools are included: this answers "is this tag already spoken for",
|
||||
* and a tag on an archived spool is still spoken for — leaving them out would
|
||||
* report a UID as free that linking then rejects with a 409.
|
||||
*
|
||||
* A malformed UID is a 400 rather than an empty result, and is left to the
|
||||
* caller (`isBadUid`) rather than flattened into "not found", so a typo is not
|
||||
* reported to the user as an unknown tag.
|
||||
*/
|
||||
export async function findTagHolder(uid: string, signal?: AbortSignal): Promise<TagHolder | undefined> {
|
||||
const [spools, filaments] = await Promise.all([
|
||||
getList('/spool', { tag: uid, allow_archived: 'true', limit: 1 }, signal),
|
||||
getList('/filament', { tag: uid, limit: 1 }, signal)
|
||||
]);
|
||||
const rawSpool = spools.items[0] as Json | undefined;
|
||||
if (rawSpool) {
|
||||
// Seed the cache the way every other spool read does, so the inspector this
|
||||
// may be about to open has its filament and manufacturer already.
|
||||
if (rawSpool.filament) {
|
||||
inventory.upsertFilament(mapFilament(rawSpool.filament));
|
||||
if (rawSpool.filament.vendor) inventory.upsertVendor(mapVendor(rawSpool.filament.vendor));
|
||||
}
|
||||
const spool = mapSpool(rawSpool);
|
||||
inventory.upsertSpool(spool);
|
||||
return { kind: 'spool', spool };
|
||||
}
|
||||
const rawFilament = filaments.items[0] as Json | undefined;
|
||||
if (rawFilament) {
|
||||
if (rawFilament.vendor) inventory.upsertVendor(mapVendor(rawFilament.vendor));
|
||||
const filament = mapFilament(rawFilament);
|
||||
inventory.upsertFilament(filament);
|
||||
return { kind: 'filament', filament };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -45,6 +45,27 @@ export interface GroupSummary {
|
||||
lastUsedLabel: string;
|
||||
/** Numeric recency for group ordering (higher = more recent). */
|
||||
lastUsedSort: number;
|
||||
/** Vendor groups only: how many filaments the group spans — every matching
|
||||
* filament of the manufacturer when includeEmpty, else those with spools. */
|
||||
filamentCount?: number;
|
||||
/** Filament groups, with GroupQuery.preview: the first few spools (by id). */
|
||||
previewSpools?: SpoolPreview[];
|
||||
/** Vendor groups, with GroupQuery.preview: the first few filaments (by name). */
|
||||
previewFilaments?: FilamentPreview[];
|
||||
}
|
||||
|
||||
/** Just enough of a spool to draw its fill pip. Grams; null when unknown. */
|
||||
export interface SpoolPreview {
|
||||
id: string;
|
||||
remaining: number | null;
|
||||
initial: number | null;
|
||||
}
|
||||
|
||||
/** Just enough of a filament to draw its swatch. */
|
||||
export interface FilamentPreview {
|
||||
id: string;
|
||||
colors: string[];
|
||||
direction?: MultiColorDirection;
|
||||
}
|
||||
|
||||
export interface GroupScope {
|
||||
@@ -74,6 +95,11 @@ export interface GroupQuery {
|
||||
sort: SortField[];
|
||||
/** Include archived spools in the group aggregates (default: excluded). */
|
||||
allowArchived?: boolean;
|
||||
/** List matching filaments (filament grouping) or manufacturers (vendor
|
||||
* grouping) that hold no matching spools, as groups of zero. */
|
||||
includeEmpty?: boolean;
|
||||
/** Attach previewSpools / previewFilaments to filament / vendor groups. */
|
||||
preview?: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
lowThreshold: number;
|
||||
@@ -85,6 +111,10 @@ export interface Page<T> {
|
||||
items: T[];
|
||||
/** Total matching rows across all pages (from X-Total-Count). */
|
||||
total: number;
|
||||
/** Group pages only: spools and remaining grams summed over ALL matching
|
||||
* groups, not just this page (X-Total-Spools / X-Total-Remaining-Weight). */
|
||||
totalSpools?: number;
|
||||
totalRemaining?: number;
|
||||
}
|
||||
|
||||
// --- cross-entity search (GET /search) -----------------------------------
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { untrack, tick } from 'svelte';
|
||||
import Swatch from './Swatch.svelte';
|
||||
import ColorEditor from './ColorEditor.svelte';
|
||||
import Button from './Button.svelte';
|
||||
import NumberInput from './NumberInput.svelte';
|
||||
import Combobox from './Combobox.svelte';
|
||||
import DateTimeField from './DateTimeField.svelte';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import ExtraFieldsSection from './ExtraFieldsSection.svelte';
|
||||
import NewFilamentCards from './NewFilamentCards.svelte';
|
||||
import type { Filament, Extra, MultiColorDirection } from '$lib/types';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import { serverInfo } from '$lib/stores/serverInfo.svelte';
|
||||
import { spoolSource, type NewFilamentDraft } from '$lib/api/spoolSource';
|
||||
import { spoolSource } 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 { ExternalSearch } from '$lib/api/externalSearch.svelte';
|
||||
import { roundGrams, weightAuto } from '$lib/utils/format';
|
||||
import { numErr } from '$lib/utils/validate';
|
||||
import { parseDecimal } from '$lib/utils/numeric';
|
||||
import { loadMaterials, type MaterialSpec } from '$lib/data/materials';
|
||||
import {
|
||||
FILAMENT_ADVANCED_KEYS,
|
||||
FILAMENT_FIELD_ORDER,
|
||||
emptyFilamentDraft,
|
||||
filamentDraftErrors,
|
||||
filamentDraftFrom,
|
||||
filamentWeightsFrom,
|
||||
toNewFilamentDraft,
|
||||
type FilamentDraft
|
||||
} from '$lib/filament/draft';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Props {
|
||||
@@ -42,9 +51,8 @@
|
||||
let query = $state('');
|
||||
let searchInput = $state<HTMLInputElement | undefined>();
|
||||
let localResults = $state<Filament[]>([]);
|
||||
let externalResults = $state<ExternalFilament[]>([]);
|
||||
const extSearch = new ExternalSearch();
|
||||
let searching = $state(false);
|
||||
let extError = $state(false);
|
||||
let chosen = $state<Choice | null>(null);
|
||||
let creating = $state(false);
|
||||
let submitting = $state(false);
|
||||
@@ -53,28 +61,19 @@
|
||||
// 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({
|
||||
vendorName: '',
|
||||
name: '',
|
||||
material: '',
|
||||
colors: [] as string[],
|
||||
multiColorDirection: undefined as MultiColorDirection | undefined,
|
||||
density: '',
|
||||
diameter: '1.75',
|
||||
nozzleTemp: '',
|
||||
bedTemp: '',
|
||||
articleNumber: '',
|
||||
comment: ''
|
||||
});
|
||||
// New-filament fields, drawn by NewFilamentCards. The weight/spool weight/price
|
||||
// this flow would put on the filament are `netWeight`/`spoolWeight`/`price`
|
||||
// below: they are written to the spool too, so they live in the spool block.
|
||||
let nf = $state<FilamentDraft>(emptyFilamentDraft());
|
||||
// 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>({});
|
||||
// Same, for a manufacturer this form creates. Only ever sent when the typed name
|
||||
// is a new one — linking an existing manufacturer must not edit its fields.
|
||||
let vendorExtra = $state<Extra>({});
|
||||
// Reported back by NewFilamentCards, which owns the manufacturer list.
|
||||
let vendorIsNew = $state(false);
|
||||
let showAdvanced = $state(false);
|
||||
let nameInput = $state<HTMLInputElement | undefined>();
|
||||
let modalEl = $state<HTMLDivElement | undefined>();
|
||||
// When to *show* an error, as opposed to have one: a field is revealed once the
|
||||
// user has left it, and everything is revealed once Add has been pressed. A form
|
||||
@@ -83,9 +82,6 @@
|
||||
// the form as it stands.
|
||||
let touched = $state<Record<string, boolean>>({});
|
||||
let attempted = $state(false);
|
||||
let vendorNames = $state<string[]>([]);
|
||||
let materialNames = $state<string[]>([]);
|
||||
let materialSpecs = $state<Record<string, MaterialSpec>>({});
|
||||
|
||||
// --- display helpers for a chosen (existing) filament ------------------
|
||||
function cName(c: Choice) {
|
||||
@@ -128,17 +124,11 @@
|
||||
}
|
||||
async function runSearch() {
|
||||
searching = true;
|
||||
extError = false;
|
||||
const [local, external] = await Promise.allSettled([
|
||||
const [local] = await Promise.allSettled([
|
||||
spoolSource.searchFilaments(query.trim()),
|
||||
spoolSource.searchExternalFilaments(query.trim())
|
||||
extSearch.search(query)
|
||||
]);
|
||||
localResults = local.status === 'fulfilled' ? local.value : [];
|
||||
if (external.status === 'fulfilled') externalResults = external.value;
|
||||
else {
|
||||
externalResults = [];
|
||||
extError = true;
|
||||
}
|
||||
searching = false;
|
||||
}
|
||||
|
||||
@@ -151,17 +141,9 @@
|
||||
fields.ensure('filament');
|
||||
fields.ensure('vendor');
|
||||
spoolSource
|
||||
.locations()
|
||||
.locationChoices()
|
||||
.then((l) => (locations = l))
|
||||
.catch(() => {});
|
||||
spoolSource
|
||||
.vendorNames()
|
||||
.then((vn) => (vendorNames = vn))
|
||||
.catch(() => {});
|
||||
loadMaterials().then(({ names, specs }) => {
|
||||
materialNames = names;
|
||||
materialSpecs = specs;
|
||||
});
|
||||
if (presetFilamentId) {
|
||||
const f = inventory.filamentById(presetFilamentId);
|
||||
if (f) choose({ source: 'catalog', filament: f });
|
||||
@@ -181,16 +163,6 @@
|
||||
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');
|
||||
@@ -306,9 +278,9 @@
|
||||
chosen = c;
|
||||
netWeight = String(cWeight(c) || 1000);
|
||||
const sw = cSpoolWeight(c);
|
||||
spoolWeight = sw ? String(sw) : '';
|
||||
spoolWeight = sw != null ? String(sw) : '';
|
||||
const p = cPrice(c);
|
||||
price = p ? String(p) : '';
|
||||
price = p != null ? String(p) : '';
|
||||
resetSpoolForm();
|
||||
step = 2;
|
||||
}
|
||||
@@ -318,19 +290,7 @@
|
||||
cloneSource = null;
|
||||
chosen = null;
|
||||
showAdvanced = false;
|
||||
nf = {
|
||||
vendorName: '',
|
||||
name: query.trim(),
|
||||
material: '',
|
||||
colors: [],
|
||||
multiColorDirection: undefined,
|
||||
density: '',
|
||||
diameter: '1.75',
|
||||
nozzleTemp: '',
|
||||
bedTemp: '',
|
||||
articleNumber: '',
|
||||
comment: ''
|
||||
};
|
||||
nf = emptyFilamentDraft(query.trim());
|
||||
filamentExtra = withDefaults('filament', {});
|
||||
vendorExtra = withDefaults('vendor', {});
|
||||
netWeight = '1000';
|
||||
@@ -357,64 +317,24 @@
|
||||
// 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
|
||||
};
|
||||
nf = filamentDraftFrom(f, inventory.vendorById(f.vendorId)?.name ?? '');
|
||||
filamentExtra = withDefaults('filament', { ...f.extra });
|
||||
// Not copied from the source's manufacturer: a duplicate keeps that same
|
||||
// manufacturer record, and these values only ever reach a newly created one.
|
||||
vendorExtra = withDefaults('vendor', {});
|
||||
netWeight = String(f.weight || 1000);
|
||||
spoolWeight = f.spoolWeight ? String(f.spoolWeight) : '';
|
||||
price = f.price ? String(f.price) : '';
|
||||
const w = filamentWeightsFrom(f);
|
||||
netWeight = w.weight;
|
||||
spoolWeight = w.spoolWeight;
|
||||
price = w.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()));
|
||||
let vendorHint = $derived(
|
||||
vendorTrimmed === ''
|
||||
? m['add.vendorHint.optional']()
|
||||
: vendorMatch
|
||||
? m['add.vendorHint.existing']({ name: vendorMatch })
|
||||
: m['add.vendorHint.new']({ name: vendorTrimmed })
|
||||
);
|
||||
// Accents the manufacturer hint when the typed name will create a second record
|
||||
// alongside the filament, rather than link an existing one.
|
||||
let vendorIsNew = $derived(vendorTrimmed !== '' && !vendorMatch);
|
||||
// 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()];
|
||||
// Only prefill when the material is a known one; typing a custom material
|
||||
// leaves density/temps untouched.
|
||||
if (spec) {
|
||||
nf.density = String(spec.density);
|
||||
if (spec.nozzle != null) nf.nozzleTemp = String(spec.nozzle);
|
||||
if (spec.bed != null) nf.bedTemp = String(spec.bed);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
step = 1;
|
||||
query = '';
|
||||
localResults = [];
|
||||
externalResults = [];
|
||||
extSearch.clear();
|
||||
chosen = null;
|
||||
creating = false;
|
||||
cloneSource = null;
|
||||
@@ -444,24 +364,12 @@
|
||||
// next colour of it without going back through search.
|
||||
let created: Filament | null = null;
|
||||
if (creating) {
|
||||
const draft: NewFilamentDraft = {
|
||||
name: nf.name.trim(),
|
||||
vendorName: nf.vendorName.trim(),
|
||||
material: nf.material.trim(),
|
||||
density: Number(nf.density),
|
||||
diameter: Number(nf.diameter) || 1.75,
|
||||
weight: Number(netWeight) || undefined,
|
||||
spoolWeight: Number(spoolWeight) || undefined,
|
||||
colors: nf.colors,
|
||||
multiColorDirection: nf.multiColorDirection,
|
||||
nozzleTemp: nf.nozzleTemp ? Number(nf.nozzleTemp) : undefined,
|
||||
bedTemp: nf.bedTemp ? Number(nf.bedTemp) : undefined,
|
||||
price: parseFloat(price) || undefined,
|
||||
articleNumber: nf.articleNumber.trim() || undefined,
|
||||
comment: nf.comment.trim() || undefined,
|
||||
extra: filamentExtra,
|
||||
// Weight/spool weight/price come from the spool block: this flow writes
|
||||
// each of them to both records.
|
||||
const draft = {
|
||||
...toNewFilamentDraft(nf, { weight: netWeight, spoolWeight, price }, filamentExtra),
|
||||
// Dropped when the typed name matches an existing manufacturer —
|
||||
// which is also when the inputs below aren't shown.
|
||||
// which is also when the inputs for it aren't shown.
|
||||
vendorExtra: vendorIsNew ? vendorExtra : undefined
|
||||
};
|
||||
const f = await spoolSource.createFilament(draft);
|
||||
@@ -475,24 +383,24 @@
|
||||
}
|
||||
|
||||
const n = countN;
|
||||
const net = Number(netWeight) || 0;
|
||||
const spool = Number(spoolWeight) || 0;
|
||||
const net = parseDecimal(netWeight) ?? 0;
|
||||
const spool = parseDecimal(spoolWeight) ?? 0;
|
||||
const body: Record<string, unknown> = {
|
||||
filament_id: filamentId,
|
||||
initial_weight: Number(netWeight) || undefined,
|
||||
spool_weight: Number(spoolWeight) || undefined,
|
||||
price: parseFloat(price) || undefined,
|
||||
initial_weight: parseDecimal(netWeight) ?? undefined,
|
||||
spool_weight: parseDecimal(spoolWeight) ?? undefined,
|
||||
price: parseDecimal(price) ?? undefined,
|
||||
location: location.trim() || undefined,
|
||||
lot_nr: lot.trim() || undefined,
|
||||
comment: comment.trim() || undefined
|
||||
};
|
||||
if (fillMode === 'used') body.used_weight = Number(fillWeight) || 0;
|
||||
else if (fillMode === 'remaining') body.remaining_weight = Number(fillWeight) || 0;
|
||||
else if (fillMode === 'measured')
|
||||
if (fillMode === 'used') body.used_weight = parseDecimal(fillWeight) ?? undefined;
|
||||
else if (fillMode === 'remaining') body.remaining_weight = parseDecimal(fillWeight) ?? undefined;
|
||||
else if (fillMode === 'measured' && parseDecimal(fillWeight) !== null)
|
||||
// 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)));
|
||||
body.used_weight = roundGrams(Math.max(0, net + spool - (parseDecimal(fillWeight) ?? 0)));
|
||||
if (firstUsed) body.first_used = firstUsed;
|
||||
if (lastUsed) body.last_used = lastUsed;
|
||||
if (Object.keys(extraValues).length) body.extra = extraValues;
|
||||
@@ -519,43 +427,12 @@
|
||||
let summary = $derived(chosen || creating ? m['add.summary']({ count: countN }) : '');
|
||||
|
||||
// --- validation ---------------------------------------------------------
|
||||
// Mirrors the filament creation API (spoolman/api/v1/filament.py):
|
||||
// density & diameter are required and must be > 0; name/material ≤ 64 chars;
|
||||
// weight > 0, spool_weight/price ≥ 0; color_hex must be 6 or 8 hex chars.
|
||||
// Material is the one field this form is stricter about than the API, which
|
||||
// accepts a null one. Leaving it blank is nearly always an oversight, and it
|
||||
// takes density down with it: density is only prefilled for a material we
|
||||
// recognise, so a blank material surfaced as "Density is required" — an error
|
||||
// on the field the user didn't fill in *because of* the field they did miss.
|
||||
function numErr(
|
||||
v: string,
|
||||
{ required = false, min, max, gt }: { required?: boolean; min?: number; max?: number; gt?: number } = {}
|
||||
) {
|
||||
const t = v.trim();
|
||||
if (t === '') return required ? m['validation.required']() : '';
|
||||
const n = parseDecimal(t);
|
||||
if (n === null) return m['validation.mustBeNumber']();
|
||||
if (gt != null && n <= gt) return m['validation.mustBeGt']({ value: gt });
|
||||
if (min != null && n < min) return m['validation.mustBeMin']({ value: min });
|
||||
if (max != null && n > max) return m['validation.mustBeMax']({ value: max });
|
||||
return '';
|
||||
}
|
||||
const HEX_RE = /^#?[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/;
|
||||
|
||||
// The filament half lives in filamentDraftErrors() — including the one rule
|
||||
// this client is stricter about than the API, a required material. The spool
|
||||
// fields below mirror the spool creation API (spoolman/api/v1/spool.py):
|
||||
// weight > 0, spool_weight/price ≥ 0, and a fill amount that fits inside them.
|
||||
let errors = $derived.by(() => {
|
||||
const e: Record<string, string> = {};
|
||||
if (creating) {
|
||||
if (nf.name.trim().length === 0) e.name = m['validation.required']();
|
||||
else if (nf.name.trim().length > 64) e.name = m['validation.maxChars']({ max: 64 });
|
||||
if (nf.material.trim().length === 0) e.material = m['validation.required']();
|
||||
else if (nf.material.trim().length > 64) e.material = m['validation.maxChars']({ max: 64 });
|
||||
if (nf.vendorName.trim().length > 64) e.vendor = m['validation.maxChars']({ max: 64 });
|
||||
e.density = numErr(nf.density, { required: true, gt: 0 });
|
||||
e.diameter = numErr(nf.diameter, { required: true, gt: 0 });
|
||||
e.nozzleTemp = numErr(nf.nozzleTemp, { min: 0 });
|
||||
e.bedTemp = numErr(nf.bedTemp, { min: 0 });
|
||||
if (nf.colors.some((c) => c.trim() && !HEX_RE.test(c.trim()))) e.colorHex = m['validation.hexDigits']();
|
||||
}
|
||||
const e: Record<string, string> = creating ? filamentDraftErrors(nf) : {};
|
||||
e.count = numErr(count, { required: true, gt: 0 });
|
||||
e.netWeight = numErr(netWeight, { gt: 0 });
|
||||
e.spoolWeight = numErr(spoolWeight, { min: 0 });
|
||||
@@ -564,9 +441,9 @@
|
||||
// Cross-check the fill amount against the net/empty-spool weights above.
|
||||
// Only applies the upper bound when those weights are themselves valid so
|
||||
// we don't cascade an unrelated error into this field.
|
||||
const netN = Number(netWeight);
|
||||
const netValid = netWeight.trim() !== '' && Number.isFinite(netN) && netN > 0;
|
||||
const spoolN = Number.isFinite(Number(spoolWeight)) ? Number(spoolWeight) : 0;
|
||||
const netN = parseDecimal(netWeight);
|
||||
const netValid = netN !== null && netN > 0;
|
||||
const spoolN = parseDecimal(spoolWeight) ?? 0;
|
||||
if (fillMode === 'measured') {
|
||||
// Weight on the scale = filament left + empty spool, so it can be at most
|
||||
// net+spool (full) and at least the empty-spool weight (nothing left).
|
||||
@@ -587,27 +464,13 @@
|
||||
// --- pointing at what's wrong -------------------------------------------
|
||||
// The fields that can carry an error, in the order they appear on the form, so
|
||||
// pressing Add sends you to the first one you'd have reached by reading down.
|
||||
// Anything not listed falls back to whatever the error map yields, so a new
|
||||
// error can never make Add silently do nothing.
|
||||
const FIELD_ORDER = [
|
||||
'vendor',
|
||||
'name',
|
||||
'material',
|
||||
'colorHex',
|
||||
'density',
|
||||
'diameter',
|
||||
'nozzleTemp',
|
||||
'bedTemp',
|
||||
'count',
|
||||
'netWeight',
|
||||
'fillWeight',
|
||||
'spoolWeight',
|
||||
'price'
|
||||
];
|
||||
// Fields inside the collapsed "Advanced specs" block. An error in here is
|
||||
// invisible until the block is opened — the one case where the form really could
|
||||
// look complete and still refuse to submit — so jumping to one has to open it.
|
||||
const ADVANCED_KEYS = new Set(['density', 'diameter', 'nozzleTemp', 'bedTemp']);
|
||||
// The filament half comes from the shared card component; the spool fields this
|
||||
// modal draws itself follow. Anything not listed falls back to whatever the
|
||||
// error map yields, so a new error can never make Add silently do nothing.
|
||||
const FIELD_ORDER = [...FILAMENT_FIELD_ORDER, 'count', 'netWeight', 'fillWeight', 'spoolWeight', 'price'];
|
||||
// This modal draws the weight trio in its own spool block, so the only fields
|
||||
// hidden behind the disclosure are the filament specs.
|
||||
const ADVANCED_KEYS = new Set(FILAMENT_ADVANCED_KEYS);
|
||||
|
||||
let firstProblem = $derived(FIELD_ORDER.find((k) => errors[k]) ?? Object.keys(errors)[0]);
|
||||
|
||||
@@ -668,7 +531,7 @@
|
||||
oninput={(e) => onSearch(e.currentTarget.value)}
|
||||
placeholder={m['add.searchPlaceholder']({ name: serverInfo.externalDbName })}
|
||||
/>
|
||||
<div class="results">
|
||||
<div class="results" onscroll={extSearch.onscroll}>
|
||||
<div class="res-hdr">{m['add.yourCatalog']()}</div>
|
||||
{#if searching && localResults.length === 0}
|
||||
<div class="res-note">{m['add.searching']()}</div>
|
||||
@@ -694,16 +557,16 @@
|
||||
{/if}
|
||||
|
||||
<div class="res-hdr"><span class="hdr-note">{serverInfo.externalDbName}</span></div>
|
||||
{#if extError}
|
||||
{#if extSearch.error}
|
||||
<div class="res-note">{m['add.dbUnavailable']({ name: serverInfo.externalDbName })}</div>
|
||||
{:else if searching && externalResults.length === 0}
|
||||
{:else if searching && extSearch.items.length === 0}
|
||||
<div class="res-note">{m['add.searching']()}</div>
|
||||
{:else if externalResults.length === 0}
|
||||
{:else if extSearch.items.length === 0}
|
||||
<div class="res-note">
|
||||
{query.trim() ? m['add.typeToSearchMatches']() : m['add.typeToSearchAll']()}
|
||||
</div>
|
||||
{:else}
|
||||
{#each externalResults as ext (ext.id)}
|
||||
{#each extSearch.items as ext (ext.id)}
|
||||
<button class="res-item" onclick={() => choose({ source: 'external', ext })}>
|
||||
<Swatch
|
||||
colors={externalColors(ext)}
|
||||
@@ -725,6 +588,9 @@
|
||||
<span class="tag external">{serverInfo.externalDbName}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if extSearch.loadingMore}
|
||||
<div class="res-note">{m['add.searching']()}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -744,169 +610,20 @@
|
||||
plain remainder of the form, since a spool is always being created and
|
||||
needs no such emphasis. -->
|
||||
{#if creating}
|
||||
<!-- Manufacturer: its own record, so its own card — a vendor combobox
|
||||
sitting among the filament fields, quietly creating a second entity,
|
||||
was the most confusing part of the flow. -->
|
||||
<section class="new-section man-section">
|
||||
<label class="ent-field" data-field="vendor" onfocusout={() => touch('vendor')}>
|
||||
<span class="ent-label">{m['add.section.manufacturer']()}</span>
|
||||
<span class="ent-note">{m['add.section.manufacturerNote']()}</span>
|
||||
<Combobox
|
||||
value={nf.vendorName}
|
||||
options={vendorNames}
|
||||
placeholder={m['add.manufacturerPlaceholder']()}
|
||||
invalid={!!err('vendor')}
|
||||
oninput={(v) => (nf.vendorName = v)}
|
||||
/>
|
||||
{#if err('vendor')}
|
||||
<span class="err">{err('vendor')}</span>
|
||||
{:else}
|
||||
<span class="hint" class:accent={vendorIsNew}>{vendorHint}</span>
|
||||
{/if}
|
||||
</label>
|
||||
<!-- Outside the <label> above: these are inputs of their own, and only
|
||||
shown while the typed name will create a manufacturer. Linking an
|
||||
existing one must not offer to rewrite its custom fields (#1055). -->
|
||||
{#if vendorIsNew}
|
||||
<ExtraFieldsSection entity="vendor" extra={vendorExtra} onchange={setVendorExtra} />
|
||||
{/if}
|
||||
</section>
|
||||
<section class="new-section">
|
||||
<div class="fs-head">
|
||||
<span class="ent-label">{m['add.section.filament']()}</span>
|
||||
<button class="fs-back" onclick={() => (step = 1)}>{m['add.useExisting']()}</button>
|
||||
</div>
|
||||
<div class="fs-title">
|
||||
{cloneSource
|
||||
? m['add.duplicateTitle']({ name: cloneSource.name })
|
||||
: m['add.newFilamentTitle']()}
|
||||
</div>
|
||||
<div class="ent-note">
|
||||
{cloneSource ? m['add.duplicateNote']() : m['add.section.filamentNote']()}
|
||||
</div>
|
||||
<div class="form">
|
||||
<label class="wide" data-field="name" onfocusout={() => touch('name')}>
|
||||
{m['filament.fields.name']()}
|
||||
{@render req()}
|
||||
<input
|
||||
bind:this={nameInput}
|
||||
bind:value={nf.name}
|
||||
placeholder={m['add.filamentNamePlaceholder']()}
|
||||
aria-required="true"
|
||||
aria-invalid={!!err('name')}
|
||||
class:invalid={!!err('name')}
|
||||
/>
|
||||
<!-- 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 err('name')}<span class="err">{err('name')}</span>
|
||||
{:else if nameStillSource}<span class="hint accent">{m['add.duplicateRename']()}</span>
|
||||
{:else}<span class="hint">{m['add.nameHint']()}</span>{/if}
|
||||
</label>
|
||||
<label data-field="material" onfocusout={() => touch('material')}>
|
||||
{m['filament.fields.material']()}
|
||||
{@render req()}
|
||||
<Combobox
|
||||
value={nf.material}
|
||||
options={materialNames}
|
||||
placeholder="PLA"
|
||||
required
|
||||
invalid={!!err('material')}
|
||||
oninput={onMaterial}
|
||||
/>
|
||||
{#if err('material')}<span class="err">{err('material')}</span>{/if}
|
||||
</label>
|
||||
<label class="color-field wide" data-field="colorHex" onfocusout={() => touch('colorHex')}>
|
||||
{m['filament.fields.colorHex']()}
|
||||
<div class="color-editor-wrap">
|
||||
<ColorEditor
|
||||
colors={nf.colors}
|
||||
direction={nf.multiColorDirection}
|
||||
onchange={(v) => {
|
||||
nf.colors = v.colors;
|
||||
nf.multiColorDirection = v.direction;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{#if err('colorHex')}<span class="err">{err('colorHex')}</span>{/if}
|
||||
</label>
|
||||
</div>
|
||||
<button class="adv-toggle" onclick={() => (showAdvanced = !showAdvanced)}>
|
||||
{#if showAdvanced}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
|
||||
{m['add.advanced']()}
|
||||
{#if !showAdvanced}<span class="adv-note">{m['add.advancedNote']()}</span>{/if}
|
||||
</button>
|
||||
{#if showAdvanced}
|
||||
<div class="form">
|
||||
<label data-field="density" onfocusout={() => touch('density')}
|
||||
>{m['filament.fields.density']()}
|
||||
{@render req()}
|
||||
<NumberInput
|
||||
bind:value={nf.density}
|
||||
min={0}
|
||||
step={0.01}
|
||||
unit="g/cm³"
|
||||
spaced
|
||||
required
|
||||
invalid={!!err('density')}
|
||||
/>
|
||||
{#if err('density')}<span class="err">{err('density')}</span>{/if}
|
||||
</label>
|
||||
<label data-field="diameter" onfocusout={() => touch('diameter')}
|
||||
>{m['filament.fields.diameter']()}
|
||||
{@render req()}
|
||||
<NumberInput
|
||||
bind:value={nf.diameter}
|
||||
min={0}
|
||||
step={0.05}
|
||||
unit="mm"
|
||||
spaced
|
||||
required
|
||||
invalid={!!err('diameter')}
|
||||
/>
|
||||
{#if err('diameter')}<span class="err">{err('diameter')}</span>{/if}
|
||||
</label>
|
||||
<label data-field="nozzleTemp" onfocusout={() => touch('nozzleTemp')}
|
||||
>{m['filament.fields.settingsExtruderTemp']()}
|
||||
<NumberInput
|
||||
bind:value={nf.nozzleTemp}
|
||||
min={0}
|
||||
step={5}
|
||||
unit="°C"
|
||||
placeholder="—"
|
||||
spaced
|
||||
invalid={!!err('nozzleTemp')}
|
||||
/>
|
||||
{#if err('nozzleTemp')}<span class="err">{err('nozzleTemp')}</span>{/if}
|
||||
</label>
|
||||
<label data-field="bedTemp" onfocusout={() => touch('bedTemp')}
|
||||
>{m['filament.fields.settingsBedTemp']()}
|
||||
<NumberInput
|
||||
bind:value={nf.bedTemp}
|
||||
min={0}
|
||||
step={5}
|
||||
unit="°C"
|
||||
placeholder="—"
|
||||
spaced
|
||||
invalid={!!err('bedTemp')}
|
||||
/>
|
||||
{#if err('bedTemp')}<span class="err">{err('bedTemp')}</span>{/if}
|
||||
</label>
|
||||
<label>
|
||||
{m['filament.fields.articleNumber']()}
|
||||
<input class="mono" bind:value={nf.articleNumber} placeholder="—" />
|
||||
</label>
|
||||
<label class="wide">
|
||||
{m['filament.fields.comment']()}
|
||||
<input bind:value={nf.comment} placeholder="—" />
|
||||
</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} />
|
||||
</section>
|
||||
<NewFilamentCards
|
||||
bind:draft={nf}
|
||||
{err}
|
||||
{touch}
|
||||
extra={filamentExtra}
|
||||
onextra={setFilamentExtra}
|
||||
{vendorExtra}
|
||||
onVendorExtra={setVendorExtra}
|
||||
bind:vendorIsNew
|
||||
bind:showAdvanced
|
||||
{cloneSource}
|
||||
backLabel={m['add.useExisting']()}
|
||||
onback={() => (step = 1)}
|
||||
/>
|
||||
{:else if chosen}
|
||||
<!-- No card here: the chosen-filament row is already a self-contained
|
||||
bordered block, so it only needs the heading that names the entity. -->
|
||||
@@ -1009,8 +726,8 @@
|
||||
<button
|
||||
type="button"
|
||||
class="preset"
|
||||
class:on={Number(netWeight) === preset}
|
||||
aria-pressed={Number(netWeight) === preset}
|
||||
class:on={parseDecimal(netWeight) === preset}
|
||||
aria-pressed={parseDecimal(netWeight) === preset}
|
||||
onclick={() => (netWeight = String(preset))}>{weightAuto(preset)}</button
|
||||
>
|
||||
{/each}
|
||||
@@ -1401,24 +1118,6 @@
|
||||
font-size: 11.5px;
|
||||
color: var(--accent-muted-2);
|
||||
}
|
||||
/* One card per record this flow will create. Whether the manufacturer is a new
|
||||
one or an existing one being linked is left to the accent hint under the
|
||||
field: --accent-border is lighter than --border-strong in the light theme, so
|
||||
swapping borders to mark "new" would read as less emphasis, not more. */
|
||||
.new-section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.man-section + .new-section {
|
||||
margin-top: 10px;
|
||||
}
|
||||
/* The manufacturer block holds a single field, so its heading is that field's
|
||||
<label> — one accessible name, no heading/label echo. */
|
||||
.ent-field {
|
||||
display: block;
|
||||
}
|
||||
.ent-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
@@ -1439,24 +1138,6 @@
|
||||
.ent-note.shared {
|
||||
color: var(--accent-muted-2);
|
||||
}
|
||||
.fs-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.fs-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.fs-back {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--accent-link);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Separates the records being created from the spool block. Deliberately
|
||||
heavier than --border-soft, which was invisible against the light theme's
|
||||
card backgrounds and left the two blocks looking like one run of fields. */
|
||||
@@ -1465,25 +1146,6 @@
|
||||
background: var(--border);
|
||||
margin: 16px 0 12px;
|
||||
}
|
||||
.color-editor-wrap {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.adv-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent-link);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
.adv-note {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
@@ -1535,9 +1197,6 @@
|
||||
.form input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.form input.invalid {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.err {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
@@ -1595,9 +1254,6 @@
|
||||
/* Flows inline in the form, so it wraps and stays inside the modal on mobile. */
|
||||
max-width: 100%;
|
||||
}
|
||||
.hint.accent {
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
.presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
<script lang="ts">
|
||||
// Link a physical tag to a spool or filament, by any of the three ways a UID can
|
||||
// reach a browser: a reader taps it and the relay delivers it, this phone reads it over
|
||||
// Web NFC, or the user types it.
|
||||
//
|
||||
// All three converge on one input, deliberately. The UID is the whole of a
|
||||
// tag's identity, so a scan is nothing more than a faster way to fill the same
|
||||
// box — and seeing what was captured before committing is what makes an
|
||||
// unfamiliar tag safe to link.
|
||||
//
|
||||
// The dialog looks the UID up as it settles rather than waiting for the link to
|
||||
// be rejected, because "this tag is already on spool #12" is the answer that
|
||||
// changes what you want to do, and learning it after pressing the button means
|
||||
// pressing a second one. A 409 on submit is still handled: the lookup can be
|
||||
// stale, and the server is the authority.
|
||||
import Button from './Button.svelte';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Nfc from '@lucide/svelte/icons/nfc';
|
||||
import Smartphone from '@lucide/svelte/icons/smartphone';
|
||||
import Radio from '@lucide/svelte/icons/radio';
|
||||
import History from '@lucide/svelte/icons/history';
|
||||
import type { Filament, Spool } from '$lib/types';
|
||||
import {
|
||||
linkTag,
|
||||
unlinkTag,
|
||||
findTagHolder,
|
||||
holderTarget,
|
||||
asTagConflict,
|
||||
isBadUid,
|
||||
type TagHolder,
|
||||
type TagKind,
|
||||
type TagTarget
|
||||
} from '$lib/api/tags';
|
||||
import { scanRelay } from '$lib/api/scanRelay';
|
||||
import { scanner } from '$lib/stores/scanner.svelte';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { toasts } from '$lib/stores/toasts.svelte';
|
||||
import { isAbortError } from '$lib/api/http';
|
||||
import { nfcSupported, readTagUid, NfcError, type NfcErrorReason } from '$lib/utils/nfc';
|
||||
import { filamentLabel } from '$lib/utils/library';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
/** What the tag is being linked to. */
|
||||
kind: TagKind;
|
||||
id: number | string;
|
||||
onclose: () => void;
|
||||
}
|
||||
let { open, kind, id, onclose }: Props = $props();
|
||||
let target = $derived<TagTarget>({ kind, id });
|
||||
|
||||
let uid = $state('');
|
||||
/** Carried through from a scan; a typed UID has no format to report. */
|
||||
let format = $state<string | undefined>(undefined);
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
/** What the UID currently in the box resolves to on the server. */
|
||||
type Lookup =
|
||||
| { state: 'empty' }
|
||||
| { state: 'checking' }
|
||||
| { state: 'free' }
|
||||
| { state: 'here' }
|
||||
| { state: 'taken'; holder: TagHolder }
|
||||
| { state: 'bad' }
|
||||
| { state: 'unknown' };
|
||||
let lookup = $state<Lookup>({ state: 'empty' });
|
||||
|
||||
let nfcReading = $state(false);
|
||||
let nfcError = $state<NfcErrorReason | null>(null);
|
||||
/** Whether this phone can read tags itself. False everywhere but Chrome on
|
||||
* Android over HTTPS, which is why the control is omitted rather than
|
||||
* disabled — there is no advice to give a desktop user about it. */
|
||||
const canReadHere = nfcSupported();
|
||||
|
||||
// Web NFC needs a real gesture to raise its permission prompt, so this is
|
||||
// started by the button rather than on open.
|
||||
let nfcAbort: AbortController | null = null;
|
||||
|
||||
function reset() {
|
||||
uid = '';
|
||||
format = undefined;
|
||||
error = null;
|
||||
lookup = { state: 'empty' };
|
||||
nfcError = null;
|
||||
stopNfc();
|
||||
}
|
||||
|
||||
function close() {
|
||||
onclose();
|
||||
}
|
||||
|
||||
function stopNfc() {
|
||||
nfcAbort?.abort();
|
||||
nfcAbort = null;
|
||||
nfcReading = false;
|
||||
}
|
||||
|
||||
// Everything the dialog does starts when it opens and stops when it closes,
|
||||
// including the relay subscription and the hold on auto-navigate. That hold
|
||||
// matters more than it looks: the tap this dialog is waiting for is exactly the
|
||||
// event that would otherwise navigate the browser somewhere else, throwing the
|
||||
// dialog away mid-flow.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
reset();
|
||||
const release = scanner.suppress();
|
||||
const off = scanRelay.subscribe(scanner.pool, (scan) => {
|
||||
scanner.receive(scan);
|
||||
uid = scan.uid;
|
||||
format = scan.format;
|
||||
nfcError = null;
|
||||
stopNfc();
|
||||
});
|
||||
return () => {
|
||||
off();
|
||||
release();
|
||||
stopNfc();
|
||||
};
|
||||
});
|
||||
|
||||
// Resolve the UID as it settles. Debounced because this also runs while the
|
||||
// user is typing one in by hand, and aborted on every change so a slow earlier
|
||||
// answer can't land on top of a newer one.
|
||||
$effect(() => {
|
||||
const value = uid.trim();
|
||||
if (!open) return;
|
||||
if (!value) {
|
||||
lookup = { state: 'empty' };
|
||||
return;
|
||||
}
|
||||
lookup = { state: 'checking' };
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const found = await findTagHolder(value, ac.signal);
|
||||
if (ac.signal.aborted) return;
|
||||
if (!found) lookup = { state: 'free' };
|
||||
else if (isTarget(found)) lookup = { state: 'here' };
|
||||
else lookup = { state: 'taken', holder: found };
|
||||
} catch (err) {
|
||||
if (isAbortError(err, ac.signal)) return;
|
||||
// A UID that isn't hexadecimal is a 400 rather than an empty result, so
|
||||
// a half-typed or mistyped one says so instead of claiming to be free.
|
||||
lookup = isBadUid(err) ? { state: 'bad' } : { state: 'unknown' };
|
||||
}
|
||||
}, 350);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
ac.abort();
|
||||
};
|
||||
});
|
||||
|
||||
async function startNfc() {
|
||||
stopNfc();
|
||||
nfcError = null;
|
||||
const ac = new AbortController();
|
||||
nfcAbort = ac;
|
||||
nfcReading = true;
|
||||
try {
|
||||
const serial = await readTagUid(ac.signal);
|
||||
uid = serial;
|
||||
// Web NFC reaches NDEF tags, so "ntag" is the honest default for what it
|
||||
// can read — but only as a starting guess the server may refine later.
|
||||
format = 'ntag';
|
||||
} catch (err) {
|
||||
if (ac.signal.aborted) return; // closed, or superseded by another read
|
||||
nfcError = err instanceof NfcError ? err.reason : 'unknown';
|
||||
} finally {
|
||||
if (nfcAbort === ac) {
|
||||
nfcAbort = null;
|
||||
nfcReading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function nfcErrorMessage(reason: NfcErrorReason): string {
|
||||
if (reason === 'notAllowed') return m['tags.nfc.notAllowed']();
|
||||
if (reason === 'notSupported') return m['tags.nfc.notSupported']();
|
||||
if (reason === 'notReadable') return m['tags.nfc.notReadable']();
|
||||
if (reason === 'insecureContext') return m['tags.nfc.insecureContext']();
|
||||
return m['tags.nfc.unknown']();
|
||||
}
|
||||
|
||||
/** Whether a holder is the very spool or filament this dialog links to. */
|
||||
function isTarget(holder: TagHolder): boolean {
|
||||
const held = holderTarget(holder);
|
||||
return held.kind === target.kind && String(held.id) === String(target.id);
|
||||
}
|
||||
|
||||
function nameOf(filament: Filament): string {
|
||||
const vendor = inventory.vendorById(filament.vendorId);
|
||||
return vendor ? filamentLabel(filament, vendor) : filament.name;
|
||||
}
|
||||
|
||||
/** Describe whatever holds the tag well enough to decide whether to take it. */
|
||||
function describe(holder: TagHolder): string {
|
||||
if (holder.kind === 'filament') {
|
||||
return m['tags.modal.filamentNamed']({ name: nameOf(holder.filament) });
|
||||
}
|
||||
const other: Spool = holder.spool;
|
||||
const filament = inventory.filamentById(other.filamentId);
|
||||
if (!filament) return m['tags.modal.spoolShort']({ id: other.id });
|
||||
return m['tags.modal.spoolNamed']({ id: other.id, name: nameOf(filament) });
|
||||
}
|
||||
|
||||
/** Load whatever a 409 said holds the tag, so the offer to move it can name it. */
|
||||
async function fetchHolder(held: TagTarget): Promise<TagHolder | undefined> {
|
||||
if (held.kind === 'spool') {
|
||||
const spool = await spoolSource.fetchSpool(Number(held.id));
|
||||
return spool ? { kind: 'spool', spool } : undefined;
|
||||
}
|
||||
const filament = await spoolSource.fetchFilament(String(held.id));
|
||||
return filament ? { kind: 'filament', filament } : undefined;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const value = uid.trim();
|
||||
if (!value || busy) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
await linkTag(target, value, format);
|
||||
toasts.success(m['tags.added']());
|
||||
close();
|
||||
} catch (err) {
|
||||
const conflict = asTagConflict(err);
|
||||
if (conflict) {
|
||||
// Lost a race, or the lookup never ran. Fetch the holder so the offer to
|
||||
// move the tag can name it, and fall back to the bare id if even that
|
||||
// fails — the offer is still valid without a name.
|
||||
const holder = await fetchHolder(conflict.holder).catch(() => undefined);
|
||||
lookup = holder ? { state: 'taken', holder } : { state: 'unknown' };
|
||||
error = holder ? null : conflict.message;
|
||||
} else if (isBadUid(err)) {
|
||||
lookup = { state: 'bad' };
|
||||
} else {
|
||||
error = m['tags.modal.failed']();
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a tag off whatever holds it and put it on this spool or filament.
|
||||
*
|
||||
* Two requests, in this order, because a tag identifies exactly one thing and
|
||||
* the server will not accept the second until the first has happened. If the
|
||||
* link then fails the tag is left on neither, which is reported rather than
|
||||
* hidden: the tag still exists and can simply be linked again.
|
||||
*/
|
||||
async function move(from: TagHolder) {
|
||||
const value = uid.trim();
|
||||
if (!value || busy) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
await unlinkTag(holderTarget(from), value);
|
||||
await linkTag(target, value, format);
|
||||
toasts.success(
|
||||
from.kind === 'spool'
|
||||
? m['tags.moved']({ id: from.spool.id })
|
||||
: m['tags.movedFromFilament']({ name: nameOf(from.filament) })
|
||||
);
|
||||
close();
|
||||
} catch {
|
||||
// "neither spool" is only the truth when both ends are spools.
|
||||
error =
|
||||
from.kind === 'spool' && target.kind === 'spool'
|
||||
? m['tags.modal.moveFailed']()
|
||||
: m['tags.modal.moveFailedFilament']();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Naming the reader you are waiting on needs the server's registry, since the
|
||||
// store keeps no copy of reader names; until it arrives the id stands in.
|
||||
$effect(() => {
|
||||
if (open && scanner.pool !== null) scanner.ensureReaderNames();
|
||||
});
|
||||
|
||||
let readerLabel = $derived(scanner.pairedLabel);
|
||||
// What the reader we listen to already has on it. Offered instead of asking
|
||||
// for another tap, which on a scale means lifting the spool off the pad and
|
||||
// putting it back. Hidden once it is what the field already holds, so it
|
||||
// never sits there as a no-op.
|
||||
let lastSeen = $derived(scanner.listeningLastSeen);
|
||||
let lastSeenOffer = $derived(
|
||||
lastSeen && lastSeen.uid.toUpperCase() !== uid.trim().toUpperCase() ? lastSeen : null
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(e) => {
|
||||
if (open && e.key === 'Escape') close();
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if open}
|
||||
<div class="overlay">
|
||||
<!-- Click-outside catcher: a sibling of the dialog rather than a parent, so
|
||||
interactive controls aren't nested inside an interactive element. -->
|
||||
<button class="backdrop" tabindex="-1" aria-hidden="true" onclick={close}></button>
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="add-tag-title" tabindex="-1">
|
||||
<div class="modal-head">
|
||||
<span class="title" id="add-tag-title">{m['tags.modal.title']()}</span>
|
||||
<button class="x" onclick={close} aria-label={m['buttons.close']()}><X size={16} /></button>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<p class="intro">
|
||||
{kind === 'spool' ? m['tags.modal.intro']({ id }) : m['tags.modal.introFilament']()}
|
||||
</p>
|
||||
|
||||
<label class="uid-label" for="add-tag-uid">{m['tags.modal.uidLabel']()}</label>
|
||||
<input
|
||||
id="add-tag-uid"
|
||||
class="uid-input mono"
|
||||
bind:value={uid}
|
||||
placeholder={m['tags.modal.uidPlaceholder']()}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') submit();
|
||||
}}
|
||||
/>
|
||||
<p class="help">{m['tags.modal.uidHelp']()}</p>
|
||||
|
||||
<div class="status" aria-live="polite">
|
||||
{#if lookup.state === 'checking'}
|
||||
<span class="muted">{m['tags.modal.checking']()}</span>
|
||||
{:else if lookup.state === 'free'}
|
||||
<span class="ok">{m['tags.modal.free']()}</span>
|
||||
{:else if lookup.state === 'here'}
|
||||
<span class="muted">
|
||||
{kind === 'spool' ? m['tags.modal.alreadyHere']() : m['tags.modal.alreadyHereFilament']()}
|
||||
</span>
|
||||
{:else if lookup.state === 'taken'}
|
||||
<span class="warn">{m['tags.modal.takenBy']({ spool: describe(lookup.holder) })}</span>
|
||||
{:else if lookup.state === 'bad'}
|
||||
<span class="warn">{m['tags.modal.badUid']()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="ways">
|
||||
<div class="way">
|
||||
<Radio size={14} class="way-ico" />
|
||||
<span>
|
||||
{#if readerLabel}
|
||||
{m['tags.modal.waitingOn']({ reader: readerLabel })}
|
||||
{:else}
|
||||
{m['tags.modal.waitingAny']()}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if lastSeenOffer}
|
||||
<div class="way">
|
||||
<History size={14} class="way-ico" />
|
||||
<span>
|
||||
{m['tags.modal.lastSeenOn']({
|
||||
reader: lastSeenOffer.label,
|
||||
uid: lastSeenOffer.uid
|
||||
})}
|
||||
</span>
|
||||
<button class="link act" onclick={() => (uid = lastSeenOffer.uid)}>
|
||||
{m['tags.modal.useLastSeen']()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if canReadHere}
|
||||
<div class="way">
|
||||
<Smartphone size={14} class="way-ico" />
|
||||
{#if nfcReading}
|
||||
<span>{m['tags.modal.phoneReading']()}</span>
|
||||
<button class="link" onclick={stopNfc}>{m['buttons.cancel']()}</button>
|
||||
{:else}
|
||||
<button class="link" onclick={startNfc}>{m['tags.modal.phoneScan']()}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if nfcError}<div class="err">{nfcErrorMessage(nfcError)}</div>{/if}
|
||||
{#if error}<div class="err">{error}</div>{/if}
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<Button variant="ghost" onclick={close}>{m['buttons.cancel']()}</Button>
|
||||
{#if lookup.state === 'taken'}
|
||||
{@const other = lookup.holder}
|
||||
<Button variant="primary" disabled={busy} onclick={() => move(other)}>
|
||||
<Nfc size={15} />
|
||||
{#if busy}
|
||||
{m['tags.modal.moving']()}
|
||||
{:else if other.kind === 'spool'}
|
||||
{m['tags.modal.move']({ id: other.spool.id })}
|
||||
{:else}
|
||||
{m['tags.modal.moveHere']()}
|
||||
{/if}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || !uid.trim() || lookup.state === 'here' || lookup.state === 'bad'}
|
||||
onclick={submit}
|
||||
>
|
||||
<Nfc size={15} />
|
||||
{busy ? m['tags.modal.linking']() : m['tags.modal.link']()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
/* Above the inspector's bottom sheet on mobile, same layer as the other
|
||||
library dialogs. */
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 8vh 16px 16px;
|
||||
}
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 460px;
|
||||
max-width: 100%;
|
||||
max-height: 84vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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;
|
||||
}
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 20px 0;
|
||||
flex: none;
|
||||
}
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.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;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.intro {
|
||||
margin: 0 0 14px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.uid-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.uid-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.uid-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-link);
|
||||
}
|
||||
.help {
|
||||
margin: 6px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.status {
|
||||
/* Reserved so the dialog doesn't jump as the lookup resolves. */
|
||||
min-height: 20px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.ok {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.warn {
|
||||
color: var(--danger-soft);
|
||||
}
|
||||
.ways {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.way {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.way :global(.way-ico) {
|
||||
color: var(--text-dim);
|
||||
flex: none;
|
||||
}
|
||||
.link {
|
||||
font-size: 12px;
|
||||
color: var(--accent-link);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* The offer is the action in its row rather than an alternative route like
|
||||
the phone reader, so it carries the weight a UID gets elsewhere. */
|
||||
.link.act {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.err {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--danger-soft);
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px 16px;
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { asset } from '$app/paths';
|
||||
import { getInfo, type Info } from '$lib/api/info';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
@@ -17,6 +16,14 @@
|
||||
Spoolman
|
||||
{#if info}
|
||||
v{info.version}{#if info.git_commit}<span class="commit"> ({info.git_commit})</span>{/if}
|
||||
{#if info.update_available && info.latest_version}
|
||||
<a
|
||||
class="update"
|
||||
href="https://github.com/Donkie/Spoolman/releases/tag/v{info.latest_version}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">{m['footer.updateAvailable']({ version: info.latest_version })}</a
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
·
|
||||
<a href="https://github.com/Donkie/Spoolman">{m['footer.documentation']()}</a>
|
||||
@@ -24,9 +31,14 @@
|
||||
<a href="https://github.com/Donkie/Spoolman/issues">{m['footer.reportIssue']()}</a>
|
||||
</div>
|
||||
|
||||
<a class="kofi" href="https://ko-fi.com/donkie" target="_blank" rel="noopener noreferrer">
|
||||
<img src={asset('/kofi_s_logo_nolabel.png')} alt="" />
|
||||
{m.kofi()}
|
||||
<a class="sponsor" href="https://github.com/sponsors/Donkie" target="_blank" rel="noopener noreferrer">
|
||||
<!-- Octicon heart-fill, inlined so the footer needs no request to github.com. -->
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
d="m8 14.25.345.666a.75.75 0 0 1-.69 0l-.008-.004-.018-.01a7.152 7.152 0 0 1-.31-.17 22.055 22.055 0 0 1-3.434-2.414C2.045 10.731 0 8.35 0 5.5 0 2.836 2.086 1 4.25 1 5.797 1 7.153 1.802 8 3.02 8.847 1.802 10.203 1 11.75 1 13.914 1 16 2.836 16 5.5c0 2.85-2.045 5.231-3.885 6.818a22.066 22.066 0 0 1-3.744 2.584l-.018.01-.006.003h-.002Z"
|
||||
/>
|
||||
</svg>
|
||||
{m.sponsor()}
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
@@ -52,7 +64,12 @@
|
||||
.commit {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.kofi {
|
||||
.update {
|
||||
margin-left: 4px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sponsor {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -67,25 +84,27 @@
|
||||
text-decoration: none;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.kofi:hover {
|
||||
.sponsor:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.kofi img {
|
||||
.sponsor svg {
|
||||
height: 16px;
|
||||
width: auto;
|
||||
width: 16px;
|
||||
fill: #db61a2;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.footer {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
.kofi {
|
||||
.sponsor {
|
||||
font-size: 0;
|
||||
gap: 0;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
.kofi img {
|
||||
.sponsor svg {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,13 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
The menu hrefs come from resolve() / catalogHref(), which already apply the deploy base path. */
|
||||
import { resolve } from '$app/paths';
|
||||
import type { Pathname } from '$app/types';
|
||||
import { page } from '$app/stores';
|
||||
import { tick } from 'svelte';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import { catalogHref, parseLibraryState } from '$lib/library/params';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
const tabs = [
|
||||
@@ -19,11 +25,159 @@
|
||||
const path = $page.url.pathname.slice(basePath.length) || '/';
|
||||
return href === '/' ? path === '/' : path.startsWith(href);
|
||||
}
|
||||
|
||||
// The Library tab's chevron menu switches between the Library's three views.
|
||||
// They all live at '/', so the tab itself stays active for each of them.
|
||||
let libraryView = $derived(parseLibraryState($page.url.searchParams).view);
|
||||
let onLibrary = $derived(isActive('/'));
|
||||
const views = [
|
||||
{
|
||||
href: resolve('/'),
|
||||
view: 'spools',
|
||||
label: m['search.section.spools']
|
||||
},
|
||||
{
|
||||
href: catalogHref('filaments'),
|
||||
view: 'filaments',
|
||||
label: m['filament.filament']
|
||||
},
|
||||
{
|
||||
href: catalogHref('manufacturers'),
|
||||
view: 'manufacturers',
|
||||
label: m['search.section.vendors']
|
||||
}
|
||||
] as const;
|
||||
|
||||
let menuOpen = $state(false);
|
||||
let split = $state<HTMLElement>();
|
||||
let chevron = $state<HTMLButtonElement>();
|
||||
let menuEl = $state<HTMLElement>();
|
||||
|
||||
function items(): HTMLElement[] {
|
||||
return menuEl ? [...menuEl.querySelectorAll<HTMLElement>('[role^="menuitem"]')] : [];
|
||||
}
|
||||
// Fixed rather than absolute: the phone nav row scrolls horizontally, which
|
||||
// would clip an absolutely-positioned menu. Placed from the tab when it opens.
|
||||
let menuPos = $state({ top: 0, left: 0 });
|
||||
async function openMenu(focusFirst: boolean) {
|
||||
if (split) {
|
||||
const r = split.getBoundingClientRect();
|
||||
const width = Math.min(220, window.innerWidth - 36);
|
||||
menuPos = { top: r.bottom + 6, left: Math.max(12, Math.min(r.left, window.innerWidth - width - 12)) };
|
||||
}
|
||||
menuOpen = true;
|
||||
if (!focusFirst) return;
|
||||
await tick();
|
||||
items()[0]?.focus();
|
||||
}
|
||||
function closeMenu(refocus = false) {
|
||||
menuOpen = false;
|
||||
if (refocus) chevron?.focus();
|
||||
}
|
||||
|
||||
// detail === 0 means the click came from Enter/Space, not a pointer.
|
||||
function onChevronClick(e: MouseEvent) {
|
||||
if (menuOpen) closeMenu();
|
||||
else openMenu(e.detail === 0);
|
||||
}
|
||||
function onChevronKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
openMenu(true);
|
||||
} else if (e.key === 'Escape' && menuOpen) {
|
||||
// Opened by a click, focus is still here rather than in the menu.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
function onMenuKeydown(e: KeyboardEvent) {
|
||||
const list = items();
|
||||
const i = list.indexOf(document.activeElement as HTMLElement);
|
||||
let next: number | undefined;
|
||||
if (e.key === 'ArrowDown') next = (i + 1) % list.length;
|
||||
else if (e.key === 'ArrowUp') next = (i - 1 + list.length) % list.length;
|
||||
else if (e.key === 'Home') next = 0;
|
||||
else if (e.key === 'End') next = list.length - 1;
|
||||
if (next !== undefined) {
|
||||
e.preventDefault();
|
||||
list[next].focus();
|
||||
} else if (e.key === 'Escape') {
|
||||
// Don't let it also close a surrounding dialog or panel.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu(true);
|
||||
} else if (e.key === 'Tab') {
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// Capture phase, like Combobox, so a click that something else swallows still closes the menu.
|
||||
$effect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (split && !split.contains(e.target as Node)) closeMenu();
|
||||
};
|
||||
window.addEventListener('pointerdown', onDown, true);
|
||||
return () => window.removeEventListener('pointerdown', onDown, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<nav class="tabs">
|
||||
{#each tabs as tab (tab.href)}
|
||||
<a href={resolve(tab.href)} class="tab" class:active={isActive(tab.href)}>{tab.label()}</a>
|
||||
{#if tab.href === '/'}
|
||||
<span class="split" class:active={isActive('/')} class:open={menuOpen} bind:this={split}>
|
||||
<a href={resolve('/')} class="tab" aria-current={isActive('/') ? 'page' : undefined}>{tab.label()}</a>
|
||||
<button
|
||||
type="button"
|
||||
class="chevron"
|
||||
bind:this={chevron}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
aria-label={m['library.views']()}
|
||||
onclick={onChevronClick}
|
||||
onkeydown={onChevronKeydown}
|
||||
>
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="menu"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
aria-label={m['library.views']()}
|
||||
bind:this={menuEl}
|
||||
style:top="{menuPos.top}px"
|
||||
style:left="{menuPos.left}px"
|
||||
onkeydown={onMenuKeydown}
|
||||
>
|
||||
{#snippet row(v: (typeof views)[number])}
|
||||
{@const checked = onLibrary && libraryView === v.view}
|
||||
<a
|
||||
class="mi"
|
||||
class:sel={checked}
|
||||
href={v.href}
|
||||
role="menuitemradio"
|
||||
aria-checked={checked}
|
||||
onclick={() => closeMenu()}
|
||||
>
|
||||
<span class="mi-check" aria-hidden="true"
|
||||
>{#if checked}<Check size={15} />{/if}</span
|
||||
>
|
||||
<span class="mi-label">{v.label()}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
{@render row(views[0])}
|
||||
<div class="menu-sep" role="separator"></div>
|
||||
<div class="menu-title" role="presentation">{m['library.viewMenu.browse']()}</div>
|
||||
{@render row(views[1])}
|
||||
{@render row(views[2])}
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<a href={resolve(tab.href)} class="tab" class:active={isActive(tab.href)}>{tab.label()}</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
@@ -60,4 +214,122 @@
|
||||
.tab.active:hover {
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
/* Library tab: label + chevron read as one pill. The wash sits on the wrapper,
|
||||
so the label drops its own background and the chevron only tints on hover/open. */
|
||||
.split {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.split .tab {
|
||||
padding-right: 4px;
|
||||
border-radius: var(--radius) 0 0 var(--radius);
|
||||
background: none;
|
||||
}
|
||||
.split.active {
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
.split.active .tab {
|
||||
font-weight: 600;
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
.chevron {
|
||||
width: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
background: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
.split.active .chevron {
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
.chevron:hover,
|
||||
.split.open .chevron {
|
||||
color: var(--text);
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
.split.active .chevron:hover,
|
||||
.split.active.open .chevron {
|
||||
color: var(--accent-soft);
|
||||
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
.split .tab:focus-visible,
|
||||
.chevron:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
width: 220px;
|
||||
max-width: calc(100vw - 36px);
|
||||
padding: 4px 0;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.menu-title {
|
||||
padding: 7px 14px 3px;
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.menu-sep {
|
||||
height: 1px;
|
||||
background: var(--border-soft);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.mi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
padding: 8px 14px;
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
.mi:hover,
|
||||
.mi:focus-visible {
|
||||
background: var(--surface-raised);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
.mi:focus-visible {
|
||||
box-shadow: inset 2px 0 0 var(--accent);
|
||||
}
|
||||
.mi-check {
|
||||
flex: none;
|
||||
width: 15px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
.mi-label {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.mi.sel .mi-label {
|
||||
color: var(--accent-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Phone nav row: 36px+ tap targets, matching the row's own height. */
|
||||
@media (max-width: 860px) {
|
||||
.chevron {
|
||||
width: 36px;
|
||||
min-height: 38px;
|
||||
}
|
||||
.mi {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,450 @@
|
||||
<script lang="ts">
|
||||
// The "describe a filament that doesn't exist yet" form, as a pair of cards.
|
||||
//
|
||||
// Two dialogs need it: the add-spools flow, where it is step 2's first half, and
|
||||
// the change-filament dialog, where refilling a shelf slot with something new is
|
||||
// the whole point (issue #1010). Keeping it here means the layout that makes the
|
||||
// flow legible — one card per record about to be created, so it is never a
|
||||
// mystery which record a field lands on (#1038) — holds in both places.
|
||||
//
|
||||
// The manufacturer gets its own card because it is its own record: a vendor
|
||||
// combobox sitting among the filament fields, quietly creating a second entity,
|
||||
// was the most confusing part of the flow.
|
||||
//
|
||||
// Validation is drawn here but decided by the caller: `err` says what to show for
|
||||
// a field and `touch` records that it has been left, so the caller keeps its own
|
||||
// policy for when an error is revealed (see AddSpoolModal's `touched`/`attempted`).
|
||||
// This component only reports and displays.
|
||||
import ColorEditor from './ColorEditor.svelte';
|
||||
import NumberInput from './NumberInput.svelte';
|
||||
import Combobox from './Combobox.svelte';
|
||||
import ExtraFieldsSection from './ExtraFieldsSection.svelte';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import type { Extra, Filament } from '$lib/types';
|
||||
import type { FilamentDraft, FilamentWeights } from '$lib/filament/draft';
|
||||
import { applyMaterialSpec } from '$lib/filament/draft';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import { loadMaterials, type MaterialSpec } from '$lib/data/materials';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Props {
|
||||
draft: FilamentDraft;
|
||||
/** The error to display for a field, or '' — the caller decides what is revealed. */
|
||||
err: (key: string) => string;
|
||||
/** Called when a field is left, so the caller can start revealing its error. */
|
||||
touch: (key: string) => void;
|
||||
/** Custom-field values for the filament being created. */
|
||||
extra: Extra;
|
||||
onextra: (key: string, json: string | undefined) => void;
|
||||
/**
|
||||
* Custom-field values for a manufacturer this form would create. Only drawn
|
||||
* while the typed name is a new one: linking an existing manufacturer must
|
||||
* never offer to rewrite its values (#1055).
|
||||
*/
|
||||
vendorExtra: Extra;
|
||||
onVendorExtra: (key: string, json: string | undefined) => void;
|
||||
/** Whether the typed manufacturer name will create a record. Read-only to callers. */
|
||||
vendorIsNew?: boolean;
|
||||
/** Open the specs block. Callers set it when the draft came from a real filament. */
|
||||
showAdvanced: boolean;
|
||||
/** The filament this draft was copied from, if any — drives the heading and nudges. */
|
||||
cloneSource?: Filament | null;
|
||||
/**
|
||||
* Weight, spool weight and price. Only drawn — and so only worth passing —
|
||||
* when `showWeights`: the add-spools flow writes each of them to the spool it
|
||||
* creates as well as to the filament, so it shows them in its spool block.
|
||||
*/
|
||||
weights?: FilamentWeights;
|
||||
showWeights?: boolean;
|
||||
/** Escape hatch in the filament card's heading, e.g. back to picking an existing one. */
|
||||
backLabel?: string;
|
||||
onback?: () => void;
|
||||
}
|
||||
let {
|
||||
draft = $bindable(),
|
||||
err,
|
||||
touch,
|
||||
extra,
|
||||
onextra,
|
||||
vendorExtra,
|
||||
onVendorExtra,
|
||||
vendorIsNew = $bindable(false),
|
||||
showAdvanced = $bindable(),
|
||||
cloneSource = null,
|
||||
weights = $bindable({ weight: '', spoolWeight: '', price: '' }),
|
||||
showWeights = false,
|
||||
backLabel,
|
||||
onback
|
||||
}: Props = $props();
|
||||
|
||||
let nameInput = $state<HTMLInputElement | undefined>();
|
||||
let vendorNames = $state<string[]>([]);
|
||||
let materialNames = $state<string[]>([]);
|
||||
let materialSpecs = $state<Record<string, MaterialSpec>>({});
|
||||
|
||||
spoolSource
|
||||
.vendorNames()
|
||||
.then((vn) => (vendorNames = vn))
|
||||
.catch(() => {});
|
||||
loadMaterials()
|
||||
.then(({ names, specs }) => {
|
||||
materialNames = names;
|
||||
materialSpecs = specs;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Vendor combobox: reuse an existing vendor if the name matches, else create.
|
||||
let vendorTrimmed = $derived(draft.vendorName.trim());
|
||||
let vendorMatch = $derived(vendorNames.find((v) => v.toLowerCase() === vendorTrimmed.toLowerCase()));
|
||||
let vendorHint = $derived(
|
||||
vendorTrimmed === ''
|
||||
? m['add.vendorHint.optional']()
|
||||
: vendorMatch
|
||||
? m['add.vendorHint.existing']({ name: vendorMatch })
|
||||
: m['add.vendorHint.new']({ name: vendorTrimmed })
|
||||
);
|
||||
// Accents the manufacturer hint when the typed name will create a second record
|
||||
// alongside the filament, rather than link an existing one. Written back to the
|
||||
// caller, which needs it to decide whether the vendor's fields are sent at all.
|
||||
$effect(() => {
|
||||
vendorIsNew = vendorTrimmed !== '' && !vendorMatch;
|
||||
});
|
||||
// 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 && draft.name.trim() === cloneSource.name.trim());
|
||||
|
||||
// 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 (cloneSource && nameInput) {
|
||||
nameInput.focus();
|
||||
nameInput.setSelectionRange(nameInput.value.length, nameInput.value.length);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- The one marker for "this must be filled in", used on every required field and
|
||||
on nothing else. It is decoration: the control itself carries aria-required, so
|
||||
screen readers hear the requirement rather than an asterisk. -->
|
||||
{#snippet req()}<span class="req" title={m['validation.required']()} aria-hidden="true">*</span>{/snippet}
|
||||
|
||||
<section class="new-section man-section">
|
||||
<label class="ent-field" data-field="vendor" onfocusout={() => touch('vendor')}>
|
||||
<span class="ent-label">{m['add.section.manufacturer']()}</span>
|
||||
<span class="ent-note">{m['add.section.manufacturerNote']()}</span>
|
||||
<Combobox
|
||||
value={draft.vendorName}
|
||||
options={vendorNames}
|
||||
placeholder={m['add.manufacturerPlaceholder']()}
|
||||
invalid={!!err('vendor')}
|
||||
oninput={(v) => (draft.vendorName = v)}
|
||||
/>
|
||||
{#if err('vendor')}
|
||||
<span class="err">{err('vendor')}</span>
|
||||
{:else}
|
||||
<span class="hint" class:accent={vendorIsNew}>{vendorHint}</span>
|
||||
{/if}
|
||||
</label>
|
||||
<!-- Outside the <label> above: these are inputs of their own, and only shown
|
||||
while the typed name will create a manufacturer. Linking an existing one
|
||||
must not offer to rewrite its custom fields (#1055). -->
|
||||
{#if vendorIsNew}
|
||||
<ExtraFieldsSection entity="vendor" extra={vendorExtra} onchange={onVendorExtra} />
|
||||
{/if}
|
||||
</section>
|
||||
<section class="new-section">
|
||||
<div class="fs-head">
|
||||
<span class="ent-label">{m['add.section.filament']()}</span>
|
||||
{#if backLabel && onback}
|
||||
<button class="fs-back" onclick={onback}>{backLabel}</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="fs-title">
|
||||
{cloneSource ? m['add.duplicateTitle']({ name: cloneSource.name }) : m['add.newFilamentTitle']()}
|
||||
</div>
|
||||
<div class="ent-note">
|
||||
{cloneSource ? m['add.duplicateNote']() : m['add.section.filamentNote']()}
|
||||
</div>
|
||||
<div class="form">
|
||||
<label class="wide" data-field="name" onfocusout={() => touch('name')}>
|
||||
{m['filament.fields.name']()}
|
||||
{@render req()}
|
||||
<input
|
||||
bind:this={nameInput}
|
||||
bind:value={draft.name}
|
||||
placeholder={m['add.filamentNamePlaceholder']()}
|
||||
aria-required="true"
|
||||
aria-invalid={!!err('name')}
|
||||
class:invalid={!!err('name')}
|
||||
/>
|
||||
<!-- 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 err('name')}<span class="err">{err('name')}</span>
|
||||
{:else if nameStillSource}<span class="hint accent">{m['add.duplicateRename']()}</span>
|
||||
{:else}<span class="hint">{m['add.nameHint']()}</span>{/if}
|
||||
</label>
|
||||
<label data-field="material" onfocusout={() => touch('material')}>
|
||||
{m['filament.fields.material']()}
|
||||
{@render req()}
|
||||
<Combobox
|
||||
value={draft.material}
|
||||
options={materialNames}
|
||||
placeholder="PLA"
|
||||
required
|
||||
invalid={!!err('material')}
|
||||
oninput={(v) => applyMaterialSpec(draft, v, materialSpecs)}
|
||||
/>
|
||||
{#if err('material')}<span class="err">{err('material')}</span>{/if}
|
||||
</label>
|
||||
{#if showWeights}
|
||||
<!-- Only shown where this form is the only thing being filled in: how much
|
||||
filament a full spool holds is the number the rest of the app measures
|
||||
against, so it belongs beside the name rather than under "advanced". -->
|
||||
<label data-field="netWeight" onfocusout={() => touch('netWeight')}>
|
||||
{m['filament.fields.weight']()}
|
||||
<NumberInput bind:value={weights.weight} min={0} unit="g" spaced invalid={!!err('netWeight')} />
|
||||
{#if err('netWeight')}<span class="err">{err('netWeight')}</span>{/if}
|
||||
</label>
|
||||
{/if}
|
||||
<label class="color-field wide" data-field="colorHex" onfocusout={() => touch('colorHex')}>
|
||||
{m['filament.fields.colorHex']()}
|
||||
<div class="color-editor-wrap">
|
||||
<ColorEditor
|
||||
colors={draft.colors}
|
||||
direction={draft.multiColorDirection}
|
||||
onchange={(v) => {
|
||||
draft.colors = v.colors;
|
||||
draft.multiColorDirection = v.direction;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{#if err('colorHex')}<span class="err">{err('colorHex')}</span>{/if}
|
||||
</label>
|
||||
</div>
|
||||
<button class="adv-toggle" onclick={() => (showAdvanced = !showAdvanced)}>
|
||||
{#if showAdvanced}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
|
||||
{m['add.advanced']()}
|
||||
{#if !showAdvanced}<span class="adv-note">{m['add.advancedNote']()}</span>{/if}
|
||||
</button>
|
||||
{#if showAdvanced}
|
||||
<div class="form">
|
||||
<label data-field="density" onfocusout={() => touch('density')}
|
||||
>{m['filament.fields.density']()}
|
||||
{@render req()}
|
||||
<NumberInput
|
||||
bind:value={draft.density}
|
||||
min={0}
|
||||
step={0.01}
|
||||
unit="g/cm³"
|
||||
spaced
|
||||
required
|
||||
invalid={!!err('density')}
|
||||
/>
|
||||
{#if err('density')}<span class="err">{err('density')}</span>{/if}
|
||||
</label>
|
||||
<label data-field="diameter" onfocusout={() => touch('diameter')}
|
||||
>{m['filament.fields.diameter']()}
|
||||
{@render req()}
|
||||
<NumberInput
|
||||
bind:value={draft.diameter}
|
||||
min={0}
|
||||
step={0.05}
|
||||
unit="mm"
|
||||
spaced
|
||||
required
|
||||
invalid={!!err('diameter')}
|
||||
/>
|
||||
{#if err('diameter')}<span class="err">{err('diameter')}</span>{/if}
|
||||
</label>
|
||||
<label data-field="nozzleTemp" onfocusout={() => touch('nozzleTemp')}
|
||||
>{m['filament.fields.settingsExtruderTemp']()}
|
||||
<NumberInput
|
||||
bind:value={draft.nozzleTemp}
|
||||
min={0}
|
||||
step={5}
|
||||
unit="°C"
|
||||
placeholder="—"
|
||||
spaced
|
||||
invalid={!!err('nozzleTemp')}
|
||||
/>
|
||||
{#if err('nozzleTemp')}<span class="err">{err('nozzleTemp')}</span>{/if}
|
||||
</label>
|
||||
<label data-field="bedTemp" onfocusout={() => touch('bedTemp')}
|
||||
>{m['filament.fields.settingsBedTemp']()}
|
||||
<NumberInput
|
||||
bind:value={draft.bedTemp}
|
||||
min={0}
|
||||
step={5}
|
||||
unit="°C"
|
||||
placeholder="—"
|
||||
spaced
|
||||
invalid={!!err('bedTemp')}
|
||||
/>
|
||||
{#if err('bedTemp')}<span class="err">{err('bedTemp')}</span>{/if}
|
||||
</label>
|
||||
{#if showWeights}
|
||||
<label data-field="spoolWeight" onfocusout={() => touch('spoolWeight')}
|
||||
>{m['filament.fields.spoolWeight']()}
|
||||
<NumberInput
|
||||
bind:value={weights.spoolWeight}
|
||||
min={0}
|
||||
unit="g"
|
||||
placeholder="—"
|
||||
spaced
|
||||
invalid={!!err('spoolWeight')}
|
||||
/>
|
||||
{#if err('spoolWeight')}<span class="err">{err('spoolWeight')}</span>{/if}
|
||||
</label>
|
||||
<label data-field="price" onfocusout={() => touch('price')}
|
||||
>{m['filament.fields.price']()} <span class="u">{settings.currency}</span>
|
||||
<NumberInput bind:value={weights.price} min={0} placeholder="—" spaced invalid={!!err('price')} />
|
||||
{#if err('price')}<span class="err">{err('price')}</span>{/if}
|
||||
</label>
|
||||
{/if}
|
||||
<label>
|
||||
{m['filament.fields.articleNumber']()}
|
||||
<input class="mono" bind:value={draft.articleNumber} placeholder="—" />
|
||||
</label>
|
||||
<label class="wide">
|
||||
{m['filament.fields.comment']()}
|
||||
<input bind:value={draft.comment} placeholder="—" />
|
||||
</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} onchange={onextra} />
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* One card per record this flow will create. Whether the manufacturer is a new
|
||||
one or an existing one being linked is left to the accent hint under the
|
||||
field: --accent-border is lighter than --border-strong in the light theme, so
|
||||
swapping borders to mark "new" would read as less emphasis, not more. */
|
||||
.new-section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.man-section + .new-section {
|
||||
margin-top: 10px;
|
||||
}
|
||||
/* The manufacturer block holds a single field, so its heading is that field's
|
||||
<label> — one accessible name, no heading/label echo. */
|
||||
.ent-field {
|
||||
display: block;
|
||||
}
|
||||
.ent-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.ent-note {
|
||||
display: block;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-faint);
|
||||
margin: 2px 0 8px;
|
||||
}
|
||||
.fs-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.fs-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.fs-back {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--accent-link);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.color-editor-wrap {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.adv-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent-link);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
.adv-note {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.form label {
|
||||
display: block;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.form label.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.u {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.form input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: none;
|
||||
border-radius: 7px;
|
||||
padding: 9px 12px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.form input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.form input.invalid {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.err {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--danger-soft);
|
||||
}
|
||||
.req {
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.hint.accent {
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.form {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,9 @@
|
||||
pageSize: number;
|
||||
total: number;
|
||||
unit?: string;
|
||||
/** Replaces the "1–20 of 50" range with the caller's own line, e.g. the
|
||||
* catalog's "11 filaments · 23 spools · 15.1 kg". */
|
||||
summary?: string;
|
||||
onpage: (page: number) => void;
|
||||
onpagesize: (size: number) => void;
|
||||
/** When set, page controls render as real `<a href>` links (open-in-new-tab,
|
||||
@@ -19,11 +22,15 @@
|
||||
hrefFor?: (page: number) => string;
|
||||
}
|
||||
|
||||
let { page, pageSize, total, unit = '', onpage, onpagesize, hrefFor }: Props = $props();
|
||||
let { page, pageSize, total, unit = '', summary, onpage, onpagesize, hrefFor }: Props = $props();
|
||||
|
||||
let pageCount = $derived(Math.max(1, Math.ceil(total / pageSize)));
|
||||
let from = $derived(total === 0 ? 0 : (page - 1) * pageSize + 1);
|
||||
let to = $derived(Math.min(total, page * pageSize));
|
||||
let countLabel = $derived(
|
||||
summary ??
|
||||
(total === 0 ? m['pagination.empty']({ unit }) : m['pagination.range']({ from, to, total, unit }))
|
||||
);
|
||||
|
||||
// Windowed page numbers with 1 … n … last.
|
||||
let pages = $derived.by(() => {
|
||||
@@ -48,73 +55,66 @@
|
||||
</script>
|
||||
|
||||
<div class="pager">
|
||||
<span class="count">
|
||||
{#if total === 0}{m['pagination.empty']({ unit })}{:else}{m['pagination.range']({
|
||||
from,
|
||||
to,
|
||||
total,
|
||||
unit
|
||||
})}{/if}
|
||||
</span>
|
||||
<span class="count">{countLabel}</span>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
{#if pageCount > 1}
|
||||
<div class="nums">
|
||||
{#if hrefFor && page > 1}
|
||||
<a
|
||||
class="pg nav"
|
||||
href={hrefFor(page - 1)}
|
||||
data-sveltekit-keepfocus
|
||||
data-sveltekit-noscroll
|
||||
aria-label={m['pagination.prev']()}><ChevronLeft size={16} /></a
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
class="pg nav"
|
||||
disabled={page <= 1}
|
||||
onclick={() => go(page - 1)}
|
||||
aria-label={m['pagination.prev']()}><ChevronLeft size={16} /></button
|
||||
>
|
||||
{/if}
|
||||
{#each pages as p, i (i)}
|
||||
{#if p === '…'}
|
||||
<span class="ellipsis">…</span>
|
||||
{:else if hrefFor && p !== page}
|
||||
<a class="pg" href={hrefFor(p)} data-sveltekit-keepfocus data-sveltekit-noscroll>{p}</a>
|
||||
<div class="controls">
|
||||
{#if pageCount > 1}
|
||||
<div class="nums">
|
||||
{#if hrefFor && page > 1}
|
||||
<a
|
||||
class="pg nav"
|
||||
href={hrefFor(page - 1)}
|
||||
data-sveltekit-keepfocus
|
||||
data-sveltekit-noscroll
|
||||
aria-label={m['pagination.prev']()}><ChevronLeft size={16} /></a
|
||||
>
|
||||
{:else}
|
||||
<button class="pg" class:active={p === page} onclick={() => go(p)}>{p}</button>
|
||||
<button
|
||||
class="pg nav"
|
||||
disabled={page <= 1}
|
||||
onclick={() => go(page - 1)}
|
||||
aria-label={m['pagination.prev']()}><ChevronLeft size={16} /></button
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if hrefFor && page < pageCount}
|
||||
<a
|
||||
class="pg nav"
|
||||
href={hrefFor(page + 1)}
|
||||
data-sveltekit-keepfocus
|
||||
data-sveltekit-noscroll
|
||||
aria-label={m['pagination.next']()}><ChevronRight size={16} /></a
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
class="pg nav"
|
||||
disabled={page >= pageCount}
|
||||
onclick={() => go(page + 1)}
|
||||
aria-label={m['pagination.next']()}><ChevronRight size={16} /></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#each pages as p, i (i)}
|
||||
{#if p === '…'}
|
||||
<span class="ellipsis">…</span>
|
||||
{:else if hrefFor && p !== page}
|
||||
<a class="pg" href={hrefFor(p)} data-sveltekit-keepfocus data-sveltekit-noscroll>{p}</a>
|
||||
{:else}
|
||||
<button class="pg" class:active={p === page} onclick={() => go(p)}>{p}</button>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if hrefFor && page < pageCount}
|
||||
<a
|
||||
class="pg nav"
|
||||
href={hrefFor(page + 1)}
|
||||
data-sveltekit-keepfocus
|
||||
data-sveltekit-noscroll
|
||||
aria-label={m['pagination.next']()}><ChevronRight size={16} /></a
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
class="pg nav"
|
||||
disabled={page >= pageCount}
|
||||
onclick={() => go(page + 1)}
|
||||
aria-label={m['pagination.next']()}><ChevronRight size={16} /></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<select
|
||||
class="size"
|
||||
value={pageSize}
|
||||
onchange={(e) => onpagesize(Number(e.currentTarget.value))}
|
||||
aria-label={m['pagination.pageSize']()}
|
||||
>
|
||||
{#each sizes as s (s)}
|
||||
<option value={s}>{m['pagination.perPage']({ size: s })}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select
|
||||
class="size"
|
||||
value={pageSize}
|
||||
onchange={(e) => onpagesize(Number(e.currentTarget.value))}
|
||||
aria-label={m['pagination.pageSize']()}
|
||||
>
|
||||
{#each sizes as s (s)}
|
||||
<option value={s}>{m['pagination.perPage']({ size: s })}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -130,12 +130,17 @@
|
||||
color: var(--text-dim);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.count {
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Page numbers and page size wrap as one, so a long count line (the catalog's
|
||||
summary on a phone) never strands the page-size picker on a line alone. */
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.nums {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { parseSpoolCode } from '$lib/utils/spoolCode';
|
||||
import {
|
||||
type Camera,
|
||||
rememberCamera,
|
||||
rememberedCamera,
|
||||
startingCamera,
|
||||
videoCameras
|
||||
} from '$lib/utils/cameraChoice';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
@@ -16,10 +23,52 @@
|
||||
let error = $state<string | null>(null);
|
||||
let starting = $state(false);
|
||||
|
||||
// Every camera the browser lists, read once the scanner is running (labels
|
||||
// are only readable after camera permission). More than one shows the picker.
|
||||
let cameras = $state<Camera[]>([]);
|
||||
// The camera the picker shows as selected: the one streaming, or the one that
|
||||
// just failed to open.
|
||||
let currentCamera = $state('');
|
||||
let switching = $state(false);
|
||||
// The running scanner, for the picker. Null while starting or closed.
|
||||
let active: QrScanner | null = null;
|
||||
|
||||
function close() {
|
||||
onclose?.();
|
||||
}
|
||||
|
||||
/** The deviceId of the camera actually streaming, whichever one we asked for. */
|
||||
function streamingCameraId(): string | undefined {
|
||||
const stream = video?.srcObject;
|
||||
return stream instanceof MediaStream ? stream.getVideoTracks()[0]?.getSettings().deviceId : undefined;
|
||||
}
|
||||
|
||||
async function pickCamera(id: string) {
|
||||
const scanner = active;
|
||||
if (!scanner || switching) return;
|
||||
switching = true;
|
||||
error = null;
|
||||
try {
|
||||
await scanner.setCamera(id);
|
||||
// After a failed open the scanner is stopped, and setCamera only
|
||||
// restarts a running one (or does nothing for the same id), so a
|
||||
// retry has to start it again.
|
||||
if (!streamingCameraId()) await scanner.start();
|
||||
if (scanner !== active) return; // closed while switching
|
||||
// qr-scanner opens some other camera if the requested one fails, so
|
||||
// show and remember what is actually streaming.
|
||||
currentCamera = streamingCameraId() ?? id;
|
||||
rememberCamera(currentCamera);
|
||||
} catch (err) {
|
||||
if (scanner !== active) return;
|
||||
console.error('QR scanner failed to switch camera:', err);
|
||||
currentCamera = id;
|
||||
error = await classifyStartFailure();
|
||||
} finally {
|
||||
switching = false;
|
||||
}
|
||||
}
|
||||
|
||||
// A scanned code that decodes to a Spoolman spool or filament opens it in the
|
||||
// Library. Non-Spoolman codes are ignored so the camera keeps scanning.
|
||||
function onDecode(result: QrScanner.ScanResult) {
|
||||
@@ -60,6 +109,8 @@
|
||||
|
||||
error = null;
|
||||
starting = false;
|
||||
cameras = [];
|
||||
currentCamera = '';
|
||||
|
||||
// Insecure (non-HTTPS, non-localhost) origins don't expose the camera API
|
||||
// at all — qr-scanner would only report a generic "camera not found", so
|
||||
@@ -79,21 +130,36 @@
|
||||
// qr-scanner is a browser-only library, so load it lazily inside the effect.
|
||||
// A static import would drag its default export into the SSR bundle, where
|
||||
// this client-only usage is stripped and Rollup then warns it is unused.
|
||||
import('qr-scanner')
|
||||
.then(({ default: QrScanner }) => {
|
||||
Promise.all([
|
||||
import('qr-scanner'),
|
||||
// Only to check the remembered camera still exists; any failure just
|
||||
// means we fall back to the rear camera.
|
||||
navigator.mediaDevices.enumerateDevices().catch(() => [])
|
||||
])
|
||||
.then(async ([{ default: QrScanner }, devices]) => {
|
||||
if (cancelled) return;
|
||||
const ids = videoCameras(devices).map((c) => c.id);
|
||||
scanner = new QrScanner(el, onDecode, {
|
||||
// Prefer the rear camera; ignore frames that don't decode.
|
||||
preferredCamera: 'environment',
|
||||
// The camera picked last time, else the rear one; ignore frames that don't decode.
|
||||
preferredCamera: startingCamera(rememberedCamera(), ids),
|
||||
highlightScanRegion: true,
|
||||
highlightCodeOutline: true,
|
||||
maxScansPerSecond: 5,
|
||||
returnDetailedScanResult: true
|
||||
});
|
||||
return scanner.start();
|
||||
})
|
||||
.then(() => {
|
||||
if (cancelled) scanner?.stop();
|
||||
// Also decode light-on-dark labels (#1165). The jsQR worker used where
|
||||
// BarcodeDetector is missing (Firefox) only tries dark-on-light by default.
|
||||
scanner.setInversionMode('both');
|
||||
await scanner.start();
|
||||
if (cancelled) {
|
||||
scanner?.stop();
|
||||
return;
|
||||
}
|
||||
active = scanner;
|
||||
currentCamera = streamingCameraId() ?? '';
|
||||
// The camera is already running, so a failed listing only costs the picker.
|
||||
const list = await navigator.mediaDevices.enumerateDevices().catch(() => []);
|
||||
if (!cancelled) cameras = videoCameras(list);
|
||||
})
|
||||
.catch(async (err) => {
|
||||
if (cancelled) return;
|
||||
@@ -106,6 +172,7 @@
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
active = null;
|
||||
scanner?.destroy();
|
||||
scanner = null;
|
||||
};
|
||||
@@ -144,6 +211,22 @@
|
||||
<div class="msg">{m['scanner.starting']()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if cameras.length > 1}
|
||||
<!-- Stays up after a failed switch, so another camera can be tried. -->
|
||||
<select
|
||||
class="camera"
|
||||
value={currentCamera}
|
||||
disabled={switching}
|
||||
aria-label={m['scanner.camera']()}
|
||||
onchange={(e) => pickCamera(e.currentTarget.value)}
|
||||
>
|
||||
{#each cameras as camera, i (camera.id)}
|
||||
<option value={camera.id}>{camera.label || m['scanner.cameraNumbered']({ number: i + 1 })}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -229,4 +312,16 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.camera {
|
||||
margin: -6px 20px 20px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
padding: 7px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.camera:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
// The NFC/RFID tags linked to a spool or filament, listed in its inspector.
|
||||
//
|
||||
// A tag is identified by its hardware UID and nothing else, so there is very
|
||||
// little to show: the UID, what kind of tag it is when the reader said, and
|
||||
// when it was linked. What matters is that the UID shown is the server's
|
||||
// canonical spelling — the same physical tag reads as `04:a2:b3:c4` on one
|
||||
// reader and `04-A2-B3-C4` on another, and showing whichever spelling happened
|
||||
// to be typed would make one tag look like several.
|
||||
//
|
||||
// Adding is a quiet section action rather than another button in the inspector
|
||||
// header: that header already carries the primary actions, and linking a tag is
|
||||
// not one of the things you do every day.
|
||||
import SectionLabel from './SectionLabel.svelte';
|
||||
import AddTagModal from './AddTagModal.svelte';
|
||||
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Nfc from '@lucide/svelte/icons/nfc';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import type { Tag } from '$lib/types';
|
||||
import { unlinkTag, type TagKind } from '$lib/api/tags';
|
||||
import { toasts } from '$lib/stores/toasts.svelte';
|
||||
import { formatShortDate } from '$lib/utils/datetime';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Props {
|
||||
/** What the tags identify, and so where links and unlinks go. */
|
||||
kind: TagKind;
|
||||
id: number | string;
|
||||
tags: Tag[];
|
||||
}
|
||||
let { kind, id, tags }: Props = $props();
|
||||
|
||||
let addOpen = $state(false);
|
||||
let pending = $state<Tag | null>(null);
|
||||
let unlinking = $state(false);
|
||||
|
||||
async function confirmUnlink() {
|
||||
const tag = pending;
|
||||
if (!tag) return;
|
||||
unlinking = true;
|
||||
try {
|
||||
await unlinkTag({ kind, id }, tag.uid);
|
||||
// No local edit: unlinking emits the holder's ordinary `updated` event, and
|
||||
// the list below re-renders from the cache when it arrives.
|
||||
toasts.success(m['tags.unlinked']());
|
||||
pending = null;
|
||||
} catch {
|
||||
toasts.error(m['tags.unlinkFailed']());
|
||||
} finally {
|
||||
unlinking = false;
|
||||
}
|
||||
}
|
||||
|
||||
function unlinkBody(uid: string): string {
|
||||
return kind === 'spool' ? m['tags.unlinkBody']({ uid }) : m['tags.unlinkBodyFilament']({ uid });
|
||||
}
|
||||
</script>
|
||||
|
||||
<SectionLabel>
|
||||
{m['tags.section']()}
|
||||
{#snippet right()}
|
||||
<button class="link" onclick={() => (addOpen = true)}>
|
||||
<Plus size={13} />
|
||||
{m['tags.add']()}
|
||||
</button>
|
||||
{/snippet}
|
||||
</SectionLabel>
|
||||
|
||||
{#if tags.length}
|
||||
<ul class="tags">
|
||||
{#each tags as tag (tag.uid)}
|
||||
<li class="tag">
|
||||
<Nfc size={14} class="ico" />
|
||||
<span class="uid mono">{tag.uid}</span>
|
||||
{#if tag.format}<span class="fmt">{tag.format}</span>{/if}
|
||||
<span class="added">{m['tags.addedOn']({ date: formatShortDate(tag.added) })}</span>
|
||||
<button
|
||||
class="unlink"
|
||||
onclick={() => (pending = tag)}
|
||||
title={m['tags.unlink']()}
|
||||
aria-label={m['tags.unlink']()}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<div class="none">{m['tags.none']()}</div>
|
||||
{/if}
|
||||
|
||||
<AddTagModal open={addOpen} {kind} {id} onclose={() => (addOpen = false)} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={pending !== null}
|
||||
busy={unlinking}
|
||||
title={m['tags.unlinkTitle']()}
|
||||
lines={pending ? [unlinkBody(pending.uid)] : []}
|
||||
confirmLabel={unlinking ? m['tags.unlinking']() : m['tags.unlink']()}
|
||||
onconfirm={confirmUnlink}
|
||||
onclose={() => (pending = null)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--accent-link);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.tags {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
.tag :global(.ico) {
|
||||
color: var(--text-dim);
|
||||
flex: none;
|
||||
}
|
||||
.uid {
|
||||
font-weight: 600;
|
||||
/* The UID is the whole identity of the tag, so it gets the room; everything
|
||||
after it is context and may be dropped on a narrow inspector. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.fmt {
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px 5px;
|
||||
flex: none;
|
||||
}
|
||||
.added {
|
||||
margin-left: auto;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.unlink {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
color: var(--text-dim);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.unlink:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
.none {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,7 @@
|
||||
// are dismissible on click) take pointer events.
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { toasts } from '$lib/stores/toasts.svelte';
|
||||
</script>
|
||||
@@ -20,8 +21,11 @@
|
||||
transition:fly={{ y: 12, duration: 150 }}
|
||||
onclick={() => toasts.dismiss(toast.id)}
|
||||
>
|
||||
<!-- `info` keeps the neutral base styling; only the icon distinguishes it. -->
|
||||
{#if toast.kind === 'error'}
|
||||
<TriangleAlert size={15} />
|
||||
{:else if toast.kind === 'info'}
|
||||
<Info size={15} />
|
||||
{:else}
|
||||
<Check size={15} />
|
||||
{/if}
|
||||
|
||||
@@ -108,6 +108,9 @@
|
||||
<p class="hint">
|
||||
{m['printing.qrcode.customUrl.hint']()} <code>{'{id}'}</code>
|
||||
</p>
|
||||
<p class="hint">
|
||||
{m['printing.qrcode.customUrl.fieldsHint']()} <code>{'{filament.id}'}</code>
|
||||
</p>
|
||||
<p class="hint">{m['printing.qrcode.customUrl.caveat']()}</p>
|
||||
{:else}
|
||||
<p class="hint">{m['printing.qrcode.useHTTPUrl.tooltip']()}</p>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import Button from '$components/Button.svelte';
|
||||
import Pagination from '$components/Pagination.svelte';
|
||||
import NumberInput from '../NumberInput.svelte';
|
||||
import LabelCanvas from './LabelCanvas.svelte';
|
||||
import { labelKind, type LabelDesign } from '$lib/labels/types';
|
||||
import { labelKind, type LabelKind, type LabelDesign } from '$lib/labels/types';
|
||||
import type { LabelBinding } from '$lib/labels/template';
|
||||
import { PAPER_NAMES, paperSize, sheetGrid } from '$lib/labels/paper';
|
||||
import { PAPER_NAMES, layoutMargin, paperSize, sheetGrid } from '$lib/labels/paper';
|
||||
import { printLabels, exportLabels, ZIP_THRESHOLD } from '$lib/labels/print';
|
||||
import { EXPORT_FORMATS, resolveExportFormat } from '$lib/labels/export';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
@@ -32,7 +33,7 @@
|
||||
|
||||
let selected = $state<Set<number>>(new Set(untrack(() => preselected)));
|
||||
let search = $state('');
|
||||
// Default to newest-first so spools you just added sit at the top of the list —
|
||||
// Default to newest-first so items you just added sit at the top of the list,
|
||||
// the common case when printing labels for a fresh batch.
|
||||
let sort = $state<'newest' | 'id'>('newest');
|
||||
let loading = $state(true);
|
||||
@@ -40,27 +41,31 @@
|
||||
let printing = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
// The full inventory can be thousands of spools, so loading every one up front
|
||||
// was an extremely heavy query. Instead preload only the latest batch — the
|
||||
// common case when printing labels for spools you just added — and let search
|
||||
// pull in anything older on demand.
|
||||
const INITIAL_LIMIT = 50;
|
||||
// …but the latest 50 isn't enough for the "Recent" quick-select once you've
|
||||
// added more than that in the last 24h, so we keep paging (newest-first) past
|
||||
// the first 50 while still inside the recent window. PAGE is the batch size for
|
||||
// that extension; MAX_INITIAL caps it so a huge same-day import can't recreate
|
||||
// the original heavy load.
|
||||
// The full inventory can be thousands of spools or filaments, so loading it all
|
||||
// up front was an extremely heavy query. Instead the list shows one page at a time and
|
||||
// search pulls in anything else on demand. Pages rather than infinite scroll so
|
||||
// "All" stays honest: it adds the page you are looking at.
|
||||
const PAGE = 100;
|
||||
const MAX_INITIAL = 1000;
|
||||
// "Recent" must still cover every spool added in the last 24h, past the first
|
||||
// page too, so it has its own newest-first loader. MAX_RECENT caps it so a huge
|
||||
// same-day import can't recreate the original heavy load.
|
||||
const MAX_RECENT = 1000;
|
||||
// Color-similarity threshold for the search (a query like "#ff0000" or "red"
|
||||
// also runs a color match); matches the value used by the library picker.
|
||||
const COLOR_THRESHOLD = 20;
|
||||
|
||||
// IDs of the preloaded "latest" spools, and of the current search result (null
|
||||
// when not searching). Both index into the reactive inventory cache, so the
|
||||
// list stays live as spools are edited or removed.
|
||||
let baseIds = $state<number[]>([]);
|
||||
// IDs of the current page, of the recently added spools, and of the current
|
||||
// search result (null when not searching). All index into the reactive inventory
|
||||
// cache, so the list stays live as spools are edited or removed.
|
||||
let pageIds = $state<number[]>([]);
|
||||
let pageFIds = $state<string[]>([]);
|
||||
let resultFIds = $state<string[] | null>(null);
|
||||
let recentIds = $state<number[]>([]);
|
||||
let resultIds = $state<number[] | null>(null);
|
||||
let pageNo = $state(1);
|
||||
let pageSize = $state(PAGE);
|
||||
let total = $state(0);
|
||||
let listEl = $state<HTMLElement>();
|
||||
|
||||
// Spools registered within this window count as "recently added" for the
|
||||
// one-click quick-select.
|
||||
@@ -77,80 +82,111 @@
|
||||
return Number.isNaN(t) ? s.id : t;
|
||||
}
|
||||
|
||||
// The list pages in its displayed order, so the next page of "newest" holds
|
||||
// older spools and the next page of "#" higher ids.
|
||||
const dir = $derived(sort === 'newest' ? 'desc' : 'asc');
|
||||
|
||||
$effect(() => {
|
||||
void kind;
|
||||
untrack(() => (pageNo = 1));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (kind !== 'spool') return;
|
||||
const ctrl = new AbortController();
|
||||
void loadInitial(ctrl.signal);
|
||||
void loadPage(kind, dir, (pageNo - 1) * pageSize, pageSize, ctrl.signal);
|
||||
return () => ctrl.abort();
|
||||
});
|
||||
async function loadInitial(signal: AbortSignal) {
|
||||
async function loadPage(
|
||||
kind: LabelKind,
|
||||
dir: 'asc' | 'desc',
|
||||
offset: number,
|
||||
limit: number,
|
||||
signal: AbortSignal
|
||||
) {
|
||||
loading = true;
|
||||
try {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local, not reactive state
|
||||
const seen = new Set<number>();
|
||||
const ids: number[] = [];
|
||||
let offset = 0;
|
||||
// Load the latest INITIAL_LIMIT, then keep paging while the oldest spool
|
||||
// of the last batch is still within the 24h window — so every spool added
|
||||
// today lands in the list (and thus in "Recent"), not just the newest 50.
|
||||
// Sorted by id desc, which is monotonic with registration, so once a batch
|
||||
// reaches past the window everything below it is older too.
|
||||
for (;;) {
|
||||
const limit = ids.length === 0 ? INITIAL_LIMIT : PAGE;
|
||||
const page = await spoolSource.listSpools({
|
||||
filters: {},
|
||||
sort: [{ field: 'id', dir: 'desc' }],
|
||||
limit,
|
||||
offset,
|
||||
lowThreshold: settings.lowThreshold,
|
||||
signal
|
||||
});
|
||||
for (const s of page.items) {
|
||||
if (!seen.has(s.id)) {
|
||||
seen.add(s.id);
|
||||
ids.push(s.id);
|
||||
}
|
||||
}
|
||||
offset += page.items.length;
|
||||
const oldest = page.items.at(-1);
|
||||
const more =
|
||||
page.items.length === limit && // a full page ⇒ there may be more
|
||||
ids.length < MAX_INITIAL && // safety cap
|
||||
!!oldest &&
|
||||
isRecent(oldest); // still inside the 24h window
|
||||
if (!more) break;
|
||||
}
|
||||
baseIds = ids;
|
||||
const query = { sort: [{ field: 'id', dir }], limit, offset, signal };
|
||||
const page =
|
||||
kind === 'filament'
|
||||
? await spoolSource.listFilamentPage(query)
|
||||
: await spoolSource.listSpools({ ...query, filters: {}, lowThreshold: settings.lowThreshold });
|
||||
if (signal.aborted) return;
|
||||
if (kind === 'filament') pageFIds = (page.items as Filament[]).map((f) => f.id);
|
||||
else pageIds = (page.items as Spool[]).map((s) => s.id);
|
||||
total = page.total;
|
||||
listEl?.scrollTo(0, 0);
|
||||
} catch (e) {
|
||||
// Abandoning the tab mid-load is exactly when cancelling matters most.
|
||||
if (isAbortError(e, signal)) return;
|
||||
console.error('Failed to load spools for printing', e);
|
||||
console.error(`Failed to load ${kind}s for printing`, e);
|
||||
} finally {
|
||||
if (!signal.aborted) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Search hits the backend rather than filtering the preloaded slice, so spools
|
||||
$effect(() => {
|
||||
if (kind !== 'spool') return;
|
||||
const ctrl = new AbortController();
|
||||
void loadRecent(ctrl.signal);
|
||||
return () => ctrl.abort();
|
||||
});
|
||||
async function loadRecent(signal: AbortSignal) {
|
||||
try {
|
||||
const ids: number[] = [];
|
||||
let offset = 0;
|
||||
// Page newest-first while the last spool of the batch is still within the
|
||||
// 24h window. Sorted by id, which is monotonic with registration, so once a
|
||||
// batch reaches past the window everything below it is older too.
|
||||
for (;;) {
|
||||
const page = await spoolSource.listSpools({
|
||||
filters: {},
|
||||
sort: [{ field: 'id', dir: 'desc' }],
|
||||
limit: PAGE,
|
||||
offset,
|
||||
lowThreshold: settings.lowThreshold,
|
||||
signal
|
||||
});
|
||||
ids.push(...page.items.filter(isRecent).map((s) => s.id));
|
||||
offset += page.items.length;
|
||||
const last = page.items.at(-1);
|
||||
if (page.items.length < PAGE || offset >= MAX_RECENT || !last || !isRecent(last)) break;
|
||||
}
|
||||
// A live insert shifts the offsets, so batches can overlap.
|
||||
recentIds = [...new Set(ids)];
|
||||
} catch (e) {
|
||||
if (isAbortError(e, signal)) return;
|
||||
console.error('Failed to load recent spools for printing', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Search hits the backend rather than filtering the preloaded slice, so entities
|
||||
// outside the latest batch stay findable. Debounced, and superseded queries are
|
||||
// aborted on cleanup.
|
||||
$effect(() => {
|
||||
if (kind !== 'spool') return;
|
||||
const q = search.trim();
|
||||
const searchKind = kind;
|
||||
const q = (searchKind === 'filament' ? fSearch : search).trim();
|
||||
if (!q) {
|
||||
resultIds = null;
|
||||
resultFIds = null;
|
||||
searching = false;
|
||||
return;
|
||||
}
|
||||
searching = true;
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => void runSearch(q, ctrl.signal), 250);
|
||||
const timer = setTimeout(() => void runSearch(searchKind, q, ctrl.signal), 250);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
ctrl.abort();
|
||||
};
|
||||
});
|
||||
async function runSearch(q: string, signal: AbortSignal) {
|
||||
async function runSearch(kind: LabelKind, q: string, signal: AbortSignal) {
|
||||
try {
|
||||
const { spools, filaments } = await searchAll(q, COLOR_THRESHOLD, signal);
|
||||
if (signal.aborted) return;
|
||||
if (kind === 'filament') {
|
||||
resultFIds = filaments.map((m) => m.entity.id);
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local, not reactive state
|
||||
const ids = new Set<number>(spools.map((m) => m.entity.id));
|
||||
// The /search spool pass only matches spool-native fields, so also pull
|
||||
@@ -168,10 +204,10 @@
|
||||
});
|
||||
for (const s of page.items) ids.add(s.id);
|
||||
}
|
||||
resultIds = [...ids];
|
||||
if (!signal.aborted) resultIds = [...ids];
|
||||
} catch (e) {
|
||||
if (isAbortError(e, signal)) return;
|
||||
console.error('Spool search failed', e);
|
||||
console.error(`${kind} search failed`, e);
|
||||
} finally {
|
||||
if (!signal.aborted) searching = false;
|
||||
}
|
||||
@@ -184,9 +220,9 @@
|
||||
return `#${s.id} · ${name}${s.location ? ' · ' + s.location : ''}`;
|
||||
}
|
||||
|
||||
// The active id set — search results when searching, otherwise the latest batch —
|
||||
// The active id set — search results when searching, otherwise the current page —
|
||||
// resolved through the reactive cache (so edits/deletes reflect) and sorted.
|
||||
const activeIds = $derived(resultIds ?? baseIds);
|
||||
const activeIds = $derived(resultIds ?? pageIds);
|
||||
const visibleSpools = $derived(
|
||||
activeIds
|
||||
.map((id) => inventory.spoolById(id))
|
||||
@@ -194,9 +230,16 @@
|
||||
.sort((a, b) => (sort === 'newest' ? recencyKey(b) - recencyKey(a) : a.id - b.id))
|
||||
);
|
||||
|
||||
// Count of currently-visible spools added in the last 24h — drives the "Recent"
|
||||
// quick-select label and whether it's actionable.
|
||||
const recentCount = $derived(visibleSpools.filter(isRecent).length);
|
||||
// Spools added in the last 24h (within the results, when searching) — drive the
|
||||
// "Recent" quick-select label and whether it's actionable.
|
||||
const recentSpools = $derived(
|
||||
resultIds
|
||||
? visibleSpools.filter(isRecent)
|
||||
: recentIds
|
||||
.map((id) => inventory.spoolById(id))
|
||||
.filter((s): s is Spool => !!s && !s.archived && isRecent(s))
|
||||
);
|
||||
const recentCount = $derived(recentSpools.length);
|
||||
|
||||
function toggle(id: number) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local; `selected` updates via reassignment below
|
||||
@@ -205,11 +248,17 @@
|
||||
else next.add(id);
|
||||
selected = next;
|
||||
}
|
||||
// Quick-selects add to the selection rather than replace it, so picks carry
|
||||
// across pages.
|
||||
function selectAll() {
|
||||
selected = new Set(visibleSpools.map((s) => s.id));
|
||||
selected = new Set([...selected, ...visibleSpools.map((s) => s.id)]);
|
||||
}
|
||||
function selectRecent() {
|
||||
selected = new Set(visibleSpools.filter(isRecent).map((s) => s.id));
|
||||
selected = new Set([...selected, ...recentSpools.map((s) => s.id)]);
|
||||
}
|
||||
function setSort(s: 'newest' | 'id') {
|
||||
sort = s;
|
||||
pageNo = 1;
|
||||
}
|
||||
function clearAll() {
|
||||
selected = new Set();
|
||||
@@ -222,27 +271,8 @@
|
||||
}
|
||||
|
||||
// --- Filament selection (filament labels) ------------------------------------
|
||||
// Filament catalogs are small enough to load in one page and filter client-side,
|
||||
// so the filament picker is simpler than the spool one: no incremental paging,
|
||||
// no backend search, no "recent" window.
|
||||
let selectedF = $state<Set<string>>(new Set());
|
||||
let fSearch = $state('');
|
||||
let fLoading = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
if (kind !== 'filament') return;
|
||||
const ctrl = new AbortController();
|
||||
fLoading = true;
|
||||
spoolSource
|
||||
.listFilaments(1000, ctrl.signal)
|
||||
.catch((e) => {
|
||||
if (!isAbortError(e, ctrl.signal)) console.error('Failed to load filaments for printing', e);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) fLoading = false;
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
});
|
||||
|
||||
function filamentLabel(f: Filament): string {
|
||||
const v = inventory.vendorById(f.vendorId);
|
||||
@@ -250,16 +280,12 @@
|
||||
return `#${f.id} · ${name}${f.material ? ' · ' + f.material : ''}`;
|
||||
}
|
||||
|
||||
const visibleFilaments = $derived.by(() => {
|
||||
const q = fSearch.trim().toLowerCase();
|
||||
return inventory.filaments
|
||||
.filter((f) => {
|
||||
if (!q) return true;
|
||||
const v = inventory.vendorById(f.vendorId);
|
||||
return `${f.name} ${f.material} ${v?.name ?? ''}`.toLowerCase().includes(q);
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
const visibleFilaments = $derived(
|
||||
(resultFIds ?? pageFIds)
|
||||
.map((id) => inventory.filamentById(id))
|
||||
.filter((f): f is Filament => !!f)
|
||||
.sort((a, b) => (sort === 'newest' ? Number(b.id) - Number(a.id) : Number(a.id) - Number(b.id)))
|
||||
);
|
||||
|
||||
function toggleF(id: string) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local; `selectedF` updates via reassignment below
|
||||
@@ -269,7 +295,7 @@
|
||||
selectedF = next;
|
||||
}
|
||||
function selectAllF() {
|
||||
selectedF = new Set(visibleFilaments.map((f) => f.id));
|
||||
selectedF = new Set([...selectedF, ...visibleFilaments.map((f) => f.id)]);
|
||||
}
|
||||
function clearAllF() {
|
||||
selectedF = new Set();
|
||||
@@ -328,7 +354,9 @@
|
||||
}
|
||||
|
||||
function setMargin(k: 't' | 'b' | 'l' | 'r', v: number) {
|
||||
layout.margin = { ...layout.margin, [k]: v };
|
||||
// Negative margins anchor the grid off the paper: the labels get clipped and
|
||||
// the safe-zone inset (safe - margin) balloons past the label itself.
|
||||
layout.margin = { ...layout.margin, [k]: Math.max(0, v) };
|
||||
}
|
||||
function setSafe(k: 't' | 'b' | 'l' | 'r', v: number) {
|
||||
layout.safe = { ...layout.safe, [k]: Math.max(0, v) };
|
||||
@@ -339,28 +367,39 @@
|
||||
// the design (the button only touches width, per its label).
|
||||
function fitLabelWidth() {
|
||||
const page = paperSize(layout);
|
||||
const usableW = page.w - layout.margin.l - layout.margin.r;
|
||||
const margin = layoutMargin(layout);
|
||||
const usableW = page.w - margin.l - margin.r;
|
||||
const cols = Math.max(1, Math.round(layout.columns));
|
||||
const w = (usableW - (cols - 1) * layout.spacing.h) / cols;
|
||||
if (w > 0) design.label = { ...design.label, w: Math.round(w * 10) / 10 };
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet sortControls()}
|
||||
<div class="seg sort" title={kind === 'spool' ? m['labels.sortHint']() : undefined}>
|
||||
<button class:active={sort === 'newest'} onclick={() => setSort('newest')}
|
||||
>{m['labels.sortNewest']()}</button
|
||||
>
|
||||
<button class:active={sort === 'id'} onclick={() => setSort('id')}>{m['labels.sortId']()}</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="print-panel">
|
||||
<div class="col spools">
|
||||
{#if kind === 'filament'}
|
||||
<div class="col-head">
|
||||
<span>{m['filament.filament']()}</span>
|
||||
<div class="mini-actions">
|
||||
<button onclick={selectAllF}>{m['labels.selectAllShort']()}</button>
|
||||
<button onclick={selectAllF} disabled={loading || searching}>{m['labels.selectAllShort']()}</button>
|
||||
<button onclick={clearAllF}>{m['labels.selectNoneShort']()}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-row">
|
||||
<input class="search" placeholder={m['labels.searchFilaments']()} bind:value={fSearch} />
|
||||
{@render sortControls()}
|
||||
</div>
|
||||
<div class="spool-list">
|
||||
{#if fLoading && visibleFilaments.length === 0}
|
||||
<div class="spool-list" bind:this={listEl}>
|
||||
{#if loading || (searching && visibleFilaments.length === 0)}
|
||||
<div class="muted">{m.loading()}…</div>
|
||||
{:else if visibleFilaments.length === 0}
|
||||
<div class="muted">{m['labels.noFilaments']()}</div>
|
||||
@@ -379,7 +418,7 @@
|
||||
<div class="col-head">
|
||||
<span>{m['spool.spool']()}</span>
|
||||
<div class="mini-actions">
|
||||
<button onclick={selectAll}>{m['labels.selectAllShort']()}</button>
|
||||
<button onclick={selectAll} disabled={loading || searching}>{m['labels.selectAllShort']()}</button>
|
||||
<button onclick={selectRecent} disabled={recentCount === 0} title={m['labels.selectRecentHint']()}
|
||||
>{m['labels.selectRecentShort']({ count: recentCount })}</button
|
||||
>
|
||||
@@ -388,14 +427,9 @@
|
||||
</div>
|
||||
<div class="search-row">
|
||||
<input class="search" placeholder={m['labels.searchSpools']()} bind:value={search} />
|
||||
<div class="seg sort" title={m['labels.sortHint']()}>
|
||||
<button class:active={sort === 'newest'} onclick={() => (sort = 'newest')}
|
||||
>{m['labels.sortNewest']()}</button
|
||||
>
|
||||
<button class:active={sort === 'id'} onclick={() => (sort = 'id')}>{m['labels.sortId']()}</button>
|
||||
</div>
|
||||
{@render sortControls()}
|
||||
</div>
|
||||
<div class="spool-list">
|
||||
<div class="spool-list" bind:this={listEl}>
|
||||
{#if loading || (searching && visibleSpools.length === 0)}
|
||||
<div class="muted">{m.loading()}…</div>
|
||||
{:else if visibleSpools.length === 0}
|
||||
@@ -412,6 +446,19 @@
|
||||
</div>
|
||||
<div class="count">{m['printing.spoolSelect.selectedTotal']({ count: selected.size })}</div>
|
||||
{/if}
|
||||
{#if !(kind === 'filament' ? resultFIds : resultIds) && total > pageSize}
|
||||
<Pagination
|
||||
page={pageNo}
|
||||
{pageSize}
|
||||
{total}
|
||||
unit={kind === 'filament' ? m['library.catalog.unitFilaments']() : m['library.unitSpools']()}
|
||||
onpage={(p) => (pageNo = p)}
|
||||
onpagesize={(s) => {
|
||||
pageSize = s;
|
||||
pageNo = 1;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="col layout">
|
||||
@@ -515,6 +562,7 @@
|
||||
<label class="fld"
|
||||
>{m['printing.generic.marginTop']()}<NumberInput
|
||||
dense
|
||||
min={0}
|
||||
unit="mm"
|
||||
value={layout.margin.t}
|
||||
onchange={(v) => setMargin('t', v)}
|
||||
@@ -523,6 +571,7 @@
|
||||
<label class="fld"
|
||||
>{m['printing.generic.marginBottom']()}<NumberInput
|
||||
dense
|
||||
min={0}
|
||||
unit="mm"
|
||||
value={layout.margin.b}
|
||||
onchange={(v) => setMargin('b', v)}
|
||||
@@ -531,6 +580,7 @@
|
||||
<label class="fld"
|
||||
>{m['printing.generic.marginLeft']()}<NumberInput
|
||||
dense
|
||||
min={0}
|
||||
unit="mm"
|
||||
value={layout.margin.l}
|
||||
onchange={(v) => setMargin('l', v)}
|
||||
@@ -539,6 +589,7 @@
|
||||
<label class="fld"
|
||||
>{m['printing.generic.marginRight']()}<NumberInput
|
||||
dense
|
||||
min={0}
|
||||
unit="mm"
|
||||
value={layout.margin.r}
|
||||
onchange={(v) => setMargin('r', v)}
|
||||
@@ -784,6 +835,13 @@
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
/* The shared pager is styled as a list-footer bar; here it sits under a bordered
|
||||
box in a narrow column, so drop the bar chrome. */
|
||||
.spools :global(.pager) {
|
||||
padding: 0;
|
||||
border-top: none;
|
||||
background: none;
|
||||
}
|
||||
.spool-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
Every href below comes from resolve() or params.catalogHref(), which already
|
||||
resolves against the deploy base path; resolving again would double-apply it. */
|
||||
import { resolve } from '$app/paths';
|
||||
import { tick } from 'svelte';
|
||||
import { catalogHref, type CatalogView } from '$lib/library/params';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Props {
|
||||
view: CatalogView;
|
||||
/** Rows in the whole list (not the page); absent until the first load. */
|
||||
count?: number;
|
||||
}
|
||||
let { view, count }: Props = $props();
|
||||
|
||||
const lists = [
|
||||
{ view: 'filaments', label: m['library.catalog.allFilaments'] },
|
||||
{ view: 'manufacturers', label: m['library.catalog.allManufacturers'] }
|
||||
] as const;
|
||||
let title = $derived(lists.find((l) => l.view === view)!.label());
|
||||
|
||||
// The menu follows the Library tab's (NavTabs): arrow keys walk the items,
|
||||
// Escape hands focus back to the button, a click anywhere else closes it.
|
||||
let open = $state(false);
|
||||
let wrap = $state<HTMLElement>();
|
||||
let button = $state<HTMLButtonElement>();
|
||||
let menuEl = $state<HTMLElement>();
|
||||
|
||||
function items(): HTMLElement[] {
|
||||
return menuEl ? [...menuEl.querySelectorAll<HTMLElement>('[role^="menuitem"]')] : [];
|
||||
}
|
||||
async function openMenu(focusFirst: boolean) {
|
||||
open = true;
|
||||
if (!focusFirst) return;
|
||||
await tick();
|
||||
items()[0]?.focus();
|
||||
}
|
||||
function closeMenu(refocus = false) {
|
||||
open = false;
|
||||
if (refocus) button?.focus();
|
||||
}
|
||||
|
||||
// detail === 0 means the click came from Enter/Space, not a pointer.
|
||||
function onButtonClick(e: MouseEvent) {
|
||||
if (open) closeMenu();
|
||||
else openMenu(e.detail === 0);
|
||||
}
|
||||
function onButtonKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
openMenu(true);
|
||||
} else if (e.key === 'Escape' && open) {
|
||||
// Opened by a click, focus is still here rather than in the menu.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
function onMenuKeydown(e: KeyboardEvent) {
|
||||
const list = items();
|
||||
const i = list.indexOf(document.activeElement as HTMLElement);
|
||||
let next: number | undefined;
|
||||
if (e.key === 'ArrowDown') next = (i + 1) % list.length;
|
||||
else if (e.key === 'ArrowUp') next = (i - 1 + list.length) % list.length;
|
||||
else if (e.key === 'Home') next = 0;
|
||||
else if (e.key === 'End') next = list.length - 1;
|
||||
if (next !== undefined) {
|
||||
e.preventDefault();
|
||||
list[next].focus();
|
||||
} else if (e.key === 'Escape') {
|
||||
// Don't let it also close a surrounding dialog or panel.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu(true);
|
||||
} else if (e.key === 'Tab') {
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// Capture phase, like Combobox, so a click that something else swallows still closes the menu.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (wrap && !wrap.contains(e.target as Node)) closeMenu();
|
||||
};
|
||||
window.addEventListener('pointerdown', onDown, true);
|
||||
return () => window.removeEventListener('pointerdown', onDown, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Where you are and the way back: the catalogs are side trips from the spool
|
||||
list, so the spools stay one click away and the title says which list this is. -->
|
||||
<div class="head">
|
||||
<a class="back" href={resolve('/')} title={m['library.catalog.backToSpools']()}
|
||||
><ChevronLeft size={16} />{m['search.section.spools']()}</a
|
||||
>
|
||||
<span class="slash" aria-hidden="true">/</span>
|
||||
<div class="title-wrap" bind:this={wrap}>
|
||||
<button
|
||||
type="button"
|
||||
class="title"
|
||||
class:open
|
||||
bind:this={button}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={m['library.catalog.switchList']()}
|
||||
onclick={onButtonClick}
|
||||
onkeydown={onButtonKeydown}
|
||||
>
|
||||
<span class="label">{title}</span>
|
||||
{#if count !== undefined}<span class="count mono">{count}</span>{/if}
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{#if open}
|
||||
<!-- eslint-disable-next-line svelte/no-static-element-interactions -- arrow keys bubble up from the focused item -->
|
||||
<div
|
||||
class="menu"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
aria-label={m['library.catalog.switchList']()}
|
||||
bind:this={menuEl}
|
||||
onkeydown={onMenuKeydown}
|
||||
>
|
||||
{#each lists as l (l.view)}
|
||||
{@const checked = l.view === view}
|
||||
<a
|
||||
class="mi"
|
||||
class:sel={checked}
|
||||
href={catalogHref(l.view)}
|
||||
role="menuitemradio"
|
||||
aria-checked={checked}
|
||||
onclick={() => closeMenu()}
|
||||
>
|
||||
<span class="mi-check" aria-hidden="true"
|
||||
>{#if checked}<Check size={15} />{/if}</span
|
||||
>
|
||||
<span class="mi-label">{l.label()}</span>
|
||||
</a>
|
||||
{/each}
|
||||
<div class="menu-sep" role="separator"></div>
|
||||
<a class="mi" href={resolve('/')} role="menuitem" onclick={() => closeMenu()}>
|
||||
<span class="mi-check" aria-hidden="true"></span>
|
||||
<span class="mi-label">{m['library.catalog.backToSpools']()}</span>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 8px 14px 0;
|
||||
flex: none;
|
||||
}
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex: none;
|
||||
height: 32px;
|
||||
padding: 0 8px 0 4px;
|
||||
margin-left: -6px;
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.back:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.slash {
|
||||
flex: none;
|
||||
padding: 0 2px;
|
||||
color: var(--text-faint);
|
||||
font-size: 14px;
|
||||
}
|
||||
.title-wrap {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
max-width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.title:hover,
|
||||
.title.open {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
/* A long translation gives way before the count and the chevron do. */
|
||||
.label {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.count {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.back:focus-visible,
|
||||
.title:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
min-width: 220px;
|
||||
padding: 4px 0;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.menu-sep {
|
||||
height: 1px;
|
||||
background: var(--border-soft);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.mi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
padding: 8px 14px;
|
||||
color: var(--text-2);
|
||||
font-size: 12.5px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mi:hover,
|
||||
.mi:focus-visible {
|
||||
background: var(--surface-raised);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
.mi:focus-visible {
|
||||
box-shadow: inset 2px 0 0 var(--accent);
|
||||
}
|
||||
.mi.sel {
|
||||
color: var(--accent-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
.mi-check {
|
||||
flex: none;
|
||||
width: 15px;
|
||||
display: flex;
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* Phone: the same 44px tap targets as the rest of the mobile chrome. */
|
||||
@media (max-width: 860px) {
|
||||
.head {
|
||||
padding-top: 6px;
|
||||
}
|
||||
.back,
|
||||
.title {
|
||||
height: 44px;
|
||||
}
|
||||
.title {
|
||||
font-size: 16px;
|
||||
}
|
||||
.mi {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
Section links come from a src/lib/library/params.ts helper, which already
|
||||
resolves against the deploy base path; resolving again would double-apply it. */
|
||||
import CatalogHeader from './CatalogHeader.svelte';
|
||||
import CatalogRow from './CatalogRow.svelte';
|
||||
import ListToolbar from './ListToolbar.svelte';
|
||||
import Pagination from '../Pagination.svelte';
|
||||
import Factory from '@lucide/svelte/icons/factory';
|
||||
import type { GroupSummary } from '$lib/api/types';
|
||||
import type { CatalogView, LibraryState } from '$lib/library/params';
|
||||
import * as params from '$lib/library/params';
|
||||
import { catalogSections } from '$lib/library/catalog';
|
||||
import { buildCatalogQuery, buildVendorTotalsQuery } from '$lib/api/query';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { isAbortError } from '$lib/api/http';
|
||||
import { live } from '$lib/api/live';
|
||||
import { weightAuto } from '$lib/utils/format';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// The filament and manufacturer catalogs: sub-views of the Library that page
|
||||
// the group endpoint the way FilamentList does, one row per filament or
|
||||
// manufacturer, spools or not.
|
||||
let { libraryState }: { libraryState: LibraryState } = $props();
|
||||
|
||||
let view = $derived(libraryState.view as CatalogView);
|
||||
|
||||
// What is on screen, swapped whole when a load lands. `sectioned` belongs to the
|
||||
// rows it was loaded with, not to the current URL: while a regrouped page is on
|
||||
// its way, the old rows keep the layout they arrived in.
|
||||
let groups = $state<GroupSummary[]>([]);
|
||||
let sectioned = $state(false);
|
||||
/** Section totals by manufacturer id, for the manufacturers on this page. */
|
||||
let vendorTotals = $state<Record<string, GroupSummary>>({});
|
||||
let total = $state<number>();
|
||||
let totalSpools = $state<number>();
|
||||
let totalRemaining = $state<number>();
|
||||
let loading = $state(false);
|
||||
let errored = $state(false);
|
||||
/** Bumped by live events; forces a refetch of the page. */
|
||||
let revision = $state(0);
|
||||
|
||||
// One request chain at a time, aborted by the effect's cleanup when superseded,
|
||||
// exactly like FilamentList. The section totals need the page's manufacturer
|
||||
// ids, so they follow the page rather than racing it, and both land together.
|
||||
let reqId = 0;
|
||||
$effect(() => {
|
||||
const ctrl = new AbortController();
|
||||
const s = libraryState;
|
||||
const q = buildCatalogQuery(s, ctrl.signal);
|
||||
const withSections = s.view === 'filaments' && s.group === 'vendor';
|
||||
void revision; // refetch on live events
|
||||
const mine = ++reqId;
|
||||
loading = true;
|
||||
errored = false;
|
||||
(async () => {
|
||||
const page = await spoolSource.listGroups(q);
|
||||
const totals: Record<string, GroupSummary> = {};
|
||||
// The filaments with no manufacturer have no vendor group to total them.
|
||||
const ids = withSections
|
||||
? [...new Set(page.items.map((g) => g.vendorId).filter((id): id is string => !!id))]
|
||||
: [];
|
||||
// The totals only dress the section headers, so losing them costs the
|
||||
// headers their meta line, not the page its rows.
|
||||
if (ids.length) {
|
||||
try {
|
||||
const vendors = await spoolSource.listGroups(buildVendorTotalsQuery(s, ids, ctrl.signal));
|
||||
for (const v of vendors.items) totals[v.key] = v;
|
||||
} catch (err) {
|
||||
if (isAbortError(err, ctrl.signal)) throw err;
|
||||
console.error('Failed to load the manufacturer totals', err);
|
||||
}
|
||||
}
|
||||
if (mine !== reqId) return;
|
||||
groups = page.items;
|
||||
sectioned = withSections;
|
||||
vendorTotals = totals;
|
||||
total = page.total;
|
||||
totalSpools = page.totalSpools;
|
||||
totalRemaining = page.totalRemaining;
|
||||
loading = false;
|
||||
})().catch((err) => {
|
||||
if (mine !== reqId || isAbortError(err, ctrl.signal)) return;
|
||||
console.error('Failed to load the catalog', err);
|
||||
errored = true;
|
||||
loading = false;
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
});
|
||||
|
||||
// Live updates, coalesced with the same leading-edge throttle as FilamentList
|
||||
// (see the reasoning there): the first event refetches at once, a burst folds
|
||||
// into one catch-up refetch when the window ends.
|
||||
const COALESCE_MS = 300;
|
||||
let cooldown: ReturnType<typeof setTimeout> | undefined;
|
||||
let coalesced = false;
|
||||
function bumpRevision() {
|
||||
if (cooldown) {
|
||||
coalesced = true;
|
||||
return;
|
||||
}
|
||||
revision++;
|
||||
cooldown = setTimeout(() => {
|
||||
cooldown = undefined;
|
||||
if (coalesced) {
|
||||
coalesced = false;
|
||||
bumpRevision();
|
||||
}
|
||||
}, COALESCE_MS);
|
||||
}
|
||||
$effect(() => {
|
||||
const offs = (['spool', 'filament', 'vendor'] as const).map((resource) =>
|
||||
live.subscribe(resource, {}, bumpRevision)
|
||||
);
|
||||
return () => {
|
||||
offs.forEach((off) => off());
|
||||
if (cooldown) clearTimeout(cooldown);
|
||||
cooldown = undefined;
|
||||
coalesced = false;
|
||||
};
|
||||
});
|
||||
|
||||
let sections = $derived(sectioned ? catalogSections(groups) : []);
|
||||
|
||||
let unit = $derived(
|
||||
view === 'filaments' ? m['library.catalog.unitFilaments']() : m['library.catalog.unitManufacturers']()
|
||||
);
|
||||
function countLabel(count: number): string {
|
||||
return view === 'filaments'
|
||||
? m['library.catalog.filamentCount']({ count })
|
||||
: m['library.catalog.manufacturerCount']({ count });
|
||||
}
|
||||
|
||||
// "11 filaments · 23 spools · 15.1 kg": the whole list, not the page. The two
|
||||
// totals come from response headers an older server doesn't send.
|
||||
let summary = $derived(
|
||||
total
|
||||
? [
|
||||
countLabel(total),
|
||||
totalSpools != null ? m['library.groupSpools']({ count: totalSpools }) : '',
|
||||
totalRemaining != null ? weightAuto(totalRemaining) : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: undefined
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="list">
|
||||
<CatalogHeader {view} count={total} />
|
||||
<ListToolbar {libraryState} />
|
||||
<div class="rows scroll-y" class:loading>
|
||||
{#if sectioned}
|
||||
{#each sections as sec (sec.vendorId + ':' + sec.rows[0].key)}
|
||||
{@const totals = vendorTotals[sec.vendorId]}
|
||||
<!-- One box per section, so its sticky header lets go where the section ends. -->
|
||||
<div>
|
||||
<div class="section">
|
||||
{#if sec.vendorId}
|
||||
<a
|
||||
class="vendor"
|
||||
href={params.selectHrefFromState(libraryState, 'vendor', sec.vendorId)}
|
||||
title={m['library.openManufacturer']({ name: sec.vendorName ?? '' })}
|
||||
data-sveltekit-keepfocus
|
||||
data-sveltekit-noscroll
|
||||
>
|
||||
<Factory size={12} />
|
||||
<span class="vname">{sec.vendorName}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<span class="vendor-none">{m['library.catalog.noManufacturer']()}</span>
|
||||
{/if}
|
||||
{#if totals}
|
||||
<span class="meta"
|
||||
>{countLabel(totals.filamentCount ?? 0)} · {weightAuto(totals.totalRemaining)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#each sec.rows as group (group.key)}
|
||||
<CatalogRow {group} {libraryState} />
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each groups as group (group.field + ':' + group.key)}
|
||||
<CatalogRow {group} {libraryState} showVendor={group.field === 'filament'} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if errored}
|
||||
<div class="empty">{m['library.apiError']()}</div>
|
||||
{:else if total === 0 && !loading}
|
||||
<div class="empty">
|
||||
{libraryState.filters.length ? m['library.emptyFiltered']({ unit }) : m['pagination.empty']({ unit })}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Pagination
|
||||
page={libraryState.page}
|
||||
pageSize={libraryState.pageSize}
|
||||
total={total ?? 0}
|
||||
{unit}
|
||||
{summary}
|
||||
onpage={(p) => params.setPage(p)}
|
||||
onpagesize={(s) => params.setPageSize(s)}
|
||||
hrefFor={(p) => params.pageHrefFromState(libraryState, p)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
.rows {
|
||||
flex: 1;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.rows.loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
/* The manufacturer section bar: sunken like a group header, and sticky so a
|
||||
long run of filaments still says whose they are. */
|
||||
.section {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 7px 14px;
|
||||
background: var(--surface-sunken);
|
||||
border-top: 1px solid var(--border-soft);
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
/* The padding makes a ~28px tap target (over the 24px WCAG 2.2 floor) while the
|
||||
matching negative margin keeps the bar as tight as it looks. */
|
||||
.vendor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 7px;
|
||||
margin: -7px;
|
||||
border-radius: var(--radius-sm);
|
||||
/* --accent-soft, not --accent-link: it holds AA on the sunken surface in both
|
||||
themes (see GroupHeader). */
|
||||
color: var(--accent-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.vendor:hover {
|
||||
background: var(--surface-raised);
|
||||
color: var(--accent-link-hover);
|
||||
}
|
||||
.vendor:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.vname,
|
||||
.meta {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.vendor-none {
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
.meta {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
max-width: 60%;
|
||||
}
|
||||
.empty {
|
||||
padding: 40px 14px;
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,290 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
The 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 Swatch from '../Swatch.svelte';
|
||||
import MaterialBadge from '../MaterialBadge.svelte';
|
||||
import type { GroupSummary } from '$lib/api/types';
|
||||
import type { LibraryState } from '$lib/library/params';
|
||||
import * as params from '$lib/library/params';
|
||||
import { spoolPips } from '$lib/library/catalog';
|
||||
import { truncTitle } from '$lib/actions/truncated';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import { weightAuto } from '$lib/utils/format';
|
||||
import { page } from '$app/state';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// One row of a catalog sub-view: a filament or a manufacturer. Both share the
|
||||
// anatomy — picture, a name line, one details line, a fixed stock column — so
|
||||
// nothing a user types or a translation spells out can widen the list: every
|
||||
// text truncates in place.
|
||||
interface Props {
|
||||
/** A filament or vendor group from the catalog query. */
|
||||
group: GroupSummary;
|
||||
libraryState: LibraryState;
|
||||
/** Flat filament list: no section header names the manufacturer, so the row does. */
|
||||
showVendor?: boolean;
|
||||
}
|
||||
let { group, libraryState, showVendor = false }: Props = $props();
|
||||
|
||||
let kind = $derived<'filament' | 'vendor'>(group.field === 'vendor' ? 'vendor' : 'filament');
|
||||
let href = $derived(params.selectHrefFromState(libraryState, kind, group.key));
|
||||
let selected = $derived(params.isSelected(page.url.searchParams, kind, group.key));
|
||||
|
||||
// The group carries the aggregates; the entity itself (upserted into the cache
|
||||
// by the same request) carries the fields the details line shows.
|
||||
let filament = $derived(kind === 'filament' ? inventory.filamentById(group.key) : undefined);
|
||||
let vendor = $derived(kind === 'vendor' ? inventory.vendorById(group.key) : undefined);
|
||||
|
||||
let pips = $derived(spoolPips(group.previewSpools ?? [], settings.lowThreshold));
|
||||
|
||||
// Up to four of a manufacturer's filaments, overlapping in a 24px square.
|
||||
const STACK_OFFSETS = [
|
||||
[0, 0],
|
||||
[10, 0],
|
||||
[0, 10],
|
||||
[10, 10]
|
||||
];
|
||||
</script>
|
||||
|
||||
<a class="row" class:selected {href} data-sveltekit-keepfocus data-sveltekit-noscroll>
|
||||
<span class="id mono">#{group.key}</span>
|
||||
{#if kind === 'filament'}
|
||||
<Swatch colors={group.colors} direction={group.direction} size={24} radius={6} />
|
||||
{:else}
|
||||
<span class="stack" aria-hidden="true">
|
||||
{#each (group.previewFilaments ?? []).slice(0, 4) as f, i (f.id)}
|
||||
<span class="dot" style:left="{STACK_OFFSETS[i][0]}px" style:top="{STACK_OFFSETS[i][1]}px"
|
||||
><Swatch colors={f.colors} direction={f.direction} size={14} radius={4} /></span
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="body">
|
||||
<span class="line">
|
||||
<!-- The name gives way first; the badge and the id stay whole. -->
|
||||
<span class="name" use:truncTitle>{group.title}</span>
|
||||
{#if group.badge}<MaterialBadge label={group.badge} />{/if}
|
||||
</span>
|
||||
<!-- Labels dim, values bright, one line ending in an ellipsis. -->
|
||||
<span class="details" use:truncTitle>
|
||||
{#if kind === 'filament'}
|
||||
{#if showVendor}
|
||||
<span class="it"
|
||||
><span class="v" class:unset={!group.vendorName}
|
||||
>{group.vendorName ?? m['library.catalog.noManufacturer']()}</span
|
||||
></span
|
||||
>
|
||||
{/if}
|
||||
{#if filament}
|
||||
<span class="it"><span class="v">{filament.diameter} mm</span></span>
|
||||
<span class="it"
|
||||
><span class="k">{m['library.sort.nozzle']()}</span>
|
||||
<span class="v" class:unset={!filament.nozzleTemp}
|
||||
>{filament.nozzleTemp ? `${filament.nozzleTemp} °C` : m['library.catalog.notSet']()}</span
|
||||
></span
|
||||
>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="it"
|
||||
><span class="v">{m['library.catalog.filamentCount']({ count: group.filamentCount ?? 0 })}</span
|
||||
></span
|
||||
>
|
||||
<!-- An empty spool weight of 0 g is no weight at all: the API's null maps to 0. -->
|
||||
<span class="it"
|
||||
><span class="k">{m['vendor.fields.emptySpoolWeight']()}</span>
|
||||
<span class="v" class:unset={!vendor?.emptyWeight}
|
||||
>{vendor?.emptyWeight ? weightAuto(vendor.emptyWeight) : m['library.catalog.notSet']()}</span
|
||||
></span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="stock">
|
||||
{#if kind === 'filament' && group.spoolCount === 0}
|
||||
<span class="no-spools">{m['library.noSpools']()}</span>
|
||||
{:else}
|
||||
<span class="kg">{weightAuto(group.totalRemaining)}</span>
|
||||
{#if kind === 'filament'}
|
||||
<!-- One pip per spool, filled by what's left; past five the count carries
|
||||
the rest. A spool of unknown size gets a plain grey pip. -->
|
||||
<span class="pips" title={m['library.groupSpools']({ count: group.spoolCount })}>
|
||||
{#each pips as p, i (i)}
|
||||
<span class="pip" class:low={p.low} class:unknown={p.fill === null} aria-hidden="true"
|
||||
><span class="fill" style:width="{(p.fill ?? 1) * 100}%"></span></span
|
||||
>
|
||||
{/each}
|
||||
<span class="count">{group.spoolCount}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="count">{m['library.groupSpools']({ count: group.spoolCount })}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px 9px 12px;
|
||||
border-top: 1px solid var(--hairline);
|
||||
border-left: 2px solid transparent;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.row:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.row.selected {
|
||||
background: var(--accent-wash);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.row:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.stack {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
/* A ring in the page colour cuts each swatch out of the one beneath it. */
|
||||
.dot {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 1.5px var(--bg);
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* Same id column as the spool rows. */
|
||||
.id {
|
||||
flex: none;
|
||||
width: 36px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.details {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.it + .it::before {
|
||||
content: '·';
|
||||
margin: 0 6px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.k {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.v {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.v.unset {
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
/* Fixed, so the column of weights lines up down the list whatever the rows say. */
|
||||
.stock {
|
||||
flex: none;
|
||||
width: 92px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
.kg {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
.pip {
|
||||
width: 11px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--track);
|
||||
overflow: hidden;
|
||||
}
|
||||
.fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
}
|
||||
.pip.low .fill {
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
.pip.unknown .fill {
|
||||
background: var(--text-faint);
|
||||
}
|
||||
.count {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pips .count {
|
||||
margin-left: 3px;
|
||||
font-size: 10px;
|
||||
}
|
||||
/* The unused-spool colours: a filament with nothing on the shelf is a
|
||||
shopping-list entry, and a catalog holds many of them, so it stays calm. */
|
||||
.no-spools {
|
||||
font-size: 11px;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--unused-bg);
|
||||
border: 1px solid var(--unused-border);
|
||||
color: var(--unused-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.row {
|
||||
min-height: 56px;
|
||||
}
|
||||
.name {
|
||||
font-size: 14px;
|
||||
}
|
||||
.details {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.stock {
|
||||
width: 78px;
|
||||
}
|
||||
.kg {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -7,17 +7,31 @@
|
||||
// delete and re-add, because that hands them a new id. The v1 client exposed it
|
||||
// as a plain filament dropdown on the spool edit form; here it gets its own
|
||||
// two-step dialog so the weight consequence can be spelled out before applying.
|
||||
import { tick } from 'svelte';
|
||||
import Swatch from '../Swatch.svelte';
|
||||
import Button from '../Button.svelte';
|
||||
import NewFilamentCards from '../NewFilamentCards.svelte';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import type { Filament, Spool } from '$lib/types';
|
||||
import type { Extra, Filament, Spool } from '$lib/types';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { serverInfo } from '$lib/stores/serverInfo.svelte';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { fields } from '$lib/stores/fields.svelte';
|
||||
import { externalColors, externalDirection, type ExternalFilament } from '$lib/api/external';
|
||||
import { ExternalSearch } from '$lib/api/externalSearch.svelte';
|
||||
import { toasts } from '$lib/stores/toasts.svelte';
|
||||
import { weightAuto } from '$lib/utils/format';
|
||||
import { parseDecimal } from '$lib/utils/numeric';
|
||||
import {
|
||||
FILAMENT_ADVANCED_KEYS,
|
||||
emptyFilamentDraft,
|
||||
filamentDraftErrors,
|
||||
toNewFilamentDraft,
|
||||
type FilamentDraft,
|
||||
type FilamentWeights
|
||||
} from '$lib/filament/draft';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Props {
|
||||
@@ -29,19 +43,115 @@
|
||||
}
|
||||
let { open, spool, current, onclose }: Props = $props();
|
||||
|
||||
// Same two sources as the add-spools flow: the local catalog, plus SpoolmanDB
|
||||
// entries which are imported into the catalog when the change is applied.
|
||||
// The same three sources as the add-spools flow: the local catalog, SpoolmanDB
|
||||
// entries (imported into the catalog when the change is applied), and a filament
|
||||
// described from scratch. That third one is what makes this dialog usable for
|
||||
// the case it was built for — a slot refilled with a roll nobody has catalogued
|
||||
// yet is the ordinary case, not the exception, and creating it through the
|
||||
// add-spools flow would mint a spool this user explicitly does not want.
|
||||
type Choice = { source: 'catalog'; filament: Filament } | { source: 'external'; ext: ExternalFilament };
|
||||
|
||||
let query = $state('');
|
||||
let searchInput = $state<HTMLInputElement | undefined>();
|
||||
let localResults = $state<Filament[]>([]);
|
||||
let externalResults = $state<ExternalFilament[]>([]);
|
||||
const extSearch = new ExternalSearch();
|
||||
let searching = $state(false);
|
||||
let extError = $state(false);
|
||||
let chosen = $state<Choice | null>(null);
|
||||
let busy = $state(false);
|
||||
|
||||
// --- new filament -------------------------------------------------------
|
||||
let creating = $state(false);
|
||||
let modalEl = $state<HTMLDivElement | undefined>();
|
||||
let nf = $state<FilamentDraft>(emptyFilamentDraft());
|
||||
// Unlike the add-spools flow, nothing else here claims these numbers: no spool
|
||||
// is being created, so they belong to the filament alone and are shown with it.
|
||||
let nfWeights = $state<FilamentWeights>({ weight: '1000', spoolWeight: '', price: '' });
|
||||
let filamentExtra = $state<Extra>({});
|
||||
let vendorExtra = $state<Extra>({});
|
||||
let vendorIsNew = $state(false);
|
||||
let showAdvanced = $state(false);
|
||||
// Same reveal policy as the add-spools flow: a field's error appears once you
|
||||
// have left it, and everything appears once the apply button has been pressed,
|
||||
// so a form you just opened stays quiet. `errors` always describes the form.
|
||||
let touched = $state<Record<string, boolean>>({});
|
||||
let attempted = $state(false);
|
||||
|
||||
/** `current` with a default filled in for every field it doesn't already carry. */
|
||||
function withDefaults(entity: 'filament' | 'vendor'): Extra {
|
||||
const out: Extra = {};
|
||||
for (const f of fields.get(entity)) if (f.default_value != null) out[f.key] = f.default_value;
|
||||
return out;
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
creating = true;
|
||||
chosen = null;
|
||||
clearValidation();
|
||||
showAdvanced = false;
|
||||
nf = emptyFilamentDraft(query.trim());
|
||||
nfWeights = { weight: '1000', spoolWeight: '', price: '' };
|
||||
filamentExtra = withDefaults('filament');
|
||||
vendorExtra = withDefaults('vendor');
|
||||
}
|
||||
function stopCreate() {
|
||||
creating = false;
|
||||
clearValidation();
|
||||
}
|
||||
function clearValidation() {
|
||||
touched = {};
|
||||
attempted = false;
|
||||
}
|
||||
function setExtraOn(current: Extra, key: string, json: string | undefined): Extra {
|
||||
const next = { ...current };
|
||||
if (json === undefined) delete next[key];
|
||||
else next[key] = json;
|
||||
return next;
|
||||
}
|
||||
function setFilamentExtra(key: string, json: string | undefined) {
|
||||
filamentExtra = setExtraOn(filamentExtra, key, json);
|
||||
}
|
||||
function setVendorExtra(key: string, json: string | undefined) {
|
||||
vendorExtra = setExtraOn(vendorExtra, key, json);
|
||||
}
|
||||
|
||||
let errors = $derived(creating ? filamentDraftErrors(nf, nfWeights) : {});
|
||||
|
||||
// Weight sits beside the name here rather than in the spool block, and the two
|
||||
// numbers that come off a product page sit under the specs disclosure with them.
|
||||
const FIELD_ORDER = [
|
||||
'vendor',
|
||||
'name',
|
||||
'material',
|
||||
'netWeight',
|
||||
'colorHex',
|
||||
'density',
|
||||
'diameter',
|
||||
'nozzleTemp',
|
||||
'bedTemp',
|
||||
'spoolWeight',
|
||||
'price'
|
||||
];
|
||||
const ADVANCED_KEYS = new Set([...FILAMENT_ADVANCED_KEYS, 'spoolWeight', 'price']);
|
||||
let firstProblem = $derived(FIELD_ORDER.find((k) => errors[k]) ?? Object.keys(errors)[0]);
|
||||
|
||||
function touch(key: string) {
|
||||
touched[key] = true;
|
||||
}
|
||||
/** The error to display for a field — empty until that field is revealed. */
|
||||
function err(key: string): string {
|
||||
return attempted || touched[key] ? (errors[key] ?? '') : '';
|
||||
}
|
||||
/** Scroll a field into view and put focus in it, opening its section if needed. */
|
||||
async function focusField(key: string) {
|
||||
if (ADVANCED_KEYS.has(key)) showAdvanced = true;
|
||||
await tick();
|
||||
const host = modalEl?.querySelector<HTMLElement>(`[data-field="${key}"]`);
|
||||
if (!host) return;
|
||||
host.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
// preventScroll: the smooth scroll above is already on its way there.
|
||||
host.querySelector<HTMLElement>('input, textarea, select')?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function cName(c: Choice) {
|
||||
return c.source === 'catalog' ? c.filament.name : c.ext.name;
|
||||
}
|
||||
@@ -77,17 +187,11 @@
|
||||
}
|
||||
async function runSearch() {
|
||||
searching = true;
|
||||
extError = false;
|
||||
const [local, external] = await Promise.allSettled([
|
||||
const [local] = await Promise.allSettled([
|
||||
spoolSource.searchFilaments(query.trim()),
|
||||
spoolSource.searchExternalFilaments(query.trim())
|
||||
extSearch.search(query)
|
||||
]);
|
||||
localResults = local.status === 'fulfilled' ? local.value : [];
|
||||
if (external.status === 'fulfilled') externalResults = external.value;
|
||||
else {
|
||||
externalResults = [];
|
||||
extError = true;
|
||||
}
|
||||
searching = false;
|
||||
}
|
||||
|
||||
@@ -99,16 +203,24 @@
|
||||
initialized = true;
|
||||
query = '';
|
||||
chosen = null;
|
||||
creating = false;
|
||||
adoptWeight = true;
|
||||
busy = false;
|
||||
runSearch();
|
||||
clearValidation();
|
||||
// Needed only for the new-filament form, but requested on open so the
|
||||
// definitions are there by the time someone reaches it.
|
||||
fields.ensure('filament');
|
||||
fields.ensure('vendor');
|
||||
} else if (!open) {
|
||||
initialized = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Focus the search box whenever the picker is showing. Re-runs when the input
|
||||
// remounts, i.e. on the way back from the new-filament form.
|
||||
$effect(() => {
|
||||
if (open && searchInput) searchInput.focus();
|
||||
if (open && !creating && searchInput) searchInput.focus();
|
||||
});
|
||||
|
||||
// --- full weight --------------------------------------------------------
|
||||
@@ -125,14 +237,18 @@
|
||||
// forever, with no field in this client to correct it. So when the new
|
||||
// filament's weight differs, offer to take it — checked, because a spool
|
||||
// holding filament X almost always holds X's amount of it.
|
||||
let newWeight = $derived(chosen ? (cWeight(chosen) ?? 0) : 0);
|
||||
/** The spool follows its filament's weight and will be moved by the change itself. */
|
||||
let weightFollows = $derived(
|
||||
chosen != null && spool.initialOverride == null && newWeight !== spool.initial
|
||||
// A filament being described right here has its weight in the form rather than
|
||||
// on a record, so it is read from the draft — which also means the note below
|
||||
// updates as that field is typed.
|
||||
let picked = $derived(creating || chosen != null);
|
||||
let newWeight = $derived(
|
||||
creating ? (parseDecimal(nfWeights.weight) ?? 0) : chosen ? (cWeight(chosen) ?? 0) : 0
|
||||
);
|
||||
/** The spool follows its filament's weight and will be moved by the change itself. */
|
||||
let weightFollows = $derived(picked && spool.initialOverride == null && newWeight !== spool.initial);
|
||||
/** The spool has a weight of its own that the change would leave stale. */
|
||||
let weightDiffers = $derived(
|
||||
chosen != null && spool.initialOverride != null && newWeight > 0 && newWeight !== spool.initial
|
||||
picked && spool.initialOverride != null && newWeight > 0 && newWeight !== spool.initial
|
||||
);
|
||||
let adoptWeight = $state(true);
|
||||
let resultingFull = $derived(weightFollows || (weightDiffers && adoptWeight) ? newWeight : spool.initial);
|
||||
@@ -142,13 +258,30 @@
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
if (!chosen || busy) return;
|
||||
if (busy || !picked) return;
|
||||
// The button stays clickable while the form is incomplete, same as the
|
||||
// add-spools flow: a dead button answers "why can't I apply this?" with
|
||||
// silence. Pressing it reveals every outstanding error and takes you to the
|
||||
// first one, opening the specs block if that is where it is hiding.
|
||||
if (firstProblem) {
|
||||
attempted = true;
|
||||
await focusField(firstProblem);
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
const filamentId =
|
||||
chosen.source === 'external'
|
||||
? (await spoolSource.importExternalFilament(chosen.ext)).id
|
||||
: chosen.filament.id;
|
||||
const filamentId = creating
|
||||
? (
|
||||
await spoolSource.createFilament({
|
||||
...toNewFilamentDraft(nf, nfWeights, filamentExtra),
|
||||
// Dropped when the typed name matches an existing manufacturer —
|
||||
// which is also when the inputs for it aren't shown.
|
||||
vendorExtra: vendorIsNew ? vendorExtra : undefined
|
||||
})
|
||||
).id
|
||||
: chosen!.source === 'external'
|
||||
? (await spoolSource.importExternalFilament(chosen!.ext)).id
|
||||
: chosen!.filament.id;
|
||||
await spoolSource.saveSpool(spool.id, {
|
||||
filamentId,
|
||||
...(weightDiffers && adoptWeight ? { initial: newWeight } : {})
|
||||
@@ -175,7 +308,14 @@
|
||||
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="modal" role="dialog" aria-modal="true" aria-labelledby="change-filament-title" tabindex="-1">
|
||||
<div
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="change-filament-title"
|
||||
tabindex="-1"
|
||||
bind:this={modalEl}
|
||||
>
|
||||
<div class="modal-head">
|
||||
<span class="title" id="change-filament-title">{m['changeFilament.title']()}</span>
|
||||
<button class="x" onclick={close} aria-label={m['buttons.close']()}><X size={16} /></button>
|
||||
@@ -198,7 +338,22 @@
|
||||
<span class="arrow" aria-hidden="true"><ArrowRight size={16} /></span>
|
||||
<div class="side">
|
||||
<div class="side-label">{m['changeFilament.next']()}</div>
|
||||
{#if chosen}
|
||||
{#if creating}
|
||||
<!-- Mirrors the form below as it is filled in, so the "from → to" pair
|
||||
keeps meaning something while a filament is being described. -->
|
||||
<div class="fil">
|
||||
<Swatch colors={nf.colors} direction={nf.multiColorDirection} size={18} radius={5} />
|
||||
<div class="fil-name">
|
||||
<span class="fn">
|
||||
{nf.name.trim() || m['add.newFilamentTitle']()}
|
||||
<span class="tag external sm">{m['changeFilament.newTag']()}</span>
|
||||
</span>
|
||||
<span class="fs">
|
||||
{nf.vendorName.trim() || m['add.noManufacturer']()} · {nf.material.trim() || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if chosen}
|
||||
<div class="fil">
|
||||
<Swatch colors={cColors(chosen)} direction={cDirection(chosen)} size={18} radius={5} />
|
||||
<div class="fil-name">
|
||||
@@ -242,87 +397,115 @@
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
{#if chosen?.source === 'external'}
|
||||
<!-- Same promise either way: the filament joins the catalog as part of
|
||||
applying the change, whether it came from SpoolmanDB or the form. -->
|
||||
{#if creating || chosen?.source === 'external'}
|
||||
<div class="note">{m['changeFilament.importNote']()}</div>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
class="search-big"
|
||||
value={query}
|
||||
oninput={(e) => onSearch(e.currentTarget.value)}
|
||||
placeholder={m['add.searchPlaceholder']({ name: serverInfo.externalDbName })}
|
||||
aria-label={m['add.searchPlaceholder']({ name: serverInfo.externalDbName })}
|
||||
/>
|
||||
<div class="results">
|
||||
<div class="res-hdr">{m['add.yourCatalog']()}</div>
|
||||
{#if searching && localResults.length === 0}
|
||||
<div class="res-note">{m['add.searching']()}</div>
|
||||
{:else if localResults.length === 0}
|
||||
<div class="res-note">{m['add.noCatalog']()}</div>
|
||||
{:else}
|
||||
{#each localResults as f (f.id)}
|
||||
{@const isCurrent = f.id === current.id}
|
||||
<button
|
||||
class="res-item"
|
||||
class:sel={chosen?.source === 'catalog' && chosen.filament.id === f.id}
|
||||
disabled={isCurrent}
|
||||
onclick={() => (chosen = { source: 'catalog', filament: f })}
|
||||
>
|
||||
<Swatch colors={f.colors} direction={f.multiColorDirection} size={18} radius={5} />
|
||||
<div class="res-name">
|
||||
<span class="rn">{f.name}</span>
|
||||
<span class="rs">{vendorName(f)} · {f.material}</span>
|
||||
</div>
|
||||
{#if f.weight}
|
||||
<span class="res-weight">{weightAuto(f.weight)}</span>
|
||||
{/if}
|
||||
<span class="tag" class:in-catalog={!isCurrent} class:cur={isCurrent}
|
||||
>{isCurrent ? m['changeFilament.current']() : m['add.inCatalog']()}</span
|
||||
{#if creating}
|
||||
<NewFilamentCards
|
||||
bind:draft={nf}
|
||||
{err}
|
||||
{touch}
|
||||
extra={filamentExtra}
|
||||
onextra={setFilamentExtra}
|
||||
{vendorExtra}
|
||||
onVendorExtra={setVendorExtra}
|
||||
bind:vendorIsNew
|
||||
bind:showAdvanced
|
||||
bind:weights={nfWeights}
|
||||
showWeights
|
||||
backLabel={m['changeFilament.useExisting']()}
|
||||
onback={stopCreate}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
class="search-big"
|
||||
value={query}
|
||||
oninput={(e) => onSearch(e.currentTarget.value)}
|
||||
placeholder={m['add.searchPlaceholder']({ name: serverInfo.externalDbName })}
|
||||
aria-label={m['add.searchPlaceholder']({ name: serverInfo.externalDbName })}
|
||||
/>
|
||||
<div class="results" onscroll={extSearch.onscroll}>
|
||||
<div class="res-hdr">{m['add.yourCatalog']()}</div>
|
||||
{#if searching && localResults.length === 0}
|
||||
<div class="res-note">{m['add.searching']()}</div>
|
||||
{:else if localResults.length === 0}
|
||||
<div class="res-note">{m['add.noCatalog']()}</div>
|
||||
{:else}
|
||||
{#each localResults as f (f.id)}
|
||||
{@const isCurrent = f.id === current.id}
|
||||
<button
|
||||
class="res-item"
|
||||
class:sel={chosen?.source === 'catalog' && chosen.filament.id === f.id}
|
||||
disabled={isCurrent}
|
||||
onclick={() => (chosen = { source: 'catalog', filament: f })}
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
<Swatch colors={f.colors} direction={f.multiColorDirection} size={18} radius={5} />
|
||||
<div class="res-name">
|
||||
<span class="rn">{f.name}</span>
|
||||
<span class="rs">{vendorName(f)} · {f.material}</span>
|
||||
</div>
|
||||
{#if f.weight}
|
||||
<span class="res-weight">{weightAuto(f.weight)}</span>
|
||||
{/if}
|
||||
<span class="tag" class:in-catalog={!isCurrent} class:cur={isCurrent}
|
||||
>{isCurrent ? m['changeFilament.current']() : m['add.inCatalog']()}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="res-hdr"><span class="hdr-note">{serverInfo.externalDbName}</span></div>
|
||||
{#if extError}
|
||||
<div class="res-note">{m['add.dbUnavailable']({ name: serverInfo.externalDbName })}</div>
|
||||
{:else if searching && externalResults.length === 0}
|
||||
<div class="res-note">{m['add.searching']()}</div>
|
||||
{:else if externalResults.length === 0}
|
||||
<div class="res-note">
|
||||
{query.trim() ? m['add.typeToSearchMatches']() : m['add.typeToSearchAll']()}
|
||||
</div>
|
||||
{:else}
|
||||
{#each externalResults as ext (ext.id)}
|
||||
<button
|
||||
class="res-item"
|
||||
class:sel={chosen?.source === 'external' && chosen.ext.id === ext.id}
|
||||
onclick={() => (chosen = { source: 'external', ext })}
|
||||
>
|
||||
<Swatch
|
||||
colors={externalColors(ext)}
|
||||
direction={externalDirection(ext)}
|
||||
size={18}
|
||||
radius={5}
|
||||
/>
|
||||
<div class="res-name">
|
||||
<span class="rn">{ext.name}</span>
|
||||
<span class="rs">{ext.manufacturer} · {ext.material}</span>
|
||||
</div>
|
||||
{#if ext.weight}
|
||||
<span class="res-weight">{weightAuto(ext.weight)}</span>
|
||||
{/if}
|
||||
<span class="tag external">{serverInfo.externalDbName}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="res-hdr"><span class="hdr-note">{serverInfo.externalDbName}</span></div>
|
||||
{#if extSearch.error}
|
||||
<div class="res-note">{m['add.dbUnavailable']({ name: serverInfo.externalDbName })}</div>
|
||||
{:else if searching && extSearch.items.length === 0}
|
||||
<div class="res-note">{m['add.searching']()}</div>
|
||||
{:else if extSearch.items.length === 0}
|
||||
<div class="res-note">
|
||||
{query.trim() ? m['add.typeToSearchMatches']() : m['add.typeToSearchAll']()}
|
||||
</div>
|
||||
{:else}
|
||||
{#each extSearch.items as ext (ext.id)}
|
||||
<button
|
||||
class="res-item"
|
||||
class:sel={chosen?.source === 'external' && chosen.ext.id === ext.id}
|
||||
onclick={() => (chosen = { source: 'external', ext })}
|
||||
>
|
||||
<Swatch
|
||||
colors={externalColors(ext)}
|
||||
direction={externalDirection(ext)}
|
||||
size={18}
|
||||
radius={5}
|
||||
/>
|
||||
<div class="res-name">
|
||||
<span class="rn">{ext.name}</span>
|
||||
<span class="rs">{ext.manufacturer} · {ext.material}</span>
|
||||
</div>
|
||||
{#if ext.weight}
|
||||
<span class="res-weight">{weightAuto(ext.weight)}</span>
|
||||
{/if}
|
||||
<span class="tag external">{serverInfo.externalDbName}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if extSearch.loadingMore}
|
||||
<div class="res-note">{m['add.searching']()}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<button class="create-new" onclick={startCreate}>
|
||||
<span class="cn-plus"><Plus size={16} /></span>
|
||||
<span>{m['add.createNew']()}</span>
|
||||
<span class="cn-sub">{m['add.createNewSub']({ name: serverInfo.externalDbName })}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<Button variant="outline" disabled={busy} onclick={close}>{m['buttons.cancel']()}</Button>
|
||||
<Button disabled={!chosen || busy} onclick={apply}>
|
||||
<Button disabled={!picked || busy} onclick={apply}>
|
||||
{busy ? m['changeFilament.applying']() : m['changeFilament.apply']()}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -612,6 +795,38 @@
|
||||
.tag.sm {
|
||||
margin-left: 6px;
|
||||
}
|
||||
/* The third option, styled as it is in the add-spools flow: dashed rather than
|
||||
solid, because it is an invitation to describe something rather than a row
|
||||
that already exists. */
|
||||
.create-new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
padding: 11px 14px;
|
||||
border: 1px dashed var(--accent-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: none;
|
||||
color: var(--accent-link);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
.create-new:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-wash-soft);
|
||||
}
|
||||
.cn-plus {
|
||||
font-size: 15px;
|
||||
flex: none;
|
||||
}
|
||||
.cn-sub {
|
||||
margin-left: auto;
|
||||
color: var(--text-faint);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import ProgressBar from '../ProgressBar.svelte';
|
||||
import SectionLabel from '../SectionLabel.svelte';
|
||||
import ExtraFieldsSection from '../ExtraFieldsSection.svelte';
|
||||
import TagsSection from '../TagsSection.svelte';
|
||||
import Breadcrumbs from '../Breadcrumbs.svelte';
|
||||
import FieldGrid from '../FieldGrid.svelte';
|
||||
import Field from '../Field.svelte';
|
||||
@@ -33,6 +34,7 @@
|
||||
import { pct, weightAuto } from '$lib/utils/format';
|
||||
import { usageLabel } from '$lib/utils/library';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { allPages } from '$lib/api/query';
|
||||
import { isAbortError } from '$lib/api/http';
|
||||
import { classifyDeleteFailure, planFilamentDelete } from '$lib/library/deletion';
|
||||
import { toasts } from '$lib/stores/toasts.svelte';
|
||||
@@ -79,6 +81,7 @@
|
||||
// fetch's own cache upsert (which replaces the `filament` prop object) doesn't
|
||||
// re-trigger this effect in an infinite loop.
|
||||
let filamentId = $derived(filament.id);
|
||||
const SPOOL_PAGE = 100;
|
||||
let spools = $state<Spool[]>([]);
|
||||
// Bumped by live spool events so adding/removing/editing a spool refetches
|
||||
// this filament's list (the fetch below is server-ordered, not read from the
|
||||
@@ -95,24 +98,33 @@
|
||||
const id = filamentId;
|
||||
const archived = showArchived;
|
||||
void revision; // refetch on live spool events
|
||||
let cancelled = false;
|
||||
spoolSource
|
||||
.listSpools({
|
||||
const ctrl = new AbortController();
|
||||
// Every spool, not the first page: the count and total above are read off
|
||||
// this list. That renders all of them; a filament with thousands of spools
|
||||
// would want a "show more" here and the group endpoint's totals for the header.
|
||||
allPages(SPOOL_PAGE, (offset) =>
|
||||
spoolSource.listSpools({
|
||||
filters: {},
|
||||
sort: [{ field: 'last_used', dir: 'desc' }],
|
||||
sort: [
|
||||
{ field: 'last_used', dir: 'desc' },
|
||||
{ field: 'id', dir: 'asc' }
|
||||
],
|
||||
groupScope: { field: 'filament', key: id },
|
||||
allowArchived: archived,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
lowThreshold: settings.lowThreshold
|
||||
limit: SPOOL_PAGE,
|
||||
offset,
|
||||
lowThreshold: settings.lowThreshold,
|
||||
signal: ctrl.signal
|
||||
})
|
||||
.then((page) => {
|
||||
if (!cancelled) spools = page.items;
|
||||
)
|
||||
.then((items) => {
|
||||
// A spool used mid-load moves up the sort and can turn up on two pages.
|
||||
if (!ctrl.signal.aborted) spools = [...new Map(items.map((s) => [s.id, s])).values()];
|
||||
})
|
||||
.catch((e) => console.error('Failed to load filament spools', e));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
.catch((e) => {
|
||||
if (!isAbortError(e, ctrl.signal)) console.error('Failed to load filament spools', e);
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
});
|
||||
|
||||
const saver = makeSaver<string, Partial<Filament>>((id, patch) =>
|
||||
@@ -221,6 +233,7 @@
|
||||
<div class="titles">
|
||||
<div class="title">
|
||||
{filament.name}
|
||||
<span class="idmono mono">#{filament.id}</span>
|
||||
{#if filament.externalId}<span
|
||||
class="ext-badge"
|
||||
title={serverInfo.externalDbName + ' · ' + filament.externalId}>{serverInfo.externalDbName}</span
|
||||
@@ -445,6 +458,8 @@
|
||||
</FieldGrid>
|
||||
|
||||
<ExtraFieldsSection entity="filament" extra={filament.extra} onchange={extraSaver.change} manage />
|
||||
|
||||
<TagsSection kind="filament" id={filament.id} tags={filament.tags} />
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
@@ -473,6 +488,11 @@
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.idmono {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.ext-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
{/each}
|
||||
{:else}
|
||||
{#each flatVMs as vm (vm.spool.id)}
|
||||
<SpoolRow {vm} showSwatch indent={14} />
|
||||
<SpoolRow showLotNumbers={libraryState.showLotNumbers} {vm} showSwatch indent={14} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import Factory from '@lucide/svelte/icons/factory';
|
||||
import type { GroupHeaderInfo } from '$lib/utils/library';
|
||||
import { onTruncated } from '$lib/actions/truncated';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Props {
|
||||
@@ -21,10 +22,36 @@
|
||||
sticky?: boolean;
|
||||
/** Whether the group's spools are folded away behind this header. */
|
||||
collapsed?: boolean;
|
||||
/** The group holds no spools at all — it is the header and nothing else
|
||||
* (#1092). It says so in place of the aggregates, which are all zero, and
|
||||
* drops the disclosure since there is nothing behind it to fold. */
|
||||
noSpools?: boolean;
|
||||
ontoggle?: () => void;
|
||||
}
|
||||
|
||||
let { group, href, vendorHref, sticky = false, collapsed = false, ontoggle }: Props = $props();
|
||||
let {
|
||||
group,
|
||||
href,
|
||||
vendorHref,
|
||||
sticky = false,
|
||||
collapsed = false,
|
||||
noSpools = false,
|
||||
ontoggle
|
||||
}: Props = $props();
|
||||
|
||||
// Whichever of the two lines is currently cut off, spelled out on hover (#1093).
|
||||
// It hangs off the row rather than off the truncated span itself: the entity
|
||||
// link below covers the whole header, so the span is never what the pointer is
|
||||
// over. The manufacturer link and the disclosure carry titles of their own,
|
||||
// which take precedence over this one where they overlap it.
|
||||
let titleCut = $state(false);
|
||||
let subCut = $state(false);
|
||||
// Stable callbacks: an inline closure would be a new parameter on every render.
|
||||
const markTitle = (cut: boolean) => (titleCut = cut);
|
||||
const markSub = (cut: boolean) => (subCut = cut);
|
||||
let fullText = $derived(
|
||||
[titleCut ? group.title : '', subCut ? group.subtitle : ''].filter(Boolean).join(' · ') || undefined
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- The row is three controls, not one: the body opens the group's entity (where it
|
||||
@@ -39,7 +66,7 @@
|
||||
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}>
|
||||
<div class="header" class:sticky class:link={href} title={fullText}>
|
||||
{#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
|
||||
@@ -61,7 +88,7 @@
|
||||
<!-- 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>
|
||||
<span class="title" use:onTruncated={markTitle}>{group.title}</span>
|
||||
{#if group.badge}<MaterialBadge label={group.badge} />{/if}
|
||||
</div>
|
||||
{#if group.vendorName || group.subtitle}
|
||||
@@ -85,7 +112,7 @@
|
||||
<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}
|
||||
{#if group.subtitle}<span class="stext" use:onTruncated={markSub}>{group.subtitle}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -93,20 +120,34 @@
|
||||
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>
|
||||
{#if noSpools}
|
||||
<!-- "0 g / 0 spools" is true but reads like any other quiet number, and the
|
||||
whole point of listing this group is that it should catch the eye. It
|
||||
says no spools rather than out of stock: Spoolman knows what you have
|
||||
recorded, not what is on your shelf. -->
|
||||
<span class="no-spools">{m['library.noSpools']()}</span>
|
||||
{:else}
|
||||
<span class="weight">{group.meta}</span>
|
||||
<span class="count">{group.count}</span>
|
||||
{/if}
|
||||
</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>
|
||||
{#if !noSpools}
|
||||
<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>
|
||||
{:else}
|
||||
<!-- Keeps the header's right edge lined up with the ones that do have a
|
||||
disclosure, so the column of aggregates doesn't jog in and out. -->
|
||||
<span class="disc-spacer" aria-hidden="true"></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -237,6 +278,22 @@
|
||||
font-size: 10.5px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
/* Outlined rather than filled: it marks a shopping-list entry, not a failure,
|
||||
and a solid danger block on every out-of-stock row would shout down the rest
|
||||
of the library. */
|
||||
.no-spools {
|
||||
font-size: 9.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--danger-soft);
|
||||
border: 1px solid var(--danger-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px 5px;
|
||||
}
|
||||
.disc-spacer {
|
||||
flex: none;
|
||||
width: 34px;
|
||||
}
|
||||
/* 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. */
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
let id = $derived(groupId(group.field, group.key));
|
||||
let collapsed = $derived(collapsedGroups.has(id));
|
||||
|
||||
// A filament the library holds no spools of, listed so that "I have none of
|
||||
// this" is visible rather than silently absent (#1092). There is nothing under
|
||||
// the header, so there is nothing to fetch, fold or page through either.
|
||||
let noSpools = $derived(group.spoolCount === 0);
|
||||
|
||||
let el: HTMLDivElement | undefined = $state();
|
||||
|
||||
async function toggle() {
|
||||
@@ -74,7 +79,7 @@
|
||||
// loaded is kept, so re-expanding paints immediately and refreshes behind it.
|
||||
let reqId = 0;
|
||||
$effect(() => {
|
||||
if (collapsed) return;
|
||||
if (collapsed || noSpools) return;
|
||||
const ctrl = new AbortController();
|
||||
void revision; // refetch on live events
|
||||
const want = untrack(() => spools.length) || PAGE;
|
||||
@@ -166,18 +171,44 @@
|
||||
</script>
|
||||
|
||||
<div bind:this={el}>
|
||||
<GroupHeader group={header} sticky href={headerHref} {vendorHref} {collapsed} ontoggle={toggle} />
|
||||
{#if !collapsed}
|
||||
<GroupHeader
|
||||
group={header}
|
||||
sticky
|
||||
href={headerHref}
|
||||
{vendorHref}
|
||||
{collapsed}
|
||||
{noSpools}
|
||||
ontoggle={toggle}
|
||||
/>
|
||||
{#if !collapsed && !noSpools}
|
||||
{#each inUse as vm (vm.spool.id)}
|
||||
<SpoolRow {vm} {showSwatch} indent={26} context={group.field} />
|
||||
<SpoolRow
|
||||
showLotNumbers={libraryState.showLotNumbers}
|
||||
{vm}
|
||||
{showSwatch}
|
||||
indent={26}
|
||||
context={group.field}
|
||||
/>
|
||||
{/each}
|
||||
{#each unusedByFilament as unused (unused[0].spool.id)}
|
||||
{#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} />
|
||||
<SpoolRow
|
||||
showLotNumbers={libraryState.showLotNumbers}
|
||||
vm={unused[0]}
|
||||
{showSwatch}
|
||||
indent={26}
|
||||
context={group.field}
|
||||
/>
|
||||
{:else}
|
||||
<UnusedRow {unused} {showSwatch} indent={26} context={group.field} />
|
||||
<UnusedRow
|
||||
showLotNumbers={libraryState.showLotNumbers}
|
||||
{unused}
|
||||
{showSwatch}
|
||||
indent={26}
|
||||
context={group.field}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if moreCount > 0}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { asset } from '$app/paths';
|
||||
import SpoolInspector from './SpoolInspector.svelte';
|
||||
import FilamentInspector from './FilamentInspector.svelte';
|
||||
@@ -6,7 +7,6 @@
|
||||
import type { Selection } from '$lib/types';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { isAbortError } from '$lib/api/http';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
let { selection }: { selection: Selection | null } = $props();
|
||||
@@ -30,17 +30,24 @@
|
||||
// across runs to stop a genuine miss / 404 from re-fetching every tick.
|
||||
let attempted = '';
|
||||
let loading = $state(false);
|
||||
// The effect follows the selection by value, not by object. Arriving on the
|
||||
// Library rewrites the URL to restore the remembered view (see +page.svelte),
|
||||
// which parses the same selection into a new object; following the object
|
||||
// re-ran the effect mid-fetch and its cleanup aborted the request (#1243).
|
||||
let key = $derived(sel ? `${sel.kind}:${sel.id}` : '');
|
||||
$effect(() => {
|
||||
const s = sel;
|
||||
if (!s || found) return;
|
||||
const key = `${s.kind}:${s.id}`;
|
||||
if (attempted === key) return;
|
||||
attempted = key;
|
||||
const k = key;
|
||||
if (!k || found) return;
|
||||
if (attempted === k) return;
|
||||
attempted = k;
|
||||
loading = true;
|
||||
|
||||
const s = untrack(() => sel)!;
|
||||
const ctrl = new AbortController();
|
||||
let pending = true;
|
||||
const done = (err?: unknown) => {
|
||||
if (isAbortError(err, ctrl.signal)) return; // superseded selection; leave loading for the next run
|
||||
if (ctrl.signal.aborted) return; // superseded; the run that aborted it owns `loading`
|
||||
pending = false;
|
||||
if (err) console.warn('deep-link fetch failed', err);
|
||||
loading = false;
|
||||
};
|
||||
@@ -52,7 +59,12 @@
|
||||
: spoolSource.fetchVendor(s.id, ctrl.signal);
|
||||
p.then(() => done()).catch(done);
|
||||
|
||||
return () => ctrl.abort();
|
||||
return () => {
|
||||
// Cancelled before it answered: forget the attempt, so whichever run
|
||||
// comes next asks again instead of waiting on a request that is gone.
|
||||
if (pending) attempted = '';
|
||||
ctrl.abort();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -11,9 +11,16 @@
|
||||
withinLast,
|
||||
type DateFilterProp
|
||||
} from '$lib/library/dateFilter';
|
||||
import { sortDefs, filamentLabel, type SortDef } from '$lib/utils/library';
|
||||
import {
|
||||
sortDefs,
|
||||
catalogSortDefs,
|
||||
filamentLabel,
|
||||
type FilterOption,
|
||||
type SortDef
|
||||
} from '$lib/utils/library';
|
||||
import { filterByQuery, matchesTerms, searchTerms } from '$lib/utils/match';
|
||||
import MenuSearch from '../MenuSearch.svelte';
|
||||
import Swatch from '../Swatch.svelte';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { fields } from '$lib/stores/fields.svelte';
|
||||
@@ -32,6 +39,10 @@
|
||||
// Named libraryState, not `state`, to avoid shadowing the $state rune.
|
||||
let { libraryState }: { libraryState: LibraryState } = $props();
|
||||
|
||||
// The catalog sub-views (filaments, manufacturers) reuse this toolbar with the
|
||||
// few groupings, orders and filters a list of filaments can answer.
|
||||
let catalog = $derived(params.isCatalogView(libraryState));
|
||||
|
||||
type Menu = 'filter' | 'group' | 'sort' | null;
|
||||
let open = $state<Menu>(null);
|
||||
|
||||
@@ -63,10 +74,6 @@
|
||||
fields.ensure('vendor');
|
||||
});
|
||||
|
||||
interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
// Most filters pick from a set of values the API can enumerate; a date filter
|
||||
// picks a range instead, so it gets a panel of its own rather than an option
|
||||
// list (see library/dateFilter for the grammar behind it).
|
||||
@@ -229,6 +236,14 @@
|
||||
return parsed?.kind === 'range' ? parsed : null;
|
||||
});
|
||||
|
||||
// The values this property already filters by. A filter's chip sits in the
|
||||
// toolbar behind the open menu, which is no help when the list is long and the
|
||||
// menu covers it, so the list says so itself (#1090). Values only: a date
|
||||
// filter holds one range at a time, so its presets are a choice, not a set.
|
||||
let activeValues = $derived(
|
||||
new Set(libraryState.filters.filter((f) => f.prop === filterProp).map((f) => f.value))
|
||||
);
|
||||
|
||||
async function openProp(category: FilterCategory) {
|
||||
filterProp = category.key;
|
||||
// The query that found this property says nothing about its values.
|
||||
@@ -255,7 +270,11 @@
|
||||
close();
|
||||
}
|
||||
|
||||
let sorts = $derived(sortDefs(fields.get('spool')));
|
||||
let sorts = $derived<SortDef[]>(
|
||||
libraryState.view === 'spools'
|
||||
? sortDefs(fields.get('spool'))
|
||||
: catalogSortDefs(libraryState.view, fields.get('filament'), fields.get('vendor'))
|
||||
);
|
||||
let activeSort = $derived(sorts.find((s) => s.key === libraryState.sortKey) ?? sorts[0]);
|
||||
|
||||
const groupOptions: { key: GroupMode; labelKey: () => string }[] = [
|
||||
@@ -265,8 +284,15 @@
|
||||
{ key: 'location', labelKey: m['spool.fields.location'] },
|
||||
{ key: 'none', labelKey: m['library.groupNone'] }
|
||||
];
|
||||
// The filament catalog sections by manufacturer or not at all; the manufacturer
|
||||
// catalog has nothing to group by and shows no Group control.
|
||||
const catalogGroupOptions: typeof groupOptions = [
|
||||
{ key: 'vendor', labelKey: m['filament.fields.vendor'] },
|
||||
{ key: 'none', labelKey: m['library.groupNone'] }
|
||||
];
|
||||
let groupChoices = $derived(catalog ? catalogGroupOptions : groupOptions);
|
||||
let groupLabel = $derived(
|
||||
groupOptions.find((g) => g.key === libraryState.group)?.labelKey() ?? m['spool.fields.filament']
|
||||
groupChoices.find((g) => g.key === libraryState.group)?.labelKey() ?? m['spool.fields.filament']
|
||||
);
|
||||
|
||||
function chipLabel(prop: string, value: string): string {
|
||||
@@ -275,6 +301,11 @@
|
||||
// Date filters hold a range, whose value is grammar rather than something to
|
||||
// show ("-24h.." reads as "Last 24 hours").
|
||||
if (isDateFilterProp(prop)) return `${label}: ${rangeLabel(value)}`;
|
||||
// A manufacturer picked by id (the inspector's "Show as a list"): its name,
|
||||
// so the chip reads like the by-name Manufacturer chip the menu makes.
|
||||
if (prop === 'vendorId') {
|
||||
return `${m['filament.fields.vendor']()}: ${inventory.vendorById(value)?.name ?? '#' + value}`;
|
||||
}
|
||||
// Filament filters store the numeric id; show the filament's name instead.
|
||||
if (prop === 'filament') {
|
||||
const fil = inventory.filamentById(value);
|
||||
@@ -328,7 +359,21 @@
|
||||
|
||||
let visibleCategories = $derived(filterByQuery(filterCategories, menuQuery, (c) => c.label()));
|
||||
let archivedMatches = $derived(matchesTerms(m['buttons.showArchived'](), searchTerms(menuQuery)));
|
||||
let visibleOptions = $derived(filterByQuery(options, menuQuery, (o) => o.label));
|
||||
// Only offered where it means something: the empty groups are filaments, so
|
||||
// there is nowhere to put them in a list grouped by anything else. Folded into
|
||||
// the match itself rather than tested at the row, so that a search matching
|
||||
// nothing else still falls through to "no results" instead of an empty menu.
|
||||
let noSpoolsMatches = $derived(
|
||||
libraryState.group === 'filament' && matchesTerms(m['buttons.showNoSpools'](), searchTerms(menuQuery))
|
||||
);
|
||||
let lotNumbersMatches = $derived(
|
||||
libraryState.view === 'spools' && matchesTerms(m['buttons.showLotNumbers'](), searchTerms(menuQuery))
|
||||
);
|
||||
// An option's secondary text is on screen, so it has to be searchable: typing
|
||||
// "petg" against a list that visibly says PETG must not come back empty.
|
||||
let visibleOptions = $derived(
|
||||
filterByQuery(options, menuQuery, (o) => (o.meta ? `${o.label} ${o.meta}` : o.label))
|
||||
);
|
||||
let visibleSortSections = $derived(
|
||||
sortSections
|
||||
.map((sec) => ({ ...sec, items: filterByQuery(sec.items, menuQuery, (it) => it.labelKey()) }))
|
||||
@@ -345,7 +390,6 @@
|
||||
const first = visibleOptions[0];
|
||||
if (!first) return;
|
||||
params.toggleFilter(filterProp!, first.value);
|
||||
close();
|
||||
}
|
||||
function chooseFirstSort() {
|
||||
const first = visibleSortSections[0]?.items[0];
|
||||
@@ -360,7 +404,12 @@
|
||||
<div
|
||||
class="toolbar"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => {
|
||||
e.stopPropagation();
|
||||
// A menu opened by a click leaves focus on its button, out here rather than
|
||||
// in the menu, so this is where Escape has to close it.
|
||||
if (e.key === 'Escape' && open) close();
|
||||
}}
|
||||
role="toolbar"
|
||||
tabindex="-1"
|
||||
>
|
||||
@@ -386,17 +435,41 @@
|
||||
{m['spool.fields.archived']()} <span class="x"><X size={12} /></span>
|
||||
</button>
|
||||
{/if}
|
||||
<!-- So is showing the filaments you hold no spools of: it widens the list
|
||||
rather than narrowing it, but it is still a deviation from the default
|
||||
view, so it says so and can be dismissed from here (#1092). -->
|
||||
{#if libraryState.showEmpty}
|
||||
<button class="chip active" onclick={() => params.setShowEmpty(false)}>
|
||||
{m['library.noSpools']()} <span class="x"><X size={12} /></span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="link-btn" onclick={() => toggle('group')}
|
||||
><span class="ctrl-label">{m['library.groupBy']()}: </span>{groupLabel}
|
||||
<ChevronDown size={13} /></button
|
||||
>
|
||||
<button class="chip active sort" onclick={() => toggle('sort')}>
|
||||
<span class="ctrl-label">{m['library.sortBy']()}: </span>{activeSort.labelKey()}
|
||||
{#if libraryState.sortAsc}<ArrowUp size={12} />{:else}<ArrowDown size={12} />{/if}
|
||||
</button>
|
||||
{#if libraryState.view !== 'manufacturers'}
|
||||
<button class="link-btn" onclick={() => toggle('group')}
|
||||
><span class="ctrl-label">{m['library.groupBy']()}: </span>{groupLabel}
|
||||
<ChevronDown size={13} /></button
|
||||
>
|
||||
{/if}
|
||||
<!-- One chip, two controls (#1091): the field opens the menu, the arrow only
|
||||
flips the direction. Reversing an order used to mean reopening the menu
|
||||
and hunting down the field that was already selected. A divider and a
|
||||
hover of its own say the arrow is clickable; buttons can't nest, so the
|
||||
chip itself is the wrapper and the padding lives on the two halves. -->
|
||||
<div class="chip active sort">
|
||||
<button class="sort-field" onclick={() => toggle('sort')}>
|
||||
<span class="ctrl-label">{m['library.sortBy']()}: </span>{activeSort.labelKey()}
|
||||
</button>
|
||||
<button
|
||||
class="sort-dir-btn"
|
||||
title={m['library.reverseSort']()}
|
||||
aria-label={m['library.reverseSort']()}
|
||||
onclick={() => params.toggleSortDir()}
|
||||
>
|
||||
{#if libraryState.sortAsc}<ArrowUp size={12} />{:else}<ArrowDown size={12} />{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if open === 'filter'}
|
||||
@@ -436,7 +509,46 @@
|
||||
>
|
||||
<span class="mi-label">{m['buttons.showArchived']()}</span>
|
||||
</button>
|
||||
{:else if !visibleCategories.length}
|
||||
{/if}
|
||||
{#if noSpoolsMatches}
|
||||
{#if visibleCategories.length && !archivedMatches}<div class="menu-sep"></div>{/if}
|
||||
<button
|
||||
class="menu-item"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={libraryState.showEmpty}
|
||||
onclick={() => {
|
||||
params.setShowEmpty(!libraryState.showEmpty);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<span class="mi-check"
|
||||
>{#if libraryState.showEmpty}<SquareCheck size={15} />{:else}<Square size={15} />{/if}</span
|
||||
>
|
||||
<span class="mi-label">{m['buttons.showNoSpools']()}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if lotNumbersMatches}
|
||||
{#if visibleCategories.length && !archivedMatches && !noSpoolsMatches}<div
|
||||
class="menu-sep"
|
||||
></div>{/if}
|
||||
<button
|
||||
class="menu-item"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={libraryState.showLotNumbers}
|
||||
onclick={() => {
|
||||
params.setShowLotNumbers(!libraryState.showLotNumbers);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<span class="mi-check"
|
||||
>{#if libraryState.showLotNumbers}<SquareCheck size={15} />{:else}<Square
|
||||
size={15}
|
||||
/>{/if}</span
|
||||
>
|
||||
<span class="mi-label">{m['buttons.showLotNumbers']()}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if !archivedMatches && !noSpoolsMatches && !lotNumbersMatches && !visibleCategories.length}
|
||||
<div class="menu-item"><span class="mi-label mi-meta">{m['search.noResults']()}</span></div>
|
||||
{/if}
|
||||
{:else}
|
||||
@@ -498,15 +610,32 @@
|
||||
onenter={chooseFirstOption}
|
||||
/>
|
||||
{/if}
|
||||
<!-- A value row is a checkbox, not a command: it reads as on or off, and
|
||||
picking one leaves the menu open so the next one is a click away
|
||||
(#1090, #1089). Escape and a click outside still close it. -->
|
||||
{#each visibleOptions as opt (opt.value)}
|
||||
{@const checked = activeValues.has(opt.value)}
|
||||
<button
|
||||
class="menu-item"
|
||||
onclick={() => {
|
||||
params.toggleFilter(filterProp!, opt.value);
|
||||
close();
|
||||
}}
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={checked}
|
||||
onclick={() => params.toggleFilter(filterProp!, opt.value)}
|
||||
>
|
||||
<span class="mi-label">{opt.label}</span>
|
||||
<span class="mi-check"
|
||||
>{#if checked}<SquareCheck size={15} />{:else}<Square size={15} />{/if}</span
|
||||
>
|
||||
{#if opt.colors}<Swatch
|
||||
colors={opt.colors}
|
||||
direction={opt.direction}
|
||||
size={16}
|
||||
radius={4}
|
||||
/>{/if}
|
||||
<!-- The gap is a real space, not just margin: it is what separates the
|
||||
two halves in the row's accessible name, which would otherwise be
|
||||
read out as one run-together word. -->
|
||||
<span class="mi-label"
|
||||
>{opt.label}{#if opt.meta} <span class="mi-sub">{opt.meta}</span>{/if}</span
|
||||
>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="menu-item"><span class="mi-label mi-meta">{m['search.noResults']()}</span></div>
|
||||
@@ -518,7 +647,7 @@
|
||||
|
||||
{#if open === 'group'}
|
||||
<div class="menu group-menu">
|
||||
{#each groupOptions as g (g.key)}
|
||||
{#each groupChoices as g (g.key)}
|
||||
<button
|
||||
class="menu-item"
|
||||
class:sel={libraryState.group === g.key}
|
||||
@@ -626,6 +755,45 @@
|
||||
border: 1px solid var(--accent-border);
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
/* The chip is only the frame now; each half carries the padding so a click
|
||||
anywhere in it still lands on the control that half belongs to. They
|
||||
STRETCH to fill it, which matters on mobile: the shared `.chip` rule in
|
||||
app.css grows the chip to a 44px tap target, and centred children would
|
||||
leave that as a 44px frame around two 20px buttons. It also runs the
|
||||
divider the full height of the chip, which is what makes the pair read as
|
||||
one segmented control rather than two things that happen to be adjacent. */
|
||||
.chip.sort {
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
.chip.sort > button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 9px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip.sort > button:hover {
|
||||
color: var(--accent-link);
|
||||
}
|
||||
.chip.sort > button:focus-visible {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.chip.sort > .sort-field {
|
||||
padding-right: 7px;
|
||||
}
|
||||
.chip.sort > .sort-dir-btn {
|
||||
border-left: 1px solid var(--accent-border);
|
||||
padding-left: 7px;
|
||||
}
|
||||
.menu-sep {
|
||||
height: 1px;
|
||||
background: var(--border-soft);
|
||||
@@ -769,6 +937,13 @@
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
/* Sits with the label rather than off at the row's right edge: it's there to
|
||||
qualify the name it follows, which is the whole point when two filaments
|
||||
share one. */
|
||||
.mi-sub {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.mi-dir {
|
||||
color: var(--accent-muted);
|
||||
font-size: 11px;
|
||||
|
||||
@@ -6,13 +6,17 @@
|
||||
import Swatch from '../Swatch.svelte';
|
||||
import MaterialBadge from '../MaterialBadge.svelte';
|
||||
import Factory from '@lucide/svelte/icons/factory';
|
||||
import List from '@lucide/svelte/icons/list';
|
||||
import { goto } from '$app/navigation';
|
||||
import { searchAll } from '$lib/api/search';
|
||||
import type { SearchResults } from '$lib/api/types';
|
||||
import { openSearchResult, searchResultHref } from '$lib/library/params';
|
||||
import { catalogHref, openSearchResult, searchResultHref } from '$lib/library/params';
|
||||
import { truncTitle } from '$lib/actions/truncated';
|
||||
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 { parseDecimal } from '$lib/utils/numeric';
|
||||
import type { EntityKind } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
@@ -30,8 +34,9 @@
|
||||
|
||||
function loadThreshold(): number {
|
||||
if (typeof localStorage === 'undefined') return DEFAULT_THRESHOLD;
|
||||
const n = Number(localStorage.getItem(THRESHOLD_KEY));
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_THRESHOLD;
|
||||
// 0 is a real choice on the slider (exact colour only), so keep it.
|
||||
const n = parseDecimal(localStorage.getItem(THRESHOLD_KEY));
|
||||
return n !== null && n >= 0 ? n : DEFAULT_THRESHOLD;
|
||||
}
|
||||
|
||||
let query = $state('');
|
||||
@@ -99,14 +104,30 @@
|
||||
});
|
||||
|
||||
interface FlatItem {
|
||||
kind: EntityKind;
|
||||
/** 'browse' rows are plain links to a catalog list, not entities. */
|
||||
kind: EntityKind | 'browse';
|
||||
id: string;
|
||||
href?: 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[]>(
|
||||
const browseFilaments = {
|
||||
kind: 'browse',
|
||||
id: 'filaments',
|
||||
href: catalogHref('filaments'),
|
||||
key: 'browse:filaments'
|
||||
} as const;
|
||||
const browseManufacturers = {
|
||||
kind: 'browse',
|
||||
id: 'manufacturers',
|
||||
href: catalogHref('manufacturers'),
|
||||
key: 'browse:manufacturers'
|
||||
} as const;
|
||||
let emptyQuery = $derived(query.trim().length === 0);
|
||||
|
||||
let resultItems = $derived<FlatItem[]>(
|
||||
results
|
||||
? [
|
||||
...results.spools.map((m) => ({
|
||||
@@ -133,8 +154,18 @@
|
||||
: []
|
||||
);
|
||||
|
||||
let hasResults = $derived(flat.length > 0);
|
||||
let showPanel = $derived(open && query.trim().length > 0);
|
||||
let hasResults = $derived(resultItems.length > 0);
|
||||
// Browse links sit last so they never displace the first result under Enter,
|
||||
// and are reachable by the same arrow keys.
|
||||
let flat = $derived<FlatItem[]>(
|
||||
emptyQuery
|
||||
? [browseFilaments, browseManufacturers]
|
||||
: hasResults
|
||||
? [...resultItems, browseFilaments, browseManufacturers]
|
||||
: []
|
||||
);
|
||||
// Focused-but-empty shows the hint and the browse links instead of nothing.
|
||||
let showPanel = $derived(open);
|
||||
|
||||
// Each result is a real `<a href>` (see searchResultHref) so it opens in a new
|
||||
// tab / copies its address like any link. Close and clear the panel afterwards.
|
||||
@@ -157,6 +188,13 @@
|
||||
dismiss();
|
||||
}
|
||||
|
||||
// Arrowing past the panel's fold must bring the row along; the browse links
|
||||
// sit last, so without this they'd be picked while out of sight.
|
||||
$effect(() => {
|
||||
if (activeIndex < 0) return;
|
||||
wrapper?.querySelector('.panel .active')?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
open = false;
|
||||
@@ -173,7 +211,10 @@
|
||||
if (activeIndex >= 0 && activeIndex < flat.length) {
|
||||
e.preventDefault();
|
||||
const item = flat[activeIndex];
|
||||
chooseByKey(item.kind, item.id);
|
||||
if (item.kind === 'browse') {
|
||||
goto(item.href!);
|
||||
dismiss();
|
||||
} else chooseByKey(item.kind, item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,7 +288,30 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading && !results}
|
||||
{#if emptyQuery}
|
||||
<p class="empty-hint">{m['search.hint']()}</p>
|
||||
<div class="section-label">{m['library.viewMenu.browse']()}</div>
|
||||
<a
|
||||
class="result"
|
||||
class:active={activeIndex === indexOf(browseFilaments.key)}
|
||||
href={browseFilaments.href}
|
||||
data-sveltekit-noscroll
|
||||
onclick={onResultClick}
|
||||
>
|
||||
<span class="vendor-icon" aria-hidden="true"><List size={14} /></span>
|
||||
<span class="text"><span class="title">{m['library.catalog.allFilaments']()}</span></span>
|
||||
</a>
|
||||
<a
|
||||
class="result"
|
||||
class:active={activeIndex === indexOf(browseManufacturers.key)}
|
||||
href={browseManufacturers.href}
|
||||
data-sveltekit-noscroll
|
||||
onclick={onResultClick}
|
||||
>
|
||||
<span class="vendor-icon" aria-hidden="true"><Factory size={14} /></span>
|
||||
<span class="text"><span class="title">{m['library.catalog.allManufacturers']()}</span></span>
|
||||
</a>
|
||||
{:else if loading && !results}
|
||||
<div class="msg">{m['search.searching']()}</div>
|
||||
{:else if errored}
|
||||
<div class="msg">{m['library.apiError']()}</div>
|
||||
@@ -273,8 +337,9 @@
|
||||
<span class="id mono">#{spool.entity.id}</span>
|
||||
<Swatch colors={filament?.colors ?? []} direction={filament?.multiColorDirection} size={20} />
|
||||
<span class="text">
|
||||
<span class="title">{filament?.name || m['search.unknownFilament']()}</span>
|
||||
{#if spool.entity.location}<span class="sub">{spool.entity.location}</span>{/if}
|
||||
<span class="title" use:truncTitle>{filament?.name || m['search.unknownFilament']()}</span>
|
||||
{#if spool.entity.location}<span class="sub" use:truncTitle>{spool.entity.location}</span
|
||||
>{/if}
|
||||
</span>
|
||||
<span class="match">{matchLabel('spool', spool.matchField)}</span>
|
||||
</a>
|
||||
@@ -307,12 +372,12 @@
|
||||
the trailing "matched" tag which describes the search hit. Two chips
|
||||
of the same shape side by side read as the same kind of fact. -->
|
||||
<span class="line">
|
||||
<span class="title">{filament.entity.name}</span>
|
||||
<span class="title" use:truncTitle>{filament.entity.name}</span>
|
||||
{#if filament.entity.material}
|
||||
<MaterialBadge label={filament.entity.material} variant="outline" />
|
||||
{/if}
|
||||
</span>
|
||||
{#if vendor?.name}<span class="sub">{vendor.name}</span>{/if}
|
||||
{#if vendor?.name}<span class="sub" use:truncTitle>{vendor.name}</span>{/if}
|
||||
</span>
|
||||
<span class="match">{matchLabel('filament', filament.matchField)}</span>
|
||||
</a>
|
||||
@@ -369,12 +434,27 @@
|
||||
>
|
||||
<span class="vendor-icon" aria-hidden="true"><Factory size={14} /></span>
|
||||
<span class="text">
|
||||
<span class="title">{vendor.entity.name}</span>
|
||||
<span class="title" use:truncTitle>{vendor.entity.name}</span>
|
||||
</span>
|
||||
<span class="match">{matchLabel('vendor', vendor.matchField)}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="foot">
|
||||
{m['search.browseAll']()}
|
||||
<a
|
||||
href={browseFilaments.href}
|
||||
class:active={activeIndex === indexOf(browseFilaments.key)}
|
||||
onclick={onResultClick}>{m['library.catalog.unitFilaments']()}</a
|
||||
>
|
||||
·
|
||||
<a
|
||||
href={browseManufacturers.href}
|
||||
class:active={activeIndex === indexOf(browseManufacturers.key)}
|
||||
onclick={onResultClick}>{m['library.catalog.unitManufacturers']()}</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -440,6 +520,31 @@
|
||||
color: var(--text-muted);
|
||||
padding: 8px 12px 4px;
|
||||
}
|
||||
.empty-hint {
|
||||
margin: 0;
|
||||
padding: 8px 12px 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.foot {
|
||||
margin-top: 4px;
|
||||
padding: 8px 12px 4px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.foot a {
|
||||
color: var(--accent-link);
|
||||
text-decoration: none;
|
||||
padding: 2px 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.foot a:hover,
|
||||
.foot a.active {
|
||||
text-decoration: underline;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.msg {
|
||||
padding: 18px 12px;
|
||||
text-align: center;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import DateTimeField from '../DateTimeField.svelte';
|
||||
import SectionLabel from '../SectionLabel.svelte';
|
||||
import ExtraFieldsSection from '../ExtraFieldsSection.svelte';
|
||||
import TagsSection from '../TagsSection.svelte';
|
||||
import Breadcrumbs from '../Breadcrumbs.svelte';
|
||||
import FieldGrid from '../FieldGrid.svelte';
|
||||
import Field from '../Field.svelte';
|
||||
@@ -56,6 +57,7 @@
|
||||
price: 0,
|
||||
comment: '',
|
||||
registeredLabel: '',
|
||||
tags: [],
|
||||
extra: {}
|
||||
};
|
||||
|
||||
@@ -122,7 +124,7 @@
|
||||
let serverLocations = $state<string[]>([]);
|
||||
$effect(() => {
|
||||
spoolSource
|
||||
.locations()
|
||||
.locationChoices()
|
||||
.then((l) => (serverLocations = l))
|
||||
.catch(() => {});
|
||||
});
|
||||
@@ -513,6 +515,8 @@
|
||||
</FieldGrid>
|
||||
|
||||
<ExtraFieldsSection entity="spool" extra={spool.extra} onchange={extraSaver.change} manage />
|
||||
|
||||
<TagsSection kind="spool" id={spool.id} tags={spool.tags} />
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import ProgressBar from '../ProgressBar.svelte';
|
||||
import { rowIdentity, type RowContext, type SpoolVM } from '$lib/utils/library';
|
||||
import * as params from '$lib/library/params';
|
||||
import { truncTitle } from '$lib/actions/truncated';
|
||||
import { page } from '$app/state';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
@@ -15,11 +16,12 @@
|
||||
indent?: number;
|
||||
/** Listing context — drops whatever the enclosing group already implies. */
|
||||
context?: RowContext;
|
||||
showLotNumbers?: boolean;
|
||||
}
|
||||
|
||||
let { vm, showSwatch = false, indent = 14, context = 'flat' }: Props = $props();
|
||||
let { vm, showSwatch = false, indent = 14, context = 'flat', showLotNumbers = false }: Props = $props();
|
||||
|
||||
let identity = $derived(rowIdentity(vm, context));
|
||||
let identity = $derived(rowIdentity(vm, context, showLotNumbers));
|
||||
let selected = $derived(params.isSelected(page.url.searchParams, 'spool', String(vm.spool.id)));
|
||||
</script>
|
||||
|
||||
@@ -36,7 +38,8 @@
|
||||
{#if showSwatch}
|
||||
<Swatch colors={vm.filament.colors} direction={vm.filament.multiColorDirection} size={22} />
|
||||
{/if}
|
||||
<span class="name">
|
||||
<!-- Both columns fall back to a hover tooltip when they run out of room (#1093). -->
|
||||
<span class="name" use:truncTitle>
|
||||
{#if identity.title}<span class="title">{identity.title}</span>{/if}
|
||||
{#if identity.sub}<span class="sub">{identity.sub}</span>{/if}
|
||||
</span>
|
||||
@@ -45,7 +48,7 @@
|
||||
{/if}
|
||||
<ProgressBar value={vm.pctValue} danger={vm.low} width="56px" />
|
||||
<span class="rem mono" class:low={vm.low}>{vm.remLabel}</span>
|
||||
<span class="right">{vm.location}</span>
|
||||
<span class="right" use:truncTitle>{vm.location}</span>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
@@ -110,8 +113,11 @@
|
||||
.rem {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
width: 44px;
|
||||
/* A floor, not a fixed width: "987.5 g" and "10.2 kg" run past 44px and would
|
||||
wrap the unit onto its own line (#1124). */
|
||||
min-width: 44px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
flex: none;
|
||||
}
|
||||
.rem.low {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import SpoolRow from './SpoolRow.svelte';
|
||||
import type { RowContext, SpoolVM } from '$lib/utils/library';
|
||||
import { isSelected } from '$lib/library/params';
|
||||
import { truncTitle } from '$lib/actions/truncated';
|
||||
import { page } from '$app/state';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
@@ -13,9 +14,10 @@
|
||||
indent?: number;
|
||||
/** Listing context, forwarded to the expanded per-instance rows. */
|
||||
context?: RowContext;
|
||||
showLotNumbers?: boolean;
|
||||
}
|
||||
|
||||
let { unused, showSwatch = false, indent = 14, context = 'flat' }: Props = $props();
|
||||
let { unused, showSwatch = false, indent = 14, context = 'flat', showLotNumbers = false }: Props = $props();
|
||||
|
||||
// Unused spools (never drawn from — used_weight 0) of one filament are
|
||||
// collapsed into a single summary row. Clicking can't know *which* instance
|
||||
@@ -94,7 +96,7 @@
|
||||
opacity={0.75}
|
||||
/>
|
||||
{/if}
|
||||
<span class="label">
|
||||
<span class="label" use:truncTitle>
|
||||
<span class="fname">{first.filament.name}</span>
|
||||
<span class="badge mono">{m['library.nUnused']({ count: unused.length })}</span>
|
||||
</span>
|
||||
@@ -108,7 +110,7 @@
|
||||
{#each unused as vm (vm.spool.id)}
|
||||
<!-- Divider-mode instances align with the sibling used rows (same indent)
|
||||
so the divider reads as the header for the section beneath it. -->
|
||||
<SpoolRow {vm} {showSwatch} indent={asDivider ? indent : indent + 14} {context} />
|
||||
<SpoolRow {vm} {showSwatch} indent={asDivider ? indent : indent + 14} {context} {showLotNumbers} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import Swatch from '../Swatch.svelte';
|
||||
import Button from '../Button.svelte';
|
||||
import ConfirmDialog from '../ConfirmDialog.svelte';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import EditableField from '../EditableField.svelte';
|
||||
import NumberInput from '../NumberInput.svelte';
|
||||
@@ -128,7 +129,7 @@
|
||||
<div class="head">
|
||||
<div class="avatar">{initials}</div>
|
||||
<div class="titles">
|
||||
<div class="title">{vendor.name}</div>
|
||||
<div class="title">{vendor.name} <span class="idmono mono">#{vendor.id}</span></div>
|
||||
<div class="subtitle">
|
||||
{m['inspector.vendorSub']({
|
||||
count: filaments.length
|
||||
@@ -156,6 +157,13 @@
|
||||
onclose={() => (confirmOpen = false)}
|
||||
/>
|
||||
|
||||
{#snippet showAsList()}
|
||||
<!-- By id, not name, so a namesake manufacturer stays out; it shows as a normal dismissable chip. -->
|
||||
<a class="golist" href={params.catalogHref('filaments', [{ prop: 'vendorId', value: vendor.id }])}
|
||||
>{m['vendor.showAsList']()} <ChevronRight size={13} /></a
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
<div class="grid">
|
||||
<div class="col">
|
||||
<SectionLabel>{m['filament.fields.vendor']()}</SectionLabel>
|
||||
@@ -186,7 +194,8 @@
|
||||
<ExtraFieldsSection entity="vendor" extra={vendor.extra} onchange={extraSaver.change} manage />
|
||||
</div>
|
||||
<div class="col">
|
||||
<SectionLabel>{m['filament.filament']()}</SectionLabel>
|
||||
<SectionLabel right={filaments.length ? showAsList : undefined}>{m['filament.filament']()}</SectionLabel
|
||||
>
|
||||
<div class="fils">
|
||||
{#each filaments as f (f.id)}
|
||||
<a
|
||||
@@ -229,6 +238,11 @@
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.idmono {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
@@ -244,6 +258,17 @@
|
||||
gap: 0 32px;
|
||||
padding: 4px 20px 24px;
|
||||
}
|
||||
.golist {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--accent-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
.golist:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.fils {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
<script lang="ts">
|
||||
// Which tag reader this browser listens to, and whether a scan moves it.
|
||||
//
|
||||
// Pairing is done by walking over and tapping a tag on the reader you mean:
|
||||
// the browser subscribes to every reader, takes the reader id off the first
|
||||
// scan that arrives, and resubscribes to just that one. No codes to copy, no
|
||||
// server state, and the binding is self-evidently right because the user made
|
||||
// it with their hands.
|
||||
//
|
||||
// The list of recently-seen readers underneath is the fallback for pairing with
|
||||
// a reader in another room. It comes from an in-memory registry that is empty
|
||||
// after a server restart until something scans again — so an empty list is a
|
||||
// normal state to be explained, never an error.
|
||||
import Card from '../Card.svelte';
|
||||
import SettingRow from './SettingRow.svelte';
|
||||
import Toggle from '../Toggle.svelte';
|
||||
import Button from '../Button.svelte';
|
||||
import Radio from '@lucide/svelte/icons/radio';
|
||||
import { scanRelay, listReaders, type KnownReader } from '$lib/api/scanRelay';
|
||||
import { scanner } from '$lib/stores/scanner.svelte';
|
||||
import { isAbortError } from '$lib/api/http';
|
||||
import { formatDurationShort } from '$lib/utils/datetime';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
let pairing = $state(false);
|
||||
let readers = $state<KnownReader[]>([]);
|
||||
let loadingReaders = $state(false);
|
||||
|
||||
// While pairing, listen to the root pool — every reader — and pair with the
|
||||
// first one heard from. The subscription is torn down by the effect's cleanup,
|
||||
// which runs as soon as `pairing` goes false, including on the scan itself.
|
||||
$effect(() => {
|
||||
if (!pairing) return;
|
||||
return scanRelay.subscribe(null, (scan) => {
|
||||
scanner.receive(scan);
|
||||
scanner.pair(scan.readerId, scan.name);
|
||||
pairing = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Refreshed on mount and whenever pairing starts, which is when the list is
|
||||
// actually being read. A reader that scans while this page is open won't appear
|
||||
// until then — acceptable for a fallback affordance, and tapping the tag is the
|
||||
// path that needs no list at all.
|
||||
async function loadReaders(signal?: AbortSignal) {
|
||||
loadingReaders = true;
|
||||
try {
|
||||
readers = await listReaders(signal);
|
||||
// The registry is the authority on what a reader is called; the store keeps
|
||||
// no copy of its own, so this is where the paired reader's label comes from.
|
||||
scanner.learnReaders(readers);
|
||||
} catch (err) {
|
||||
if (isAbortError(err, signal)) return; // navigated away mid-request
|
||||
readers = []; // registry unreachable — the empty-state copy still applies
|
||||
} finally {
|
||||
loadingReaders = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const ac = new AbortController();
|
||||
loadReaders(ac.signal);
|
||||
return () => ac.abort();
|
||||
});
|
||||
|
||||
let pairedLabel = $derived(scanner.pairedLabel);
|
||||
// A reader that has scanned since the server started is described by that
|
||||
// registry entry, which is fresher than whatever was stored at pairing time.
|
||||
let others = $derived(readers.filter((r) => r.readerId !== scanner.pairedReaderId));
|
||||
</script>
|
||||
|
||||
<Card divided>
|
||||
<SettingRow
|
||||
title={m['settings.scanner.autoNavigate.label']()}
|
||||
desc={m['settings.scanner.autoNavigate.desc']()}
|
||||
>
|
||||
<Toggle
|
||||
checked={scanner.autoNavigate}
|
||||
onchange={(v) => scanner.setAutoNavigate(v)}
|
||||
ariaLabel={m['settings.scanner.autoNavigate.label']()}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title={m['settings.scanner.reader.label']()}
|
||||
desc={pairedLabel ? m['settings.scanner.reader.pairedDesc']() : m['settings.scanner.reader.anyDesc']()}
|
||||
>
|
||||
{#if pairing}
|
||||
<span class="waiting">{m['settings.scanner.waiting']()}</span>
|
||||
<Button variant="ghost" onclick={() => (pairing = false)}>{m['buttons.cancel']()}</Button>
|
||||
{:else}
|
||||
{#if scanner.pairedReaderId}
|
||||
<!-- The reader's own name, set apart from the prose. Ids are frequently
|
||||
things like `ip-192-168-1-50` or a bare `desk`, which read as a typo
|
||||
mid-sentence rather than as the name of a device. The raw id stays
|
||||
reachable on hover for when a friendly name hides it. -->
|
||||
<span class="reader-chip mono" title={scanner.pairedReaderId}>{pairedLabel}</span>
|
||||
<Button variant="ghost" onclick={() => scanner.unpair()}>{m['settings.scanner.unpair']()}</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
pairing = true;
|
||||
loadReaders();
|
||||
}}
|
||||
>
|
||||
<Radio size={15} />
|
||||
{m['settings.scanner.pair']()}
|
||||
</Button>
|
||||
{/if}
|
||||
</SettingRow>
|
||||
</Card>
|
||||
|
||||
<div class="known">
|
||||
<div class="known-head">{m['settings.scanner.recent']()}</div>
|
||||
{#if others.length}
|
||||
<ul class="readers">
|
||||
{#each others as reader (reader.readerId)}
|
||||
<li class="reader">
|
||||
<div class="who">
|
||||
<span class="rname">{reader.name ?? reader.readerId}</span>
|
||||
{#if reader.name}<span class="rid mono">{reader.readerId}</span>{/if}
|
||||
</div>
|
||||
<span class="seen"
|
||||
>{m['settings.scanner.lastSeen']({ when: formatDurationShort(reader.lastSeen) })}</span
|
||||
>
|
||||
<button class="link" onclick={() => scanner.pair(reader.readerId, reader.name)}>
|
||||
{m['settings.scanner.use']()}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if !loadingReaders}
|
||||
<p class="empty">{m['settings.scanner.recentNone']()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.waiting {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.reader-chip {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 8px;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.known {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.known-head {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.readers {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.reader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
.who {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.rname {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.rid {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.seen {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.link {
|
||||
font-size: 12px;
|
||||
color: var(--accent-link);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
}
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { emptyFilamentDraft, filamentDraftFrom, filamentWeightsFrom, toNewFilamentDraft } from './draft';
|
||||
import { mapFilament } from '$lib/api/map';
|
||||
|
||||
describe('filament numeric drafts', () => {
|
||||
it.each([
|
||||
['', undefined],
|
||||
['0', 0],
|
||||
['0,5', 0.5],
|
||||
['garbage', undefined]
|
||||
])('preserves optional numeric input %j as %s', (raw, expected) => {
|
||||
const draft = { ...emptyFilamentDraft(), density: '1,24', nozzleTemp: raw, bedTemp: raw };
|
||||
const result = toNewFilamentDraft(draft, { weight: '1000,5', spoolWeight: raw, price: raw }, {});
|
||||
expect(result).toMatchObject({
|
||||
density: 1.24,
|
||||
diameter: 1.75,
|
||||
weight: 1000.5,
|
||||
spoolWeight: expected,
|
||||
price: expected,
|
||||
nozzleTemp: expected,
|
||||
bedTemp: expected
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps zero values when duplicating a filament', () => {
|
||||
const filament = mapFilament({
|
||||
id: 1,
|
||||
weight: 1000,
|
||||
spool_weight: 0,
|
||||
price: 0,
|
||||
settings_extruder_temp: 0,
|
||||
settings_bed_temp: 0
|
||||
});
|
||||
expect(filamentWeightsFrom(filament)).toEqual({ weight: '1000', spoolWeight: '0', price: '0' });
|
||||
expect(filamentDraftFrom(filament, '')).toMatchObject({ nozzleTemp: '0', bedTemp: '0' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
// The new-filament form, as data.
|
||||
//
|
||||
// Two dialogs now let you describe a filament that exists in neither the local
|
||||
// catalog nor SpoolmanDB: the add-spools flow, and the change-filament dialog
|
||||
// (issue #1010 — "I ran out of slot #13 and refilled it with something new").
|
||||
// Both need the same fields, the same rules and the same mapping onto the API,
|
||||
// so all three live here and the components only draw them.
|
||||
//
|
||||
// Every value is a string, because that is what the user typed: a half-entered
|
||||
// "1," has to survive a keystroke without being coerced to a number and back.
|
||||
// Conversion happens once, at submit, in {@link toNewFilamentDraft}.
|
||||
|
||||
import type { Extra, Filament, MultiColorDirection } from '$lib/types';
|
||||
import type { NewFilamentDraft } from '$lib/api/spoolSource';
|
||||
import { parseDecimal } from '$lib/utils/numeric';
|
||||
import { HEX_RE, numErr } from '$lib/utils/validate';
|
||||
import type { MaterialSpec } from '$lib/data/materials';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
/** What identifies and specifies a filament, as typed into a form. */
|
||||
export interface FilamentDraft {
|
||||
vendorName: string;
|
||||
name: string;
|
||||
material: string;
|
||||
colors: string[];
|
||||
multiColorDirection: MultiColorDirection | undefined;
|
||||
density: string;
|
||||
diameter: string;
|
||||
nozzleTemp: string;
|
||||
bedTemp: string;
|
||||
articleNumber: string;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three numbers that describe how much filament a full spool of this holds
|
||||
* and what it cost. Kept out of {@link FilamentDraft} because the add-spools flow
|
||||
* writes them to the spool it creates as well as to the filament, and so shows
|
||||
* them in its own spool block rather than among the filament fields.
|
||||
*/
|
||||
export interface FilamentWeights {
|
||||
weight: string;
|
||||
spoolWeight: string;
|
||||
price: string;
|
||||
}
|
||||
|
||||
/** 1.75 mm is what the overwhelming majority of spools are; 2.85 is the exception. */
|
||||
const DEFAULT_DIAMETER = '1.75';
|
||||
|
||||
/** A blank draft, optionally seeded with a name the user has already typed elsewhere. */
|
||||
export function emptyFilamentDraft(name = ''): FilamentDraft {
|
||||
return {
|
||||
vendorName: '',
|
||||
name,
|
||||
material: '',
|
||||
colors: [],
|
||||
multiColorDirection: undefined,
|
||||
density: '',
|
||||
diameter: DEFAULT_DIAMETER,
|
||||
nozzleTemp: '',
|
||||
bedTemp: '',
|
||||
articleNumber: '',
|
||||
comment: ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A draft copied from an existing filament — the "I bought the same filament in
|
||||
* another colour" case. Everything that describes the *product* carries over;
|
||||
* 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 is usually one word away from the new one.
|
||||
*/
|
||||
export function filamentDraftFrom(f: Filament, vendorName: string): FilamentDraft {
|
||||
return {
|
||||
vendorName,
|
||||
name: f.name,
|
||||
material: f.material,
|
||||
colors: [],
|
||||
multiColorDirection: undefined,
|
||||
density: String(f.density),
|
||||
diameter: String(f.diameter),
|
||||
nozzleTemp: f.nozzleTemp != null ? String(f.nozzleTemp) : '',
|
||||
bedTemp: f.bedTemp != null ? String(f.bedTemp) : '',
|
||||
articleNumber: '',
|
||||
comment: f.comment
|
||||
};
|
||||
}
|
||||
|
||||
/** The weights of an existing filament, for a draft copied from it. */
|
||||
export function filamentWeightsFrom(f: Filament): FilamentWeights {
|
||||
return {
|
||||
weight: String(f.weight || 1000),
|
||||
spoolWeight: f.spoolWeight != null ? String(f.spoolWeight) : '',
|
||||
price: f.price != null ? String(f.price) : ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in density and print temps for a material the app knows about, leaving the
|
||||
* draft untouched for one it doesn't — typing a custom material must not wipe
|
||||
* figures the user entered by hand.
|
||||
*/
|
||||
export function applyMaterialSpec(
|
||||
draft: FilamentDraft,
|
||||
material: string,
|
||||
specs: Record<string, MaterialSpec>
|
||||
): void {
|
||||
draft.material = material;
|
||||
const spec = specs[material.trim().toLowerCase()];
|
||||
if (!spec) return;
|
||||
draft.density = String(spec.density);
|
||||
if (spec.nozzle != null) draft.nozzleTemp = String(spec.nozzle);
|
||||
if (spec.bed != null) draft.bedTemp = String(spec.bed);
|
||||
}
|
||||
|
||||
/**
|
||||
* The filament fields that can carry an error, in the order they appear in the
|
||||
* cards, so pressing submit sends you to the first one you'd have reached by
|
||||
* reading down. Callers weave in the fields they draw themselves — the weight
|
||||
* trio included, since where those sit differs per dialog.
|
||||
*/
|
||||
export const FILAMENT_FIELD_ORDER = [
|
||||
'vendor',
|
||||
'name',
|
||||
'material',
|
||||
'colorHex',
|
||||
'density',
|
||||
'diameter',
|
||||
'nozzleTemp',
|
||||
'bedTemp'
|
||||
];
|
||||
|
||||
/**
|
||||
* Filament fields inside the collapsed "Advanced specs" block. An error in here
|
||||
* is invisible until the block is opened — the one case where a form really can
|
||||
* look complete and still refuse to submit — so jumping to one has to open it.
|
||||
* Callers add their own: a dialog that shows the weight trio in this component
|
||||
* puts spoolWeight and price in there too.
|
||||
*/
|
||||
export const FILAMENT_ADVANCED_KEYS = ['density', 'diameter', 'nozzleTemp', 'bedTemp'];
|
||||
|
||||
/**
|
||||
* Field errors for the filament half of a form, keyed by field name ('' entries
|
||||
* dropped, so an empty object means valid).
|
||||
*
|
||||
* Mirrors the filament creation API (spoolman/api/v1/filament.py): density and
|
||||
* diameter are required and must be > 0; name/material/vendor are capped at 64
|
||||
* chars; colors must be 6 or 8 hex digits. Material is required by this client
|
||||
* even though the API accepts a filament without one — a blank material would
|
||||
* otherwise surface as "Density is required", an error on the field you didn't
|
||||
* fill in because of the one you did miss. Pass `weights` for the callers that
|
||||
* show those fields here rather than on a spool.
|
||||
*/
|
||||
export function filamentDraftErrors(draft: FilamentDraft, weights?: FilamentWeights): Record<string, string> {
|
||||
const e: Record<string, string> = {};
|
||||
if (draft.name.trim().length === 0) e.name = m['validation.required']();
|
||||
else if (draft.name.trim().length > 64) e.name = m['validation.maxChars']({ max: 64 });
|
||||
if (draft.material.trim().length === 0) e.material = m['validation.required']();
|
||||
else if (draft.material.trim().length > 64) e.material = m['validation.maxChars']({ max: 64 });
|
||||
if (draft.vendorName.trim().length > 64) e.vendor = m['validation.maxChars']({ max: 64 });
|
||||
e.density = numErr(draft.density, { required: true, gt: 0 });
|
||||
e.diameter = numErr(draft.diameter, { required: true, gt: 0 });
|
||||
e.nozzleTemp = numErr(draft.nozzleTemp, { min: 0 });
|
||||
e.bedTemp = numErr(draft.bedTemp, { min: 0 });
|
||||
if (draft.colors.some((c) => c.trim() && !HEX_RE.test(c.trim()))) e.colorHex = m['validation.hexDigits']();
|
||||
if (weights) {
|
||||
e.netWeight = numErr(weights.weight, { gt: 0 });
|
||||
e.spoolWeight = numErr(weights.spoolWeight, { min: 0 });
|
||||
e.price = numErr(weights.price, { min: 0 });
|
||||
}
|
||||
for (const k of Object.keys(e)) if (!e[k]) delete e[k];
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Number as typed, or undefined when blank/unparseable — the API omits either way. */
|
||||
function num(v: string): number | undefined {
|
||||
return parseDecimal(v) ?? undefined;
|
||||
}
|
||||
|
||||
/** Turn a validated draft into the body {@link spoolSource.createFilament} expects. */
|
||||
export function toNewFilamentDraft(
|
||||
draft: FilamentDraft,
|
||||
weights: FilamentWeights,
|
||||
extra: Extra
|
||||
): NewFilamentDraft {
|
||||
return {
|
||||
name: draft.name.trim(),
|
||||
vendorName: draft.vendorName.trim(),
|
||||
material: draft.material.trim(),
|
||||
// Both are required and > 0 by the time this runs, so the fallbacks below are
|
||||
// only there to keep the types honest.
|
||||
density: num(draft.density) ?? 0,
|
||||
diameter: num(draft.diameter) ?? Number(DEFAULT_DIAMETER),
|
||||
weight: num(weights.weight) || undefined,
|
||||
spoolWeight: num(weights.spoolWeight),
|
||||
colors: draft.colors,
|
||||
multiColorDirection: draft.multiColorDirection,
|
||||
nozzleTemp: num(draft.nozzleTemp),
|
||||
bedTemp: num(draft.bedTemp),
|
||||
price: num(weights.price),
|
||||
articleNumber: draft.articleNumber.trim() || undefined,
|
||||
comment: draft.comment.trim() || undefined,
|
||||
extra
|
||||
};
|
||||
}
|
||||
@@ -34,7 +34,9 @@ export const languages: Record<string, LanguageMeta> = {
|
||||
ta: { name: 'தமிழ்', code: 'ta-IN' },
|
||||
th: { name: 'ไทย', code: 'th-TH' },
|
||||
lt: { name: 'Lietuvių', code: 'lt-LT' },
|
||||
tr: { name: 'Türkçe', code: 'tr-TR' }
|
||||
tr: { name: 'Türkçe', code: 'tr-TR' },
|
||||
sl: { name: 'Slovenščina', code: 'sl-SI' },
|
||||
ko: { name: '한국어', code: 'ko-KR' }
|
||||
};
|
||||
|
||||
export const languageCodes = Object.keys(languages);
|
||||
|
||||
@@ -140,6 +140,26 @@ describe('presetToDesign', () => {
|
||||
expect(d.layout.columns).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('clamps v1 negative margins so the label matches the paper (#1149)', () => {
|
||||
// A real preset: -20mm margins with a 1.5mm printer margin on a 62x34 roll.
|
||||
// Carried over raw, the derived label came out 102x74.
|
||||
const d = presetToDesign({
|
||||
labelSettings: {
|
||||
printSettings: {
|
||||
paperSize: 'custom',
|
||||
customPaperSize: { width: 62, height: 34 },
|
||||
columns: 1,
|
||||
rows: 1,
|
||||
margin: { top: -20, bottom: -20, left: -20, right: -20 },
|
||||
printerMargin: { top: 1.5, bottom: 1.5, left: 1.5, right: 1.5 }
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(d.layout.margin).toEqual({ t: 0, b: 0, l: 0, r: 0 });
|
||||
expect(d.layout.safe).toEqual({ t: 1.5, b: 1.5, l: 1.5, r: 1.5 });
|
||||
expect(d.label).toEqual({ w: 62, h: 34 });
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -133,11 +133,14 @@ function mapLayout(ps: V1PrintSettings): PrintLayout {
|
||||
paper,
|
||||
custom,
|
||||
landscape: false,
|
||||
// v1 users paired negative margins with a printer margin to pull an oversized
|
||||
// grid cell back onto a label-sized page. Clamped, the safe-zone alone gives the
|
||||
// same inset and the label is sized to the paper instead of that cell (#1149).
|
||||
margin: {
|
||||
t: ps.margin?.top ?? DEFAULT_LAYOUT.margin.t,
|
||||
b: ps.margin?.bottom ?? DEFAULT_LAYOUT.margin.b,
|
||||
l: ps.margin?.left ?? DEFAULT_LAYOUT.margin.l,
|
||||
r: ps.margin?.right ?? DEFAULT_LAYOUT.margin.r
|
||||
t: Math.max(0, ps.margin?.top ?? DEFAULT_LAYOUT.margin.t),
|
||||
b: Math.max(0, ps.margin?.bottom ?? DEFAULT_LAYOUT.margin.b),
|
||||
l: Math.max(0, ps.margin?.left ?? DEFAULT_LAYOUT.margin.l),
|
||||
r: Math.max(0, ps.margin?.right ?? DEFAULT_LAYOUT.margin.r)
|
||||
},
|
||||
safe: {
|
||||
t: ps.printerMargin?.top ?? 0,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { layoutMargin, sheetGrid } from './paper';
|
||||
import { DEFAULT_LAYOUT, type PrintLayout } from './types';
|
||||
|
||||
// Issue #1149: a QL-700 preset carried over from v1, which allowed margins down
|
||||
// to -20mm. Negative margins made sheetGrid think a 34mm page fit two rows, and
|
||||
// made the safe-zone inset (safe - margin) larger than the label itself.
|
||||
const ql700: PrintLayout = {
|
||||
...DEFAULT_LAYOUT,
|
||||
paper: 'custom',
|
||||
custom: { w: 62, h: 34 },
|
||||
columns: 1,
|
||||
spacing: { h: 0, v: 0 },
|
||||
margin: { t: -20, b: -20, l: -20, r: -20 },
|
||||
safe: { t: 1.5, b: 1.5, l: 0, r: 0 }
|
||||
};
|
||||
|
||||
describe('layoutMargin', () => {
|
||||
it('clamps negative margins to zero', () => {
|
||||
expect(layoutMargin(ql700)).toEqual({ t: 0, b: 0, l: 0, r: 0 });
|
||||
});
|
||||
|
||||
it('leaves non-negative margins alone', () => {
|
||||
expect(layoutMargin(DEFAULT_LAYOUT)).toEqual(DEFAULT_LAYOUT.margin);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sheetGrid', () => {
|
||||
it('fits one label per page when the page is the label', () => {
|
||||
const grid = sheetGrid(ql700, { w: 62, h: 34 });
|
||||
expect([grid.cols, grid.rows, grid.perPage]).toEqual([1, 1, 1]);
|
||||
expect(grid.fits).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,17 @@ export function paperSize(layout: PrintLayout): { w: number; h: number } {
|
||||
return layout.landscape ? { w: base.h, h: base.w } : { w: base.w, h: base.h };
|
||||
}
|
||||
|
||||
/**
|
||||
* A layout's page margins, clamped to zero. A negative margin anchors the grid
|
||||
* off the paper (so labels get clipped away) and inflates the safe-zone inset in
|
||||
* print.ts — v1 presets allowed down to -20mm, so old saved layouts still carry
|
||||
* negative values. Clamp on read rather than trusting the stored numbers.
|
||||
*/
|
||||
export function layoutMargin(layout: PrintLayout): { t: number; b: number; l: number; r: number } {
|
||||
const { t, b, l, r } = layout.margin;
|
||||
return { t: Math.max(0, t), b: Math.max(0, b), l: Math.max(0, l), r: Math.max(0, r) };
|
||||
}
|
||||
|
||||
/** 1 inch = 25.4 mm. */
|
||||
export const MM_PER_INCH = 25.4;
|
||||
|
||||
@@ -53,8 +64,9 @@ export interface SheetGrid {
|
||||
*/
|
||||
export function sheetGrid(layout: PrintLayout, label: { w: number; h: number }): SheetGrid {
|
||||
const page = paperSize(layout);
|
||||
const usableW = page.w - layout.margin.l - layout.margin.r;
|
||||
const usableH = page.h - layout.margin.t - layout.margin.b;
|
||||
const margin = layoutMargin(layout);
|
||||
const usableW = page.w - margin.l - margin.r;
|
||||
const usableH = page.h - margin.t - margin.b;
|
||||
// Fall back to an auto column fit for older saved layouts that predate `columns`.
|
||||
const cols =
|
||||
layout.columns && layout.columns > 0
|
||||
|
||||
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user