mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
feat(inventory): multi-colour gradients, transparency, visual effects (#1154)
Spool and color_catalog rows carry extra_colors (comma-separated hex stops) and effect_type (14 visual variants: surface effects, sheen, structural). The shared FilamentSwatch component renders gradient, conic, effect overlay, and alpha-checkerboard consistently across the inventory grid, table, group banner, card, ColorSection preview, and catalog editor. Catalog hex_color accepts #RRGGBBAA so catalog entries can carry transparency too. The paste field accepts the exact format 3dfilamentprofiles.com puts on its filament details pages, so users can copy a multi-colour combo directly. The effect dropdown spans the full filament-variant vocabulary -- surface effects (sparkle/wood/marble/glow/matte), sheen variants (silk/galaxy/rainbow/metal/translucent), and structural variants (gradient/dual-color/tri-color/multicolor). None of these fields touch MQTT/firmware -- pure visual hint. Spool group-key extended to include extra_colors + effect_type so "Group similar" no longer collapses visually distinct spools. Migrations: 4 idempotent ALTER TABLE ADD COLUMN (Postgres-safe), plus ALTER COLUMN hex_color TYPE VARCHAR(9) on Postgres only (SQLite ignores VARCHAR length). Tests: 42 new backend (35 unit + 7 integration), 20 new frontend (14 FilamentSwatch + 3 ColorCatalogSettings + 3 InventoryPageGrouping regression). 3522 backend + 1582 frontend tests pass; ruff clean. Localised across all 8 UI locales.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -217,6 +217,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
|
||||
- **Per-spool cost tracking** — Set cost/kg on each spool; costs are automatically calculated at print completion and aggregated to archives. Print modal shows real-time cost preview. Configurable default cost and currency in Settings.
|
||||
- **Bulk spool addition** — Add multiple identical spools at once (quantity 1–100) with a single form submission. Quick Add mode for stock spools that only need material, color, and weight.
|
||||
- Spool catalog, color catalog, PA profile matching, and low-stock alerts
|
||||
- **Multi-colour gradients, transparency, and visual effects** — Paste a comma-separated hex list (e.g. from 3dfilamentprofiles.com) to render a spool as a gradient or conic colour wheel; transparency shows through a checkerboard so the alpha you set is the alpha you see; pick a visual effect (sparkle, wood, marble, glow, matte) for the swatch overlay. Same fields are editable on the colour catalog so combos can be reused across spools.
|
||||
|
||||
### 🔧 Integrations
|
||||
- [Spoolman](https://github.com/Donkie/Spoolman) filament sync with per-filament usage tracking and fill level display
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -30,6 +30,8 @@ from backend.app.schemas.spool import (
|
||||
SpoolKProfileResponse,
|
||||
SpoolResponse,
|
||||
SpoolUpdate,
|
||||
normalize_effect_type,
|
||||
normalize_extra_colors,
|
||||
)
|
||||
from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
|
||||
from backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament
|
||||
@@ -95,23 +97,52 @@ class ColorEntryResponse(BaseModel):
|
||||
hex_color: str
|
||||
material: str | None
|
||||
is_default: bool
|
||||
extra_colors: str | None = None
|
||||
effect_type: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
_HEX_COLOR_PATTERN = r"^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$"
|
||||
|
||||
|
||||
class ColorEntryCreate(BaseModel):
|
||||
manufacturer: str
|
||||
color_name: str
|
||||
hex_color: str
|
||||
hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
|
||||
material: str | None = None
|
||||
extra_colors: str | None = None
|
||||
effect_type: str | None = None
|
||||
|
||||
@field_validator("extra_colors")
|
||||
@classmethod
|
||||
def _validate_extra_colors(cls, v: str | None) -> str | None:
|
||||
return normalize_extra_colors(v)
|
||||
|
||||
@field_validator("effect_type")
|
||||
@classmethod
|
||||
def _validate_effect_type(cls, v: str | None) -> str | None:
|
||||
return normalize_effect_type(v)
|
||||
|
||||
|
||||
class ColorEntryUpdate(BaseModel):
|
||||
manufacturer: str
|
||||
color_name: str
|
||||
hex_color: str
|
||||
hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
|
||||
material: str | None = None
|
||||
extra_colors: str | None = None
|
||||
effect_type: str | None = None
|
||||
|
||||
@field_validator("extra_colors")
|
||||
@classmethod
|
||||
def _validate_extra_colors(cls, v: str | None) -> str | None:
|
||||
return normalize_extra_colors(v)
|
||||
|
||||
@field_validator("effect_type")
|
||||
@classmethod
|
||||
def _validate_effect_type(cls, v: str | None) -> str | None:
|
||||
return normalize_effect_type(v)
|
||||
|
||||
|
||||
class ColorLookupResult(BaseModel):
|
||||
@@ -286,6 +317,8 @@ async def add_color_entry(
|
||||
hex_color=entry.hex_color,
|
||||
material=entry.material,
|
||||
is_default=False,
|
||||
extra_colors=entry.extra_colors,
|
||||
effect_type=entry.effect_type,
|
||||
)
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
@@ -309,6 +342,8 @@ async def update_color_entry(
|
||||
row.color_name = entry.color_name
|
||||
row.hex_color = entry.hex_color
|
||||
row.material = entry.material
|
||||
row.extra_colors = entry.extra_colors
|
||||
row.effect_type = entry.effect_type
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return row
|
||||
|
||||
@@ -647,6 +647,15 @@ async def run_migrations(conn):
|
||||
await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN url VARCHAR(2048)")
|
||||
await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN cover_image_filename VARCHAR(255)")
|
||||
|
||||
# Migration: enhanced filament colour handling on color_catalog (#1154).
|
||||
# Mirrors the Spool columns added below; widens hex_color to VARCHAR(9)
|
||||
# so catalog entries can store an alpha component (#RRGGBBAA). SQLite
|
||||
# ignores VARCHAR length, so the widen only matters on PostgreSQL.
|
||||
await _safe_execute(conn, "ALTER TABLE color_catalog ADD COLUMN extra_colors VARCHAR(255)")
|
||||
await _safe_execute(conn, "ALTER TABLE color_catalog ADD COLUMN effect_type VARCHAR(20)")
|
||||
if not is_sqlite():
|
||||
await _safe_execute(conn, "ALTER TABLE color_catalog ALTER COLUMN hex_color TYPE VARCHAR(9)")
|
||||
|
||||
# Migration: Make printer_id nullable in print_queue for unassigned queue items
|
||||
# SQLite doesn't support ALTER COLUMN, so we need to recreate the table
|
||||
# PostgreSQL gets the correct schema from create_all(), so skip this
|
||||
@@ -1266,6 +1275,14 @@ async def run_migrations(conn):
|
||||
# falls back to the global low_stock_threshold setting.
|
||||
await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN category VARCHAR(50)")
|
||||
await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN low_stock_threshold_pct INTEGER")
|
||||
|
||||
# Migration: enhanced filament colour handling (#1154). `extra_colors` is
|
||||
# a comma-separated list of 6- or 8-char hex tokens (no `#`) for multi-
|
||||
# colour gradients; `effect_type` is one of {sparkle, wood, marble, glow,
|
||||
# matte} as a visual rendering hint. Both nullable — NULL keeps the
|
||||
# current single-rgba/no-effect behaviour.
|
||||
await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN extra_colors VARCHAR(255)")
|
||||
await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN effect_type VARCHAR(20)")
|
||||
# Migration: Add cost field to spool_usage_history table
|
||||
await _safe_execute(conn, "ALTER TABLE spool_usage_history ADD COLUMN cost REAL")
|
||||
# Migration: Add archive_id field to spool_usage_history table
|
||||
|
||||
@@ -14,7 +14,10 @@ class ColorCatalogEntry(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
manufacturer: Mapped[str] = mapped_column(String(200))
|
||||
color_name: Mapped[str] = mapped_column(String(200))
|
||||
hex_color: Mapped[str] = mapped_column(String(7)) # #RRGGBB
|
||||
hex_color: Mapped[str] = mapped_column(String(9)) # #RRGGBB or #RRGGBBAA
|
||||
material: Mapped[str | None] = mapped_column(String(100))
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# Optional multi-colour stops + visual effect (#1154), mirrors Spool fields.
|
||||
extra_colors: Mapped[str | None] = mapped_column(String(255))
|
||||
effect_type: Mapped[str | None] = mapped_column(String(20))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
@@ -16,6 +16,14 @@ class Spool(Base):
|
||||
subtype: Mapped[str | None] = mapped_column(String(50)) # Basic, Matte, Silk, etc.
|
||||
color_name: Mapped[str | None] = mapped_column(String(100)) # "Jade White"
|
||||
rgba: Mapped[str | None] = mapped_column(String(8)) # RRGGBBAA hex
|
||||
# Multi-colour gradient stops for filaments with more than one colour
|
||||
# (e.g. tri-colour, multi-colour). Stored as comma-separated 6- or 8-char
|
||||
# hex tokens without `#`. Empty/NULL means solid (uses `rgba`). Up to 8
|
||||
# stops; combination mode is driven by `subtype` (Gradient, Multicolor).
|
||||
extra_colors: Mapped[str | None] = mapped_column(String(255))
|
||||
# Visual effect overlay independent of subtype: sparkle, wood, marble,
|
||||
# glow, matte. Purely a rendering hint — does not affect MQTT/firmware.
|
||||
effect_type: Mapped[str | None] = mapped_column(String(20))
|
||||
brand: Mapped[str | None] = mapped_column(String(100)) # "Polymaker"
|
||||
label_weight: Mapped[int] = mapped_column(Integer, default=1000) # Advertised net weight (g)
|
||||
core_weight: Mapped[int] = mapped_column(Integer, default=250) # Empty spool weight (g)
|
||||
|
||||
@@ -1,6 +1,80 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
# Visual variant applied to a spool's swatch — purely cosmetic, does not
|
||||
# affect MQTT/firmware. Kept independent of `subtype` so users can override
|
||||
# the rendering hint without touching Bambu's categorical filament label.
|
||||
# Mirrors the visual variants the spool form's `KNOWN_VARIANTS` exposes so
|
||||
# the catalog and spool form share one vocabulary; structural variants like
|
||||
# gradient/dual-color/tri-color/multicolor combine with `extra_colors` for
|
||||
# rendering, surface effects (sparkle/wood/marble/glow/matte) layer overlays.
|
||||
ALLOWED_EFFECT_TYPES = frozenset(
|
||||
{
|
||||
# Surface effects
|
||||
"sparkle",
|
||||
"wood",
|
||||
"marble",
|
||||
"glow",
|
||||
"matte",
|
||||
# Sheen / finish variants
|
||||
"silk",
|
||||
"galaxy",
|
||||
"rainbow",
|
||||
"metal",
|
||||
"translucent",
|
||||
# Multi-colour structures (drive gradient rendering when paired with extra_colors)
|
||||
"gradient",
|
||||
"dual-color",
|
||||
"tri-color",
|
||||
"multicolor",
|
||||
}
|
||||
)
|
||||
|
||||
# Cap how many gradient stops we accept on input so a paste of arbitrary text
|
||||
# can't blow up the stored value or downstream rendering.
|
||||
MAX_EXTRA_COLOR_STOPS = 8
|
||||
|
||||
|
||||
def normalize_extra_colors(value: str | None) -> str | None:
|
||||
"""Parse comma-separated hex tokens into canonical lowercase form.
|
||||
|
||||
Accepts 6- or 8-char hex per token, with or without leading `#`. Returns
|
||||
None for blank input, raises ValueError for malformed tokens or too many
|
||||
stops. Output is the comma-joined canonical form (no `#`, lowercase).
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
raw = value.strip()
|
||||
if not raw:
|
||||
return None
|
||||
tokens = [tok.strip().lstrip("#").lower() for tok in raw.split(",") if tok.strip()]
|
||||
if not tokens:
|
||||
return None
|
||||
if len(tokens) > MAX_EXTRA_COLOR_STOPS:
|
||||
raise ValueError(f"extra_colors accepts at most {MAX_EXTRA_COLOR_STOPS} stops")
|
||||
for tok in tokens:
|
||||
if len(tok) not in (6, 8):
|
||||
raise ValueError(f"extra_colors token '{tok}' must be 6 or 8 hex chars")
|
||||
try:
|
||||
int(tok, 16)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"extra_colors token '{tok}' is not valid hex") from exc
|
||||
return ",".join(tokens)
|
||||
|
||||
|
||||
def normalize_effect_type(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
trimmed = value.strip().lower()
|
||||
if not trimmed:
|
||||
return None
|
||||
# Tolerate "Dual Color" / "dual_color" / "dual color" → "dual-color" so
|
||||
# users pasting from spool-subtype labels don't hit a validation wall.
|
||||
canonical = trimmed.replace("_", "-").replace(" ", "-")
|
||||
if canonical not in ALLOWED_EFFECT_TYPES:
|
||||
raise ValueError(f"effect_type must be one of: {sorted(ALLOWED_EFFECT_TYPES)}")
|
||||
return canonical
|
||||
|
||||
|
||||
class SpoolBase(BaseModel):
|
||||
@@ -8,7 +82,20 @@ class SpoolBase(BaseModel):
|
||||
subtype: str | None = None
|
||||
color_name: str | None = None
|
||||
rgba: str | None = Field(None, pattern=r"^[0-9A-Fa-f]{8}$")
|
||||
extra_colors: str | None = None
|
||||
effect_type: str | None = None
|
||||
brand: str | None = None
|
||||
|
||||
@field_validator("extra_colors")
|
||||
@classmethod
|
||||
def _validate_extra_colors(cls, v: str | None) -> str | None:
|
||||
return normalize_extra_colors(v)
|
||||
|
||||
@field_validator("effect_type")
|
||||
@classmethod
|
||||
def _validate_effect_type(cls, v: str | None) -> str | None:
|
||||
return normalize_effect_type(v)
|
||||
|
||||
label_weight: int = 1000
|
||||
core_weight: int = 250
|
||||
core_weight_catalog_id: int | None = None
|
||||
@@ -45,7 +132,20 @@ class SpoolUpdate(BaseModel):
|
||||
subtype: str | None = None
|
||||
color_name: str | None = None
|
||||
rgba: str | None = Field(None, pattern=r"^[0-9A-Fa-f]{8}$")
|
||||
extra_colors: str | None = None
|
||||
effect_type: str | None = None
|
||||
brand: str | None = None
|
||||
|
||||
@field_validator("extra_colors")
|
||||
@classmethod
|
||||
def _validate_extra_colors(cls, v: str | None) -> str | None:
|
||||
return normalize_extra_colors(v)
|
||||
|
||||
@field_validator("effect_type")
|
||||
@classmethod
|
||||
def _validate_effect_type(cls, v: str | None) -> str | None:
|
||||
return normalize_effect_type(v)
|
||||
|
||||
label_weight: int | None = None
|
||||
core_weight: int | None = None
|
||||
core_weight_catalog_id: int | None = None
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Integration tests for the multi-colour + effect extensions on the colour
|
||||
catalog routes (#1154).
|
||||
|
||||
End-to-end coverage that the new fields on `ColorEntryCreate` / `ColorEntryUpdate`
|
||||
round-trip through the database, that catalog GET surfaces them in the response,
|
||||
and that paste-style values from 3dfilamentprofiles.com are normalized.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_color_entry_with_extras(async_client: AsyncClient):
|
||||
"""POST /inventory/colors stores extra_colors + effect_type."""
|
||||
payload = {
|
||||
"manufacturer": "3dfilamentprofiles",
|
||||
"color_name": "Aurora Tetracolour",
|
||||
"hex_color": "#EC984C",
|
||||
"material": "PLA",
|
||||
"extra_colors": "EC984C,#6CD4BC,A66EB9,D87694",
|
||||
"effect_type": "Sparkle",
|
||||
}
|
||||
response = await async_client.post("/api/v1/inventory/colors", json=payload)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
# Canonical form: lowercase, no `#`, comma-joined.
|
||||
assert body["extra_colors"] == "ec984c,6cd4bc,a66eb9,d87694"
|
||||
assert body["effect_type"] == "sparkle"
|
||||
assert body["hex_color"] == "#EC984C"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_color_entry_accepts_8char_hex(async_client: AsyncClient):
|
||||
"""Catalog hex_color may include alpha (#RRGGBBAA) post-#1154."""
|
||||
payload = {
|
||||
"manufacturer": "Bambu Lab",
|
||||
"color_name": "Translucent Galaxy",
|
||||
"hex_color": "#1A2B3C80",
|
||||
"material": "PETG",
|
||||
}
|
||||
response = await async_client.post("/api/v1/inventory/colors", json=payload)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["hex_color"] == "#1A2B3C80"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_color_entry_clears_extras(async_client: AsyncClient):
|
||||
"""PUT with empty extra_colors clears the field (server normalizes "" → null)."""
|
||||
create = await async_client.post(
|
||||
"/api/v1/inventory/colors",
|
||||
json={
|
||||
"manufacturer": "Test",
|
||||
"color_name": "Fade",
|
||||
"hex_color": "#FF0000",
|
||||
"extra_colors": "FF0000,00FF00",
|
||||
"effect_type": "wood",
|
||||
},
|
||||
)
|
||||
assert create.status_code == 200
|
||||
entry_id = create.json()["id"]
|
||||
|
||||
update = await async_client.put(
|
||||
f"/api/v1/inventory/colors/{entry_id}",
|
||||
json={
|
||||
"manufacturer": "Test",
|
||||
"color_name": "Fade",
|
||||
"hex_color": "#FF0000",
|
||||
"extra_colors": "",
|
||||
"effect_type": None,
|
||||
},
|
||||
)
|
||||
assert update.status_code == 200, update.text
|
||||
body = update.json()
|
||||
assert body["extra_colors"] is None
|
||||
assert body["effect_type"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_color_entry_rejects_bad_extra_colors(async_client: AsyncClient):
|
||||
response = await async_client.post(
|
||||
"/api/v1/inventory/colors",
|
||||
json={
|
||||
"manufacturer": "Test",
|
||||
"color_name": "Bad",
|
||||
"hex_color": "#FF0000",
|
||||
"extra_colors": "not-hex,GGHHII",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_color_entry_rejects_bad_effect_type(async_client: AsyncClient):
|
||||
response = await async_client.post(
|
||||
"/api/v1/inventory/colors",
|
||||
json={
|
||||
"manufacturer": "Test",
|
||||
"color_name": "Bad",
|
||||
"hex_color": "#FF0000",
|
||||
"effect_type": "not-a-real-variant",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_color_catalog_returns_extras(async_client: AsyncClient):
|
||||
"""GET /inventory/colors response shape includes the new fields."""
|
||||
await async_client.post(
|
||||
"/api/v1/inventory/colors",
|
||||
json={
|
||||
"manufacturer": "Test",
|
||||
"color_name": "Glitter Black",
|
||||
"hex_color": "#101010",
|
||||
"extra_colors": "101010,303030",
|
||||
"effect_type": "sparkle",
|
||||
},
|
||||
)
|
||||
response = await async_client.get("/api/v1/inventory/colors")
|
||||
assert response.status_code == 200
|
||||
rows = response.json()
|
||||
glitter = next((r for r in rows if r["color_name"] == "Glitter Black"), None)
|
||||
assert glitter is not None
|
||||
assert glitter["extra_colors"] == "101010,303030"
|
||||
assert glitter["effect_type"] == "sparkle"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_spool_with_color_extras(async_client: AsyncClient):
|
||||
"""POST /inventory/spools threads the new spool-side fields end-to-end."""
|
||||
payload = {
|
||||
"material": "PLA",
|
||||
"subtype": "Multicolor",
|
||||
"rgba": "EC984CFF",
|
||||
"extra_colors": "#EC984C,#6CD4BC,#A66EB9,#D87694",
|
||||
"effect_type": "matte",
|
||||
}
|
||||
response = await async_client.post("/api/v1/inventory/spools", json=payload)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["extra_colors"] == "ec984c,6cd4bc,a66eb9,d87694"
|
||||
assert body["effect_type"] == "matte"
|
||||
|
||||
# PATCH clears via empty string + null.
|
||||
patch = await async_client.patch(
|
||||
f"/api/v1/inventory/spools/{body['id']}",
|
||||
json={"extra_colors": "", "effect_type": None},
|
||||
)
|
||||
assert patch.status_code == 200
|
||||
assert patch.json()["extra_colors"] is None
|
||||
assert patch.json()["effect_type"] is None
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Schema validation for enhanced filament colour fields (#1154).
|
||||
|
||||
Two new fields land on Spool and ColorCatalogEntry:
|
||||
|
||||
- `extra_colors`: comma-separated 6- or 8-char hex tokens. Stored canonical
|
||||
form is lowercase, no `#`, no whitespace. Bounded to MAX_EXTRA_COLOR_STOPS
|
||||
stops so a paste of arbitrary text can't blow up the column.
|
||||
- `effect_type`: one of {sparkle, wood, marble, glow, matte}. Independent of
|
||||
Spool.subtype — purely a rendering hint.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from backend.app.schemas.spool import (
|
||||
ALLOWED_EFFECT_TYPES,
|
||||
MAX_EXTRA_COLOR_STOPS,
|
||||
SpoolCreate,
|
||||
SpoolUpdate,
|
||||
normalize_effect_type,
|
||||
normalize_extra_colors,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeExtraColors:
|
||||
"""The shared helper that both Spool and ColorCatalog schemas delegate to."""
|
||||
|
||||
def test_none_passthrough(self):
|
||||
assert normalize_extra_colors(None) is None
|
||||
|
||||
def test_empty_string_returns_none(self):
|
||||
assert normalize_extra_colors("") is None
|
||||
assert normalize_extra_colors(" ") is None
|
||||
|
||||
def test_strips_hash_prefix(self):
|
||||
assert normalize_extra_colors("#FF0000,#00FF00") == "ff0000,00ff00"
|
||||
|
||||
def test_lowercases(self):
|
||||
assert normalize_extra_colors("AABBCC") == "aabbcc"
|
||||
|
||||
def test_accepts_8char_alpha(self):
|
||||
assert normalize_extra_colors("AABBCC80,DDEEFF40") == "aabbcc80,ddeeff40"
|
||||
|
||||
def test_mixed_6_and_8_char(self):
|
||||
assert normalize_extra_colors("FF0000,00FF0080") == "ff0000,00ff0080"
|
||||
|
||||
def test_handles_whitespace_around_tokens(self):
|
||||
# 3dfilamentprofiles.com paste sometimes has spaces after commas.
|
||||
assert normalize_extra_colors("EC984C, #6CD4BC ,A66EB9, D87694") == "ec984c,6cd4bc,a66eb9,d87694"
|
||||
|
||||
def test_drops_empty_tokens(self):
|
||||
# ",,FF0000," is a degenerate paste — keep what's valid.
|
||||
assert normalize_extra_colors(",,FF0000,") == "ff0000"
|
||||
|
||||
def test_rejects_too_many_stops(self):
|
||||
too_many = ",".join(["FF0000"] * (MAX_EXTRA_COLOR_STOPS + 1))
|
||||
with pytest.raises(ValueError, match="at most"):
|
||||
normalize_extra_colors(too_many)
|
||||
|
||||
def test_accepts_max_stops(self):
|
||||
boundary = ",".join(["FF0000"] * MAX_EXTRA_COLOR_STOPS)
|
||||
out = normalize_extra_colors(boundary)
|
||||
assert out is not None
|
||||
assert out.count(",") == MAX_EXTRA_COLOR_STOPS - 1
|
||||
|
||||
def test_rejects_non_hex(self):
|
||||
with pytest.raises(ValueError, match="not valid hex"):
|
||||
normalize_extra_colors("FF0000,GGHHII")
|
||||
|
||||
def test_rejects_wrong_length(self):
|
||||
with pytest.raises(ValueError, match="6 or 8 hex"):
|
||||
normalize_extra_colors("FFF")
|
||||
with pytest.raises(ValueError, match="6 or 8 hex"):
|
||||
normalize_extra_colors("FFFFF")
|
||||
with pytest.raises(ValueError, match="6 or 8 hex"):
|
||||
normalize_extra_colors("FFFFFFFFF")
|
||||
|
||||
|
||||
class TestNormalizeEffectType:
|
||||
def test_none_passthrough(self):
|
||||
assert normalize_effect_type(None) is None
|
||||
|
||||
def test_empty_string_returns_none(self):
|
||||
assert normalize_effect_type("") is None
|
||||
assert normalize_effect_type(" ") is None
|
||||
|
||||
def test_lowercases(self):
|
||||
assert normalize_effect_type("SPARKLE") == "sparkle"
|
||||
assert normalize_effect_type("Wood") == "wood"
|
||||
|
||||
def test_accepts_all_known_types(self):
|
||||
for effect in ALLOWED_EFFECT_TYPES:
|
||||
assert normalize_effect_type(effect) == effect
|
||||
|
||||
def test_canonicalizes_space_to_dash(self):
|
||||
# User pastes the spool-subtype label "Dual Color" / "Tri Color".
|
||||
assert normalize_effect_type("Dual Color") == "dual-color"
|
||||
assert normalize_effect_type("Tri Color") == "tri-color"
|
||||
|
||||
def test_canonicalizes_underscore_to_dash(self):
|
||||
assert normalize_effect_type("dual_color") == "dual-color"
|
||||
|
||||
def test_accepts_structural_variants(self):
|
||||
# Gradient / Multicolor were added in #1154 follow-up so the catalog
|
||||
# can express the full spool variant vocabulary.
|
||||
for variant in ("gradient", "dual-color", "tri-color", "multicolor"):
|
||||
assert normalize_effect_type(variant) == variant
|
||||
|
||||
def test_accepts_sheen_variants(self):
|
||||
for sheen in ("silk", "galaxy", "rainbow", "metal", "translucent"):
|
||||
assert normalize_effect_type(sheen) == sheen
|
||||
|
||||
def test_rejects_unknown_type(self):
|
||||
# "neon" isn't in the allowed set — must reject.
|
||||
with pytest.raises(ValueError, match="effect_type must be one of"):
|
||||
normalize_effect_type("neon")
|
||||
|
||||
|
||||
class TestSpoolCreateColorExtensions:
|
||||
def test_accepts_extra_colors_paste(self):
|
||||
spool = SpoolCreate(material="PLA", extra_colors="EC984C,#6CD4BC,A66EB9,D87694")
|
||||
assert spool.extra_colors == "ec984c,6cd4bc,a66eb9,d87694"
|
||||
|
||||
def test_accepts_effect_type(self):
|
||||
spool = SpoolCreate(material="PLA", effect_type="sparkle")
|
||||
assert spool.effect_type == "sparkle"
|
||||
|
||||
def test_defaults_to_none(self):
|
||||
spool = SpoolCreate(material="PLA")
|
||||
assert spool.extra_colors is None
|
||||
assert spool.effect_type is None
|
||||
|
||||
def test_rejects_bad_extra_colors(self):
|
||||
with pytest.raises(ValidationError, match="extra_colors"):
|
||||
SpoolCreate(material="PLA", extra_colors="not-hex")
|
||||
|
||||
def test_rejects_bad_effect_type(self):
|
||||
with pytest.raises(ValidationError, match="effect_type"):
|
||||
SpoolCreate(material="PLA", effect_type="not-a-real-variant")
|
||||
|
||||
|
||||
class TestSpoolUpdateColorExtensions:
|
||||
def test_clears_extra_colors_via_empty_string(self):
|
||||
# Frontend sends "" to clear; normalizer maps that to None.
|
||||
update = SpoolUpdate(extra_colors="")
|
||||
assert update.extra_colors is None
|
||||
|
||||
def test_clears_effect_type_via_explicit_null(self):
|
||||
update = SpoolUpdate(effect_type=None)
|
||||
assert update.effect_type is None
|
||||
|
||||
def test_round_trips_canonical_form(self):
|
||||
update = SpoolUpdate(extra_colors="FF0000,00FF00", effect_type="MATTE")
|
||||
assert update.extra_colors == "ff0000,00ff00"
|
||||
assert update.effect_type == "matte"
|
||||
|
||||
|
||||
class TestColorCatalogSchemas:
|
||||
"""The catalog Create/Update mirror the spool fields."""
|
||||
|
||||
def test_create_accepts_8char_hex_color(self):
|
||||
from backend.app.api.routes.inventory import ColorEntryCreate
|
||||
|
||||
entry = ColorEntryCreate(
|
||||
manufacturer="Bambu Lab",
|
||||
color_name="Galaxy",
|
||||
hex_color="#112233AA",
|
||||
)
|
||||
assert entry.hex_color == "#112233AA"
|
||||
|
||||
def test_create_still_accepts_6char_hex_color(self):
|
||||
# Backward compat: existing #RRGGBB rows must keep working.
|
||||
from backend.app.api.routes.inventory import ColorEntryCreate
|
||||
|
||||
entry = ColorEntryCreate(
|
||||
manufacturer="Bambu Lab",
|
||||
color_name="Jade White",
|
||||
hex_color="#A1B2C3",
|
||||
)
|
||||
assert entry.hex_color == "#A1B2C3"
|
||||
|
||||
def test_create_rejects_hex_without_hash(self):
|
||||
from backend.app.api.routes.inventory import ColorEntryCreate
|
||||
|
||||
with pytest.raises(ValidationError, match="hex_color"):
|
||||
ColorEntryCreate(manufacturer="X", color_name="Y", hex_color="A1B2C3")
|
||||
|
||||
def test_create_threads_extra_colors_and_effect(self):
|
||||
from backend.app.api.routes.inventory import ColorEntryCreate
|
||||
|
||||
entry = ColorEntryCreate(
|
||||
manufacturer="3dfilamentprofiles",
|
||||
color_name="Aurora",
|
||||
hex_color="#EC984C",
|
||||
extra_colors="EC984C,#6CD4BC,A66EB9,D87694",
|
||||
effect_type="sparkle",
|
||||
)
|
||||
assert entry.extra_colors == "ec984c,6cd4bc,a66eb9,d87694"
|
||||
assert entry.effect_type == "sparkle"
|
||||
|
||||
def test_update_validators_match_create(self):
|
||||
from backend.app.api.routes.inventory import ColorEntryUpdate
|
||||
|
||||
with pytest.raises(ValidationError, match="extra_colors"):
|
||||
ColorEntryUpdate(
|
||||
manufacturer="X",
|
||||
color_name="Y",
|
||||
hex_color="#A1B2C3",
|
||||
extra_colors="not-hex",
|
||||
)
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Tests for the colour catalog admin (ColorCatalogSettings).
|
||||
*
|
||||
* Pin the #1154 wiring contract:
|
||||
* - The Add form sends `extra_colors` + `effect_type` alongside the legacy
|
||||
* manufacturer / color_name / hex_color / material fields.
|
||||
* - Inline-edit hydrates the new fields from the existing entry and sends
|
||||
* them back through `updateColorEntry`.
|
||||
* - Effect dropdown lists the full unified vocabulary (surface effects
|
||||
* + sheen variants + structural variants), not just the original 5.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { render } from '../utils';
|
||||
import { api } from '../../api/client';
|
||||
import { ColorCatalogSettings } from '../../components/ColorCatalogSettings';
|
||||
|
||||
vi.mock('../../api/client', async () => {
|
||||
// Preserve every other method on `api` (ThemeContext / AuthContext call
|
||||
// some on mount) and override only the catalog ones the component touches.
|
||||
const actual: typeof import('../../api/client') = await vi.importActual('../../api/client');
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
getColorCatalog: vi.fn(),
|
||||
addColorEntry: vi.fn(),
|
||||
updateColorEntry: vi.fn(),
|
||||
deleteColorEntry: vi.fn(),
|
||||
bulkDeleteColorEntries: vi.fn(),
|
||||
resetColorCatalog: vi.fn(),
|
||||
},
|
||||
getAuthToken: vi.fn(() => null),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ColorCatalogSettings — Add form (#1154)', () => {
|
||||
it('sends extra_colors and effect_type when adding an entry', async () => {
|
||||
vi.mocked(api.getColorCatalog).mockResolvedValueOnce([]);
|
||||
vi.mocked(api.addColorEntry).mockResolvedValueOnce({
|
||||
id: 1,
|
||||
manufacturer: 'Test',
|
||||
color_name: 'Aurora',
|
||||
hex_color: '#EC984C',
|
||||
material: null,
|
||||
is_default: false,
|
||||
extra_colors: 'ec984c,6cd4bc,a66eb9,d87694',
|
||||
effect_type: 'sparkle',
|
||||
});
|
||||
|
||||
render(<ColorCatalogSettings />);
|
||||
// Wait for initial load to settle so the Add button is rendered.
|
||||
await waitFor(() => expect(api.getColorCatalog).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Open the Add form via the toolbar's "Add" button — there's only one
|
||||
// before the form opens, so this is unambiguous.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add$/i }));
|
||||
|
||||
// The form now renders with manufacturer, color name, hex, material,
|
||||
// extra_colors, and effect_type inputs.
|
||||
fireEvent.change(screen.getByPlaceholderText('Manufacturer'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('Color Name'), {
|
||||
target: { value: 'Aurora' },
|
||||
});
|
||||
// Hex has both a <input type="color"> and a <input type="text"
|
||||
// placeholder="#FFFFFF">. Pick the text variant by placeholder.
|
||||
fireEvent.change(screen.getByPlaceholderText('#FFFFFF'), {
|
||||
target: { value: '#EC984C' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('EC984C,#6CD4BC,A66EB9,D87694'), {
|
||||
target: { value: 'EC984C,#6CD4BC,A66EB9,D87694' },
|
||||
});
|
||||
// Pick "Sparkle" from the effect-type combobox (the manufacturer filter
|
||||
// is also a <select>, so disambiguate by looking for the one whose
|
||||
// options include 'sparkle').
|
||||
const effectSelectAdd = (
|
||||
screen.getAllByRole('combobox') as HTMLSelectElement[]
|
||||
).find((s) => Array.from(s.options).some((o) => o.value === 'sparkle'));
|
||||
expect(effectSelectAdd).toBeDefined();
|
||||
fireEvent.change(effectSelectAdd!, { target: { value: 'sparkle' } });
|
||||
|
||||
// Submit. The form's submit "Add" button is now the second button with
|
||||
// that label (toolbar Add still exists), so query inside the form
|
||||
// container by clicking the last matching button.
|
||||
const allAddButtons = screen.getAllByRole('button', { name: /Add$/i });
|
||||
fireEvent.click(allAddButtons[allAddButtons.length - 1]);
|
||||
|
||||
await waitFor(() => expect(api.addColorEntry).toHaveBeenCalledTimes(1));
|
||||
expect(api.addColorEntry).toHaveBeenCalledWith({
|
||||
manufacturer: 'Test',
|
||||
color_name: 'Aurora',
|
||||
hex_color: '#EC984C',
|
||||
material: null,
|
||||
extra_colors: 'EC984C,#6CD4BC,A66EB9,D87694',
|
||||
effect_type: 'sparkle',
|
||||
});
|
||||
});
|
||||
|
||||
it('lists every variant in the effect dropdown (not just the original 5)', async () => {
|
||||
vi.mocked(api.getColorCatalog).mockResolvedValueOnce([]);
|
||||
render(<ColorCatalogSettings />);
|
||||
await waitFor(() => expect(api.getColorCatalog).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add$/i }));
|
||||
// Disambiguate from the toolbar's manufacturer-filter <select>.
|
||||
const effectSelect = (
|
||||
screen.getAllByRole('combobox') as HTMLSelectElement[]
|
||||
).find((s) => Array.from(s.options).some((o) => o.value === 'sparkle'));
|
||||
expect(effectSelect).toBeDefined();
|
||||
const options = Array.from(effectSelect!.options).map((o) => o.value);
|
||||
|
||||
// Surface effects (V1).
|
||||
expect(options).toContain('sparkle');
|
||||
expect(options).toContain('wood');
|
||||
expect(options).toContain('marble');
|
||||
expect(options).toContain('glow');
|
||||
expect(options).toContain('matte');
|
||||
// Structural variants added in #1154 follow-up.
|
||||
expect(options).toContain('gradient');
|
||||
expect(options).toContain('dual-color');
|
||||
expect(options).toContain('tri-color');
|
||||
expect(options).toContain('multicolor');
|
||||
// Sheen / finish variants.
|
||||
expect(options).toContain('silk');
|
||||
expect(options).toContain('galaxy');
|
||||
expect(options).toContain('rainbow');
|
||||
expect(options).toContain('metal');
|
||||
expect(options).toContain('translucent');
|
||||
// None / no-effect option.
|
||||
expect(options).toContain('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ColorCatalogSettings — inline edit (#1154)', () => {
|
||||
it('hydrates extra_colors and effect_type when entering edit mode', async () => {
|
||||
const seed = {
|
||||
id: 42,
|
||||
manufacturer: 'Bambu Lab',
|
||||
color_name: 'Galaxy',
|
||||
hex_color: '#1A2B3C',
|
||||
material: 'PLA',
|
||||
is_default: true,
|
||||
extra_colors: 'aabbcc,ddeeff',
|
||||
effect_type: 'galaxy',
|
||||
};
|
||||
vi.mocked(api.getColorCatalog).mockResolvedValueOnce([seed]);
|
||||
|
||||
render(<ColorCatalogSettings />);
|
||||
await waitFor(() => expect(screen.getByText('Galaxy')).toBeInTheDocument());
|
||||
|
||||
// Click the Edit button on the seeded row.
|
||||
// The row's edit button has no accessible label so query by SVG-bearing
|
||||
// button containing the Pencil icon — there's only one in the rendered tree.
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const editButton = buttons.find((b) => b.querySelector('svg.lucide-pencil'));
|
||||
expect(editButton).toBeDefined();
|
||||
fireEvent.click(editButton!);
|
||||
|
||||
// The extra-colors input should now be populated with the seeded value.
|
||||
const extraColorsInputs = screen.getAllByPlaceholderText(
|
||||
'EC984C,#6CD4BC,A66EB9,D87694',
|
||||
) as HTMLInputElement[];
|
||||
expect(extraColorsInputs[0].value).toBe('aabbcc,ddeeff');
|
||||
|
||||
// The effect dropdown reflects the seeded effect. The manufacturer
|
||||
// filter is also a <select> at the toolbar level, so query all and
|
||||
// pick the one whose value matches what we expect — the last one,
|
||||
// since the filter never has 'galaxy' in its options.
|
||||
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
||||
const effectSelect = selects.find((s) => s.value === 'galaxy');
|
||||
expect(effectSelect).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Tests for the FilamentSwatch component (#1154).
|
||||
*
|
||||
* Covers the three independent inputs the swatch composes (rgba, extraColors,
|
||||
* effectType) and the buildFilamentBackground helper used to paint banners.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { render } from '../utils';
|
||||
import { FilamentSwatch } from '../../components/FilamentSwatch';
|
||||
import { buildFilamentBackground } from '../../components/filamentSwatchHelpers';
|
||||
|
||||
describe('FilamentSwatch', () => {
|
||||
it('renders a solid swatch when only rgba is set', () => {
|
||||
render(<FilamentSwatch rgba="ff0000ff" />);
|
||||
const el = screen.getByTestId('filament-swatch');
|
||||
// Solid swatches are emitted as a 1-stop linear-gradient so the
|
||||
// checkerboard layer below is still visible through alpha.
|
||||
const bg = el.getAttribute('style') ?? '';
|
||||
expect(bg).toMatch(/linear-gradient/);
|
||||
expect(bg.toLowerCase()).toContain('#ff0000ff');
|
||||
});
|
||||
|
||||
it('falls back to grey when nothing is set', () => {
|
||||
render(<FilamentSwatch />);
|
||||
const el = screen.getByTestId('filament-swatch');
|
||||
expect(el.style.backgroundImage.toLowerCase()).toContain('#808080');
|
||||
});
|
||||
|
||||
it('renders a linear gradient when extraColors has multiple stops', () => {
|
||||
render(<FilamentSwatch rgba="ff0000ff" extraColors="ec984c,6cd4bc,a66eb9,d87694" />);
|
||||
const el = screen.getByTestId('filament-swatch');
|
||||
const bg = el.style.backgroundImage.toLowerCase();
|
||||
// Linear (not conic) for non-Multicolor subtype.
|
||||
expect(bg).toMatch(/linear-gradient/);
|
||||
expect(bg).toContain('#ec984c');
|
||||
expect(bg).toContain('#6cd4bc');
|
||||
expect(bg).toContain('#a66eb9');
|
||||
expect(bg).toContain('#d87694');
|
||||
});
|
||||
|
||||
it('uses conic-gradient for Multicolor subtype', () => {
|
||||
render(
|
||||
<FilamentSwatch
|
||||
rgba="ff0000ff"
|
||||
extraColors="ec984c,6cd4bc,a66eb9"
|
||||
subtype="Multicolor"
|
||||
/>,
|
||||
);
|
||||
const el = screen.getByTestId('filament-swatch');
|
||||
expect(el.style.backgroundImage.toLowerCase()).toMatch(/conic-gradient/);
|
||||
});
|
||||
|
||||
it('also uses conic-gradient when effectType is multicolor (catalog path)', () => {
|
||||
// Catalog entries don't have a `subtype`, so the multicolor effect_type
|
||||
// value also has to trigger conic rendering for parity with the spool path.
|
||||
render(<FilamentSwatch extraColors="ec984c,6cd4bc,a66eb9" effectType="multicolor" />);
|
||||
const el = screen.getByTestId('filament-swatch');
|
||||
expect(el.style.backgroundImage.toLowerCase()).toMatch(/conic-gradient/);
|
||||
});
|
||||
|
||||
it('layers an effect overlay on top of the colour layer for sparkle', () => {
|
||||
render(<FilamentSwatch rgba="ff0000ff" effectType="sparkle" />);
|
||||
const el = screen.getByTestId('filament-swatch');
|
||||
// Sparkle overlay is built from radial-gradient layers — confirm at least
|
||||
// one is in the composed background, ahead of the colour layer.
|
||||
expect(el.style.backgroundImage).toMatch(/radial-gradient/);
|
||||
});
|
||||
|
||||
it('renders an overlay for silk variant', () => {
|
||||
// Silk gets a soft sheen overlay (added in #1154 follow-up).
|
||||
render(<FilamentSwatch rgba="ff0000ff" effectType="silk" />);
|
||||
const el = screen.getByTestId('filament-swatch');
|
||||
expect(el.style.backgroundImage).toMatch(/linear-gradient/);
|
||||
});
|
||||
|
||||
it('treats categorical-only variants (gradient/dual-color) as labels without an overlay', () => {
|
||||
// No extra_colors set → swatch falls back to the solid colour layer; the
|
||||
// categorical effect value alone does not paint a sheen overlay.
|
||||
render(<FilamentSwatch rgba="ff0000ff" effectType="gradient" />);
|
||||
const el = screen.getByTestId('filament-swatch');
|
||||
// No radial-gradient (sparkle/glow) and no rainbow/sheen overlay either —
|
||||
// gradient/dual-color/tri-color are pure labels until extra_colors is set.
|
||||
expect(el.style.backgroundImage).not.toMatch(/radial-gradient/);
|
||||
});
|
||||
|
||||
it('ignores unknown effect types instead of throwing', () => {
|
||||
render(<FilamentSwatch rgba="ff0000ff" effectType="not-a-real-variant" />);
|
||||
const el = screen.getByTestId('filament-swatch');
|
||||
expect(el.style.backgroundImage).not.toMatch(/radial-gradient/);
|
||||
});
|
||||
|
||||
it('renders a checkerboard underneath so alpha is visible', () => {
|
||||
render(<FilamentSwatch rgba="ff000080" />);
|
||||
const el = screen.getByTestId('filament-swatch');
|
||||
// The component always appends a checkerboard layer last so semi-
|
||||
// transparent rgba values actually look transparent to the user.
|
||||
expect(el.style.backgroundImage).toMatch(/repeating-conic-gradient/);
|
||||
});
|
||||
|
||||
it('skips invalid hex tokens in extraColors instead of throwing', () => {
|
||||
render(<FilamentSwatch extraColors="ff0000,not-hex,00ff00" />);
|
||||
const el = screen.getByTestId('filament-swatch');
|
||||
const bg = el.style.backgroundImage.toLowerCase();
|
||||
// The two valid stops survive; the garbage token is dropped.
|
||||
expect(bg).toContain('#ff0000');
|
||||
expect(bg).toContain('#00ff00');
|
||||
expect(bg).not.toContain('not-hex');
|
||||
});
|
||||
|
||||
it('uses extra_colors for the title fallback when provided', () => {
|
||||
render(<FilamentSwatch extraColors="ff0000,00ff00" />);
|
||||
const el = screen.getByTestId('filament-swatch');
|
||||
// Tooltip should show the comma-joined hex stops, not the (unset) rgba.
|
||||
expect(el.title.toLowerCase()).toContain('#ff0000');
|
||||
expect(el.title.toLowerCase()).toContain('#00ff00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFilamentBackground', () => {
|
||||
it('emits the same layered background string the component renders', () => {
|
||||
const bg = buildFilamentBackground({
|
||||
rgba: 'ff0000ff',
|
||||
extraColors: 'aabbcc,ddeeff',
|
||||
effectType: 'matte',
|
||||
});
|
||||
// Effect overlay → colour layer → checkerboard, in that order.
|
||||
expect(bg).toMatch(/linear-gradient/);
|
||||
expect(bg).toMatch(/repeating-conic-gradient/);
|
||||
expect(bg.toLowerCase()).toContain('#aabbcc');
|
||||
expect(bg.toLowerCase()).toContain('#ddeeff');
|
||||
});
|
||||
|
||||
it('returns a usable solid background when only rgba is provided', () => {
|
||||
const bg = buildFilamentBackground({ rgba: '00ff00ff' });
|
||||
expect(bg.toLowerCase()).toContain('#00ff00ff');
|
||||
expect(bg).toMatch(/repeating-conic-gradient/);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,8 @@ const existingSpool: InventorySpool = {
|
||||
brand: 'Polymaker',
|
||||
color_name: 'Red',
|
||||
rgba: 'FF0000FF',
|
||||
extra_colors: null,
|
||||
effect_type: null,
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
core_weight_catalog_id: null,
|
||||
|
||||
@@ -60,6 +60,8 @@ const existingSpool: InventorySpool = {
|
||||
brand: 'Polymaker',
|
||||
color_name: 'Red',
|
||||
rgba: 'FF0000FF',
|
||||
extra_colors: null,
|
||||
effect_type: null,
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
core_weight_catalog_id: null,
|
||||
|
||||
@@ -11,9 +11,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { InventorySpool, SpoolAssignment } from '../../api/client';
|
||||
|
||||
// Replicate the grouping key function from InventoryPage (not exported)
|
||||
// Replicate the grouping key function from InventoryPage (not exported).
|
||||
// Must stay in lockstep with InventoryPage.tsx::spoolGroupKey — extra_colors
|
||||
// and effect_type are part of the key (#1154) so multi-colour / effect
|
||||
// variants don't get collapsed under "Group similar".
|
||||
function spoolGroupKey(s: InventorySpool): string {
|
||||
return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.label_weight}`;
|
||||
return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.extra_colors || ''}|${s.effect_type || ''}|${s.label_weight}`;
|
||||
}
|
||||
|
||||
type DisplayItem =
|
||||
@@ -66,6 +69,8 @@ function makeSpool(overrides: Partial<InventorySpool> & { id: number }): Invento
|
||||
brand: 'Polymaker',
|
||||
color_name: 'Red',
|
||||
rgba: 'FF0000FF',
|
||||
extra_colors: null,
|
||||
effect_type: null,
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
core_weight_catalog_id: null,
|
||||
@@ -128,6 +133,28 @@ describe('spoolGroupKey', () => {
|
||||
expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
|
||||
});
|
||||
|
||||
it('generates different key when extra_colors differs (#1154)', () => {
|
||||
// Two spools that share the base hex but have different gradient stops
|
||||
// are visually different — they must not collapse under "Group similar".
|
||||
const a = makeSpool({ id: 1, extra_colors: 'ff0000,00ff00' });
|
||||
const b = makeSpool({ id: 2, extra_colors: 'ff0000,0000ff' });
|
||||
expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
|
||||
});
|
||||
|
||||
it('generates different key when effect_type differs (#1154)', () => {
|
||||
const a = makeSpool({ id: 1, effect_type: 'sparkle' });
|
||||
const b = makeSpool({ id: 2, effect_type: 'matte' });
|
||||
expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
|
||||
});
|
||||
|
||||
it('still groups identical multi-colour spools (#1154)', () => {
|
||||
// Same base + same stops + same effect → same group; the new fields
|
||||
// join the key but don't break the existing identical-grouping case.
|
||||
const a = makeSpool({ id: 1, extra_colors: 'ff0000,00ff00', effect_type: 'multicolor' });
|
||||
const b = makeSpool({ id: 2, extra_colors: 'ff0000,00ff00', effect_type: 'multicolor' });
|
||||
expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));
|
||||
});
|
||||
|
||||
it('treats null and empty string subtype the same', () => {
|
||||
const a = makeSpool({ id: 1, subtype: null as unknown as string });
|
||||
const b = makeSpool({ id: 2, subtype: '' });
|
||||
|
||||
@@ -1063,6 +1063,9 @@ export interface ColorCatalogEntry {
|
||||
hex_color: string;
|
||||
material: string | null;
|
||||
is_default: boolean;
|
||||
// #1154: optional multi-colour gradient stops + visual effect.
|
||||
extra_colors?: string | null;
|
||||
effect_type?: string | null;
|
||||
}
|
||||
|
||||
export interface ColorLookupResult {
|
||||
@@ -2235,6 +2238,10 @@ export interface InventorySpool {
|
||||
subtype: string | null;
|
||||
color_name: string | null;
|
||||
rgba: string | null;
|
||||
// Multi-colour gradient stops (#1154): comma-separated 6/8-char hex.
|
||||
extra_colors: string | null;
|
||||
// Visual effect overlay: sparkle | wood | marble | glow | matte.
|
||||
effect_type: string | null;
|
||||
brand: string | null;
|
||||
label_weight: number;
|
||||
core_weight: number;
|
||||
@@ -4346,10 +4353,26 @@ export const api = {
|
||||
request<ColorCatalogEntry[]>('/inventory/colors'),
|
||||
getColorNameMap: () =>
|
||||
request<{ colors: Record<string, string> }>('/inventory/colors/map'),
|
||||
addColorEntry: (data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>
|
||||
addColorEntry: (data: {
|
||||
manufacturer: string;
|
||||
color_name: string;
|
||||
hex_color: string;
|
||||
material: string | null;
|
||||
extra_colors?: string | null;
|
||||
effect_type?: string | null;
|
||||
}) =>
|
||||
request<ColorCatalogEntry>('/inventory/colors', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateColorEntry: (id: number, data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>
|
||||
request<ColorCatalogEntry>(`/inventory/colors/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
updateColorEntry: (
|
||||
id: number,
|
||||
data: {
|
||||
manufacturer: string;
|
||||
color_name: string;
|
||||
hex_color: string;
|
||||
material: string | null;
|
||||
extra_colors?: string | null;
|
||||
effect_type?: string | null;
|
||||
},
|
||||
) => request<ColorCatalogEntry>(`/inventory/colors/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
deleteColorEntry: (id: number) =>
|
||||
request<{ status: string }>(`/inventory/colors/${id}`, { method: 'DELETE' }),
|
||||
bulkDeleteColorEntries: (ids: number[]) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Fragment, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Palette, Plus, Trash2, RotateCcw, Loader2, Pencil, Check, X, Search, Download, Upload, Cloud } from 'lucide-react';
|
||||
import { api, getAuthToken } from '../api/client';
|
||||
@@ -6,6 +6,8 @@ import type { ColorCatalogEntry } from '../api/client';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { Card, CardHeader, CardContent } from './Card';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { FilamentSwatch } from './FilamentSwatch';
|
||||
import { FILAMENT_EFFECT_OPTIONS } from './filamentSwatchHelpers';
|
||||
|
||||
export function ColorCatalogSettings() {
|
||||
const { t } = useTranslation();
|
||||
@@ -23,6 +25,8 @@ export function ColorCatalogSettings() {
|
||||
const [formColorName, setFormColorName] = useState('');
|
||||
const [formHexColor, setFormHexColor] = useState('#FFFFFF');
|
||||
const [formMaterial, setFormMaterial] = useState('');
|
||||
const [formExtraColors, setFormExtraColors] = useState('');
|
||||
const [formEffectType, setFormEffectType] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Selection state
|
||||
@@ -68,6 +72,8 @@ export function ColorCatalogSettings() {
|
||||
setFormColorName('');
|
||||
setFormHexColor('#FFFFFF');
|
||||
setFormMaterial('');
|
||||
setFormExtraColors('');
|
||||
setFormEffectType('');
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
@@ -82,6 +88,8 @@ export function ColorCatalogSettings() {
|
||||
color_name: formColorName.trim(),
|
||||
hex_color: formHexColor,
|
||||
material: formMaterial.trim() || null,
|
||||
extra_colors: formExtraColors.trim() || null,
|
||||
effect_type: formEffectType.trim() || null,
|
||||
});
|
||||
setCatalog(prev => [...prev, entry].sort((a, b) =>
|
||||
a.manufacturer.localeCompare(b.manufacturer) ||
|
||||
@@ -104,6 +112,8 @@ export function ColorCatalogSettings() {
|
||||
setFormColorName(entry.color_name);
|
||||
setFormHexColor(entry.hex_color);
|
||||
setFormMaterial(entry.material || '');
|
||||
setFormExtraColors(entry.extra_colors || '');
|
||||
setFormEffectType(entry.effect_type || '');
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
@@ -123,6 +133,8 @@ export function ColorCatalogSettings() {
|
||||
color_name: formColorName.trim(),
|
||||
hex_color: formHexColor,
|
||||
material: formMaterial.trim() || null,
|
||||
extra_colors: formExtraColors.trim() || null,
|
||||
effect_type: formEffectType.trim() || null,
|
||||
});
|
||||
setCatalog(prev =>
|
||||
prev.map(e => e.id === id ? updated : e).sort((a, b) =>
|
||||
@@ -254,8 +266,8 @@ export function ColorCatalogSettings() {
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const exportData = catalog.map(({ manufacturer, color_name, hex_color, material }) => ({
|
||||
manufacturer, color_name, hex_color, material,
|
||||
const exportData = catalog.map(({ manufacturer, color_name, hex_color, material, extra_colors, effect_type }) => ({
|
||||
manufacturer, color_name, hex_color, material, extra_colors, effect_type,
|
||||
}));
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -276,6 +288,7 @@ export function ColorCatalogSettings() {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text) as Array<{
|
||||
manufacturer: string; color_name: string; hex_color: string; material?: string | null;
|
||||
extra_colors?: string | null; effect_type?: string | null;
|
||||
}>;
|
||||
if (!Array.isArray(data)) throw new Error('Invalid format');
|
||||
|
||||
@@ -295,6 +308,8 @@ export function ColorCatalogSettings() {
|
||||
color_name: item.color_name,
|
||||
hex_color: item.hex_color,
|
||||
material: item.material || null,
|
||||
extra_colors: item.extra_colors || null,
|
||||
effect_type: item.effect_type || null,
|
||||
});
|
||||
setCatalog(prev => [...prev, entry].sort((a, b) =>
|
||||
a.manufacturer.localeCompare(b.manufacturer) ||
|
||||
@@ -473,6 +488,27 @@ export function ColorCatalogSettings() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* #1154: optional multi-colour stops + visual effect. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
|
||||
<input
|
||||
type="text"
|
||||
className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-sm font-mono placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
|
||||
placeholder={t('inventory.extraColorsPlaceholder')}
|
||||
value={formExtraColors}
|
||||
onChange={(e) => setFormExtraColors(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
|
||||
value={formEffectType}
|
||||
onChange={(e) => setFormEffectType(e.target.value)}
|
||||
>
|
||||
{FILAMENT_EFFECT_OPTIONS.map((opt) => (
|
||||
<option key={opt.value || 'none'} value={opt.value}>
|
||||
{t(opt.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -519,7 +555,8 @@ export function ColorCatalogSettings() {
|
||||
</tr>
|
||||
) : (
|
||||
filteredCatalog.map(entry => (
|
||||
<tr key={entry.id} className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>
|
||||
<Fragment key={entry.id}>
|
||||
<tr className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>
|
||||
{editingId === entry.id ? (
|
||||
<>
|
||||
<td className="px-2 py-2">
|
||||
@@ -596,9 +633,12 @@ export function ColorCatalogSettings() {
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div
|
||||
className="w-8 h-8 rounded border border-bambu-dark-tertiary"
|
||||
style={{ backgroundColor: entry.hex_color }}
|
||||
<FilamentSwatch
|
||||
rgba={entry.hex_color.replace(/^#/, '') + (entry.hex_color.length === 7 ? 'FF' : '')}
|
||||
extraColors={entry.extra_colors}
|
||||
effectType={entry.effect_type}
|
||||
className="w-8 h-8"
|
||||
shape="square"
|
||||
title={entry.hex_color}
|
||||
/>
|
||||
</td>
|
||||
@@ -625,6 +665,35 @@ export function ColorCatalogSettings() {
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
{editingId === entry.id && (
|
||||
<tr className="border-t border-bambu-dark-tertiary/50 bg-bambu-dark">
|
||||
<td colSpan={2}></td>
|
||||
<td className="px-3 py-2" colSpan={2}>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm font-mono focus:border-bambu-green focus:outline-none"
|
||||
placeholder={t('inventory.extraColorsPlaceholder')}
|
||||
value={formExtraColors}
|
||||
onChange={(e) => setFormExtraColors(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2" colSpan={2}>
|
||||
<select
|
||||
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
|
||||
value={formEffectType}
|
||||
onChange={(e) => setFormEffectType(e.target.value)}
|
||||
>
|
||||
{FILAMENT_EFFECT_OPTIONS.map((opt) => (
|
||||
<option key={opt.value || 'none'} value={opt.value}>
|
||||
{t(opt.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
CHECKERBOARD_BG,
|
||||
EFFECT_OVERLAYS,
|
||||
buildColorLayer,
|
||||
parseStops,
|
||||
type FilamentEffect,
|
||||
} from './filamentSwatchHelpers';
|
||||
|
||||
/** Shared filament-colour swatch. See `filamentSwatchHelpers.ts` for the
|
||||
* pure helpers + constants this component composes. */
|
||||
|
||||
export interface FilamentSwatchProps {
|
||||
/** RRGGBBAA hex without `#` (Bambu/AMS canonical). Falls back to grey when null. */
|
||||
rgba?: string | null;
|
||||
/** Comma-separated 6/8-char hex tokens (no `#`). Empty/undefined → solid. */
|
||||
extraColors?: string | null;
|
||||
/** Visual effect overlay. */
|
||||
effectType?: FilamentEffect | string | null;
|
||||
/** When `Multicolor`, a conic gradient is used instead of linear. */
|
||||
subtype?: string | null;
|
||||
/** Tailwind size token applied to width/height (e.g. `w-5 h-5`). Default: `w-5 h-5`. */
|
||||
className?: string;
|
||||
/** Override the rounded shape — defaults to `rounded-full` (circular). */
|
||||
shape?: 'circle' | 'pill' | 'square';
|
||||
/** Optional inline style overrides (e.g. height of a card banner). */
|
||||
style?: React.CSSProperties;
|
||||
/** Native title attribute for hover tooltip. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function FilamentSwatch({
|
||||
rgba,
|
||||
extraColors,
|
||||
effectType,
|
||||
subtype,
|
||||
className = 'w-5 h-5',
|
||||
shape = 'circle',
|
||||
style,
|
||||
title,
|
||||
}: FilamentSwatchProps) {
|
||||
const stops = useMemo(() => parseStops(extraColors), [extraColors]);
|
||||
const colorLayer = useMemo(
|
||||
() => buildColorLayer(rgba, stops, subtype, effectType),
|
||||
[rgba, stops, subtype, effectType],
|
||||
);
|
||||
|
||||
const effectKey =
|
||||
typeof effectType === 'string' && effectType in EFFECT_OVERLAYS
|
||||
? (effectType as FilamentEffect)
|
||||
: null;
|
||||
const effectLayer = effectKey ? EFFECT_OVERLAYS[effectKey] ?? null : null;
|
||||
|
||||
// Layer order (top → bottom): effect overlay → colour layer → checkerboard.
|
||||
// Set as `background-image` (not the `background` shorthand) so the value
|
||||
// remains a pure list-of-images that browsers and test runners parse cleanly.
|
||||
const backgroundImage = [effectLayer, colorLayer, CHECKERBOARD_BG]
|
||||
.filter((layer): layer is string => Boolean(layer))
|
||||
.join(', ');
|
||||
|
||||
const shapeClass =
|
||||
shape === 'circle' ? 'rounded-full' : shape === 'pill' ? 'rounded-full' : 'rounded';
|
||||
|
||||
// Compute a sensible title fallback — solid hex or gradient summary.
|
||||
const computedTitle =
|
||||
title ??
|
||||
(stops.length > 0
|
||||
? stops.join(', ')
|
||||
: rgba
|
||||
? `#${rgba.substring(0, 6)}`
|
||||
: undefined);
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid="filament-swatch"
|
||||
className={`${className} ${shapeClass} border border-black/20 inline-block flex-shrink-0`}
|
||||
style={{ backgroundImage, backgroundSize: 'cover', ...style }}
|
||||
title={computedTitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilamentSwatch;
|
||||
@@ -269,6 +269,8 @@ export function SpoolFormModal({
|
||||
brand: spool.brand || '',
|
||||
color_name: spool.color_name || '',
|
||||
rgba: validRgba,
|
||||
extra_colors: spool.extra_colors || '',
|
||||
effect_type: spool.effect_type || '',
|
||||
label_weight: spool.label_weight || 1000,
|
||||
core_weight: spool.core_weight || 250,
|
||||
core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
|
||||
@@ -518,6 +520,8 @@ export function SpoolFormModal({
|
||||
brand: formData.brand || null,
|
||||
color_name: formData.color_name || null,
|
||||
rgba: formData.rgba || null,
|
||||
extra_colors: formData.extra_colors || null,
|
||||
effect_type: formData.effect_type || null,
|
||||
label_weight: formData.label_weight,
|
||||
core_weight: formData.core_weight,
|
||||
core_weight_catalog_id: formData.core_weight_catalog_id,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/* Enhanced filament-colour rendering helpers (#1154).
|
||||
*
|
||||
* Pure (non-component) exports that drive `<FilamentSwatch>` and any caller
|
||||
* that needs the same composed background as a CSS string. Lives in its own
|
||||
* file so `FilamentSwatch.tsx` can stay component-only and satisfy the
|
||||
* `react-refresh/only-export-components` ESLint rule.
|
||||
*
|
||||
* Inputs the swatch composes:
|
||||
* 1. `rgba` — RRGGBBAA hex (the Bambu/AMS canonical form)
|
||||
* 2. `extraColors` — comma-separated 6/8-char hex stops; turns the swatch
|
||||
* into a gradient. Conic when either `subtype` or
|
||||
* `effectType` is `multicolor`, otherwise linear.
|
||||
* 3. `effectType` — visual variant. Some carry a CSS overlay (sparkle,
|
||||
* wood, marble, glow, matte, silk, galaxy, metal),
|
||||
* others are categorical labels only.
|
||||
*
|
||||
* Alpha < 0xFF on any layer is shown against a checkerboard so the user can
|
||||
* actually see the transparency they configured.
|
||||
*/
|
||||
|
||||
export type FilamentEffect =
|
||||
// Surface effects with their own CSS overlay
|
||||
| 'sparkle'
|
||||
| 'wood'
|
||||
| 'marble'
|
||||
| 'glow'
|
||||
| 'matte'
|
||||
// Sheen / finish variants (categorical labels; some carry an overlay)
|
||||
| 'silk'
|
||||
| 'galaxy'
|
||||
| 'rainbow'
|
||||
| 'metal'
|
||||
| 'translucent'
|
||||
// Multi-colour structures (mostly drive the colour-layer choice)
|
||||
| 'gradient'
|
||||
| 'dual-color'
|
||||
| 'tri-color'
|
||||
| 'multicolor';
|
||||
|
||||
/** Public list of all known effect/variant values, in display order. Shared
|
||||
* by the spool form's ColorSection dropdown and the colour-catalog editor
|
||||
* so the two stay in lockstep. Each value pairs with an i18n key under
|
||||
* `inventory.colorEffect.<value>` (kebab → camel: `dualColor`/`triColor`). */
|
||||
export const FILAMENT_EFFECT_OPTIONS: ReadonlyArray<{
|
||||
value: '' | FilamentEffect;
|
||||
labelKey: string;
|
||||
}> = [
|
||||
{ value: '', labelKey: 'inventory.colorEffect.none' },
|
||||
// Surface effects
|
||||
{ value: 'sparkle', labelKey: 'inventory.colorEffect.sparkle' },
|
||||
{ value: 'wood', labelKey: 'inventory.colorEffect.wood' },
|
||||
{ value: 'marble', labelKey: 'inventory.colorEffect.marble' },
|
||||
{ value: 'glow', labelKey: 'inventory.colorEffect.glow' },
|
||||
{ value: 'matte', labelKey: 'inventory.colorEffect.matte' },
|
||||
// Sheen / finish
|
||||
{ value: 'silk', labelKey: 'inventory.colorEffect.silk' },
|
||||
{ value: 'galaxy', labelKey: 'inventory.colorEffect.galaxy' },
|
||||
{ value: 'rainbow', labelKey: 'inventory.colorEffect.rainbow' },
|
||||
{ value: 'metal', labelKey: 'inventory.colorEffect.metal' },
|
||||
{ value: 'translucent', labelKey: 'inventory.colorEffect.translucent' },
|
||||
// Multi-colour structures
|
||||
{ value: 'gradient', labelKey: 'inventory.colorEffect.gradient' },
|
||||
{ value: 'dual-color', labelKey: 'inventory.colorEffect.dualColor' },
|
||||
{ value: 'tri-color', labelKey: 'inventory.colorEffect.triColor' },
|
||||
{ value: 'multicolor', labelKey: 'inventory.colorEffect.multicolor' },
|
||||
];
|
||||
|
||||
// Checkerboard pattern shown beneath the colour layer so alpha < FF is
|
||||
// actually visible to the user. Kept as a pure gradient (no position/size)
|
||||
// so the value parses cleanly inside `background-image:` everywhere.
|
||||
export const CHECKERBOARD_BG =
|
||||
'repeating-conic-gradient(#cbcbcb 0% 25%, #f5f5f5 0% 50%)';
|
||||
|
||||
/** Optional CSS overlay layer for variants that have a visual treatment.
|
||||
* Variants without an entry are categorical labels only — they don't paint
|
||||
* an overlay, just sit in the data. `multicolor` is special: its visual
|
||||
* effect is to switch the colour layer to a conic-gradient (see
|
||||
* `buildColorLayer`), not to add an overlay layer. */
|
||||
export const EFFECT_OVERLAYS: Partial<Record<FilamentEffect, string>> = {
|
||||
// Sparkle: fine bright dots scattered across the swatch.
|
||||
sparkle:
|
||||
'radial-gradient(circle at 30% 20%, rgba(255,255,255,0.85) 0 1px, transparent 1.5px), ' +
|
||||
'radial-gradient(circle at 70% 60%, rgba(255,255,255,0.7) 0 1px, transparent 1.5px), ' +
|
||||
'radial-gradient(circle at 45% 75%, rgba(255,255,255,0.6) 0 1px, transparent 1.5px), ' +
|
||||
'radial-gradient(circle at 80% 30%, rgba(255,255,255,0.5) 0 1px, transparent 1.5px)',
|
||||
// Wood: subtle horizontal banding to mimic grain.
|
||||
wood:
|
||||
'repeating-linear-gradient(90deg, ' +
|
||||
'rgba(0,0,0,0.18) 0 1px, transparent 1px 6px, ' +
|
||||
'rgba(0,0,0,0.08) 6px 7px, transparent 7px 12px)',
|
||||
// Marble: soft diagonal swirls.
|
||||
marble:
|
||||
'repeating-linear-gradient(135deg, rgba(255,255,255,0.18) 0 2px, transparent 2px 8px), ' +
|
||||
'repeating-linear-gradient(45deg, rgba(0,0,0,0.10) 0 1px, transparent 1px 7px)',
|
||||
// Glow: bright center fade — visual hint for glow-in-the-dark filaments.
|
||||
glow:
|
||||
'radial-gradient(circle at 50% 50%, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0) 70%)',
|
||||
// Matte: very subtle inset shadow to flatten the highlight.
|
||||
matte:
|
||||
'linear-gradient(180deg, rgba(0,0,0,0.10) 0%, rgba(0,0,0,0) 50%, rgba(0,0,0,0.10) 100%)',
|
||||
// Silk / Galaxy: diagonal sheen to suggest the lustrous finish those
|
||||
// filaments have. Galaxy uses a slightly stronger highlight.
|
||||
silk:
|
||||
'linear-gradient(110deg, rgba(255,255,255,0) 30%, rgba(255,255,255,0.30) 50%, rgba(255,255,255,0) 70%)',
|
||||
galaxy:
|
||||
'linear-gradient(110deg, rgba(255,255,255,0) 25%, rgba(255,255,255,0.40) 50%, rgba(255,255,255,0) 75%)',
|
||||
// Metal: brushed-metal look via tight horizontal striations + soft sheen.
|
||||
metal:
|
||||
'repeating-linear-gradient(90deg, rgba(255,255,255,0.10) 0 1px, transparent 1px 3px), ' +
|
||||
'linear-gradient(180deg, rgba(255,255,255,0.18) 0%, rgba(0,0,0,0.18) 100%)',
|
||||
};
|
||||
|
||||
/** Normalize a hex token (with or without `#`, 6 or 8 chars) → CSS hex string. */
|
||||
export function toCssHex(token: string): string | null {
|
||||
const t = token.trim().replace(/^#/, '');
|
||||
if (t.length !== 6 && t.length !== 8) return null;
|
||||
if (!/^[0-9a-fA-F]+$/.test(t)) return null;
|
||||
return `#${t}`;
|
||||
}
|
||||
|
||||
/** Parse extra_colors string into an array of CSS hex strings. */
|
||||
export function parseStops(extra: string | null | undefined): string[] {
|
||||
if (!extra) return [];
|
||||
return extra
|
||||
.split(',')
|
||||
.map((s) => toCssHex(s))
|
||||
.filter((s): s is string => Boolean(s));
|
||||
}
|
||||
|
||||
/** Build the colour layer (gradient or solid) given rgba + stops + subtype/effect.
|
||||
* A conic gradient is used when either subtype OR effect_type is `Multicolor`,
|
||||
* giving the catalog editor a way to flag a multicolor variant directly. */
|
||||
export function buildColorLayer(
|
||||
rgba: string | null | undefined,
|
||||
stops: string[],
|
||||
subtype: string | null | undefined,
|
||||
effectType?: string | null,
|
||||
): string {
|
||||
const baseHex = rgba ? toCssHex(rgba) : null;
|
||||
// No stops → solid colour (or default grey when nothing is set at all).
|
||||
if (stops.length === 0) {
|
||||
return `linear-gradient(${baseHex ?? '#808080'}, ${baseHex ?? '#808080'})`;
|
||||
}
|
||||
// With stops we ignore the single rgba and gradient across the stops.
|
||||
const allStops = stops.length === 1 ? [stops[0], stops[0]] : stops;
|
||||
const isMulticolor =
|
||||
(subtype ?? '').toLowerCase() === 'multicolor' ||
|
||||
(effectType ?? '').toLowerCase() === 'multicolor';
|
||||
if (isMulticolor) {
|
||||
return `conic-gradient(from 0deg, ${allStops.join(', ')}, ${allStops[0]})`;
|
||||
}
|
||||
return `linear-gradient(135deg, ${allStops.join(', ')})`;
|
||||
}
|
||||
|
||||
/** Public helper: produce a CSS background-image value (list of layered
|
||||
* <image>s) for a filament, for callers that want to paint a banner or
|
||||
* large area instead of using the swatch element. Pair with
|
||||
* `background-size: cover` and the swatch logic stays consistent. */
|
||||
export function buildFilamentBackground(opts: {
|
||||
rgba?: string | null;
|
||||
extraColors?: string | null;
|
||||
effectType?: FilamentEffect | string | null;
|
||||
subtype?: string | null;
|
||||
}): string {
|
||||
const stops = parseStops(opts.extraColors);
|
||||
const colorLayer = buildColorLayer(opts.rgba, stops, opts.subtype, opts.effectType);
|
||||
const effectKey =
|
||||
typeof opts.effectType === 'string' && opts.effectType in EFFECT_OVERLAYS
|
||||
? (opts.effectType as FilamentEffect)
|
||||
: null;
|
||||
const effectLayer = effectKey ? EFFECT_OVERLAYS[effectKey] ?? null : null;
|
||||
return [effectLayer, colorLayer, CHECKERBOARD_BG]
|
||||
.filter((layer): layer is string => Boolean(layer))
|
||||
.join(', ');
|
||||
}
|
||||
@@ -1,8 +1,30 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Search, Clock, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Search, Clock, ChevronDown, ChevronUp, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ColorSectionProps, CatalogDisplayColor } from './types';
|
||||
import { QUICK_COLORS, ALL_COLORS } from './constants';
|
||||
import { FilamentSwatch } from '../FilamentSwatch';
|
||||
import { buildFilamentBackground, FILAMENT_EFFECT_OPTIONS } from '../filamentSwatchHelpers';
|
||||
|
||||
/** Parse user paste from 3dfilamentprofiles.com etc.: split on commas/whitespace,
|
||||
* drop the leading `#`, accept 6/8-char hex, lowercase. Returns null when no
|
||||
* valid stops are found. Mirrors the server-side validator output. */
|
||||
function normalizeExtraColorsInput(raw: string): { value: string; invalid: string[] } {
|
||||
const tokens = raw
|
||||
.split(/[\s,]+/)
|
||||
.map((t) => t.trim().replace(/^#/, ''))
|
||||
.filter(Boolean);
|
||||
const valid: string[] = [];
|
||||
const invalid: string[] = [];
|
||||
for (const tok of tokens) {
|
||||
if ((tok.length === 6 || tok.length === 8) && /^[0-9a-fA-F]+$/.test(tok)) {
|
||||
valid.push(tok.toLowerCase());
|
||||
} else {
|
||||
invalid.push(tok);
|
||||
}
|
||||
}
|
||||
return { value: valid.join(','), invalid };
|
||||
}
|
||||
|
||||
export function ColorSection({
|
||||
formData,
|
||||
@@ -143,12 +165,41 @@ export function ColorSection({
|
||||
return showAllColors ? ALL_COLORS : QUICK_COLORS;
|
||||
}, [colorSearch, showAllColors]);
|
||||
|
||||
// #1154: editable buffer for the multi-colour paste field. We keep the raw
|
||||
// text the user typed/pasted so they can still see invalid tokens — only
|
||||
// commit the canonical form to formData on blur or when valid.
|
||||
const [extraColorsDraft, setExtraColorsDraft] = useState<string>(formData.extra_colors);
|
||||
const [extraColorsErrors, setExtraColorsErrors] = useState<string[]>([]);
|
||||
const previewBackground = useMemo(
|
||||
() =>
|
||||
buildFilamentBackground({
|
||||
rgba: formData.rgba,
|
||||
extraColors: formData.extra_colors,
|
||||
effectType: formData.effect_type,
|
||||
subtype: formData.subtype,
|
||||
}),
|
||||
[formData.rgba, formData.extra_colors, formData.effect_type, formData.subtype],
|
||||
);
|
||||
|
||||
const commitExtraColors = (text: string) => {
|
||||
setExtraColorsDraft(text);
|
||||
if (!text.trim()) {
|
||||
setExtraColorsErrors([]);
|
||||
updateField('extra_colors', '');
|
||||
return;
|
||||
}
|
||||
const { value, invalid } = normalizeExtraColorsInput(text);
|
||||
setExtraColorsErrors(invalid);
|
||||
updateField('extra_colors', value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Color preview banner */}
|
||||
{/* Color preview banner — shows gradient + effect overlay. */}
|
||||
<div
|
||||
className="h-10 rounded-lg border border-bambu-dark-tertiary"
|
||||
style={{ backgroundColor: `#${currentHex}` }}
|
||||
style={{ backgroundImage: previewBackground, backgroundSize: 'cover' }}
|
||||
data-testid="color-preview-banner"
|
||||
/>
|
||||
|
||||
{/* Recently Used Colors */}
|
||||
@@ -326,6 +377,60 @@ export function ColorSection({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* #1154: Multi-colour gradient stops + visual effect. Optional —
|
||||
empty values keep the spool rendering as a solid swatch. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2 border-t border-bambu-dark-tertiary/50">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">
|
||||
{t('inventory.extraColorsLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm font-mono placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
|
||||
placeholder={t('inventory.extraColorsPlaceholder')}
|
||||
value={extraColorsDraft}
|
||||
onChange={(e) => commitExtraColors(e.target.value)}
|
||||
data-testid="extra-colors-input"
|
||||
/>
|
||||
{extraColorsErrors.length > 0 && (
|
||||
<p className="text-xs text-red-400 mt-1">
|
||||
{t('inventory.extraColorsInvalid', { tokens: extraColorsErrors.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{!extraColorsErrors.length && (
|
||||
<p className="text-xs text-bambu-gray/70 mt-1">{t('inventory.extraColorsHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1 flex items-center gap-1.5">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
{t('inventory.colorEffectLabel')}
|
||||
</label>
|
||||
<div className="flex gap-2 items-stretch">
|
||||
<select
|
||||
className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
|
||||
value={formData.effect_type}
|
||||
onChange={(e) => updateField('effect_type', e.target.value)}
|
||||
data-testid="effect-type-select"
|
||||
>
|
||||
{FILAMENT_EFFECT_OPTIONS.map((opt) => (
|
||||
<option key={opt.value || 'none'} value={opt.value}>
|
||||
{t(opt.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<FilamentSwatch
|
||||
rgba={formData.rgba}
|
||||
extraColors={formData.extra_colors}
|
||||
effectType={formData.effect_type}
|
||||
subtype={formData.subtype}
|
||||
className="w-10 h-10"
|
||||
shape="square"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ export interface SpoolFormData {
|
||||
brand: string;
|
||||
color_name: string;
|
||||
rgba: string;
|
||||
// #1154: extra gradient stops + visual effect. Stored as the canonical
|
||||
// server form ("ec984c,6cd4bc,..." — no `#`, lowercase). Empty string means
|
||||
// solid (the default).
|
||||
extra_colors: string;
|
||||
effect_type: string;
|
||||
label_weight: number;
|
||||
core_weight: number;
|
||||
core_weight_catalog_id: number | null;
|
||||
@@ -34,6 +39,8 @@ export const defaultFormData: SpoolFormData = {
|
||||
brand: '',
|
||||
color_name: '',
|
||||
rgba: '808080FF',
|
||||
extra_colors: '',
|
||||
effect_type: '',
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
core_weight_catalog_id: null,
|
||||
|
||||
@@ -3423,6 +3423,32 @@ export default {
|
||||
showAll: 'Alle',
|
||||
noColorsFound: 'Keine Farben gefunden',
|
||||
noResults: 'Keine Ergebnisse',
|
||||
// Multi-Color-Verlauf + visueller Effekt (#1154)
|
||||
extraColorsLabel: 'Zusätzliche Farben',
|
||||
extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
|
||||
extraColorsHint: '2 bis 8 Hex-Stops, durch Kommas getrennt. Wird als Verlauf dargestellt.',
|
||||
extraColorsInvalid: 'Ungültige Hex-Werte ignoriert: {{tokens}}',
|
||||
colorEffectLabel: 'Effekt',
|
||||
colorEffect: {
|
||||
none: 'Keiner',
|
||||
// Oberflächeneffekte
|
||||
sparkle: 'Glitzer',
|
||||
wood: 'Holz',
|
||||
marble: 'Marmor',
|
||||
glow: 'Leuchtend',
|
||||
matte: 'Matt',
|
||||
// Glanz- / Finish-Varianten
|
||||
silk: 'Seide',
|
||||
galaxy: 'Galaxy',
|
||||
rainbow: 'Regenbogen',
|
||||
metal: 'Metallic',
|
||||
translucent: 'Lichtdurchlässig',
|
||||
// Mehrfarbige Varianten
|
||||
gradient: 'Verlauf',
|
||||
dualColor: 'Zweifarbig',
|
||||
triColor: 'Dreifarbig',
|
||||
multicolor: 'Mehrfarbig',
|
||||
},
|
||||
selectMaterialFirst: 'Bitte zuerst ein Material im Filament-Info Tab auswählen.',
|
||||
noPrintersConfigured: 'Keine Drucker konfiguriert. Fügen Sie Drucker hinzu.',
|
||||
matchingFilter: 'Filter',
|
||||
|
||||
@@ -3430,6 +3430,32 @@ export default {
|
||||
showAll: 'Show all',
|
||||
noColorsFound: 'No colors match your search',
|
||||
noResults: 'No matches found',
|
||||
// Multi-colour gradient + visual effect (#1154)
|
||||
extraColorsLabel: 'Extra colours',
|
||||
extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
|
||||
extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
|
||||
extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
|
||||
colorEffectLabel: 'Effect',
|
||||
colorEffect: {
|
||||
none: 'None',
|
||||
// Surface effects
|
||||
sparkle: 'Sparkle',
|
||||
wood: 'Wood',
|
||||
marble: 'Marble',
|
||||
glow: 'Glow',
|
||||
matte: 'Matte',
|
||||
// Sheen / finish variants
|
||||
silk: 'Silk',
|
||||
galaxy: 'Galaxy',
|
||||
rainbow: 'Rainbow',
|
||||
metal: 'Metal',
|
||||
translucent: 'Translucent',
|
||||
// Multi-colour structural variants
|
||||
gradient: 'Gradient',
|
||||
dualColor: 'Dual Color',
|
||||
triColor: 'Tri Color',
|
||||
multicolor: 'Multicolor',
|
||||
},
|
||||
// PA Profiles
|
||||
selectMaterialFirst: 'Please select a material first in the Filament Info tab.',
|
||||
noPrintersConfigured: 'No printers configured. Add printers to use PA profiles.',
|
||||
|
||||
@@ -3347,6 +3347,29 @@ export default {
|
||||
showAll: 'Toutes',
|
||||
noColorsFound: 'Aucune couleur correspondante',
|
||||
noResults: 'Aucun résultat',
|
||||
// Multi-colour gradient + visual effect (#1154) — English fallback.
|
||||
extraColorsLabel: 'Extra colours',
|
||||
extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
|
||||
extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
|
||||
extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
|
||||
colorEffectLabel: 'Effect',
|
||||
colorEffect: {
|
||||
none: 'None',
|
||||
sparkle: 'Sparkle',
|
||||
wood: 'Wood',
|
||||
marble: 'Marble',
|
||||
glow: 'Glow',
|
||||
matte: 'Matte',
|
||||
silk: 'Silk',
|
||||
galaxy: 'Galaxy',
|
||||
rainbow: 'Rainbow',
|
||||
metal: 'Metal',
|
||||
translucent: 'Translucent',
|
||||
gradient: 'Gradient',
|
||||
dualColor: 'Dual Color',
|
||||
triColor: 'Tri Color',
|
||||
multicolor: 'Multicolor',
|
||||
},
|
||||
// PA Profiles
|
||||
selectMaterialFirst: 'Veuillez choisir un matériau dans l\'onglet Infos Filament.',
|
||||
noPrintersConfigured: 'Ajoutez une imprimante pour utiliser les profils PA.',
|
||||
|
||||
@@ -3346,6 +3346,29 @@ export default {
|
||||
showAll: 'Mostra tutto',
|
||||
noColorsFound: 'Nessun colore corrisponde alla ricerca',
|
||||
noResults: 'Nessun risultato trovato',
|
||||
// Multi-colour gradient + visual effect (#1154) — English fallback.
|
||||
extraColorsLabel: 'Extra colours',
|
||||
extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
|
||||
extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
|
||||
extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
|
||||
colorEffectLabel: 'Effect',
|
||||
colorEffect: {
|
||||
none: 'None',
|
||||
sparkle: 'Sparkle',
|
||||
wood: 'Wood',
|
||||
marble: 'Marble',
|
||||
glow: 'Glow',
|
||||
matte: 'Matte',
|
||||
silk: 'Silk',
|
||||
galaxy: 'Galaxy',
|
||||
rainbow: 'Rainbow',
|
||||
metal: 'Metal',
|
||||
translucent: 'Translucent',
|
||||
gradient: 'Gradient',
|
||||
dualColor: 'Dual Color',
|
||||
triColor: 'Tri Color',
|
||||
multicolor: 'Multicolor',
|
||||
},
|
||||
// PA Profiles
|
||||
selectMaterialFirst: 'Selezionare prima un materiale nella scheda Info filamento.',
|
||||
noPrintersConfigured: 'Nessuna stampante configurata. Aggiungi stampanti per usare i profili PA.',
|
||||
|
||||
@@ -3385,6 +3385,29 @@ export default {
|
||||
showAll: 'すべて表示',
|
||||
noColorsFound: '一致する色がありません',
|
||||
noResults: '結果なし',
|
||||
// Multi-colour gradient + visual effect (#1154) — English fallback.
|
||||
extraColorsLabel: 'Extra colours',
|
||||
extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
|
||||
extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
|
||||
extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
|
||||
colorEffectLabel: 'Effect',
|
||||
colorEffect: {
|
||||
none: 'None',
|
||||
sparkle: 'Sparkle',
|
||||
wood: 'Wood',
|
||||
marble: 'Marble',
|
||||
glow: 'Glow',
|
||||
matte: 'Matte',
|
||||
silk: 'Silk',
|
||||
galaxy: 'Galaxy',
|
||||
rainbow: 'Rainbow',
|
||||
metal: 'Metal',
|
||||
translucent: 'Translucent',
|
||||
gradient: 'Gradient',
|
||||
dualColor: 'Dual Color',
|
||||
triColor: 'Tri Color',
|
||||
multicolor: 'Multicolor',
|
||||
},
|
||||
// PA Profiles
|
||||
selectMaterialFirst: 'フィラメント情報タブで素材を選択してください。',
|
||||
noPrintersConfigured: 'プリンターが設定されていません。プリンターを追加してください。',
|
||||
|
||||
@@ -3360,6 +3360,29 @@ export default {
|
||||
showAll: 'Mostrar tudo',
|
||||
noColorsFound: 'Nenhuma cor corresponde à sua pesquisa',
|
||||
noResults: 'Nenhum resultado encontrado',
|
||||
// Multi-colour gradient + visual effect (#1154) — English fallback.
|
||||
extraColorsLabel: 'Extra colours',
|
||||
extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
|
||||
extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
|
||||
extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
|
||||
colorEffectLabel: 'Effect',
|
||||
colorEffect: {
|
||||
none: 'None',
|
||||
sparkle: 'Sparkle',
|
||||
wood: 'Wood',
|
||||
marble: 'Marble',
|
||||
glow: 'Glow',
|
||||
matte: 'Matte',
|
||||
silk: 'Silk',
|
||||
galaxy: 'Galaxy',
|
||||
rainbow: 'Rainbow',
|
||||
metal: 'Metal',
|
||||
translucent: 'Translucent',
|
||||
gradient: 'Gradient',
|
||||
dualColor: 'Dual Color',
|
||||
triColor: 'Tri Color',
|
||||
multicolor: 'Multicolor',
|
||||
},
|
||||
// PA Profiles
|
||||
selectMaterialFirst: 'Por favor, selecione um material primeiro na aba Informações do Filamento.',
|
||||
noPrintersConfigured: 'Nenhuma impressora configurada. Adicione impressoras para usar perfis PA.',
|
||||
|
||||
@@ -3417,6 +3417,29 @@ export default {
|
||||
showAll: '显示全部',
|
||||
noColorsFound: '没有颜色匹配您的搜索',
|
||||
noResults: '未找到匹配项',
|
||||
// 多色渐变 + 视觉效果 (#1154)
|
||||
extraColorsLabel: '附加颜色',
|
||||
extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
|
||||
extraColorsHint: '粘贴 2 到 8 个十六进制色值,以逗号分隔。将渲染为渐变。',
|
||||
extraColorsInvalid: '已忽略无效的十六进制值:{{tokens}}',
|
||||
colorEffectLabel: '效果',
|
||||
colorEffect: {
|
||||
none: '无',
|
||||
sparkle: '闪光',
|
||||
wood: '木纹',
|
||||
marble: '大理石',
|
||||
glow: '夜光',
|
||||
matte: '哑光',
|
||||
silk: '丝光',
|
||||
galaxy: '星空',
|
||||
rainbow: '彩虹',
|
||||
metal: '金属',
|
||||
translucent: '半透明',
|
||||
gradient: '渐变',
|
||||
dualColor: '双色',
|
||||
triColor: '三色',
|
||||
multicolor: '多色',
|
||||
},
|
||||
// PA Profiles
|
||||
selectMaterialFirst: '请先在耗材信息选项卡中选择材料。',
|
||||
noPrintersConfigured: '未配置打印机。添加打印机以使用 PA 配置。',
|
||||
|
||||
@@ -3417,6 +3417,29 @@ export default {
|
||||
showAll: '顯示全部',
|
||||
noColorsFound: '沒有顏色匹配您的搜尋',
|
||||
noResults: '未找到匹配項',
|
||||
// 多色漸層 + 視覺效果 (#1154)
|
||||
extraColorsLabel: '附加顏色',
|
||||
extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
|
||||
extraColorsHint: '貼上 2 至 8 個十六進位色值,以逗號分隔。將以漸層方式呈現。',
|
||||
extraColorsInvalid: '已略過無效的十六進位值:{{tokens}}',
|
||||
colorEffectLabel: '效果',
|
||||
colorEffect: {
|
||||
none: '無',
|
||||
sparkle: '閃光',
|
||||
wood: '木紋',
|
||||
marble: '大理石',
|
||||
glow: '夜光',
|
||||
matte: '霧面',
|
||||
silk: '絲光',
|
||||
galaxy: '星空',
|
||||
rainbow: '彩虹',
|
||||
metal: '金屬',
|
||||
translucent: '半透明',
|
||||
gradient: '漸層',
|
||||
dualColor: '雙色',
|
||||
triColor: '三色',
|
||||
multicolor: '多色',
|
||||
},
|
||||
// PA Profiles
|
||||
selectMaterialFirst: '請先在耗材資訊分頁中選擇材料。',
|
||||
noPrintersConfigured: '未設定印表機。新增印表機以使用 PA 設定。',
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
import { api, spoolbuddyApi } from '../api/client';
|
||||
import type { InventorySpool, SpoolAssignment, SpoolCatalogEntry } from '../api/client';
|
||||
import { Button } from '../components/Button';
|
||||
import { FilamentSwatch } from '../components/FilamentSwatch';
|
||||
import { buildFilamentBackground } from '../components/filamentSwatchHelpers';
|
||||
import { SpoolFormModal } from '../components/SpoolFormModal';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
|
||||
@@ -30,7 +32,10 @@ type DisplayItem =
|
||||
| { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };
|
||||
|
||||
function spoolGroupKey(s: InventorySpool): string {
|
||||
return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.label_weight}`;
|
||||
// Include extra_colors + effect_type so the "Group similar" toggle does
|
||||
// not collapse two spools that share the base colour but differ on
|
||||
// gradient stops or visual effect (#1154).
|
||||
return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.extra_colors || ''}|${s.effect_type || ''}|${s.label_weight}`;
|
||||
}
|
||||
|
||||
// Column definitions for the inventory table
|
||||
@@ -180,10 +185,11 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
|
||||
),
|
||||
rgba: ({ spool }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<span
|
||||
className="w-5 h-5 rounded-full border border-black/20 flex-shrink-0"
|
||||
style={{ backgroundColor: spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080' }}
|
||||
title={spool.rgba ? `#${spool.rgba.substring(0, 6)}` : undefined}
|
||||
<FilamentSwatch
|
||||
rgba={spool.rgba}
|
||||
extraColors={spool.extra_colors}
|
||||
effectType={spool.effect_type}
|
||||
subtype={spool.subtype}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@@ -1289,7 +1295,12 @@ function InventoryPage() {
|
||||
{pagedItems.map((item) => {
|
||||
if (item.type === 'group') {
|
||||
const { key, spools: groupSpools, representative: rep } = item;
|
||||
const colorStyle = rep.rgba ? `#${rep.rgba.substring(0, 6)}` : '#808080';
|
||||
const groupBannerImage = buildFilamentBackground({
|
||||
rgba: rep.rgba,
|
||||
extraColors: rep.extra_colors,
|
||||
effectType: rep.effect_type,
|
||||
subtype: rep.subtype,
|
||||
});
|
||||
const isExpanded = expandedGroups.has(key);
|
||||
return (
|
||||
<div key={`group-${key}`} className="col-span-full">
|
||||
@@ -1298,7 +1309,7 @@ function InventoryPage() {
|
||||
className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-green/30 hover:border-bambu-green transition-colors cursor-pointer"
|
||||
onClick={() => toggleGroupExpand(key)}
|
||||
>
|
||||
<div className="h-10 flex items-center px-4 gap-3" style={{ backgroundColor: colorStyle }}>
|
||||
<div className="h-10 flex items-center px-4 gap-3" style={{ backgroundImage: groupBannerImage, backgroundSize: 'cover' }}>
|
||||
<span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
|
||||
{resolveSpoolColorName(rep.color_name, rep.rgba) || '-'}
|
||||
</span>
|
||||
@@ -1325,14 +1336,12 @@ function InventoryPage() {
|
||||
{groupSpools.map((spool) => {
|
||||
const remaining = Math.max(0, spool.label_weight - spool.weight_used);
|
||||
const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
|
||||
const spoolColor = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
|
||||
return (
|
||||
<SpoolCard
|
||||
key={spool.id}
|
||||
spool={spool}
|
||||
remaining={remaining}
|
||||
pct={pct}
|
||||
colorStyle={spoolColor}
|
||||
onClick={() => setFormModal({ spool })}
|
||||
t={t}
|
||||
/>
|
||||
@@ -1346,14 +1355,12 @@ function InventoryPage() {
|
||||
const spool = item.spool;
|
||||
const remaining = Math.max(0, spool.label_weight - spool.weight_used);
|
||||
const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
|
||||
const colorStyle = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
|
||||
return (
|
||||
<SpoolCard
|
||||
key={spool.id}
|
||||
spool={spool}
|
||||
remaining={remaining}
|
||||
pct={pct}
|
||||
colorStyle={colorStyle}
|
||||
onClick={() => setFormModal({ spool })}
|
||||
t={t}
|
||||
/>
|
||||
@@ -1662,21 +1669,26 @@ function PaginationBar({
|
||||
|
||||
/* Spool card for cards view */
|
||||
function SpoolCard({
|
||||
spool, remaining, pct, colorStyle, onClick, t,
|
||||
spool, remaining, pct, onClick, t,
|
||||
}: {
|
||||
spool: InventorySpool;
|
||||
remaining: number;
|
||||
pct: number;
|
||||
colorStyle: string;
|
||||
onClick: () => void;
|
||||
t: (key: string, opts?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const bannerImage = buildFilamentBackground({
|
||||
rgba: spool.rgba,
|
||||
extraColors: spool.extra_colors,
|
||||
effectType: spool.effect_type,
|
||||
subtype: spool.subtype,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className={`bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors cursor-pointer ${spool.archived_at ? 'opacity-50' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="h-14 flex items-center justify-center" style={{ backgroundColor: colorStyle }}>
|
||||
<div className="h-14 flex items-center justify-center" style={{ backgroundImage: bannerImage, backgroundSize: 'cover' }}>
|
||||
<span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
|
||||
{resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}
|
||||
</span>
|
||||
|
||||
@@ -287,6 +287,8 @@ export function SpoolBuddyDashboard() {
|
||||
subtype: null,
|
||||
color_name: null,
|
||||
rgba: null,
|
||||
extra_colors: null,
|
||||
effect_type: null,
|
||||
brand: null,
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
|
||||
@@ -637,6 +637,8 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: {
|
||||
brand: formData.brand || null,
|
||||
color_name: formData.color_name || null,
|
||||
rgba: formData.rgba || null,
|
||||
extra_colors: formData.extra_colors || null,
|
||||
effect_type: formData.effect_type || null,
|
||||
label_weight: formData.label_weight,
|
||||
core_weight: formData.core_weight,
|
||||
core_weight_catalog_id: formData.core_weight_catalog_id,
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-8qP2vyrm.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DE-w2t-x.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-7GmlJb0k.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user