Easy management of printer locations (groups) (issue #2962)- #2970 (#2995)

This commit is contained in:
WATARIE
2026-10-03 14:28:35 +02:00
committed by GitHub
parent 3ce9b917cb
commit fa25956738
38 changed files with 2519 additions and 143 deletions
+1
View File
@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
## [1.2.6b1] - Unreleased
### Added
- **A page for managing printer locations (#2962, requested and contributed by @SVAN19 in #2995)** — Printer locations used to be a free-text field in each printer's settings, so renaming a room meant editing every printer in it, and a typo quietly made a second location. The new **Printer Locations** page, opened from the button next to the sort control on the Printers page, lists every location with its printers and their status. You can create a location before any printer is in it, give it an icon and a colour, rename it, delete it, and move printers between locations one at a time or several at once. Search, sorting and hiding empty locations help on a large farm. Locations are stored by Bambuddy, so every user and every device sees the same ones. Renaming a location also updates queued jobs that target it, so a job waiting for "any X1C in Workshop" still finds its printers after Workshop becomes Garage, and so do the next runs of a batch. Locations typed with stray spaces before are tidied up on upgrade, so "Workshop " and "Workshop" become one. Each change is saved as one step, so a failure cannot leave a location half renamed, and two locations that differ only in upper and lower case are refused. Location names are limited to 100 characters, in the printer dialog too. Changing locations needs the printer edit permission; anyone who can see printers can view the page. Wiki updated. Covered by backend and frontend tests.
- **A Column view in the File Manager (#3020, requested and contributed by @Thomansky in #3190)** — Next to Grid and List there is now **Column view**, as in the macOS Finder: one column per folder level, each listing that level's subfolders and then its own files, with the selected folder's files in the pane on the right. It shows where you are in the folder tree and lets you walk up and down it without going back to the sidebar. File rows carry the same actions as the list view (Print, Slice, Preview, Download, Details, Rename, Delete and the rest), and folder rows the same ⋮ menu as the sidebar. It works from the keyboard too: ↑/↓ move within a column, → or Enter go down a level, ← goes up, Enter opens a file's preview, Space ticks it, and the context-menu key or Shift+F10 moves into the row's actions. A file ticked in any column counts for the toolbar's Print, Preview, Combine, Move and Delete. With a search or tag filter active the columns step aside and the results take the full width, since they span every folder. The choice is remembered like Grid and List. Changing folders now clears the selection in every view, so Move and Delete can't act on files that are no longer on screen. The sidebar's folder ⋮ menu now closes when you click it again.
- **Announcements from the Bambuddy maintainers, inside Bambuddy** — Security fixes, breaking changes, new releases and calls for testers now reach the people running Bambuddy where they already look, instead of only on GitHub and Discord. An **Announcements** entry sits at the bottom of the sidebar above the System icon for everyone who may see them, with a count of unread messages; it opens a list, and **important** and **critical** messages also show a banner until dismissed. Messages past their expiry stay readable under **Earlier** for a year (up to 50); withdrawn ones disappear everywhere. Read state is kept per user on the server. Bambuddy fetches one file, `feed.json` from the public `maziggy/bambuddy-notifications` repo on GitHub, at startup and every 6 hours: no Bambuddy server is contacted and nothing about the install is sent, and whether a message applies (version range, beta channel, install type) is decided locally. The file is signed with Ed25519 against a key built into Bambuddy, so a copy of the repo or anyone in between can't make it show a message, and an older file is refused so a withdrawn message can't be brought back. Messages are plain text, and links go only to github.com and bambuddy.cool. Shown to administrators; **Settings → General → Updates** can show them to every user, or turn them off entirely, in which case nothing is fetched.
- **Camera snapshots reach more notifications and more providers, with an Attach Photo switch per provider (#3089, requested and contributed by @bbbenji in #3199)** — Plate Not Empty now carries a photo of the plate, taken before the chamber light is switched back off, and AI Failure Detection carries the frame the model flagged. Home Assistant (with a notify service set), Bark and Slack-format webhooks get photos too: they fetch the image themselves, so Bambuddy saves it under a random name and sends a link that opens only that one photo and stops working after 3 days. This needs **External URL** set in **Settings → Network**. Each provider has an **Attach Photo** toggle, on by default so nothing changes for existing setups, and **Test** sends a sample image when it is on. The print emails sent to the user who queued a job can show the finish photo inline when the template contains `{finish_photo_url}`. The template editor shows which events can carry a photo, and lists the variables for AI Failure Detection, Plate Not Empty, Plate Clear Required and First Layer Complete. The chamber light switched on for the plate check is now always switched back off, even when the check fails.
+240
View File
@@ -0,0 +1,240 @@
"""Printer locations (groups): list, create, rename, restyle, delete, assign (#2962).
A printer's location is the free-text ``printers.location`` column, which the
scheduler's model-based targeting (``print_queue.target_location``) and the
printers filter match exactly. ``printer_locations`` adds the rest: a location
with no printers yet, and its icon and colour. The list is the union of both,
so a location typed into the printer dialog shows up here without a row.
Every write is one transaction on the server. The page used to send one PATCH
per printer from its cached printer list: a failure halfway left a location
split under two names, and a printer another user had moved in the meantime
was moved back.
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.core.websocket import ws_manager
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.models.printer_location import PrinterLocation
from backend.app.models.user import User
from backend.app.schemas.printer_location import (
PrinterLocationAssign,
PrinterLocationAssignResult,
PrinterLocationCreate,
PrinterLocationDelete,
PrinterLocationDeleteResult,
PrinterLocationResponse,
PrinterLocationUpdate,
)
from backend.app.utils.natural_sort import natural_sort_key
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/printer-locations", tags=["printer-locations"])
DUPLICATE_NAME = "A location with this name already exists"
def _blank_location():
"""Printers with no location: NULL, or "" from before blanks were folded."""
return (Printer.location.is_(None)) | (func.trim(Printer.location) == "")
async def _printer_counts(db: AsyncSession) -> dict[str, int]:
result = await db.execute(
select(Printer.location, func.count(Printer.id)).where(~_blank_location()).group_by(Printer.location)
)
return dict(result.all())
async def _row(db: AsyncSession, name: str) -> PrinterLocation | None:
result = await db.execute(select(PrinterLocation).where(PrinterLocation.name == name))
return result.scalar_one_or_none()
async def _name_taken(db: AsyncSession, name: str, *, ignore: str | None = None) -> bool:
"""Whether ``name`` is a location already, ignoring case.
Matching elsewhere is exact, so "Workshop" and "workshop" would be two
locations that look like one. Refusing the second keeps them apart.
"""
key = name.casefold()
rows = (await db.execute(select(PrinterLocation.name))).scalars().all()
used = (await db.execute(select(Printer.location).where(~_blank_location()).distinct())).scalars().all()
return any(n.casefold() == key and n != ignore for n in (*rows, *used))
async def _conflicts(db: AsyncSession, name: str) -> bool:
"""Whether using ``name`` would add a case variant of a location.
A name that already exists exactly is never a conflict, even when an older
install also holds a case variant of it: refusing would leave no way to
move a printer into, or style, a location that is plainly there.
"""
if await _row(db, name) is not None:
return False
if name in await _printer_counts(db):
return False
return await _name_taken(db, name)
async def _broadcast() -> None:
await ws_manager.broadcast({"type": "printer_locations_changed"})
@router.get("/", response_model=list[PrinterLocationResponse])
async def list_printer_locations(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
):
"""Every location: those with a row, and those only printers carry."""
counts = await _printer_counts(db)
rows = (await db.execute(select(PrinterLocation))).scalars().all()
out = {
row.name: PrinterLocationResponse(
id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0)
)
for row in rows
}
for name, count in counts.items():
if name not in out:
out[name] = PrinterLocationResponse(name=name, printer_count=count)
return sorted(out.values(), key=lambda loc: natural_sort_key(loc.name))
@router.post("/", response_model=PrinterLocationResponse, status_code=201)
async def create_printer_location(
data: PrinterLocationCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
):
"""Create a location, with or without printers in it yet."""
if await _row(db, data.name) is not None or await _conflicts(db, data.name):
raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
# A location printers already use but that has no row yet gets one, which is
# how it gets an icon and colour.
row = PrinterLocation(name=data.name, icon=data.icon, color=data.color)
db.add(row)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail=DUPLICATE_NAME) from exc
await db.refresh(row)
await _broadcast()
counts = await _printer_counts(db)
return PrinterLocationResponse(
id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0)
)
@router.patch("/", response_model=PrinterLocationResponse)
async def update_printer_location(
data: PrinterLocationUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
):
"""Rename a location and/or change its icon and colour.
A rename moves its printers and the queue items that target it in the same
transaction, so an "any printer in <location>" job, and the next run of a
batch, keep finding their printers.
"""
row = await _row(db, data.name)
counts = await _printer_counts(db)
if row is None and data.name not in counts:
raise HTTPException(status_code=404, detail="Location not found")
name = data.name
new_name = data.new_name if "new_name" in data.model_fields_set else None
if new_name is not None and new_name != name:
if await _name_taken(db, new_name, ignore=name):
raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
await db.execute(update(Printer).where(Printer.location == name).values(location=new_name))
# Every row, not only pending ones: a batch clones its next run from
# its newest row whatever that row's status, so a finished run still
# pointing at the old name would send future runs nowhere. A rename is
# the same place under a new name, so history follows it too.
await db.execute(
update(PrintQueueItem).where(PrintQueueItem.target_location == name).values(target_location=new_name)
)
name = new_name
if row is None:
row = PrinterLocation(name=name)
db.add(row)
else:
row.name = name
if "icon" in data.model_fields_set:
row.icon = data.icon
if "color" in data.model_fields_set:
row.color = data.color
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail=DUPLICATE_NAME) from exc
await db.refresh(row)
await _broadcast()
counts = await _printer_counts(db)
return PrinterLocationResponse(
id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0)
)
@router.post("/delete", response_model=PrinterLocationDeleteResult)
async def delete_printer_locations(
data: PrinterLocationDelete,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
):
"""Delete locations; their printers end up with no location.
Pending queue items that target a deleted location are left alone: changing
them to "any location" would let them start on printers they were meant to
stay off.
"""
names = set(data.names)
existing = set((await db.execute(select(PrinterLocation.name).where(PrinterLocation.name.in_(names)))).scalars())
existing |= set(await _printer_counts(db)) & names
await db.execute(delete(PrinterLocation).where(PrinterLocation.name.in_(names)))
moved = await db.execute(update(Printer).where(Printer.location.in_(names)).values(location=None))
await db.commit()
await _broadcast()
return PrinterLocationDeleteResult(deleted=len(existing), printers_ungrouped=moved.rowcount or 0)
@router.post("/assign", response_model=PrinterLocationAssignResult)
async def assign_printer_location(
data: PrinterLocationAssign,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
):
"""Move printers into a location, or out of any with null.
By printer id, on the server: a printer someone else moved meanwhile is
moved again only if it is in this request.
"""
# Moving into a new case variant of an existing location would split it.
if data.location is not None and await _conflicts(db, data.location):
raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
ids = list(dict.fromkeys(data.printer_ids))
found = (await db.execute(select(Printer.id).where(Printer.id.in_(ids)))).scalars().all()
missing = sorted(set(ids) - set(found))
if missing:
raise HTTPException(status_code=404, detail=f"Printer not found: {', '.join(map(str, missing))}")
result = await db.execute(update(Printer).where(Printer.id.in_(ids)).values(location=data.location))
await db.commit()
await _broadcast()
return PrinterLocationAssignResult(moved=result.rowcount or 0)
+29
View File
@@ -349,6 +349,7 @@ async def init_db():
print_queue,
printer,
printer_ha_sensor,
printer_location,
printer_sensor_history,
project,
project_bom,
@@ -5184,6 +5185,9 @@ async def run_migrations(conn):
# Spoolman and the location sync then imported as storage locations.
await _migrate_drop_ams_slot_locations(conn)
# Data migration: printer locations as the locations API stores them (#2962).
await _migrate_normalize_printer_locations(conn)
# Migration: link a batch to the external record that asked for it (a shop
# order an integration turned into prints). The unique index is what makes
# a retried create safe; both columns are new, so no row can violate it.
@@ -5531,6 +5535,31 @@ async def _migrate_location_ha_sensor_unique_binding(conn) -> None:
)
async def _migrate_normalize_printer_locations(conn) -> None:
"""Trim printer locations and store a blank one as NULL (#2962).
Nothing trimmed ``printers.location`` before, so "Workshop " and "Workshop"
could both be stored, and "" sat next to NULL for "no location". The
Printer Locations API trims every name it is given, so an untrimmed stored
value could never be matched to rename or delete it. Queue items'
``target_location`` is trimmed the same way, so model-based jobs keep
matching their printers exactly. Idempotent: only rows that change are
touched.
"""
from sqlalchemy import text
async with conn.begin_nested():
for table, column in (("printers", "location"), ("print_queue", "target_location")):
await conn.execute(
text(f"UPDATE {table} SET {column} = NULL WHERE {column} IS NOT NULL AND TRIM({column}) = ''") # noqa: S608 # nosec B608 — fixed identifiers
)
await conn.execute(
text(
f"UPDATE {table} SET {column} = TRIM({column}) WHERE {column} IS NOT NULL AND {column} <> TRIM({column})"
) # noqa: S608 # nosec B608 — fixed identifiers
)
async def _migrate_drop_ams_slot_locations(conn) -> None:
"""Remove imported AMS slot markers from the storage-location catalogue.
+2
View File
@@ -61,6 +61,7 @@ from backend.app.api.routes import (
pipeline_runs,
print_log,
print_queue,
printer_locations,
printer_sensor_history,
printers,
projects,
@@ -10637,6 +10638,7 @@ app.include_router(bug_report.router, prefix=app_settings.api_prefix)
app.include_router(users.router, prefix=app_settings.api_prefix)
app.include_router(groups.router, prefix=app_settings.api_prefix)
app.include_router(printers.router, prefix=app_settings.api_prefix)
app.include_router(printer_locations.router, prefix=app_settings.api_prefix)
app.include_router(archives.router, prefix=app_settings.api_prefix)
app.include_router(filaments.router, prefix=app_settings.api_prefix)
app.include_router(finance.router, prefix=app_settings.api_prefix)
+2
View File
@@ -25,6 +25,7 @@ from backend.app.models.pipeline_run import PipelineJob, PipelineRun
from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
from backend.app.models.printer import Printer
from backend.app.models.printer_ha_sensor import PrinterHASensor
from backend.app.models.printer_location import PrinterLocation
from backend.app.models.printer_sensor_history import PrinterSensorHistory
from backend.app.models.project import Project
from backend.app.models.scheduled_drying import ScheduledDrying
@@ -48,6 +49,7 @@ from backend.app.models.user_totp import UserTOTP
__all__ = [
"Printer",
"PrinterLocation",
"PrintArchive",
"Filament",
"Settings",
+30
View File
@@ -0,0 +1,30 @@
from datetime import datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
class PrinterLocation(Base):
"""A printer location (group) and how it looks (#2962).
A printer's location is still the free-text ``printers.location`` column,
which the scheduler's model-based targeting and the printers filter match
exactly. This table only adds what that column cannot hold: a location with
no printers yet (a rack or room planned before the printers move in), and
the icon and colour the Printer Locations page shows for it. A location used
by printers but missing here is still a location, with no icon or colour.
"""
__tablename__ = "printer_locations"
id: Mapped[int] = mapped_column(primary_key=True)
# Same width as printers.location, which holds the same value.
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
# An IconPicker name ("home", "wrench"); None shows the default icon.
icon: Mapped[str | None] = mapped_column(String(50))
# "#rrggbb"; None means no colour.
color: Mapped[str | None] = mapped_column(String(7))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+6
View File
@@ -2,6 +2,7 @@ from datetime import datetime
from pydantic import BaseModel, Field, field_validator, model_validator
from backend.app.schemas.printer_location import normalize_location_name
from backend.app.utils.printer_models import supports_nozzle_flow_type
@@ -47,6 +48,10 @@ class PrinterCreate(PrinterBase):
# connect to the printer's MQTT and bypass Bambuddy's RBAC.
access_code: str = Field(..., min_length=1, max_length=20)
# Input only, not on PrinterBase: SQLite never enforced the column width,
# so a stored location may be longer, and the response must still read it.
_location = field_validator("location")(normalize_location_name)
class PlateDetectionROI(BaseModel):
"""Region of interest for plate detection (percentages 0.0-1.0)."""
@@ -67,6 +72,7 @@ class PrinterUpdate(BaseModel):
access_code: str | None = None
model: str | None = None
location: str | None = None
_location = field_validator("location")(normalize_location_name)
is_active: bool | None = None
auto_archive: bool | None = None
print_hours_offset: float | None = None
+116
View File
@@ -0,0 +1,116 @@
"""Printer locations (groups) and their appearance (#2962)."""
from pydantic import BaseModel, Field, field_validator
# printers.location, print_queue.target_location and printer_locations.name are
# all VARCHAR(100). PostgreSQL refuses a longer value with an error mid-write,
# so it is refused here instead.
LOCATION_NAME_MAX_LENGTH = 100
_ICON_PATTERN = r"^[a-z0-9-]{1,50}$"
_COLOR_PATTERN = r"^#[0-9a-fA-F]{6}$"
def normalize_location_name(value: str | None) -> str | None:
"""A location as stored: trimmed, and None for blank (no location).
"" and None both meant "no location" before this existed; folding them into
one value keeps the exact-match filters from treating them as two groups.
"""
if value is None:
return None
if not isinstance(value, str):
raise ValueError("location must be a string")
value = value.strip()
if not value:
return None
if len(value) > LOCATION_NAME_MAX_LENGTH:
raise ValueError(f"location must be at most {LOCATION_NAME_MAX_LENGTH} characters")
return value
def _required_name(value: str) -> str:
name = normalize_location_name(value)
if name is None:
raise ValueError("location name is required")
return name
def _existing_name(value: str) -> str:
"""The name of a location that already exists, as stored.
Not length-checked: SQLite never enforced the column width, so a stored
location can be longer than a new one may be, and it must still be possible
to rename or delete it.
"""
if not isinstance(value, str) or not value.strip():
raise ValueError("location name is required")
return value.strip()
def _blank_to_none(value: str | None) -> str | None:
if isinstance(value, str) and not value.strip():
return None
return value
class PrinterLocationResponse(BaseModel):
# None for a location that only exists on printers and has no row yet.
id: int | None = None
name: str
icon: str | None = None
color: str | None = None
printer_count: int = 0
class PrinterLocationCreate(BaseModel):
name: str
icon: str | None = Field(default=None, pattern=_ICON_PATTERN)
color: str | None = Field(default=None, pattern=_COLOR_PATTERN)
_name = field_validator("name")(_required_name)
_blank = field_validator("icon", "color", mode="before")(_blank_to_none)
class PrinterLocationUpdate(BaseModel):
"""Rename and/or restyle the location called ``name``.
``icon`` and ``color`` are only changed when sent; sending null or "" clears
them.
"""
name: str
new_name: str | None = None
icon: str | None = Field(default=None, pattern=_ICON_PATTERN)
color: str | None = Field(default=None, pattern=_COLOR_PATTERN)
_name = field_validator("name")(_existing_name)
_new_name = field_validator("new_name")(_required_name)
_blank = field_validator("icon", "color", mode="before")(_blank_to_none)
class PrinterLocationDelete(BaseModel):
names: list[str] = Field(min_length=1, max_length=500)
@field_validator("names")
@classmethod
def _names(cls, values: list[str]) -> list[str]:
return list(dict.fromkeys(_existing_name(v) for v in values))
class PrinterLocationDeleteResult(BaseModel):
deleted: int
printers_ungrouped: int
class PrinterLocationAssign(BaseModel):
"""Move printers into ``location``, or out of any location with null."""
printer_ids: list[int] = Field(min_length=1, max_length=1000)
location: str | None = None
_location = field_validator("location")(normalize_location_name)
class PrinterLocationAssignResult(BaseModel):
moved: int
@@ -0,0 +1,341 @@
"""Printer locations (groups) API (#2962).
Locations live on printers (``printers.location``) and, for appearance and
empty locations, in ``printer_locations``. Every write is one server-side
transaction: the page used to send one PATCH per printer from its cached list,
so a failure halfway split a location, and another user's move was undone.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy import inspect, select
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.models.printer_location import PrinterLocation
BASE = "/api/v1/printer-locations/"
async def _locations(client: AsyncClient) -> dict[str, dict]:
response = await client.get(BASE)
assert response.status_code == 200
return {loc["name"]: loc for loc in response.json()}
async def _printer_location(db_session, printer) -> str | None:
# A column select reads the row, not the identity map, so nothing needs
# expiring; the id comes from the identity, which never lazy-loads.
printer_id = inspect(printer).identity[0]
return (await db_session.execute(select(Printer.location).where(Printer.id == printer_id))).scalar_one()
@pytest.mark.asyncio
@pytest.mark.integration
class TestList:
async def test_union_of_rows_and_printer_locations(self, async_client, printer_factory, db_session):
await printer_factory(name="A", location="Workshop")
await printer_factory(name="B", location="Workshop")
await printer_factory(name="C", location="")
await printer_factory(name="D", location=None)
db_session.add(PrinterLocation(name="Future rack", icon="home", color="#3b82f6"))
await db_session.commit()
locations = await _locations(async_client)
assert set(locations) == {"Workshop", "Future rack"}
assert locations["Workshop"]["printer_count"] == 2
assert locations["Workshop"]["id"] is None
assert locations["Future rack"] == {
"id": locations["Future rack"]["id"],
"name": "Future rack",
"icon": "home",
"color": "#3b82f6",
"printer_count": 0,
}
async def test_natural_order(self, async_client, db_session):
for name in ("Rack 10", "Rack 2", "Office"):
db_session.add(PrinterLocation(name=name))
await db_session.commit()
response = await async_client.get(BASE)
assert [loc["name"] for loc in response.json()] == ["Office", "Rack 2", "Rack 10"]
@pytest.mark.asyncio
@pytest.mark.integration
class TestCreate:
async def test_an_empty_location_persists(self, async_client):
response = await async_client.post(BASE, json={"name": " Basement ", "icon": "home", "color": "#ef4444"})
assert response.status_code == 201
assert response.json()["name"] == "Basement"
assert "Basement" in await _locations(async_client)
async def test_a_location_printers_already_use_gets_a_row(self, async_client, printer_factory):
await printer_factory(location="Workshop")
response = await async_client.post(BASE, json={"name": "Workshop", "color": "#22c55e"})
assert response.status_code == 201
assert response.json()["printer_count"] == 1
assert response.json()["color"] == "#22c55e"
@pytest.mark.parametrize("name", ["Basement", "basement", "BASEMENT"])
async def test_a_duplicate_is_refused_ignoring_case(self, async_client, name):
await async_client.post(BASE, json={"name": "Basement"})
response = await async_client.post(BASE, json={"name": name})
assert response.status_code == 409
@pytest.mark.parametrize(
"body",
[
{"name": ""},
{"name": " "},
{"name": "x" * 101},
{"name": "Ok", "color": "red"},
{"name": "Ok", "icon": "<script>"},
],
)
async def test_invalid_input_is_a_422_not_a_500(self, async_client, body):
response = await async_client.post(BASE, json=body)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
class TestUpdate:
async def test_rename_moves_printers_and_queue_items_together(
self, async_client, printer_factory, archive_factory, db_session
):
inside = await printer_factory(location="Workshop")
outside = await printer_factory(location="Office")
archive = await archive_factory(inside.id)
pending = PrintQueueItem(
archive_id=archive.id, target_model="X1C", target_location="Workshop", status="pending"
)
done = PrintQueueItem(archive_id=archive.id, target_model="X1C", target_location="Workshop", status="completed")
db_session.add_all([pending, done])
await db_session.commit()
response = await async_client.patch(BASE, json={"name": "Workshop", "new_name": "Garage"})
assert response.status_code == 200
assert response.json()["printer_count"] == 1
assert await _printer_location(db_session, inside) == "Garage"
assert await _printer_location(db_session, outside) == "Office"
await db_session.refresh(pending)
await db_session.refresh(done)
assert pending.target_location == "Garage"
# Finished rows too: a batch clones its next run from its newest row,
# whatever its status.
assert done.target_location == "Garage"
assert set(await _locations(async_client)) == {"Garage", "Office"}
async def test_rename_onto_another_location_is_refused(self, async_client, printer_factory, db_session):
a = await printer_factory(location="Workshop")
await printer_factory(location="Office")
response = await async_client.patch(BASE, json={"name": "Workshop", "new_name": "office"})
assert response.status_code == 409
assert await _printer_location(db_session, a) == "Workshop"
async def test_a_case_only_rename_is_allowed(self, async_client, printer_factory, db_session):
a = await printer_factory(location="workshop")
response = await async_client.patch(BASE, json={"name": "workshop", "new_name": "Workshop"})
assert response.status_code == 200
assert await _printer_location(db_session, a) == "Workshop"
async def test_restyle_only_sent_fields(self, async_client):
await async_client.post(BASE, json={"name": "Shop", "icon": "home", "color": "#ef4444"})
await async_client.patch(BASE, json={"name": "Shop", "color": "#22c55e"})
after_color = (await _locations(async_client))["Shop"]
await async_client.patch(BASE, json={"name": "Shop", "icon": ""})
after_clear = (await _locations(async_client))["Shop"]
assert (after_color["icon"], after_color["color"]) == ("home", "#22c55e")
assert (after_clear["icon"], after_clear["color"]) == (None, "#22c55e")
async def test_unknown_location_is_a_404(self, async_client):
response = await async_client.patch(BASE, json={"name": "Nowhere", "color": "#22c55e"})
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
class TestDelete:
async def test_printers_end_up_ungrouped_and_rows_go(self, async_client, printer_factory, db_session):
a = await printer_factory(location="Workshop")
b = await printer_factory(location="Office")
await async_client.post(BASE, json={"name": "Empty"})
response = await async_client.post(f"{BASE}delete", json={"names": ["Workshop", "Empty", "Nowhere"]})
assert response.status_code == 200
assert response.json() == {"deleted": 2, "printers_ungrouped": 1}
assert await _printer_location(db_session, a) is None
assert await _printer_location(db_session, b) == "Office"
assert set(await _locations(async_client)) == {"Office"}
async def test_pending_jobs_keep_their_target(self, async_client, printer_factory, archive_factory, db_session):
"""Turning them into "any location" would start them where they were
meant not to run."""
printer = await printer_factory(location="Workshop")
archive = await archive_factory(printer.id)
item = PrintQueueItem(archive_id=archive.id, target_model="X1C", target_location="Workshop", status="pending")
db_session.add(item)
await db_session.commit()
await async_client.post(f"{BASE}delete", json={"names": ["Workshop"]})
await db_session.refresh(item)
assert item.target_location == "Workshop"
@pytest.mark.asyncio
@pytest.mark.integration
class TestAssign:
async def test_moves_only_the_listed_printers(self, async_client, printer_factory, db_session):
a = await printer_factory(location="Workshop")
b = await printer_factory(location="Workshop")
c = await printer_factory(location="Office")
response = await async_client.post(f"{BASE}assign", json={"printer_ids": [a.id, c.id], "location": "Garage"})
assert response.status_code == 200
assert response.json() == {"moved": 2}
assert await _printer_location(db_session, a) == "Garage"
assert await _printer_location(db_session, b) == "Workshop"
assert await _printer_location(db_session, c) == "Garage"
async def test_null_and_blank_ungroup(self, async_client, printer_factory, db_session):
a = await printer_factory(location="Workshop")
b = await printer_factory(location="Workshop")
await async_client.post(f"{BASE}assign", json={"printer_ids": [a.id], "location": None})
await async_client.post(f"{BASE}assign", json={"printer_ids": [b.id], "location": " "})
assert await _printer_location(db_session, a) is None
assert await _printer_location(db_session, b) is None
async def test_an_unknown_printer_moves_nothing(self, async_client, printer_factory, db_session):
"""All or nothing, so a bad id cannot leave half the selection moved."""
a = await printer_factory(location="Workshop")
response = await async_client.post(f"{BASE}assign", json={"printer_ids": [a.id, 99999], "location": "Garage"})
assert response.status_code == 404
assert await _printer_location(db_session, a) == "Workshop"
async def test_a_case_variant_of_a_location_is_refused(self, async_client, printer_factory, db_session):
a = await printer_factory(location="Workshop")
b = await printer_factory(location=None)
response = await async_client.post(f"{BASE}assign", json={"printer_ids": [b.id], "location": "workshop"})
assert response.status_code == 409
assert await _printer_location(db_session, a) == "Workshop"
assert await _printer_location(db_session, b) is None
@pytest.mark.asyncio
@pytest.mark.integration
class TestPrinterLocationInput:
"""The printer dialog writes the same column, with the same rules."""
async def test_too_long_is_a_422(self, async_client, printer_factory):
printer = await printer_factory()
response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"location": "x" * 101})
assert response.status_code == 422
async def test_blank_is_stored_as_no_location(self, async_client, printer_factory, db_session):
printer = await printer_factory(location="Workshop")
response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"location": " "})
assert response.status_code == 200
assert await _printer_location(db_session, printer) is None
@pytest.mark.asyncio
@pytest.mark.integration
class TestExistingData:
"""Locations stored before the page existed: untrimmed, blank, case
variants, and on SQLite longer than the column width."""
async def test_migration_trims_and_blanks(self, test_engine, printer_factory, archive_factory, db_session):
from backend.app.core.database import _migrate_normalize_printer_locations
a = await printer_factory(location="Workshop ")
b = await printer_factory(location=" ")
c = await printer_factory(location="Office")
archive = await archive_factory(a.id)
item = PrintQueueItem(archive_id=archive.id, target_model="X1C", target_location=" Workshop", status="pending")
db_session.add(item)
await db_session.commit()
async with test_engine.begin() as conn:
await _migrate_normalize_printer_locations(conn)
# Idempotent: a second run changes nothing.
await _migrate_normalize_printer_locations(conn)
assert await _printer_location(db_session, a) == "Workshop"
assert await _printer_location(db_session, b) is None
assert await _printer_location(db_session, c) == "Office"
await db_session.refresh(item)
assert item.target_location == "Workshop"
async def test_case_variants_from_before_can_still_be_used(self, async_client, printer_factory, db_session):
"""Refusing would leave no way to move a printer into a location that is
plainly there, or to style it."""
await printer_factory(location="Workshop")
await printer_factory(location="workshop")
newcomer = await printer_factory(location=None)
moved = await async_client.post(f"{BASE}assign", json={"printer_ids": [newcomer.id], "location": "Workshop"})
styled = await async_client.post(BASE, json={"name": "workshop", "color": "#22c55e"})
third = await async_client.post(BASE, json={"name": "WORKSHOP"})
assert moved.status_code == 200
assert await _printer_location(db_session, newcomer) == "Workshop"
assert styled.status_code == 201
assert third.status_code == 409
async def test_an_overlong_stored_name_can_be_renamed_and_deleted(self, async_client, printer_factory, db_session):
"""SQLite never enforced VARCHAR(100)."""
long_name = "L" * 150
a = await printer_factory(location=long_name)
b = await printer_factory(location=long_name + "2")
renamed = await async_client.patch(BASE, json={"name": long_name, "new_name": "Short"})
deleted = await async_client.post(f"{BASE}delete", json={"names": [long_name + "2"]})
assert renamed.status_code == 200
assert await _printer_location(db_session, a) == "Short"
assert deleted.json() == {"deleted": 1, "printers_ungrouped": 1}
assert await _printer_location(db_session, b) is None
class TestPrinterCreateLocation:
def test_create_trims_blanks_and_limits(self):
from pydantic import ValidationError
from backend.app.schemas.printer import PrinterCreate
base = {"name": "P", "serial_number": "00M09A000000001", "ip_address": "10.0.0.1", "access_code": "1234"}
assert PrinterCreate(**base, location=" Shop ").location == "Shop"
assert PrinterCreate(**base, location=" ").location is None
with pytest.raises(ValidationError):
PrinterCreate(**base, location="x" * 101)
+2
View File
@@ -19,6 +19,7 @@ import { CamWallPage } from './pages/CamWallPage';
import { StreamOverlayPage } from './pages/StreamOverlayPage';
import { ExternalLinkPage } from './pages/ExternalLinkPage';
import { GroupEditPage } from './pages/GroupEditPage';
import { PrinterLocationsPage } from './pages/PrinterLocationsPage';
import InventoryPage from './pages/InventoryPage';
import { MakerworldPage } from './pages/MakerworldPage';
import { SystemInfoPage } from './pages/SystemInfoPage';
@@ -228,6 +229,7 @@ function App() {
<Route path="settings" element={<PermissionRoute permission="settings:read"><SettingsPage /></PermissionRoute>} />
<Route path="groups/new" element={<PermissionRoute permission="groups:create"><GroupEditPage /></PermissionRoute>} />
<Route path="groups/:id/edit" element={<PermissionRoute permission="groups:update"><GroupEditPage /></PermissionRoute>} />
<Route path="printer-locations" element={<PermissionRoute permission="printers:read"><PrinterLocationsPage /></PermissionRoute>} />
<Route path="users" element={<Navigate to="/settings?tab=users" replace />} />
<Route path="groups" element={<Navigate to="/settings?tab=users" replace />} />
<Route path="system" element={<SystemInfoPage />} />
@@ -0,0 +1,187 @@
/**
* Printer Locations page (#2962).
*
* Locations, with their icon and colour, come from the server: a location with
* no printers used to live only in one browser's localStorage, invisible to
* other users and devices. Every change is one request, so a failure cannot
* leave a location split across printers, and English counts never render as
* "5 printer".
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { render } from '../utils';
import i18n from '../../i18n';
const permissions = { granted: ['printers:read', 'printers:update'] as string[] };
const mockUseAuth = {
user: { id: 1, username: 'operator', permissions: [] as string[] },
authEnabled: true,
requiresSetup: false,
loading: false,
isAdmin: false,
login: vi.fn(),
loginWithToken: vi.fn(),
logout: vi.fn(),
refreshUser: vi.fn(),
refreshAuth: vi.fn(),
hasPermission: vi.fn((permission: string) => permissions.granted.includes(permission)),
hasAnyPermission: vi.fn(() => true),
hasAllPermissions: vi.fn(() => true),
canModify: vi.fn(() => true),
};
vi.mock('../../contexts/AuthContext', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
return { ...actual, useAuth: () => mockUseAuth };
});
import { PrinterLocationsPage } from '../../pages/PrinterLocationsPage';
const printer = (id: number, name: string, location: string | null) => ({
id,
name,
location,
model: 'X1C',
serial_number: `S${id}`,
ip_address: `10.0.0.${id}`,
is_active: true,
});
const PRINTERS = [
printer(1, 'Alpha', 'Workshop'),
printer(2, 'Bravo', 'Workshop'),
printer(3, 'Charlie', null),
printer(4, 'Delta', ''),
];
const LOCATIONS = [
{ id: 1, name: 'Future rack', icon: 'home', color: '#3b82f6', printer_count: 0 },
{ id: null, name: 'Workshop', icon: null, color: null, printer_count: 5 },
];
let requests: { method: string; path: string; body: unknown }[];
beforeEach(() => {
permissions.granted = ['printers:read', 'printers:update'];
requests = [];
const record = async (request: Request) => {
requests.push({ method: request.method, path: new URL(request.url).pathname, body: await request.json() });
};
server.use(
http.get('/api/v1/printers/', () => HttpResponse.json(PRINTERS)),
http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ connected: true, state: 'IDLE' })),
http.get('/api/v1/printer-locations/', () => HttpResponse.json(LOCATIONS)),
http.post('/api/v1/printer-locations/', async ({ request }) => {
await record(request);
return HttpResponse.json({ id: 9, name: 'Basement', icon: null, color: null, printer_count: 0 }, { status: 201 });
}),
http.post('/api/v1/printer-locations/assign', async ({ request }) => {
await record(request);
return HttpResponse.json({ moved: 2 });
}),
http.post('/api/v1/printer-locations/delete', async ({ request }) => {
await record(request);
return HttpResponse.json({ deleted: 2, printers_ungrouped: 5 });
}),
);
});
describe('PrinterLocationsPage', () => {
it('shows server locations, including an empty one, with readable English counts', async () => {
render(<PrinterLocationsPage />);
expect(await screen.findByText('Future rack')).toBeInTheDocument();
expect(screen.getByText('Workshop')).toBeInTheDocument();
expect(screen.getByText('Printers: 0')).toBeInTheDocument();
expect(screen.getByText('Printers: 5')).toBeInTheDocument();
// NULL and "" are both "no location".
expect(screen.getByText('Printers without a location: 2')).toBeInTheDocument();
expect(screen.getByText('Charlie')).toBeInTheDocument();
expect(screen.getByText('Delta')).toBeInTheDocument();
});
it('creates a location on the server, with a name capped at the column width', async () => {
const user = userEvent.setup();
render(<PrinterLocationsPage />);
await user.click(await screen.findByRole('button', { name: /new location/i }));
const input = screen.getByRole('textbox', { name: /location name/i });
expect(input).toHaveAttribute('maxLength', '100');
await user.type(input, 'Basement');
await user.click(screen.getByRole('button', { name: '#ef4444' }));
const dialog = screen.getByRole('heading', { name: 'Create Location' }).closest('div')!;
await user.click(within(dialog).getByRole('button', { name: /new location/i }));
await waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0]).toEqual({
method: 'POST',
path: '/api/v1/printer-locations/',
body: { name: 'Basement', icon: null, color: '#ef4444' },
});
});
it('moves selected printers in one request', async () => {
const user = userEvent.setup();
render(<PrinterLocationsPage />);
await user.click(await screen.findByRole('button', { name: /select all/i }));
expect(screen.getByText('Selected: 2')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^move$/i }));
const dialog = screen.getByRole('heading', { name: 'Move printers (2)' }).closest('div')!;
await user.selectOptions(within(dialog).getByRole('combobox'), 'Future rack');
await user.click(within(dialog).getByRole('button', { name: /^move$/i }));
await waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0]).toEqual({
method: 'POST',
path: '/api/v1/printer-locations/assign',
body: { printer_ids: [3, 4], location: 'Future rack' },
});
expect(await screen.findByText('Printers moved: 2')).toBeInTheDocument();
});
it('deletes selected locations in one request and names what it affects', async () => {
const user = userEvent.setup();
render(<PrinterLocationsPage />);
await user.click(await screen.findByRole('button', { name: /^select$/i }));
await user.click(screen.getByText('Future rack'));
await user.click(screen.getByText('Workshop'));
await user.click(screen.getByRole('button', { name: /delete selected/i }));
expect(screen.getByText('Delete locations (2)')).toBeInTheDocument();
expect(
screen.getByText('Delete Future rack, Workshop? Printers left without a location: 5.'),
).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0]).toEqual({
method: 'POST',
path: '/api/v1/printer-locations/delete',
body: { names: ['Future rack', 'Workshop'] },
});
});
it('is read-only without printers:update', async () => {
permissions.granted = ['printers:read'];
render(<PrinterLocationsPage />);
expect(await screen.findByText('Workshop')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /new location/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^select$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /select all/i })).not.toBeInTheDocument();
});
});
describe('printer locations strings', () => {
it('keeps printers.dropToQueue, which the printer card still uses', () => {
expect(i18n.t('printers.dropToQueue', { lng: 'en' })).toBe('Drop to queue');
expect(i18n.t('printers.dropToQueue', { lng: 'ru' })).not.toBe('printers.dropToQueue');
});
});
+32
View File
@@ -413,6 +413,23 @@ export interface OverlayStatus {
}
// Printer types
// Printer locations (groups) and their appearance (#2962). `id` is null for a
// location only printers carry, with no row of its own yet.
export interface PrinterLocation {
id: number | null;
name: string;
icon: string | null;
color: string | null;
printer_count: number;
}
export interface PrinterLocationUpdate {
name: string;
new_name?: string;
icon?: string | null;
color?: string | null;
}
export interface Printer {
id: number;
name: string;
@@ -4906,6 +4923,21 @@ export const api = {
if (location) params.set('location', location);
return request<Array<{ type: string; color: string; tray_info_idx: string; tray_sub_brands: string; extruder_id: number | null }>>(`/printers/available-filaments?${params}`);
},
getPrinterLocations: () => request<PrinterLocation[]>('/printer-locations/'),
createPrinterLocation: (data: { name: string; icon?: string | null; color?: string | null }) =>
request<PrinterLocation>('/printer-locations/', { method: 'POST', body: JSON.stringify(data) }),
updatePrinterLocation: (data: PrinterLocationUpdate) =>
request<PrinterLocation>('/printer-locations/', { method: 'PATCH', body: JSON.stringify(data) }),
deletePrinterLocations: (names: string[]) =>
request<{ deleted: number; printers_ungrouped: number }>('/printer-locations/delete', {
method: 'POST',
body: JSON.stringify({ names }),
}),
assignPrinterLocation: (printerIds: number[], location: string | null) =>
request<{ moved: number }>('/printer-locations/assign', {
method: 'POST',
body: JSON.stringify({ printer_ids: printerIds, location }),
}),
getPrinterStatus: (id: number) =>
request<PrinterStatus>(`/printers/${id}/status`),
refreshPrinterStatus: (id: number) =>
+8
View File
@@ -409,6 +409,14 @@ export function useWebSocket() {
debouncedInvalidate('announcements');
break;
case 'printer_locations_changed':
// A location was created, renamed, restyled or deleted, or printers moved
// between them (#2962). Every page that shows a printer's location reads
// it from the printer list.
debouncedInvalidate('printer-locations');
debouncedInvalidate('printers');
break;
case 'inventory_changed':
// Spool created/updated/deleted/archived/restored - refresh inventory across all tabs
debouncedInvalidate('inventory-spools');
+43
View File
@@ -237,6 +237,49 @@ export default {
// Printers page
printers: {
locations: {
title: 'Druckerstandorte',
subtitle: '{{grouped}} mit Standort, {{ungrouped}} ohne',
search: 'Standorte durchsuchen...',
showEmpty: 'Leere anzeigen',
hideEmpty: 'Leere ausblenden',
select: 'Auswählen',
done: 'Fertig',
sortNameAsc: 'Nach Name A→Z',
sortNameDesc: 'Nach Name Z→A',
sortCountAsc: 'Wenigste Drucker',
sortCountDesc: 'Meiste Drucker',
create: 'Neuer Standort',
createTitle: 'Standort anlegen',
editTitle: 'Standort bearbeiten',
namePlaceholder: 'Name des Standorts',
icon: 'Symbol',
color: 'Farbe',
noColor: 'Keine Farbe',
none: 'Noch keine Druckerstandorte',
noResults: 'Kein Standort passt zur Suche',
printerCount: 'Drucker: {{count}}',
noPrinters: 'Keine Drucker an diesem Standort',
selectPrinter: 'Drucker auswählen',
move: 'An einen anderen Standort verschieben',
moveButton: 'Verschieben',
removeFromLocation: 'Vom Standort entfernen',
ungroupedPrinters: 'Drucker ohne Standort: {{count}}',
selected: 'Ausgewählt: {{count}}',
deleteSelected: 'Auswahl löschen',
moveTitle: 'Drucker verschieben',
moveTitleMany: 'Drucker verschieben ({{count}})',
targetPlaceholder: 'Standort auswählen...',
noLocation: 'Kein Standort',
deleteTitle: 'Standort löschen',
deleteTitleMany: 'Standorte löschen ({{count}})',
deleteMessage: '{{names}} löschen? Drucker danach ohne Standort: {{count}}.',
created: 'Standort angelegt',
saved: 'Standort aktualisiert',
deleted: 'Standorte gelöscht: {{count}}',
moved: 'Drucker verschoben: {{count}}',
failed: 'Die Änderung konnte nicht gespeichert werden: {{error}}',
},
addPreflight: {
checking: 'Verbindung wird geprüft...',
warning: 'Einige Verbindungsprüfungen sind fehlgeschlagen. Dieser Drucker wird möglicherweise als offline angezeigt. Prüfe die Punkte unten, behebe was möglich ist, oder speichere trotzdem.',
+45
View File
@@ -237,6 +237,51 @@ export default {
// Printers page
printers: {
// Printer Locations page (#2962). Counts are written as "label: N" so
// they read correctly in every language without plural forms.
locations: {
title: 'Printer Locations',
subtitle: '{{grouped}} in a location, {{ungrouped}} without one',
search: 'Search locations...',
showEmpty: 'Show empty',
hideEmpty: 'Hide empty',
select: 'Select',
done: 'Done',
sortNameAsc: 'Name A→Z',
sortNameDesc: 'Name Z→A',
sortCountAsc: 'Fewest printers',
sortCountDesc: 'Most printers',
create: 'New Location',
createTitle: 'Create Location',
editTitle: 'Edit Location',
namePlaceholder: 'Location name',
icon: 'Icon',
color: 'Color',
noColor: 'No color',
none: 'No printer locations yet',
noResults: 'No locations match your search',
printerCount: 'Printers: {{count}}',
noPrinters: 'No printers in this location',
selectPrinter: 'Select printer',
move: 'Move to another location',
moveButton: 'Move',
removeFromLocation: 'Remove from location',
ungroupedPrinters: 'Printers without a location: {{count}}',
selected: 'Selected: {{count}}',
deleteSelected: 'Delete selected',
moveTitle: 'Move printer',
moveTitleMany: 'Move printers ({{count}})',
targetPlaceholder: 'Select a location...',
noLocation: 'No location',
deleteTitle: 'Delete location',
deleteTitleMany: 'Delete locations ({{count}})',
deleteMessage: 'Delete {{names}}? Printers left without a location: {{count}}.',
created: 'Location created',
saved: 'Location updated',
deleted: 'Locations deleted: {{count}}',
moved: 'Printers moved: {{count}}',
failed: 'Could not save the change: {{error}}',
},
title: 'Printers',
addPrinter: 'Add Printer',
addPreflight: {
+43
View File
@@ -237,6 +237,49 @@ export default {
// Printers page
printers: {
locations: {
title: 'Ubicaciones de impresoras',
subtitle: '{{grouped}} con ubicación, {{ungrouped}} sin ubicación',
search: 'Buscar ubicaciones...',
showEmpty: 'Mostrar vacías',
hideEmpty: 'Ocultar vacías',
select: 'Seleccionar',
done: 'Listo',
sortNameAsc: 'Nombre A→Z',
sortNameDesc: 'Nombre Z→A',
sortCountAsc: 'Menos impresoras',
sortCountDesc: 'Más impresoras',
create: 'Nueva ubicación',
createTitle: 'Crear ubicación',
editTitle: 'Editar ubicación',
namePlaceholder: 'Nombre de la ubicación',
icon: 'Icono',
color: 'Color de la ubicación',
noColor: 'Sin color',
none: 'Aún no hay ubicaciones de impresoras',
noResults: 'Ninguna ubicación coincide con la búsqueda',
printerCount: 'Impresoras: {{count}}',
noPrinters: 'No hay impresoras en esta ubicación',
selectPrinter: 'Seleccionar impresora',
move: 'Mover a otra ubicación',
moveButton: 'Mover',
removeFromLocation: 'Quitar de la ubicación',
ungroupedPrinters: 'Impresoras sin ubicación: {{count}}',
selected: 'Seleccionadas: {{count}}',
deleteSelected: 'Eliminar selección',
moveTitle: 'Mover impresora',
moveTitleMany: 'Mover impresoras ({{count}})',
targetPlaceholder: 'Selecciona una ubicación...',
noLocation: 'Sin ubicación',
deleteTitle: 'Eliminar ubicación',
deleteTitleMany: 'Eliminar ubicaciones ({{count}})',
deleteMessage: '¿Eliminar {{names}}? Impresoras que quedarán sin ubicación: {{count}}.',
created: 'Ubicación creada',
saved: 'Ubicación actualizada',
deleted: 'Ubicaciones eliminadas: {{count}}',
moved: 'Impresoras movidas: {{count}}',
failed: 'No se pudo guardar el cambio: {{error}}',
},
addPreflight: {
checking: 'Comprobando la conexión...',
warning: 'Algunas comprobaciones de conexión fallaron. Esta impresora podría aparecer como desconectada. Revisa las comprobaciones de abajo, soluciona lo que puedas o guárdala de todos modos.',
+43
View File
@@ -237,6 +237,49 @@ export default {
// Printers page
printers: {
locations: {
title: 'Emplacements des imprimantes',
subtitle: '{{grouped}} avec emplacement, {{ungrouped}} sans',
search: 'Rechercher des emplacements...',
showEmpty: 'Afficher les vides',
hideEmpty: 'Masquer les vides',
select: 'Sélectionner',
done: 'Terminé',
sortNameAsc: 'Nom A→Z',
sortNameDesc: 'Nom Z→A',
sortCountAsc: 'Moins d\'imprimantes',
sortCountDesc: 'Plus d\'imprimantes',
create: 'Nouvel emplacement',
createTitle: 'Créer un emplacement',
editTitle: 'Modifier l\'emplacement',
namePlaceholder: 'Nom de l\'emplacement',
icon: 'Icône',
color: 'Couleur',
noColor: 'Aucune couleur',
none: 'Aucun emplacement d\'imprimante pour l\'instant',
noResults: 'Aucun emplacement ne correspond à la recherche',
printerCount: 'Imprimantes : {{count}}',
noPrinters: 'Aucune imprimante à cet emplacement',
selectPrinter: 'Sélectionner l\'imprimante',
move: 'Déplacer vers un autre emplacement',
moveButton: 'Déplacer',
removeFromLocation: 'Retirer de l\'emplacement',
ungroupedPrinters: 'Imprimantes sans emplacement : {{count}}',
selected: 'Sélection : {{count}}',
deleteSelected: 'Supprimer la sélection',
moveTitle: 'Déplacer l\'imprimante',
moveTitleMany: 'Déplacer les imprimantes ({{count}})',
targetPlaceholder: 'Choisir un emplacement...',
noLocation: 'Aucun emplacement',
deleteTitle: 'Supprimer l\'emplacement',
deleteTitleMany: 'Supprimer les emplacements ({{count}})',
deleteMessage: 'Supprimer {{names}} ? Imprimantes qui resteront sans emplacement : {{count}}.',
created: 'Emplacement créé',
saved: 'Emplacement mis à jour',
deleted: 'Emplacements supprimés : {{count}}',
moved: 'Imprimantes déplacées : {{count}}',
failed: 'Impossible d\'enregistrer la modification : {{error}}',
},
addPreflight: {
checking: 'Vérification de la connexion...',
warning: 'Certaines vérifications de connexion ont échoué. Cette imprimante pourrait apparaître hors ligne. Examinez les vérifications ci-dessous, corrigez ce que vous pouvez, ou enregistrez quand même.',
+43
View File
@@ -237,6 +237,49 @@ export default {
// Printers page
printers: {
locations: {
title: 'Posizioni delle stampanti',
subtitle: '{{grouped}} con posizione, {{ungrouped}} senza',
search: 'Cerca posizioni...',
showEmpty: 'Mostra vuote',
hideEmpty: 'Nascondi vuote',
select: 'Seleziona',
done: 'Fatto',
sortNameAsc: 'Nome A→Z',
sortNameDesc: 'Nome Z→A',
sortCountAsc: 'Meno stampanti',
sortCountDesc: 'Più stampanti',
create: 'Nuova posizione',
createTitle: 'Crea posizione',
editTitle: 'Modifica posizione',
namePlaceholder: 'Nome della posizione',
icon: 'Icona',
color: 'Colore',
noColor: 'Nessun colore',
none: 'Ancora nessuna posizione delle stampanti',
noResults: 'Nessuna posizione corrisponde alla ricerca',
printerCount: 'Stampanti: {{count}}',
noPrinters: 'Nessuna stampante in questa posizione',
selectPrinter: 'Seleziona stampante',
move: 'Sposta in un\'altra posizione',
moveButton: 'Sposta',
removeFromLocation: 'Rimuovi dalla posizione',
ungroupedPrinters: 'Stampanti senza posizione: {{count}}',
selected: 'Selezionate: {{count}}',
deleteSelected: 'Elimina selezionate',
moveTitle: 'Sposta stampante',
moveTitleMany: 'Sposta stampanti ({{count}})',
targetPlaceholder: 'Seleziona una posizione...',
noLocation: 'Nessuna posizione',
deleteTitle: 'Elimina posizione',
deleteTitleMany: 'Elimina posizioni ({{count}})',
deleteMessage: 'Eliminare {{names}}? Stampanti che resteranno senza posizione: {{count}}.',
created: 'Posizione creata',
saved: 'Posizione aggiornata',
deleted: 'Posizioni eliminate: {{count}}',
moved: 'Stampanti spostate: {{count}}',
failed: 'Impossibile salvare la modifica: {{error}}',
},
addPreflight: {
checking: 'Verifica della connessione...',
warning: 'Alcuni controlli di connessione non sono riusciti. Questa stampante potrebbe risultare offline. Controlla le verifiche qui sotto, risolvi ciò che puoi oppure salva comunque.',
+43
View File
@@ -236,6 +236,49 @@ export default {
},
// Printers page
printers: {
locations: {
title: 'プリンターの場所',
subtitle: '場所あり {{grouped}} 台、場所なし {{ungrouped}} 台',
search: '場所を検索...',
showEmpty: '空の場所を表示',
hideEmpty: '空の場所を隠す',
select: '選択',
done: '完了',
sortNameAsc: '名前 A→Z',
sortNameDesc: '名前 Z→A',
sortCountAsc: 'プリンターが少ない順',
sortCountDesc: 'プリンターが多い順',
create: '新しい場所',
createTitle: '場所を作成',
editTitle: '場所を編集',
namePlaceholder: '場所の名前',
icon: 'アイコン',
color: '色',
noColor: '色なし',
none: 'プリンターの場所はまだありません',
noResults: '検索に一致する場所はありません',
printerCount: 'プリンター: {{count}} 台',
noPrinters: 'この場所にプリンターはありません',
selectPrinter: 'プリンターを選択',
move: '別の場所へ移動',
moveButton: '移動',
removeFromLocation: '場所から外す',
ungroupedPrinters: '場所のないプリンター: {{count}} 台',
selected: '選択中: {{count}}',
deleteSelected: '選択したものを削除',
moveTitle: 'プリンターを移動',
moveTitleMany: 'プリンターを移動 ({{count}} 台)',
targetPlaceholder: '場所を選択...',
noLocation: '場所なし',
deleteTitle: '場所を削除',
deleteTitleMany: '場所を削除 ({{count}})',
deleteMessage: '{{names}} を削除しますか?場所がなくなるプリンター: {{count}} 台',
created: '場所を作成しました',
saved: '場所を更新しました',
deleted: '削除した場所: {{count}}',
moved: '移動したプリンター: {{count}} 台',
failed: '変更を保存できませんでした: {{error}}',
},
addPreflight: {
checking: '接続を確認しています...',
warning: '一部の接続チェックに失敗しました。このプリンターはオフラインと表示される可能性があります。下のチェックを確認し、可能な範囲で修正するか、そのまま保存してください。',
+43
View File
@@ -231,6 +231,49 @@ export default {
deleteTransaction: '거래 삭제',
},
printers: {
locations: {
title: '프린터 위치',
subtitle: '위치 있음 {{grouped}}대, 위치 없음 {{ungrouped}}대',
search: '위치 검색...',
showEmpty: '빈 위치 표시',
hideEmpty: '빈 위치 숨기기',
select: '선택',
done: '완료',
sortNameAsc: '이름 A→Z',
sortNameDesc: '이름 Z→A',
sortCountAsc: '프린터 적은 순',
sortCountDesc: '프린터 많은 순',
create: '새 위치',
createTitle: '위치 만들기',
editTitle: '위치 편집',
namePlaceholder: '위치 이름',
icon: '아이콘',
color: '색상',
noColor: '색상 없음',
none: '아직 프린터 위치가 없습니다',
noResults: '검색과 일치하는 위치가 없습니다',
printerCount: '프린터: {{count}}대',
noPrinters: '이 위치에 프린터가 없습니다',
selectPrinter: '프린터 선택',
move: '다른 위치로 이동',
moveButton: '이동',
removeFromLocation: '위치에서 제거',
ungroupedPrinters: '위치가 없는 프린터: {{count}}대',
selected: '선택됨: {{count}}',
deleteSelected: '선택 항목 삭제',
moveTitle: '프린터 이동',
moveTitleMany: '프린터 이동 ({{count}}대)',
targetPlaceholder: '위치 선택...',
noLocation: '위치 없음',
deleteTitle: '위치 삭제',
deleteTitleMany: '위치 삭제 ({{count}})',
deleteMessage: '{{names}}을(를) 삭제할까요? 위치가 없어지는 프린터: {{count}}대',
created: '위치를 만들었습니다',
saved: '위치를 업데이트했습니다',
deleted: '삭제된 위치: {{count}}',
moved: '이동한 프린터: {{count}}대',
failed: '변경 사항을 저장하지 못했습니다: {{error}}',
},
title: '프린터',
addPrinter: '프린터 추가',
editPrinter: '프린터 편집',
+43
View File
@@ -237,6 +237,49 @@ export default {
// Printers page
printers: {
locations: {
title: 'Printerlocaties',
subtitle: '{{grouped}} met locatie, {{ungrouped}} zonder',
search: 'Locaties zoeken...',
showEmpty: 'Lege tonen',
hideEmpty: 'Lege verbergen',
select: 'Selecteren',
done: 'Klaar',
sortNameAsc: 'Naam A→Z',
sortNameDesc: 'Naam Z→A',
sortCountAsc: 'Minste printers',
sortCountDesc: 'Meeste printers',
create: 'Nieuwe locatie',
createTitle: 'Locatie aanmaken',
editTitle: 'Locatie bewerken',
namePlaceholder: 'Naam van de locatie',
icon: 'Pictogram',
color: 'Kleur',
noColor: 'Geen kleur',
none: 'Nog geen printerlocaties',
noResults: 'Geen locaties gevonden voor deze zoekopdracht',
printerCount: 'Aantal printers: {{count}}',
noPrinters: 'Geen printers op deze locatie',
selectPrinter: 'Printer selecteren',
move: 'Naar een andere locatie verplaatsen',
moveButton: 'Verplaatsen',
removeFromLocation: 'Uit locatie verwijderen',
ungroupedPrinters: 'Printers zonder locatie: {{count}}',
selected: 'Geselecteerd: {{count}}',
deleteSelected: 'Selectie verwijderen',
moveTitle: 'Printer verplaatsen',
moveTitleMany: 'Printers verplaatsen ({{count}})',
targetPlaceholder: 'Kies een locatie...',
noLocation: 'Geen locatie',
deleteTitle: 'Locatie verwijderen',
deleteTitleMany: 'Locaties verwijderen ({{count}})',
deleteMessage: '{{names}} verwijderen? Printers die daarna geen locatie hebben: {{count}}.',
created: 'Locatie aangemaakt',
saved: 'Locatie bijgewerkt',
deleted: 'Locaties verwijderd: {{count}}',
moved: 'Printers verplaatst: {{count}}',
failed: 'De wijziging kon niet worden opgeslagen: {{error}}',
},
title: 'Printers',
addPrinter: 'Printer toevoegen',
addPreflight: {
+43
View File
@@ -237,6 +237,49 @@ export default {
// Printers page
printers: {
locations: {
title: 'Locais das impressoras',
subtitle: '{{grouped}} com local, {{ungrouped}} sem local',
search: 'Buscar locais...',
showEmpty: 'Mostrar vazios',
hideEmpty: 'Ocultar vazios',
select: 'Selecionar',
done: 'Concluído',
sortNameAsc: 'Nome A→Z',
sortNameDesc: 'Nome Z→A',
sortCountAsc: 'Menos impressoras',
sortCountDesc: 'Mais impressoras',
create: 'Novo local',
createTitle: 'Criar local',
editTitle: 'Editar local',
namePlaceholder: 'Nome do local',
icon: 'Ícone',
color: 'Cor',
noColor: 'Sem cor',
none: 'Ainda não há locais de impressoras',
noResults: 'Nenhum local corresponde à busca',
printerCount: 'Impressoras: {{count}}',
noPrinters: 'Nenhuma impressora neste local',
selectPrinter: 'Selecionar impressora',
move: 'Mover para outro local',
moveButton: 'Mover',
removeFromLocation: 'Remover do local',
ungroupedPrinters: 'Impressoras sem local: {{count}}',
selected: 'Selecionados: {{count}}',
deleteSelected: 'Excluir selecionados',
moveTitle: 'Mover impressora',
moveTitleMany: 'Mover impressoras ({{count}})',
targetPlaceholder: 'Selecione um local...',
noLocation: 'Sem local',
deleteTitle: 'Excluir local',
deleteTitleMany: 'Excluir locais ({{count}})',
deleteMessage: 'Excluir {{names}}? Impressoras que ficarão sem local: {{count}}.',
created: 'Local criado',
saved: 'Local atualizado',
deleted: 'Locais excluídos: {{count}}',
moved: 'Impressoras movidas: {{count}}',
failed: 'Não foi possível salvar a alteração: {{error}}',
},
addPreflight: {
checking: 'Verificando a conexão...',
warning: 'Algumas verificações de conexão falharam. Esta impressora pode aparecer como offline. Revise as verificações abaixo, corrija o que puder ou salve mesmo assim.',
+43
View File
@@ -231,6 +231,49 @@ export default {
deleteTransaction: "Удалить операцию",
},
printers: {
locations: {
title: "Расположение принтеров",
subtitle: "С расположением: {{grouped}}, без расположения: {{ungrouped}}",
search: "Поиск расположений...",
showEmpty: "Показать пустые",
hideEmpty: "Скрыть пустые",
select: "Выбрать",
done: "Готово",
sortNameAsc: "По названию А→Я",
sortNameDesc: "По названию Я→А",
sortCountAsc: "Меньше принтеров",
sortCountDesc: "Больше принтеров",
create: "Новое расположение",
createTitle: "Создать расположение",
editTitle: "Изменить расположение",
namePlaceholder: "Название расположения",
icon: "Значок",
color: "Цвет",
noColor: "Без цвета",
none: "Расположений принтеров пока нет",
noResults: "Нет расположений, подходящих под запрос",
printerCount: "Принтеров: {{count}}",
noPrinters: "В этом расположении нет принтеров",
selectPrinter: "Выбрать принтер",
move: "Переместить в другое расположение",
moveButton: "Переместить",
removeFromLocation: "Убрать из расположения",
ungroupedPrinters: "Принтеры без расположения: {{count}}",
selected: "Выбрано: {{count}}",
deleteSelected: "Удалить выбранные",
moveTitle: "Переместить принтер",
moveTitleMany: "Переместить принтеры ({{count}})",
targetPlaceholder: "Выберите расположение...",
noLocation: "Без расположения",
deleteTitle: "Удалить расположение",
deleteTitleMany: "Удалить расположения ({{count}})",
deleteMessage: "Удалить {{names}}? Принтеров останется без расположения: {{count}}.",
created: "Расположение создано",
saved: "Расположение обновлено",
deleted: "Удалено расположений: {{count}}",
moved: "Перемещено принтеров: {{count}}",
failed: "Не удалось сохранить изменение: {{error}}",
},
title: "Принтеры",
addPrinter: "Добавить принтер",
addPreflight: {
+43
View File
@@ -237,6 +237,49 @@ export default {
// Printers page
printers: {
locations: {
title: 'Skrivarplatser',
subtitle: '{{grouped}} med plats, {{ungrouped}} utan',
search: 'Sök platser...',
showEmpty: 'Visa tomma',
hideEmpty: 'Dölj tomma',
select: 'Välj',
done: 'Klar',
sortNameAsc: 'Namn A→Ö',
sortNameDesc: 'Namn Ö→A',
sortCountAsc: 'Minst antal skrivare',
sortCountDesc: 'Flest skrivare',
create: 'Ny plats',
createTitle: 'Skapa plats',
editTitle: 'Redigera plats',
namePlaceholder: 'Platsens namn',
icon: 'Ikon',
color: 'Färg',
noColor: 'Ingen färg',
none: 'Inga skrivarplatser ännu',
noResults: 'Inga platser matchar sökningen',
printerCount: 'Skrivare: {{count}}',
noPrinters: 'Inga skrivare på den här platsen',
selectPrinter: 'Välj skrivare',
move: 'Flytta till en annan plats',
moveButton: 'Flytta',
removeFromLocation: 'Ta bort från platsen',
ungroupedPrinters: 'Skrivare utan plats: {{count}}',
selected: 'Valda: {{count}}',
deleteSelected: 'Ta bort valda',
moveTitle: 'Flytta skrivare',
moveTitleMany: 'Flytta skrivare ({{count}})',
targetPlaceholder: 'Välj en plats...',
noLocation: 'Ingen plats',
deleteTitle: 'Ta bort plats',
deleteTitleMany: 'Ta bort platser ({{count}})',
deleteMessage: 'Ta bort {{names}}? Skrivare som blir utan plats: {{count}}.',
created: 'Platsen har skapats',
saved: 'Platsen har uppdaterats',
deleted: 'Borttagna platser: {{count}}',
moved: 'Flyttade skrivare: {{count}}',
failed: 'Det gick inte att spara ändringen: {{error}}',
},
title: 'Skrivare',
addPrinter: 'Lägg till skrivare',
addPreflight: {
+43
View File
@@ -237,6 +237,49 @@ export default {
// Yazıcılar sayfası
printers: {
locations: {
title: 'Yazıcı konumları',
subtitle: '{{grouped}} konumlu, {{ungrouped}} konumsuz',
search: 'Konum ara...',
showEmpty: 'Boşları göster',
hideEmpty: 'Boşları gizle',
select: 'Seç',
done: 'Bitti',
sortNameAsc: 'Ad A→Z',
sortNameDesc: 'Ad Z→A',
sortCountAsc: 'En az yazıcı',
sortCountDesc: 'En çok yazıcı',
create: 'Yeni konum',
createTitle: 'Konum oluştur',
editTitle: 'Konumu düzenle',
namePlaceholder: 'Konum adı',
icon: 'Simge',
color: 'Renk',
noColor: 'Renk yok',
none: 'Henüz yazıcı konumu yok',
noResults: 'Aramayla eşleşen konum yok',
printerCount: 'Yazıcı: {{count}}',
noPrinters: 'Bu konumda yazıcı yok',
selectPrinter: 'Yazıcıyı seç',
move: 'Başka bir konuma taşı',
moveButton: 'Taşı',
removeFromLocation: 'Konumdan çıkar',
ungroupedPrinters: 'Konumu olmayan yazıcılar: {{count}}',
selected: 'Seçili: {{count}}',
deleteSelected: 'Seçilenleri sil',
moveTitle: 'Yazıcıyı taşı',
moveTitleMany: 'Yazıcıları taşı ({{count}})',
targetPlaceholder: 'Bir konum seçin...',
noLocation: 'Konum yok',
deleteTitle: 'Konumu sil',
deleteTitleMany: 'Konumları sil ({{count}})',
deleteMessage: '{{names}} silinsin mi? Konumsuz kalacak yazıcı: {{count}}.',
created: 'Konum oluşturuldu',
saved: 'Konum güncellendi',
deleted: 'Silinen konum: {{count}}',
moved: 'Taşınan yazıcı: {{count}}',
failed: 'Değişiklik kaydedilemedi: {{error}}',
},
title: 'Yazıcılar',
addPrinter: 'Yazıcı Ekle',
addPreflight: {
+43
View File
@@ -237,6 +237,49 @@ export default {
// Printers page
printers: {
locations: {
title: "Розташування принтерів",
subtitle: "З розташуванням: {{grouped}}, без розташування: {{ungrouped}}",
search: "Пошук розташувань...",
showEmpty: "Показати порожні",
hideEmpty: "Сховати порожні",
select: "Вибрати",
done: "Готово",
sortNameAsc: "За назвою А→Я",
sortNameDesc: "За назвою Я→А",
sortCountAsc: "Менше принтерів",
sortCountDesc: "Більше принтерів",
create: "Нове розташування",
createTitle: "Створити розташування",
editTitle: "Редагувати розташування",
namePlaceholder: "Назва розташування",
icon: "Значок",
color: "Колір",
noColor: "Без кольору",
none: "Розташувань принтерів ще немає",
noResults: "Немає розташувань, що відповідають пошуку",
printerCount: "Принтерів: {{count}}",
noPrinters: "У цьому розташуванні немає принтерів",
selectPrinter: "Вибрати принтер",
move: "Перемістити в інше розташування",
moveButton: "Перемістити",
removeFromLocation: "Прибрати з розташування",
ungroupedPrinters: "Принтери без розташування: {{count}}",
selected: "Вибрано: {{count}}",
deleteSelected: "Видалити вибрані",
moveTitle: "Перемістити принтер",
moveTitleMany: "Перемістити принтери ({{count}})",
targetPlaceholder: "Виберіть розташування...",
noLocation: "Без розташування",
deleteTitle: "Видалити розташування",
deleteTitleMany: "Видалити кілька розташувань ({{count}})",
deleteMessage: "Видалити {{names}}? Принтерів залишиться без розташування: {{count}}.",
created: "Розташування створено",
saved: "Розташування оновлено",
deleted: "Видалено розташувань: {{count}}",
moved: "Переміщено принтерів: {{count}}",
failed: "Не вдалося зберегти зміну: {{error}}",
},
title: "Принтери",
addPrinter: "Додати принтер",
addPreflight: {
+43
View File
@@ -237,6 +237,49 @@ export default {
// Printers page
printers: {
locations: {
title: '打印机位置',
subtitle: '有位置 {{grouped}} 台,无位置 {{ungrouped}} 台',
search: '搜索位置...',
showEmpty: '显示空位置',
hideEmpty: '隐藏空位置',
select: '选择',
done: '完成',
sortNameAsc: '名称 A→Z',
sortNameDesc: '名称 Z→A',
sortCountAsc: '打印机最少',
sortCountDesc: '打印机最多',
create: '新建位置',
createTitle: '创建位置',
editTitle: '编辑位置',
namePlaceholder: '位置名称',
icon: '图标',
color: '颜色',
noColor: '无颜色',
none: '还没有打印机位置',
noResults: '没有与搜索匹配的位置',
printerCount: '打印机:{{count}} 台',
noPrinters: '此位置没有打印机',
selectPrinter: '选择打印机',
move: '移动到其他位置',
moveButton: '移动',
removeFromLocation: '从位置中移除',
ungroupedPrinters: '没有位置的打印机:{{count}} 台',
selected: '已选择:{{count}}',
deleteSelected: '删除所选',
moveTitle: '移动打印机',
moveTitleMany: '移动打印机({{count}} 台)',
targetPlaceholder: '选择位置...',
noLocation: '无位置',
deleteTitle: '删除位置',
deleteTitleMany: '删除多个位置({{count}})',
deleteMessage: '删除 {{names}}?将失去位置的打印机:{{count}} 台。',
created: '位置已创建',
saved: '位置已更新',
deleted: '已删除位置:{{count}}',
moved: '已移动打印机:{{count}} 台',
failed: '无法保存更改:{{error}}',
},
addPreflight: {
checking: '正在检查连接...',
warning: '部分连接检查未通过。此打印机可能显示为离线。请查看下方的检查项,尽量修复,或仍然保存。',
+43
View File
@@ -237,6 +237,49 @@ export default {
// Printers page
printers: {
locations: {
title: '印表機位置',
subtitle: '有位置 {{grouped}} 台,無位置 {{ungrouped}} 台',
search: '搜尋位置...',
showEmpty: '顯示空位置',
hideEmpty: '隱藏空位置',
select: '選取',
done: '完成',
sortNameAsc: '名稱 A→Z',
sortNameDesc: '名稱 Z→A',
sortCountAsc: '印表機最少',
sortCountDesc: '印表機最多',
create: '新增位置',
createTitle: '建立位置',
editTitle: '編輯位置',
namePlaceholder: '位置名稱',
icon: '圖示',
color: '顏色',
noColor: '無顏色',
none: '尚無印表機位置',
noResults: '沒有符合搜尋的位置',
printerCount: '印表機:{{count}} 台',
noPrinters: '此位置沒有印表機',
selectPrinter: '選取印表機',
move: '移到其他位置',
moveButton: '移動',
removeFromLocation: '從位置中移除',
ungroupedPrinters: '沒有位置的印表機:{{count}} 台',
selected: '已選取:{{count}}',
deleteSelected: '刪除所選',
moveTitle: '移動印表機',
moveTitleMany: '移動印表機({{count}} 台)',
targetPlaceholder: '選取位置...',
noLocation: '無位置',
deleteTitle: '刪除位置',
deleteTitleMany: '刪除多個位置({{count}})',
deleteMessage: '刪除 {{names}}?將失去位置的印表機:{{count}} 台。',
created: '位置已建立',
saved: '位置已更新',
deleted: '已刪除位置:{{count}}',
moved: '已移動印表機:{{count}} 台',
failed: '無法儲存變更:{{error}}',
},
addPreflight: {
checking: '正在檢查連線...',
warning: '部分連線檢查未通過。此印表機可能顯示為離線。請查看下方的檢查項目,盡量修復,或仍然儲存。',
+723
View File
@@ -0,0 +1,723 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Box, CheckSquare, ChevronDown, Loader2, Move, Pencil, Plus, Search, Square, Trash2, UserMinus, X } from 'lucide-react';
import { api } from '../api/client';
import type { Printer, PrinterLocation } from '../api/client';
import { Button } from '../components/Button';
import { Card, CardContent } from '../components/Card';
import { ConfirmModal } from '../components/ConfirmModal';
import { IconPicker, AVAILABLE_ICONS } from '../components/IconPicker';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
// Matches printers.location / printer_locations.name (VARCHAR(100)).
const LOCATION_NAME_MAX_LENGTH = 100;
const LOCATION_COLORS = [
'#ef4444', // red
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#14b8a6', // teal
'#3b82f6', // blue
'#8b5cf6', // violet
'#ec4899', // pink
'#6b7280', // gray
];
type SortMode = 'name-asc' | 'name-desc' | 'count-asc' | 'count-desc';
const SORT_MODES: SortMode[] = ['name-asc', 'name-desc', 'count-asc', 'count-desc'];
// Per-viewer conveniences only; the locations themselves live on the server.
const HIDE_EMPTY_KEY = 'printerLocations.hideEmpty';
const SORT_KEY = 'printerLocations.sort';
function readStored<T extends string>(key: string, allowed: readonly T[], fallback: T): T {
try {
const value = localStorage.getItem(key);
return allowed.includes(value as T) ? (value as T) : fallback;
} catch {
return fallback;
}
}
function writeStored(key: string, value: string) {
try {
localStorage.setItem(key, value);
} catch {
// Private mode or storage disabled: the setting just is not remembered.
}
}
const locationOf = (printer: Printer) => printer.location?.trim() || '';
function LocationIcon({ location }: { location: Pick<PrinterLocation, 'icon' | 'color'> }) {
const Icon = AVAILABLE_ICONS.find((i) => i.name === location.icon)?.icon ?? Box;
return (
<div className="p-2 rounded-lg bg-bambu-dark relative overflow-hidden flex-shrink-0">
<Icon className="w-[25px] h-[25px] text-bambu-gray" />
{location.color && (
<div className="absolute top-0 left-0 w-1 h-full" style={{ backgroundColor: location.color }} />
)}
</div>
);
}
interface PrinterRowProps {
printer: Printer;
status?: { connected?: boolean; state?: string | null };
selected: boolean;
canEdit: boolean;
busy: boolean;
onToggle: () => void;
onMove: () => void;
onRemove?: () => void;
}
function PrinterRow({ printer, status, selected, canEdit, busy, onToggle, onMove, onRemove }: PrinterRowProps) {
const { t } = useTranslation();
const state = status?.state;
const connected = status?.connected;
const label = !connected
? t('printers.status.offline')
: state === 'RUNNING'
? t('printers.status.printing')
: state === 'PAUSE'
? t('printers.status.paused')
: state === 'FINISH'
? t('printers.status.finished')
: t('printers.status.idle');
const badge = !connected
? 'bg-gray-500/20 text-gray-400'
: state === 'RUNNING'
? 'bg-orange-500/20 text-orange-400'
: state === 'PAUSE'
? 'bg-yellow-500/20 text-yellow-400'
: state === 'FINISH'
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-bambu-dark text-bambu-gray';
return (
<div
className={`flex items-center justify-between gap-2 py-2 px-3 rounded-lg transition-colors ${
selected ? 'bg-bambu-green/10 border border-bambu-green/30' : 'bg-bambu-dark-secondary hover:bg-bambu-dark'
}`}
>
<div className="flex items-center gap-3 min-w-0">
{canEdit && (
<button
type="button"
onClick={onToggle}
className="text-bambu-gray hover:text-bambu-green transition-colors"
title={t('printers.locations.selectPrinter')}
aria-label={t('printers.locations.selectPrinter')}
aria-pressed={selected}
>
{selected ? <CheckSquare className="w-4 h-4 text-bambu-green" /> : <Square className="w-4 h-4" />}
</button>
)}
<div
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${
connected ? (state === 'RUNNING' || state === 'PAUSE' ? 'bg-orange-500' : 'bg-bambu-green') : 'bg-gray-500'
}`}
/>
<div className="min-w-0">
<p className="text-white text-sm font-medium truncate">{printer.name}</p>
<p className="text-xs text-bambu-gray">{printer.model || t('printers.status.unknown')}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className={`text-xs px-2 py-1 rounded-full ${badge}`}>{label}</span>
{canEdit && (
<Button
variant="ghost"
size="sm"
onClick={onMove}
disabled={busy}
className="text-bambu-gray hover:text-bambu-green hover:bg-bambu-green/10"
title={t('printers.locations.move')}
aria-label={t('printers.locations.move')}
>
<Move className="w-3.5 h-3.5" />
</Button>
)}
{canEdit && onRemove && (
<Button
variant="ghost"
size="sm"
onClick={onRemove}
disabled={busy}
className="text-bambu-gray hover:text-red-400 hover:bg-red-500/10"
title={t('printers.locations.removeFromLocation')}
aria-label={t('printers.locations.removeFromLocation')}
>
<UserMinus className="w-3.5 h-3.5" />
</Button>
)}
</div>
</div>
);
}
interface LocationDialogProps {
initial?: PrinterLocation;
saving: boolean;
onSave: (value: { name: string; icon: string; color: string }) => void;
onCancel: () => void;
}
function LocationDialog({ initial, saving, onSave, onCancel }: LocationDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState(initial?.name ?? '');
const [icon, setIcon] = useState(initial?.icon ?? '');
const [color, setColor] = useState(initial?.color ?? '');
const canSave = name.trim().length > 0 && !saving;
const save = () => canSave && onSave({ name: name.trim(), icon, color });
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-md">
<CardContent className="space-y-4">
<h2 className="text-lg font-semibold text-white">
{initial ? t('printers.locations.editTitle') : t('printers.locations.createTitle')}
</h2>
<input
type="text"
value={name}
maxLength={LOCATION_NAME_MAX_LENGTH}
onChange={(e) => setName(e.target.value)}
placeholder={t('printers.locations.namePlaceholder')}
aria-label={t('printers.locations.namePlaceholder')}
className="w-full px-4 py-2 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') save();
if (e.key === 'Escape') onCancel();
}}
/>
<div>
<p className="text-sm text-bambu-gray mb-2">{t('printers.locations.icon')}</p>
<IconPicker value={icon} onChange={setIcon} />
</div>
<div>
<p className="text-sm text-bambu-gray mb-2">{t('printers.locations.color')}</p>
<div className="flex gap-2 flex-wrap">
{LOCATION_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(color === c ? '' : c)}
className={`w-8 h-8 rounded-lg transition-all ${
color === c ? 'ring-2 ring-white ring-offset-2 ring-offset-bambu-dark-secondary scale-110' : 'hover:scale-105'
}`}
style={{ backgroundColor: c }}
title={c}
aria-label={c}
aria-pressed={color === c}
/>
))}
{color && (
<button
type="button"
onClick={() => setColor('')}
className="w-8 h-8 rounded-lg border-2 border-bambu-dark-tertiary bg-bambu-dark-secondary text-bambu-gray hover:text-white hover:border-bambu-gray transition-all flex items-center justify-center"
title={t('printers.locations.noColor')}
aria-label={t('printers.locations.noColor')}
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
<div className="flex gap-2 justify-end">
<Button variant="secondary" onClick={onCancel} disabled={saving}>
{t('common.cancel')}
</Button>
<Button onClick={save} disabled={!canSave}>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : initial ? t('common.save') : t('printers.locations.create')}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
interface MoveDialogProps {
count: number;
printerName?: string;
locations: PrinterLocation[];
saving: boolean;
onMove: (location: string | null) => void;
onCancel: () => void;
}
const NO_LOCATION = '__none__';
function MoveDialog({ count, printerName, locations, saving, onMove, onCancel }: MoveDialogProps) {
const { t } = useTranslation();
const [target, setTarget] = useState('');
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-md">
<CardContent className="space-y-4">
<h2 className="text-lg font-semibold text-white">
{count === 1 ? t('printers.locations.moveTitle') : t('printers.locations.moveTitleMany', { count })}
</h2>
{printerName && <p className="text-sm text-bambu-gray">{printerName}</p>}
<select
value={target}
onChange={(e) => setTarget(e.target.value)}
aria-label={t('printers.locations.targetPlaceholder')}
className="w-full px-4 py-2 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
disabled={saving}
autoFocus
>
<option value="" disabled>
{t('printers.locations.targetPlaceholder')}
</option>
{locations.map((loc) => (
<option key={loc.name} value={loc.name}>
{loc.name}
</option>
))}
<option value={NO_LOCATION}>{t('printers.locations.noLocation')}</option>
</select>
<div className="flex gap-2 justify-end">
<Button variant="secondary" onClick={onCancel} disabled={saving}>
{t('common.cancel')}
</Button>
<Button onClick={() => onMove(target === NO_LOCATION ? null : target)} disabled={saving || !target}>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : t('printers.locations.moveButton')}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
export function PrinterLocationsPage() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const canEdit = hasPermission('printers:update');
const [search, setSearch] = useState('');
const [hideEmpty, setHideEmpty] = useState(() => readStored(HIDE_EMPTY_KEY, ['true', 'false'], 'false') === 'true');
const [sortMode, setSortMode] = useState<SortMode>(() => readStored(SORT_KEY, SORT_MODES, 'name-asc'));
const [showSortMenu, setShowSortMenu] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const [selectingLocations, setSelectingLocations] = useState(false);
const [selectedLocations, setSelectedLocations] = useState<Set<string>>(new Set());
const [selectedPrinters, setSelectedPrinters] = useState<Set<number>>(new Set());
const [dialog, setDialog] = useState<{ mode: 'create' } | { mode: 'edit'; location: PrinterLocation } | null>(null);
const [moving, setMoving] = useState<{ ids: number[]; printerName?: string } | null>(null);
const [deleting, setDeleting] = useState<string[] | null>(null);
useEffect(() => writeStored(HIDE_EMPTY_KEY, String(hideEmpty)), [hideEmpty]);
useEffect(() => writeStored(SORT_KEY, sortMode), [sortMode]);
useEffect(() => {
if (selectingLocations) setExpanded(null);
}, [selectingLocations]);
const { data: printers, isLoading: printersLoading } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters });
const { data: locations, isLoading: locationsLoading } = useQuery({
queryKey: ['printer-locations'],
queryFn: api.getPrinterLocations,
});
// Same query the printer cards use, so the cache and the WebSocket updates
// are shared rather than polled twice.
const statusQueries = useQueries({
queries: (printers ?? []).map((p) => ({
queryKey: ['printerStatus', p.id],
queryFn: () => api.getPrinterStatus(p.id),
refetchInterval: 30000,
})),
});
const statusById = new Map((printers ?? []).map((p, i) => [p.id, statusQueries[i]?.data]));
const printersByLocation = useMemo(() => {
const map = new Map<string, Printer[]>();
for (const p of printers ?? []) {
const key = locationOf(p);
map.set(key, [...(map.get(key) ?? []), p]);
}
return map;
}, [printers]);
const ungrouped = printersByLocation.get('') ?? [];
const groupedCount = (printers?.length ?? 0) - ungrouped.length;
const displayed = useMemo(() => {
const q = search.trim().toLowerCase();
const list = (locations ?? []).filter(
(loc) => (!q || loc.name.toLowerCase().includes(q)) && (!hideEmpty || loc.printer_count > 0),
);
const byName = (a: PrinterLocation, b: PrinterLocation) =>
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
return [...list].sort((a, b) => {
switch (sortMode) {
case 'name-desc':
return byName(b, a);
case 'count-asc':
return a.printer_count - b.printer_count || byName(a, b);
case 'count-desc':
return b.printer_count - a.printer_count || byName(a, b);
default:
return byName(a, b);
}
});
}, [locations, search, hideEmpty, sortMode]);
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ['printer-locations'] });
queryClient.invalidateQueries({ queryKey: ['printers'] });
};
const fail = (error: unknown) =>
showToast(t('printers.locations.failed', { error: error instanceof Error ? error.message : String(error) }), 'error');
const saveMutation = useMutation({
mutationFn: ({ value, location }: { value: { name: string; icon: string; color: string }; location?: PrinterLocation }) =>
location
? api.updatePrinterLocation({
name: location.name,
...(value.name !== location.name ? { new_name: value.name } : {}),
icon: value.icon || null,
color: value.color || null,
})
: api.createPrinterLocation({ name: value.name, icon: value.icon || null, color: value.color || null }),
onSuccess: (saved, { location }) => {
refresh();
if (location && expanded === location.name) setExpanded(saved.name);
showToast(location ? t('printers.locations.saved') : t('printers.locations.created'));
setDialog(null);
},
onError: fail,
});
const moveMutation = useMutation({
mutationFn: ({ ids, location }: { ids: number[]; location: string | null }) => api.assignPrinterLocation(ids, location),
onSuccess: ({ moved }) => {
refresh();
showToast(t('printers.locations.moved', { count: moved }));
setSelectedPrinters(new Set());
setMoving(null);
},
onError: fail,
});
const deleteMutation = useMutation({
mutationFn: (names: string[]) => api.deletePrinterLocations(names),
onSuccess: ({ deleted }) => {
refresh();
showToast(t('printers.locations.deleted', { count: deleted }));
setSelectedLocations(new Set());
setSelectingLocations(false);
setDeleting(null);
},
onError: (error) => {
fail(error);
setDeleting(null);
},
});
const busy = moveMutation.isPending || deleteMutation.isPending || saveMutation.isPending;
const toggle = <T,>(set: Set<T>, value: T) => {
const next = new Set(set);
if (next.has(value)) next.delete(value);
else next.add(value);
return next;
};
if (printersLoading || locationsLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
);
}
const deletingPrinterCount = (deleting ?? []).reduce(
(sum, name) => sum + (locations?.find((l) => l.name === name)?.printer_count ?? 0),
0,
);
const sortLabel = (mode: SortMode) =>
({
'name-asc': t('printers.locations.sortNameAsc'),
'name-desc': t('printers.locations.sortNameDesc'),
'count-asc': t('printers.locations.sortCountAsc'),
'count-desc': t('printers.locations.sortCountDesc'),
})[mode];
const renderPrinter = (printer: Printer, removable: boolean) => (
<PrinterRow
key={printer.id}
printer={printer}
status={statusById.get(printer.id)}
selected={selectedPrinters.has(printer.id)}
canEdit={canEdit && !selectingLocations}
busy={busy}
onToggle={() => setSelectedPrinters(toggle(selectedPrinters, printer.id))}
onMove={() => setMoving({ ids: [printer.id], printerName: printer.name })}
onRemove={removable ? () => moveMutation.mutate({ ids: [printer.id], location: null }) : undefined}
/>
);
const allUngroupedSelected = ungrouped.length > 0 && ungrouped.every((p) => selectedPrinters.has(p.id));
return (
<div className="p-4 md:p-8 pb-28">
<div className="mb-4">
<div className="flex items-center gap-3 mb-1">
<Box className="w-[25px] h-[25px] text-bambu-green" />
<h1 className="text-2xl font-bold text-white">{t('printers.locations.title')}</h1>
</div>
<p className="text-sm text-bambu-gray">
{t('printers.locations.subtitle', { grouped: groupedCount, ungrouped: ungrouped.length })}
</p>
</div>
<div className="flex flex-wrap gap-3 mb-6">
<div className="relative flex-1 min-w-[12rem]">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('printers.locations.search')}
aria-label={t('printers.locations.search')}
className="w-full pl-9 pr-4 py-2 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
/>
</div>
<Button onClick={() => setHideEmpty(!hideEmpty)} variant={hideEmpty ? 'primary' : 'secondary'}>
{hideEmpty ? t('printers.locations.showEmpty') : t('printers.locations.hideEmpty')}
</Button>
{canEdit && (
<Button
onClick={() => {
setSelectedLocations(new Set());
setSelectingLocations(!selectingLocations);
}}
variant={selectingLocations ? 'primary' : 'secondary'}
>
<CheckSquare className="w-4 h-4 mr-1" />
{selectingLocations ? t('printers.locations.done') : t('printers.locations.select')}
</Button>
)}
<div className="relative">
<Button onClick={() => setShowSortMenu(!showSortMenu)} variant="secondary" aria-haspopup="menu">
<ChevronDown className={`w-4 h-4 mr-1 transition-transform ${showSortMenu ? 'rotate-180' : ''}`} />
{sortLabel(sortMode)}
</Button>
{showSortMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowSortMenu(false)} />
<div role="menu" className="absolute right-0 top-full mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg p-1 min-w-[10rem]">
{SORT_MODES.map((mode) => (
<button
key={mode}
role="menuitem"
onClick={() => {
setSortMode(mode);
setShowSortMenu(false);
}}
className={`w-full text-left px-3 py-2 text-sm rounded transition-colors ${
sortMode === mode ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:bg-bambu-dark-tertiary hover:text-white'
}`}
>
{sortLabel(mode)}
</button>
))}
</div>
</>
)}
</div>
{canEdit && (
<Button onClick={() => setDialog({ mode: 'create' })}>
<Plus className="w-4 h-4 mr-1" />
{t('printers.locations.create')}
</Button>
)}
</div>
{displayed.length === 0 && ungrouped.length === 0 ? (
<Card>
<CardContent className="text-center py-12 text-bambu-gray">
{search ? t('printers.locations.noResults') : t('printers.locations.none')}
</CardContent>
</Card>
) : (
<div className="space-y-3">
{displayed.map((loc) => {
const isExpanded = expanded === loc.name;
const isSelected = selectedLocations.has(loc.name);
return (
<Card key={loc.name}>
<CardContent className="p-4">
<div className="flex items-center justify-between gap-2">
<button
type="button"
onClick={() =>
selectingLocations
? setSelectedLocations(toggle(selectedLocations, loc.name))
: setExpanded(isExpanded ? null : loc.name)
}
className="flex items-center gap-3 flex-1 min-w-0 text-left"
aria-expanded={selectingLocations ? undefined : isExpanded}
aria-pressed={selectingLocations ? isSelected : undefined}
>
{selectingLocations ? (
isSelected ? (
<CheckSquare className="w-4 h-4 text-bambu-green flex-shrink-0" />
) : (
<Square className="w-4 h-4 text-bambu-gray flex-shrink-0" />
)
) : (
<ChevronDown
className={`w-4 h-4 text-bambu-gray flex-shrink-0 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
/>
)}
<LocationIcon location={loc} />
<div className="min-w-0">
<p className="text-white font-medium truncate">{loc.name}</p>
<p className="text-sm text-bambu-gray">{t('printers.locations.printerCount', { count: loc.printer_count })}</p>
</div>
</button>
{canEdit && !selectingLocations && (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setDialog({ mode: 'edit', location: loc })}
disabled={busy}
className="text-bambu-gray hover:text-blue-400 hover:bg-blue-500/10"
title={t('common.edit')}
aria-label={t('common.edit')}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleting([loc.name])}
disabled={busy}
className="text-red-500 hover:text-red-400 hover:bg-red-500/10"
title={t('common.delete')}
aria-label={t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
</div>
{isExpanded && (
<div className="border-t border-bambu-dark-tertiary pt-3 mt-3 space-y-2">
{(printersByLocation.get(loc.name) ?? []).length === 0 ? (
<p className="text-sm text-bambu-gray text-center py-4">{t('printers.locations.noPrinters')}</p>
) : (
(printersByLocation.get(loc.name) ?? []).map((p) => renderPrinter(p, true))
)}
</div>
)}
</CardContent>
</Card>
);
})}
{ungrouped.length > 0 && (
<div className="pt-4 border-t border-bambu-dark-tertiary">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-bambu-gray">
{t('printers.locations.ungroupedPrinters', { count: ungrouped.length })}
</h3>
{canEdit && !selectingLocations && (
<button
type="button"
onClick={() => {
const next = new Set(selectedPrinters);
ungrouped.forEach((p) => (allUngroupedSelected ? next.delete(p.id) : next.add(p.id)));
setSelectedPrinters(next);
}}
className="text-xs text-bambu-green hover:text-bambu-green-light transition-colors"
>
{allUngroupedSelected ? t('common.deselectAll') : t('common.selectAll')}
</button>
)}
</div>
<div className="space-y-2">{ungrouped.map((p) => renderPrinter(p, false))}</div>
</div>
)}
</div>
)}
{canEdit && !selectingLocations && selectedPrinters.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-bambu-dark border border-bambu-dark-tertiary rounded-xl shadow-2xl px-6 py-4 flex items-center gap-4 z-40">
<span className="text-white text-sm">{t('printers.locations.selected', { count: selectedPrinters.size })}</span>
<Button onClick={() => setMoving({ ids: Array.from(selectedPrinters) })} disabled={busy}>
<Move className="w-4 h-4 mr-1" />
{t('printers.locations.moveButton')}
</Button>
<Button variant="ghost" onClick={() => setSelectedPrinters(new Set())} disabled={busy}>
{t('common.cancel')}
</Button>
</div>
)}
{selectingLocations && selectedLocations.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-bambu-dark border border-bambu-dark-tertiary rounded-xl shadow-2xl px-6 py-4 flex items-center gap-4 z-40">
<span className="text-white text-sm">{t('printers.locations.selected', { count: selectedLocations.size })}</span>
<Button variant="danger" onClick={() => setDeleting(Array.from(selectedLocations))} disabled={busy}>
<Trash2 className="w-4 h-4 mr-1" />
{t('printers.locations.deleteSelected')}
</Button>
<Button variant="ghost" onClick={() => setSelectedLocations(new Set())} disabled={busy}>
{t('common.cancel')}
</Button>
</div>
)}
{dialog && (
<LocationDialog
initial={dialog.mode === 'edit' ? dialog.location : undefined}
saving={saveMutation.isPending}
onSave={(value) => saveMutation.mutate({ value, location: dialog.mode === 'edit' ? dialog.location : undefined })}
onCancel={() => setDialog(null)}
/>
)}
{moving && (
<MoveDialog
count={moving.ids.length}
printerName={moving.printerName}
locations={locations ?? []}
saving={moveMutation.isPending}
onMove={(location) => moveMutation.mutate({ ids: moving.ids, location })}
onCancel={() => setMoving(null)}
/>
)}
{deleting && (
<ConfirmModal
title={
deleting.length === 1
? t('printers.locations.deleteTitle')
: t('printers.locations.deleteTitleMany', { count: deleting.length })
}
message={t('printers.locations.deleteMessage', {
count: deletingPrinterCount,
names: deleting.join(', '),
})}
confirmText={t('common.delete')}
variant="danger"
isLoading={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate(deleting)}
onCancel={() => setDeleting(null)}
/>
)}
</div>
);
}
+10
View File
@@ -8080,6 +8080,7 @@ export function AddPrinterModal({
value={form.location || ''}
onChange={(e) => setForm({ ...form, location: e.target.value })}
placeholder={t('printers.modal.locationPlaceholder')}
maxLength={100}
/>
<p className="text-xs text-bambu-gray mt-1">{t('printers.locationHelp')}</p>
</div>
@@ -8611,6 +8612,7 @@ function EditPrinterModal({
value={form.location}
onChange={(e) => setForm({ ...form, location: e.target.value })}
placeholder={t('printers.modal.locationPlaceholder')}
maxLength={100}
/>
<p className="text-xs text-bambu-gray mt-1">{t('printers.locationHelp')}</p>
</div>
@@ -9608,6 +9610,14 @@ export function PrintersPage() {
<ArrowDown className="w-4 h-4 text-white" />
)}
</button>
<RouterLink
to="/printer-locations"
className="h-8 shrink-0 px-2 rounded-lg border bg-bambu-dark border-bambu-dark-tertiary text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center justify-center"
title={t('printers.locations.title')}
aria-label={t('printers.locations.title')}
>
<Box className="w-4 h-4 text-bambu-green" />
</RouterLink>
</div>
{/* Page view toggle: Cards / Cam Wall */}
@@ -1 +1 @@
import{i as e}from"./chunk-aKtaBQYM.js";import{a as t,c as n,d as r,g as i,i as a,l as o,n as s,o as c,r as l,s as u,t as d,u as f,v as p,y as m}from"./index-7L3DC4So.js";var h=e(m(),1),g=i(),_=.4,v=8,y=1.25,b=30,x=Math.log(y)/b,S=e=>Math.min(v,Math.max(_,e)),C={x:0,y:0};function w(e){return e.deltaMode===1?e.deltaY*16:e.deltaMode===2?e.deltaY*100:e.deltaY}function T({libraryFileId:e,filename:i,fileSize:m,onClose:_}){let{t:v}=p(),T=d(),{isFullscreen:E,toggleFullscreen:D}=T,O=(0,h.useRef)(null),k=(0,h.useRef)(null),[A,j]=(0,h.useState)(null),[M,N]=(0,h.useState)(!1),[P,F]=(0,h.useState)(null),[I,L]=(0,h.useState)(1),[R,z]=(0,h.useState)(C),[B,V]=(0,h.useState)(!1),H=(0,h.useRef)(1),U=(0,h.useRef)(C),W=(0,h.useRef)(new Map),G=(0,h.useRef)(null),K=(0,h.useCallback)((e,t)=>{let n=O.current,r=k.current;if(!n||!r)return e;let i=Math.max(0,(r.offsetWidth*t-n.clientWidth)/2),a=Math.max(0,(r.offsetHeight*t-n.clientHeight)/2);return{x:Math.min(i,Math.max(-i,e.x)),y:Math.min(a,Math.max(-a,e.y))}},[]),q=(0,h.useCallback)((e,t)=>{H.current=e,U.current=t,L(e),z(t)},[]),J=(0,h.useCallback)((e,t,n)=>{let r=H.current,i=S(r*e);if(i===r)return;let a=O.current,o=U.current,s={x:o.x*(i/r),y:o.y*(i/r)};if(a){let e=a.getBoundingClientRect(),c=(t??e.left+e.width/2)-(e.left+e.width/2),l=(n??e.top+e.height/2)-(e.top+e.height/2),u=i/r;s={x:c-(c-o.x)*u,y:l-(l-o.y)*u}}q(i,K(s,i))},[K,q]),Y=(0,h.useCallback)(()=>q(1,C),[q]);(0,h.useEffect)(()=>{let n=!1,r=null;if(j(null),N(!1),F(null),q(1,C),m>52428800){F(v(`fileManager.preview.tooLarge`,{size:a(m)}));return}let i={},o=c();return o&&(i.Authorization=`Bearer ${o}`),(async()=>{let a=await fetch(t.getLibraryFileDownloadUrl(e),{headers:i});if(!a.ok)throw Error(`HTTP ${a.status}`);let o=await a.blob();n||(r=URL.createObjectURL(o),j(r))})().catch(e=>{console.error(`[image-preview] load failed`,e),n||F(v(`fileManager.preview.error`))}),()=>{n=!0,r&&URL.revokeObjectURL(r)}},[e,m,v,q]),(0,h.useEffect)(()=>{let e=e=>{e.ctrlKey||e.metaKey||e.altKey||(e.key===`+`||e.key===`=`?(e.preventDefault(),J(y)):e.key===`-`?(e.preventDefault(),J(1/y)):e.key===`0`&&(e.preventDefault(),Y()))};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[Y,J]),(0,h.useEffect)(()=>{let e=O.current;if(!e)return;let t=e=>{e.preventDefault();let t=Math.max(-30,Math.min(b,w(e)));J(Math.exp(-t*x),e.clientX,e.clientY)};return e.addEventListener(`wheel`,t,{passive:!1}),()=>e.removeEventListener(`wheel`,t)},[J,A]);let X=e=>{W.current.set(e.pointerId,{x:e.clientX,y:e.clientY}),G.current=null,W.current.size===1&&H.current>1&&(e.currentTarget.setPointerCapture(e.pointerId),V(!0))},Z=e=>{W.current.delete(e.pointerId),G.current=null,W.current.size===0&&V(!1)},Q=e=>{let t=W.current,n=t.get(e.pointerId);if(!n)return;if(e.pointerType===`mouse`&&e.buttons===0){Z(e);return}if(t.set(e.pointerId,{x:e.clientX,y:e.clientY}),t.size===1){if(H.current<=1)return;let t={x:U.current.x+(e.clientX-n.x),y:U.current.y+(e.clientY-n.y)};q(H.current,K(t,H.current));return}if(t.size!==2)return;let[r,i]=Array.from(t.values()),a=Math.hypot(r.x-i.x,r.y-i.y),o=G.current;G.current=a,o!=null&&o>0&&a>0&&J(a/o,(r.x+i.x)/2,(r.y+i.y)/2)},$=I>1;return(0,g.jsx)(s,{title:i,fullscreen:T,onClose:_,icon:(0,g.jsx)(r,{className:`w-5 h-5 text-bambu-green flex-shrink-0`}),actions:A?(0,g.jsxs)(g.Fragment,{children:[(0,g.jsx)(`button`,{onClick:()=>J(1/y),className:l,"aria-label":v(`fileManager.preview.zoomOut`),children:(0,g.jsx)(u,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:()=>J(y),className:l,"aria-label":v(`fileManager.preview.zoomIn`),children:(0,g.jsx)(n,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:Y,className:l,"aria-label":v(`fileManager.preview.resetZoom`),children:(0,g.jsx)(o,{className:`w-4 h-4`})})]}):void 0,children:(0,g.jsx)(`div`,{ref:O,"data-testid":`image-preview-content`,className:`relative flex-1 min-h-0 overflow-hidden bg-bambu-dark flex items-center justify-center touch-none ${E?``:`rounded-b-lg`} ${$?B?`cursor-grabbing`:`cursor-grab`:``}`,onDoubleClick:D,onPointerDown:X,onPointerMove:Q,onPointerUp:Z,onPointerCancel:Z,onPointerLeave:Z,children:P?(0,g.jsx)(`p`,{className:`text-bambu-gray text-center p-6`,children:P}):(0,g.jsxs)(g.Fragment,{children:[A&&(0,g.jsx)(`img`,{ref:k,src:A,alt:i,draggable:!1,onLoad:()=>N(!0),onError:()=>F(v(`fileManager.preview.error`)),style:{transform:`translate(${R.x}px, ${R.y}px) scale(${I})`},className:`max-w-full max-h-full object-contain select-none`}),!M&&(0,g.jsx)(`div`,{className:`absolute inset-0 flex items-center justify-center pointer-events-none`,children:(0,g.jsx)(f,{className:`w-8 h-8 text-bambu-green animate-spin`})})]})})})}export{T as ImagePreviewModal};
import{i as e}from"./chunk-aKtaBQYM.js";import{a as t,c as n,d as r,g as i,i as a,l as o,n as s,o as c,r as l,s as u,t as d,u as f,v as p,y as m}from"./index-CFtTE_hL.js";var h=e(m(),1),g=i(),_=.4,v=8,y=1.25,b=30,x=Math.log(y)/b,S=e=>Math.min(v,Math.max(_,e)),C={x:0,y:0};function w(e){return e.deltaMode===1?e.deltaY*16:e.deltaMode===2?e.deltaY*100:e.deltaY}function T({libraryFileId:e,filename:i,fileSize:m,onClose:_}){let{t:v}=p(),T=d(),{isFullscreen:E,toggleFullscreen:D}=T,O=(0,h.useRef)(null),k=(0,h.useRef)(null),[A,j]=(0,h.useState)(null),[M,N]=(0,h.useState)(!1),[P,F]=(0,h.useState)(null),[I,L]=(0,h.useState)(1),[R,z]=(0,h.useState)(C),[B,V]=(0,h.useState)(!1),H=(0,h.useRef)(1),U=(0,h.useRef)(C),W=(0,h.useRef)(new Map),G=(0,h.useRef)(null),K=(0,h.useCallback)((e,t)=>{let n=O.current,r=k.current;if(!n||!r)return e;let i=Math.max(0,(r.offsetWidth*t-n.clientWidth)/2),a=Math.max(0,(r.offsetHeight*t-n.clientHeight)/2);return{x:Math.min(i,Math.max(-i,e.x)),y:Math.min(a,Math.max(-a,e.y))}},[]),q=(0,h.useCallback)((e,t)=>{H.current=e,U.current=t,L(e),z(t)},[]),J=(0,h.useCallback)((e,t,n)=>{let r=H.current,i=S(r*e);if(i===r)return;let a=O.current,o=U.current,s={x:o.x*(i/r),y:o.y*(i/r)};if(a){let e=a.getBoundingClientRect(),c=(t??e.left+e.width/2)-(e.left+e.width/2),l=(n??e.top+e.height/2)-(e.top+e.height/2),u=i/r;s={x:c-(c-o.x)*u,y:l-(l-o.y)*u}}q(i,K(s,i))},[K,q]),Y=(0,h.useCallback)(()=>q(1,C),[q]);(0,h.useEffect)(()=>{let n=!1,r=null;if(j(null),N(!1),F(null),q(1,C),m>52428800){F(v(`fileManager.preview.tooLarge`,{size:a(m)}));return}let i={},o=c();return o&&(i.Authorization=`Bearer ${o}`),(async()=>{let a=await fetch(t.getLibraryFileDownloadUrl(e),{headers:i});if(!a.ok)throw Error(`HTTP ${a.status}`);let o=await a.blob();n||(r=URL.createObjectURL(o),j(r))})().catch(e=>{console.error(`[image-preview] load failed`,e),n||F(v(`fileManager.preview.error`))}),()=>{n=!0,r&&URL.revokeObjectURL(r)}},[e,m,v,q]),(0,h.useEffect)(()=>{let e=e=>{e.ctrlKey||e.metaKey||e.altKey||(e.key===`+`||e.key===`=`?(e.preventDefault(),J(y)):e.key===`-`?(e.preventDefault(),J(1/y)):e.key===`0`&&(e.preventDefault(),Y()))};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[Y,J]),(0,h.useEffect)(()=>{let e=O.current;if(!e)return;let t=e=>{e.preventDefault();let t=Math.max(-30,Math.min(b,w(e)));J(Math.exp(-t*x),e.clientX,e.clientY)};return e.addEventListener(`wheel`,t,{passive:!1}),()=>e.removeEventListener(`wheel`,t)},[J,A]);let X=e=>{W.current.set(e.pointerId,{x:e.clientX,y:e.clientY}),G.current=null,W.current.size===1&&H.current>1&&(e.currentTarget.setPointerCapture(e.pointerId),V(!0))},Z=e=>{W.current.delete(e.pointerId),G.current=null,W.current.size===0&&V(!1)},Q=e=>{let t=W.current,n=t.get(e.pointerId);if(!n)return;if(e.pointerType===`mouse`&&e.buttons===0){Z(e);return}if(t.set(e.pointerId,{x:e.clientX,y:e.clientY}),t.size===1){if(H.current<=1)return;let t={x:U.current.x+(e.clientX-n.x),y:U.current.y+(e.clientY-n.y)};q(H.current,K(t,H.current));return}if(t.size!==2)return;let[r,i]=Array.from(t.values()),a=Math.hypot(r.x-i.x,r.y-i.y),o=G.current;G.current=a,o!=null&&o>0&&a>0&&J(a/o,(r.x+i.x)/2,(r.y+i.y)/2)},$=I>1;return(0,g.jsx)(s,{title:i,fullscreen:T,onClose:_,icon:(0,g.jsx)(r,{className:`w-5 h-5 text-bambu-green flex-shrink-0`}),actions:A?(0,g.jsxs)(g.Fragment,{children:[(0,g.jsx)(`button`,{onClick:()=>J(1/y),className:l,"aria-label":v(`fileManager.preview.zoomOut`),children:(0,g.jsx)(u,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:()=>J(y),className:l,"aria-label":v(`fileManager.preview.zoomIn`),children:(0,g.jsx)(n,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:Y,className:l,"aria-label":v(`fileManager.preview.resetZoom`),children:(0,g.jsx)(o,{className:`w-4 h-4`})})]}):void 0,children:(0,g.jsx)(`div`,{ref:O,"data-testid":`image-preview-content`,className:`relative flex-1 min-h-0 overflow-hidden bg-bambu-dark flex items-center justify-center touch-none ${E?``:`rounded-b-lg`} ${$?B?`cursor-grabbing`:`cursor-grab`:``}`,onDoubleClick:D,onPointerDown:X,onPointerMove:Q,onPointerUp:Z,onPointerCancel:Z,onPointerLeave:Z,children:P?(0,g.jsx)(`p`,{className:`text-bambu-gray text-center p-6`,children:P}):(0,g.jsxs)(g.Fragment,{children:[A&&(0,g.jsx)(`img`,{ref:k,src:A,alt:i,draggable:!1,onLoad:()=>N(!0),onError:()=>F(v(`fileManager.preview.error`)),style:{transform:`translate(${R.x}px, ${R.y}px) scale(${I})`},className:`max-w-full max-h-full object-contain select-none`}),!M&&(0,g.jsx)(`div`,{className:`absolute inset-0 flex items-center justify-center pointer-events-none`,children:(0,g.jsx)(f,{className:`w-8 h-8 text-bambu-green animate-spin`})})]})})})}export{T as ImagePreviewModal};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -26,9 +26,9 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-7L3DC4So.js"></script>
<script type="module" crossorigin src="/assets/index-CFtTE_hL.js"></script>
<link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
<link rel="stylesheet" crossorigin href="/assets/index-MteGyJn2.css">
<link rel="stylesheet" crossorigin href="/assets/index-CaVfzyDI.css">
</head>
<body>
<div id="root"></div>