diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5f1a7a6..305087669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.4b1] - Unreleased ### Added +- **Enhanced filament colour handling: multi-colour gradients, transparency, visual effects** ([#1154](https://github.com/maziggy/bambuddy/issues/1154)) — A solid hex swatch is the wrong abstraction for a tri-colour, gradient, or sparkle filament — the colour you saw on the spool inventory page was just whatever Bambu's firmware reported as the dominant tone, and there was no way to record what the spool actually looked like. The Spool form's "Colour" section now accepts a paste of up to 8 comma-separated hex stops (`EC984C,#6CD4BC,A66EB9,D87694` — exact format from 3dfilamentprofiles.com) and renders them as a CSS gradient on every swatch site (inventory grid, table, group banner, card, ColorSection preview, color-catalog admin). A new **Effect** dropdown — covering surface effects (Sparkle / Wood / Marble / Glow / Matte), sheen variants (Silk / Galaxy / Rainbow / Metal / Translucent), and structural variants (Gradient / Dual Color / Tri Color / Multicolor) — layers a CSS overlay on top of the colour layer (or, for Multicolor, switches the colour layer to a conic-gradient even when no spool subtype is set, so the catalog editor can flag a multicolor variant directly without needing a paired Spool row). Independent of `subtype` — so the user can override the visual hint without touching Bambu's categorical filament label or the MQTT auto-detection chain. **Transparency is now actually visible**: the existing `rgba` column has always stored an alpha byte but every render site flattened it with `substring(0, 6)`; the new shared `` component renders against a checkerboard layer beneath the colour layer so any alpha < 0xFF shows through (matches the convention used by image editors and 3dfilamentprofiles.com). **Multicolor subtype** swaps the linear gradient for a `conic-gradient` so the swatch reads as a colour wheel pie instead of a stripe — visually distinguishes a true multi-colour spool from a 2-stop gradient. **Colour-catalog parity**: the same fields land on `ColorCatalogEntry` (Settings → Color Catalog) so a user can save a multi-colour combo once and pick it from the catalog palette across spools — added inline to both the Add form and the inline-edit row, threaded through the JSON export/import path so catalog backups round-trip the new fields. Catalog `hex_color` regex extended to optionally accept `#RRGGBBAA` for transparency-aware catalog entries (backward-compatible — existing 6-char rows still validate). **Schema validation** (`backend/app/schemas/spool.py::normalize_extra_colors` + `normalize_effect_type` — public so `ColorEntryCreate` / `ColorEntryUpdate` can reuse them): comma-separated hex with 6-or-8-char tokens, lowercase canonical form, `#` prefix stripped, max-8-stop cap, empty tokens dropped (so a degenerate paste like `,,FF0000,` survives), invalid tokens rejected at the Pydantic layer with a precise field error. Effect type validated against the fixed set `{sparkle, wood, marble, glow, matte, silk, galaxy, rainbow, metal, translucent, gradient, dual-color, tri-color, multicolor}` — paste-friendly normaliser tolerates `Dual Color` / `dual_color` / `dual-color` and canonicalises to `dual-color`. Both validators live next to `SpoolBase` and are reused by `ColorEntryCreate` / `ColorEntryUpdate` so spool-side and catalog-side rejection rules can never drift. **Frontend swatch component** is one shared `` (and `buildFilamentBackground()` helper for callers that want just the CSS background-image string for a banner) — used by InventoryPage table, group banner, SpoolCard, ColorSection preview, and ColorCatalogSettings — so there's exactly one place that decides how a filament looks. Colour layer is built as a list of CSS images (no `background:` shorthand) so jsdom and every browser parse it consistently; checkerboard layer is the one that makes alpha visible. Migrations are 4 idempotent `ALTER TABLE ... ADD COLUMN` (Postgres-safe, no `DEFAULT 0` traps) plus a Postgres-only widen of `color_catalog.hex_color` to `VARCHAR(9)`. **i18n**: 12 new keys under `inventory.*` across all 8 locales (en/de/zh-CN/zh-TW fully translated; fr/it/ja/pt-BR seeded with English copies pending native translation, matching the project's flow for newly-added user-facing features). 42 new backend tests (35 unit + 7 integration) covering the normalizer (paste-from-3dfilamentprofiles canonicalisation, whitespace tolerance, mixed 6/8-char, empty-token drop, max-stop cap, invalid-hex rejection, wrong-length rejection, `Dual Color` / `dual_color` → `dual-color` canonicalisation), effect-type validator across all 14 allowed values, end-to-end POST/PUT/PATCH round-trip on both spool + catalog routes, 8-char `hex_color` acceptance, dedupe-on-update, and field clearing via empty string vs explicit null. 20 new frontend tests covering FilamentSwatch (14 — solid render, multi-stop linear gradient, conic for Multicolor *subtype* AND for `multicolor` *effect_type* via the catalog path, surface-effect overlays for sparkle and silk, categorical-only-no-overlay for gradient/dual-color, unknown-effect-ignored, checkerboard rendering for alpha, invalid-hex skip in stops, title fallback), `buildFilamentBackground` helper, ColorCatalogSettings (3 — Add form sends extra_colors + effect_type, full 14-value dropdown, inline edit hydrates from existing entry), and InventoryPage spool grouping (3 — different extra_colors don't collapse, different effect_type don't collapse, identical multi-colour spools still group). Out of scope for V1: gradient stop *positions* (e.g. 25%/75%), MQTT-side auto-import of multi-colour from Bambu (firmware doesn't expose this), per-effect tunable parameters — the current shape closes the user's actual paste-from-3dfilamentprofiles workflow without taking on a structured-stop-position editor. - **Project URL + cover photo** ([#1155](https://github.com/maziggy/bambuddy/issues/1155)) — Two new fields on every project: a free-text **URL** (rendered as a 24×24 bordered green button beside the project name on every card; opens in a new tab and click is `e.stopPropagation()`-guarded so it doesn't enter the project) and a **cover photo** (replaces the status-icon box on the card with a square thumbnail). The URL field is plumbed through `ProjectCreate`/`ProjectUpdate`/`ProjectResponse`/`ProjectListResponse`, including from-template + create-template flows so the URL inherits between a project and its template; cover photo is *not* inherited because the file would be shared on disk between the source and copy. **Schema validator** rejects anything other than `http://` or `https://` prefixes — `` rendering would otherwise execute `javascript:` / `data:` / `file:` URLs even with React's default escaping. **Cover image storage**: `Project.cover_image_filename` references a file inside the existing `archives/projects/{id}/attachments/` directory, but it's tracked as a separate column from the `attachments` JSON list so swap/delete operations on the cover don't perturb the user's other attachments. Three new routes (`POST /projects/{id}/cover-image`, `GET /projects/{id}/cover-image`, `DELETE /projects/{id}/cover-image`) — accepts only `.jpg`/`.jpeg`/`.png`/`.gif`/`.webp` (no SVG: SVG can carry script payloads), replaces in place (the prior file is removed from disk before the new one lands so repeat uploads can't accumulate orphans), and self-heals when a DB reference points at a vanished disk file by clearing the column and 404'ing rather than repeatedly touching the filesystem. **GET auth gate**: the cover-image GET route is gated by `RequireCameraStreamTokenIfAuthEnabled` (accepts the same `?token=…` stream credential the archive thumbnail route uses) rather than the bearer-token gate — `` requests can't carry an `Authorization` header, and the bearer gate would silently 401 every cover image when auth is enabled. The frontend client wraps the URL with `withStreamToken(...)` so the modal preview AND the card thumbnail load in both auth-on and auth-off configurations. **PATCH update** uses `model_fields_set` for the URL field so users can clear it by sending `{"url": null}`. Permissions: `PROJECTS_UPDATE` for upload/delete/PATCH, `PROJECTS_READ` for the GET (via the stream-token gate). **Migration**: 2 idempotent `ALTER TABLE projects ADD COLUMN ...` statements. Localised across all 8 UI languages (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW) — English fully translated, the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features. 7 backend integration tests covering URL accept/reject (https/javascript/data), URL clear, cover image upload→serve→delete round-trip with content-type assertion, non-image rejection, and a regression guard verifying the GET route is wired to the stream-token gate (not the bearer gate). 4 frontend ProjectsPage tests covering the link icon render condition, click-propagation guard, no-link-when-unset, and cover-image thumbnail render; 3 frontend client tests pinning that `getProjectCoverImageUrl` appends the stream token, returns the bare URL when no token is set, and URL-encodes tokens with query-string-unsafe characters. Cover image upload is only available on the edit modal (an existing project), since the upload needs a project_id; new projects can add it after first save. - **"Not Printed" / "Printed" collections on the Archives page** ([#1153](https://github.com/maziggy/bambuddy/issues/1153)) — Virtual-printer uploads land in the archives view with `status='archived'` (uploaded but never sent to a printer), but the existing `Collection` sidebar only had `All / Recent / This Week / This Month / Favorites / Failed / Duplicates` so there was no way to surface "what's still queued in my library that I haven't printed yet" vs "what already went to a printer." Two new collections fill that gap: **Not Printed** filters to `status === 'archived'` (the VP upload state); **Printed** filters to any final-status archive — `completed`, `failed`, `aborted`, `cancelled`, `stopped` — so a user can see every archive that had a print attempt regardless of outcome (the existing "Failed" collection covers just the failure subset). Frontend-only — the data has always been there, just no UI handle for it. 2 new tests in `ArchivesPage.test.tsx::Not Printed / Printed collections` pin the filter behaviour against a fixture covering all 4 status states (archived / completed / failed / cancelled). - **Virtual-printer archive name source toggle** ([#1152](https://github.com/maziggy/bambuddy/issues/1152)) — Slicer-uploaded archives picked up their display name from the 3MF's embedded `print_name` metadata, which is whatever the original creator set; users who renamed a job in BambuStudio's "Send to printer" dialog never saw that name surface in Bambuddy because the FTP-uploaded filename was only ever used as a fallback when the metadata was empty. Settings → Virtual Printer now exposes an **Archive name source** toggle (Metadata / Filename, default Metadata, preserves existing behaviour) at the top of the page that flips precedence in `ArchiveService.archive_print` for every VP-sourced archive — `_archive_file`, `_add_to_print_queue`, `POST /pending-uploads/archive-all`, and `POST /pending-uploads/{id}/archive` all read the new `virtual_printer_archive_name_source` setting and forward `prefer_filename_for_name` accordingly. Backend validates the value to `metadata`/`filename` only. Strict locales (en/de/zh-CN/zh-TW) get full translations; 4 unit tests parametrised over `filename` / `metadata` / unset / empty-string pin the precedence rule end-to-end through `_archive_file`. Existing post-archive `PATCH /archives/{id}` rename path is unchanged. diff --git a/README.md b/README.md index 2a1d7e415..2ef744578 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/app/api/routes/inventory.py b/backend/app/api/routes/inventory.py index 939730a60..238458719 100644 --- a/backend/app/api/routes/inventory.py +++ b/backend/app/api/routes/inventory.py @@ -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 diff --git a/backend/app/core/database.py b/backend/app/core/database.py index d3b019b48..e72e7c4e8 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -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 diff --git a/backend/app/models/color_catalog.py b/backend/app/models/color_catalog.py index fa2a66bdf..530140790 100644 --- a/backend/app/models/color_catalog.py +++ b/backend/app/models/color_catalog.py @@ -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()) diff --git a/backend/app/models/spool.py b/backend/app/models/spool.py index a61de1700..33c413b9a 100644 --- a/backend/app/models/spool.py +++ b/backend/app/models/spool.py @@ -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) diff --git a/backend/app/schemas/spool.py b/backend/app/schemas/spool.py index cb9274e27..bccd77a82 100644 --- a/backend/app/schemas/spool.py +++ b/backend/app/schemas/spool.py @@ -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 diff --git a/backend/tests/integration/test_color_catalog_extras.py b/backend/tests/integration/test_color_catalog_extras.py new file mode 100644 index 000000000..56334ef39 --- /dev/null +++ b/backend/tests/integration/test_color_catalog_extras.py @@ -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 diff --git a/backend/tests/unit/test_spool_schemas_colors.py b/backend/tests/unit/test_spool_schemas_colors.py new file mode 100644 index 000000000..2867170f1 --- /dev/null +++ b/backend/tests/unit/test_spool_schemas_colors.py @@ -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", + ) diff --git a/frontend/src/__tests__/components/ColorCatalogSettings.test.tsx b/frontend/src/__tests__/components/ColorCatalogSettings.test.tsx new file mode 100644 index 000000000..92914ae05 --- /dev/null +++ b/frontend/src/__tests__/components/ColorCatalogSettings.test.tsx @@ -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(); + // 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 and a . 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 . + 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(); + 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 setFormExtraColors(e.target.value)} + /> + + )} @@ -519,7 +555,8 @@ export function ColorCatalogSettings() { ) : ( filteredCatalog.map(entry => ( - + + {editingId === entry.id ? ( <> @@ -596,9 +633,12 @@ export function ColorCatalogSettings() { /> -
@@ -625,6 +665,35 @@ export function ColorCatalogSettings() { )} + {editingId === entry.id && ( + + + + setFormExtraColors(e.target.value)} + /> + + + + + + + )} + )) )} diff --git a/frontend/src/components/FilamentSwatch.tsx b/frontend/src/components/FilamentSwatch.tsx new file mode 100644 index 000000000..6116655d0 --- /dev/null +++ b/frontend/src/components/FilamentSwatch.tsx @@ -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 ( + + ); +} + +export default FilamentSwatch; diff --git a/frontend/src/components/SpoolFormModal.tsx b/frontend/src/components/SpoolFormModal.tsx index 214440da2..877a64b96 100644 --- a/frontend/src/components/SpoolFormModal.tsx +++ b/frontend/src/components/SpoolFormModal.tsx @@ -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, diff --git a/frontend/src/components/filamentSwatchHelpers.ts b/frontend/src/components/filamentSwatchHelpers.ts new file mode 100644 index 000000000..a93e577fa --- /dev/null +++ b/frontend/src/components/filamentSwatchHelpers.ts @@ -0,0 +1,175 @@ +/* Enhanced filament-colour rendering helpers (#1154). + * + * Pure (non-component) exports that drive `` 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.` (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> = { + // 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 + * 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(', '); +} diff --git a/frontend/src/components/spool-form/ColorSection.tsx b/frontend/src/components/spool-form/ColorSection.tsx index d6c4cc9ab..2ada79247 100644 --- a/frontend/src/components/spool-form/ColorSection.tsx +++ b/frontend/src/components/spool-form/ColorSection.tsx @@ -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(formData.extra_colors); + const [extraColorsErrors, setExtraColorsErrors] = useState([]); + 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 (
- {/* Color preview banner */} + {/* Color preview banner — shows gradient + effect overlay. */}
{/* Recently Used Colors */} @@ -326,6 +377,60 @@ export function ColorSection({
+ + {/* #1154: Multi-colour gradient stops + visual effect. Optional — + empty values keep the spool rendering as a solid swatch. */} +
+
+ + commitExtraColors(e.target.value)} + data-testid="extra-colors-input" + /> + {extraColorsErrors.length > 0 && ( +

+ {t('inventory.extraColorsInvalid', { tokens: extraColorsErrors.join(', ') })} +

+ )} + {!extraColorsErrors.length && ( +

{t('inventory.extraColorsHint')}

+ )} +
+
+ +
+ + +
+
+
); } diff --git a/frontend/src/components/spool-form/types.ts b/frontend/src/components/spool-form/types.ts index 39104b0a9..c16796ba3 100644 --- a/frontend/src/components/spool-form/types.ts +++ b/frontend/src/components/spool-form/types.ts @@ -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, diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 212fb9a9b..d65de0e5a 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -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', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 3d6798c49..68571dc6e 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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.', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index afab66270..261a7ea50 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -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.', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 19e6ed98c..1d1e98e7a 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -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.', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 71cabf078..59a50b739 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -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: 'プリンターが設定されていません。プリンターを追加してください。', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index c5a928259..e271da064 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -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.', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 9c615450f..e06d80296 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -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 配置。', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index b5fdb740e..9ddbeb438 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -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 設定。', diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index dc470b825..6413b8809 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -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 ReactNode> = { ), rgba: ({ spool }) => (
-
), @@ -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 (
@@ -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)} > -
+
{resolveSpoolColorName(rep.color_name, rep.rgba) || '-'} @@ -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 ( 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 ( 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; }) { + const bannerImage = buildFilamentBackground({ + rgba: spool.rgba, + extraColors: spool.extra_colors, + effectType: spool.effect_type, + subtype: spool.subtype, + }); return (
-
+
{resolveSpoolColorName(spool.color_name, spool.rgba) || '-'} diff --git a/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx b/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx index 77f65fa78..eeaaba0ae 100644 --- a/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx +++ b/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx @@ -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, diff --git a/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx b/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx index 6efe1d996..d1ad67ecb 100644 --- a/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx +++ b/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx @@ -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, diff --git a/static/assets/index-8qP2vyrm.js b/static/assets/index-DE-w2t-x.js similarity index 60% rename from static/assets/index-8qP2vyrm.js rename to static/assets/index-DE-w2t-x.js index 74666d76c..05712103b 100644 --- a/static/assets/index-8qP2vyrm.js +++ b/static/assets/index-DE-w2t-x.js @@ -1,42 +1,42 @@ -function tue(t,e){for(var n=0;nr[i]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))r(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const o of s.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?s.credentials="include":i.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function r(i){if(i.ep)return;i.ep=!0;const s=n(i);fetch(i.href,s)}})();var Z_=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function gc(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var _M={exports:{}},Ev={};var V8;function nue(){if(V8)return Ev;V8=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.fragment");function n(r,i,s){var o=null;if(s!==void 0&&(o=""+s),i.key!==void 0&&(o=""+i.key),"key"in i){s={};for(var l in i)l!=="key"&&(s[l]=i[l])}else s=i;return i=s.ref,{$$typeof:t,type:r,key:o,ref:i!==void 0?i:null,props:s}}return Ev.Fragment=e,Ev.jsx=n,Ev.jsxs=n,Ev}var G8;function rue(){return G8||(G8=1,_M.exports=nue()),_M.exports}var a=rue(),kM={exports:{}},Wn={};var W8;function aue(){if(W8)return Wn;W8=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.portal"),n=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),s=Symbol.for("react.consumer"),o=Symbol.for("react.context"),l=Symbol.for("react.forward_ref"),c=Symbol.for("react.suspense"),d=Symbol.for("react.memo"),u=Symbol.for("react.lazy"),m=Symbol.for("react.activity"),p=Symbol.iterator;function f(K){return K===null||typeof K!="object"?null:(K=p&&K[p]||K["@@iterator"],typeof K=="function"?K:null)}var x={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},v=Object.assign,b={};function g(K,ae,me){this.props=K,this.context=ae,this.refs=b,this.updater=me||x}g.prototype.isReactComponent={},g.prototype.setState=function(K,ae){if(typeof K!="object"&&typeof K!="function"&&K!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,K,ae,"setState")},g.prototype.forceUpdate=function(K){this.updater.enqueueForceUpdate(this,K,"forceUpdate")};function S(){}S.prototype=g.prototype;function N(K,ae,me){this.props=K,this.context=ae,this.refs=b,this.updater=me||x}var P=N.prototype=new S;P.constructor=N,v(P,g.prototype),P.isPureReactComponent=!0;var C=Array.isArray;function A(){}var T={H:null,A:null,T:null,S:null},F=Object.prototype.hasOwnProperty;function k(K,ae,me){var xe=me.ref;return{$$typeof:t,type:K,key:ae,ref:xe!==void 0?xe:null,props:me}}function E(K,ae){return k(K.type,ae,K.props)}function V(K){return typeof K=="object"&&K!==null&&K.$$typeof===t}function O(K){var ae={"=":"=0",":":"=2"};return"$"+K.replace(/[=:]/g,function(me){return ae[me]})}var X=/\/+/g;function L(K,ae){return typeof K=="object"&&K!==null&&K.key!=null?O(""+K.key):ae.toString(36)}function ne(K){switch(K.status){case"fulfilled":return K.value;case"rejected":throw K.reason;default:switch(typeof K.status=="string"?K.then(A,A):(K.status="pending",K.then(function(ae){K.status==="pending"&&(K.status="fulfilled",K.value=ae)},function(ae){K.status==="pending"&&(K.status="rejected",K.reason=ae)})),K.status){case"fulfilled":return K.value;case"rejected":throw K.reason}}throw K}function re(K,ae,me,xe,fe){var B=typeof K;(B==="undefined"||B==="boolean")&&(K=null);var Q=!1;if(K===null)Q=!0;else switch(B){case"bigint":case"string":case"number":Q=!0;break;case"object":switch(K.$$typeof){case t:case e:Q=!0;break;case u:return Q=K._init,re(Q(K._payload),ae,me,xe,fe)}}if(Q)return fe=fe(K),Q=xe===""?"."+L(K,0):xe,C(fe)?(me="",Q!=null&&(me=Q.replace(X,"$&/")+"/"),re(fe,ae,me,"",function(U){return U})):fe!=null&&(V(fe)&&(fe=E(fe,me+(fe.key==null||K&&K.key===fe.key?"":(""+fe.key).replace(X,"$&/")+"/")+Q)),ae.push(fe)),1;Q=0;var D=xe===""?".":xe+":";if(C(K))for(var M=0;M>>1,oe=re[se];if(0>>1;sei(me,ue))xei(fe,me)?(re[se]=fe,re[xe]=ue,se=xe):(re[se]=me,re[ae]=ue,se=ae);else if(xei(fe,ue))re[se]=fe,re[xe]=ue,se=xe;else break e}}return ee}function i(re,ee){var ue=re.sortIndex-ee.sortIndex;return ue!==0?ue:re.id-ee.id}if(t.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var s=performance;t.unstable_now=function(){return s.now()}}else{var o=Date,l=o.now();t.unstable_now=function(){return o.now()-l}}var c=[],d=[],u=1,m=null,p=3,f=!1,x=!1,v=!1,b=!1,g=typeof setTimeout=="function"?setTimeout:null,S=typeof clearTimeout=="function"?clearTimeout:null,N=typeof setImmediate<"u"?setImmediate:null;function P(re){for(var ee=n(d);ee!==null;){if(ee.callback===null)r(d);else if(ee.startTime<=re)r(d),ee.sortIndex=ee.expirationTime,e(c,ee);else break;ee=n(d)}}function C(re){if(v=!1,P(re),!x)if(n(c)!==null)x=!0,A||(A=!0,O());else{var ee=n(d);ee!==null&&ne(C,ee.startTime-re)}}var A=!1,T=-1,F=5,k=-1;function E(){return b?!0:!(t.unstable_now()-kre&&E());){var se=m.callback;if(typeof se=="function"){m.callback=null,p=m.priorityLevel;var oe=se(m.expirationTime<=re);if(re=t.unstable_now(),typeof oe=="function"){m.callback=oe,P(re),ee=!0;break t}m===n(c)&&r(c),P(re)}else r(c);m=n(c)}if(m!==null)ee=!0;else{var K=n(d);K!==null&&ne(C,K.startTime-re),ee=!1}}break e}finally{m=null,p=ue,f=!1}ee=void 0}}finally{ee?O():A=!1}}}var O;if(typeof N=="function")O=function(){N(V)};else if(typeof MessageChannel<"u"){var X=new MessageChannel,L=X.port2;X.port1.onmessage=V,O=function(){L.postMessage(null)}}else O=function(){g(V,0)};function ne(re,ee){T=g(function(){re(t.unstable_now())},ee)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(re){re.callback=null},t.unstable_forceFrameRate=function(re){0>re||125se?(re.sortIndex=ue,e(d,re),n(c)===null&&re===n(d)&&(v?(S(T),T=-1):v=!0,ne(C,ue-se))):(re.sortIndex=oe,e(c,re),x||f||(x=!0,A||(A=!0,O()))),re},t.unstable_shouldYield=E,t.unstable_wrapCallback=function(re){var ee=p;return function(){var ue=p;p=ee;try{return re.apply(this,arguments)}finally{p=ue}}}})(PM)),PM}var Y8;function oue(){return Y8||(Y8=1,CM.exports=sue()),CM.exports}var TM={exports:{}},Ds={};var Q8;function lue(){if(Q8)return Ds;Q8=1;var t=Ug();function e(c){var d="https://react.dev/errors/"+c;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),TM.exports=lue(),TM.exports}var Z8;function cue(){if(Z8)return Dv;Z8=1;var t=oue(),e=Ug(),n=sQ();function r(h){var y="https://react.dev/errors/"+h;if(1oe||(h.current=se[oe],se[oe]=null,oe--)}function me(h,y){oe++,se[oe]=h.current,h.current=y}var xe=K(null),fe=K(null),B=K(null),Q=K(null);function D(h,y){switch(me(B,y),me(fe,h),me(xe,null),y.nodeType){case 9:case 11:h=(h=y.documentElement)&&(h=h.namespaceURI)?p8(h):0;break;default:if(h=y.tagName,y=y.namespaceURI)y=p8(y),h=f8(y,h);else switch(h){case"svg":h=1;break;case"math":h=2;break;default:h=0}}ae(xe),me(xe,h)}function M(){ae(xe),ae(fe),ae(B)}function U(h){h.memoizedState!==null&&me(Q,h);var y=xe.current,_=f8(y,h.type);y!==_&&(me(fe,h),me(xe,_))}function Y(h){fe.current===h&&(ae(xe),ae(fe)),Q.current===h&&(ae(Q),Tv._currentValue=ue)}var z,ce;function I(h){if(z===void 0)try{throw Error()}catch(_){var y=_.stack.trim().match(/\n( *(at )?)/);z=y&&y[1]||"",ce=-1<_.stack.indexOf(` +function cue(t,e){for(var n=0;nr[i]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))r(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const o of s.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?s.credentials="include":i.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function r(i){if(i.ep)return;i.ep=!0;const s=n(i);fetch(i.href,s)}})();var Z_=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function gc(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var kM={exports:{}},Ev={};var X8;function due(){if(X8)return Ev;X8=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.fragment");function n(r,i,s){var o=null;if(s!==void 0&&(o=""+s),i.key!==void 0&&(o=""+i.key),"key"in i){s={};for(var l in i)l!=="key"&&(s[l]=i[l])}else s=i;return i=s.ref,{$$typeof:t,type:r,key:o,ref:i!==void 0?i:null,props:s}}return Ev.Fragment=e,Ev.jsx=n,Ev.jsxs=n,Ev}var Y8;function uue(){return Y8||(Y8=1,kM.exports=due()),kM.exports}var a=uue(),NM={exports:{}},Wn={};var Q8;function mue(){if(Q8)return Wn;Q8=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.portal"),n=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),s=Symbol.for("react.consumer"),o=Symbol.for("react.context"),l=Symbol.for("react.forward_ref"),c=Symbol.for("react.suspense"),d=Symbol.for("react.memo"),u=Symbol.for("react.lazy"),m=Symbol.for("react.activity"),p=Symbol.iterator;function f(X){return X===null||typeof X!="object"?null:(X=p&&X[p]||X["@@iterator"],typeof X=="function"?X:null)}var x={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},v=Object.assign,g={};function b(X,ae,pe){this.props=X,this.context=ae,this.refs=g,this.updater=pe||x}b.prototype.isReactComponent={},b.prototype.setState=function(X,ae){if(typeof X!="object"&&typeof X!="function"&&X!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,X,ae,"setState")},b.prototype.forceUpdate=function(X){this.updater.enqueueForceUpdate(this,X,"forceUpdate")};function S(){}S.prototype=b.prototype;function N(X,ae,pe){this.props=X,this.context=ae,this.refs=g,this.updater=pe||x}var P=N.prototype=new S;P.constructor=N,v(P,b.prototype),P.isPureReactComponent=!0;var C=Array.isArray;function A(){}var T={H:null,A:null,T:null,S:null},F=Object.prototype.hasOwnProperty;function k(X,ae,pe){var be=pe.ref;return{$$typeof:t,type:X,key:ae,ref:be!==void 0?be:null,props:pe}}function D(X,ae){return k(X.type,ae,X.props)}function $(X){return typeof X=="object"&&X!==null&&X.$$typeof===t}function O(X){var ae={"=":"=0",":":"=2"};return"$"+X.replace(/[=:]/g,function(pe){return ae[pe]})}var K=/\/+/g;function L(X,ae){return typeof X=="object"&&X!==null&&X.key!=null?O(""+X.key):ae.toString(36)}function ne(X){switch(X.status){case"fulfilled":return X.value;case"rejected":throw X.reason;default:switch(typeof X.status=="string"?X.then(A,A):(X.status="pending",X.then(function(ae){X.status==="pending"&&(X.status="fulfilled",X.value=ae)},function(ae){X.status==="pending"&&(X.status="rejected",X.reason=ae)})),X.status){case"fulfilled":return X.value;case"rejected":throw X.reason}}throw X}function re(X,ae,pe,be,fe){var B=typeof X;(B==="undefined"||B==="boolean")&&(X=null);var Y=!1;if(X===null)Y=!0;else switch(B){case"bigint":case"string":case"number":Y=!0;break;case"object":switch(X.$$typeof){case t:case e:Y=!0;break;case u:return Y=X._init,re(Y(X._payload),ae,pe,be,fe)}}if(Y)return fe=fe(X),Y=be===""?"."+L(X,0):be,C(fe)?(pe="",Y!=null&&(pe=Y.replace(K,"$&/")+"/"),re(fe,ae,pe,"",function(U){return U})):fe!=null&&($(fe)&&(fe=D(fe,pe+(fe.key==null||X&&X.key===fe.key?"":(""+fe.key).replace(K,"$&/")+"/")+Y)),ae.push(fe)),1;Y=0;var E=be===""?".":be+":";if(C(X))for(var M=0;M>>1,se=re[ie];if(0>>1;iei(pe,ue))bei(fe,pe)?(re[ie]=fe,re[be]=ue,ie=be):(re[ie]=pe,re[ae]=ue,ie=ae);else if(bei(fe,ue))re[ie]=fe,re[be]=ue,ie=be;else break e}}return Z}function i(re,Z){var ue=re.sortIndex-Z.sortIndex;return ue!==0?ue:re.id-Z.id}if(t.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var s=performance;t.unstable_now=function(){return s.now()}}else{var o=Date,l=o.now();t.unstable_now=function(){return o.now()-l}}var c=[],d=[],u=1,m=null,p=3,f=!1,x=!1,v=!1,g=!1,b=typeof setTimeout=="function"?setTimeout:null,S=typeof clearTimeout=="function"?clearTimeout:null,N=typeof setImmediate<"u"?setImmediate:null;function P(re){for(var Z=n(d);Z!==null;){if(Z.callback===null)r(d);else if(Z.startTime<=re)r(d),Z.sortIndex=Z.expirationTime,e(c,Z);else break;Z=n(d)}}function C(re){if(v=!1,P(re),!x)if(n(c)!==null)x=!0,A||(A=!0,O());else{var Z=n(d);Z!==null&&ne(C,Z.startTime-re)}}var A=!1,T=-1,F=5,k=-1;function D(){return g?!0:!(t.unstable_now()-kre&&D());){var ie=m.callback;if(typeof ie=="function"){m.callback=null,p=m.priorityLevel;var se=ie(m.expirationTime<=re);if(re=t.unstable_now(),typeof se=="function"){m.callback=se,P(re),Z=!0;break t}m===n(c)&&r(c),P(re)}else r(c);m=n(c)}if(m!==null)Z=!0;else{var X=n(d);X!==null&&ne(C,X.startTime-re),Z=!1}}break e}finally{m=null,p=ue,f=!1}Z=void 0}}finally{Z?O():A=!1}}}var O;if(typeof N=="function")O=function(){N($)};else if(typeof MessageChannel<"u"){var K=new MessageChannel,L=K.port2;K.port1.onmessage=$,O=function(){L.postMessage(null)}}else O=function(){b($,0)};function ne(re,Z){T=b(function(){re(t.unstable_now())},Z)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(re){re.callback=null},t.unstable_forceFrameRate=function(re){0>re||125ie?(re.sortIndex=ue,e(d,re),n(c)===null&&re===n(d)&&(v?(S(T),T=-1):v=!0,ne(C,ue-ie))):(re.sortIndex=se,e(c,re),x||f||(x=!0,A||(A=!0,O()))),re},t.unstable_shouldYield=D,t.unstable_wrapCallback=function(re){var Z=p;return function(){var ue=p;p=Z;try{return re.apply(this,arguments)}finally{p=ue}}}})(TM)),TM}var eH;function fue(){return eH||(eH=1,PM.exports=pue()),PM.exports}var AM={exports:{}},Ds={};var tH;function gue(){if(tH)return Ds;tH=1;var t=Bg();function e(c){var d="https://react.dev/errors/"+c;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),AM.exports=gue(),AM.exports}var rH;function bue(){if(rH)return Dv;rH=1;var t=fue(),e=Bg(),n=dQ();function r(h){var y="https://react.dev/errors/"+h;if(1se||(h.current=ie[se],ie[se]=null,se--)}function pe(h,y){se++,ie[se]=h.current,h.current=y}var be=X(null),fe=X(null),B=X(null),Y=X(null);function E(h,y){switch(pe(B,y),pe(fe,h),pe(be,null),y.nodeType){case 9:case 11:h=(h=y.documentElement)&&(h=h.namespaceURI)?x8(h):0;break;default:if(h=y.tagName,y=y.namespaceURI)y=x8(y),h=y8(y,h);else switch(h){case"svg":h=1;break;case"math":h=2;break;default:h=0}}ae(be),pe(be,h)}function M(){ae(be),ae(fe),ae(B)}function U(h){h.memoizedState!==null&&pe(Y,h);var y=be.current,_=y8(y,h.type);y!==_&&(pe(fe,h),pe(be,_))}function Q(h){fe.current===h&&(ae(be),ae(fe)),Y.current===h&&(ae(Y),Tv._currentValue=ue)}var z,ce;function I(h){if(z===void 0)try{throw Error()}catch(_){var y=_.stack.trim().match(/\n( *(at )?)/);z=y&&y[1]||"",ce=-1<_.stack.indexOf(` at`)?" ()":-1<_.stack.indexOf("@")?"@unknown:0:0":""}return` -`+z+h+ce}var W=!1;function $(h,y){if(!h||W)return"";W=!0;var _=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var j={DetermineComponentFrameRoot:function(){try{if(y){var Ut=function(){throw Error()};if(Object.defineProperty(Ut.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(Ut,[])}catch(At){var kt=At}Reflect.construct(h,[],Ut)}else{try{Ut.call()}catch(At){kt=At}h.call(Ut.prototype)}}else{try{throw Error()}catch(At){kt=At}(Ut=h())&&typeof Ut.catch=="function"&&Ut.catch(function(){})}}catch(At){if(At&&kt&&typeof At.stack=="string")return[At.stack,kt.stack]}return[null,null]}};j.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var G=Object.getOwnPropertyDescriptor(j.DetermineComponentFrameRoot,"name");G&&G.configurable&&Object.defineProperty(j.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var Z=j.DetermineComponentFrameRoot(),Ne=Z[0],qe=Z[1];if(Ne&&qe){var lt=Ne.split(` +`+z+h+ce}var G=!1;function q(h,y){if(!h||G)return"";G=!0;var _=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var j={DetermineComponentFrameRoot:function(){try{if(y){var Ut=function(){throw Error()};if(Object.defineProperty(Ut.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(Ut,[])}catch(At){var kt=At}Reflect.construct(h,[],Ut)}else{try{Ut.call()}catch(At){kt=At}h.call(Ut.prototype)}}else{try{throw Error()}catch(At){kt=At}(Ut=h())&&typeof Ut.catch=="function"&&Ut.catch(function(){})}}catch(At){if(At&&kt&&typeof At.stack=="string")return[At.stack,kt.stack]}return[null,null]}};j.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var V=Object.getOwnPropertyDescriptor(j.DetermineComponentFrameRoot,"name");V&&V.configurable&&Object.defineProperty(j.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var J=j.DetermineComponentFrameRoot(),Ne=J[0],qe=J[1];if(Ne&&qe){var lt=Ne.split(` `),vt=qe.split(` -`);for(G=j=0;jG||lt[j]!==vt[G]){var Rt=` -`+lt[j].replace(" at new "," at ");return h.displayName&&Rt.includes("")&&(Rt=Rt.replace("",h.displayName)),Rt}while(1<=j&&0<=G);break}}}finally{W=!1,Error.prepareStackTrace=_}return(_=h?h.displayName||h.name:"")?I(_):""}function H(h,y){switch(h.tag){case 26:case 27:case 5:return I(h.type);case 16:return I("Lazy");case 13:return h.child!==y&&y!==null?I("Suspense Fallback"):I("Suspense");case 19:return I("SuspenseList");case 0:case 15:return $(h.type,!1);case 11:return $(h.type.render,!1);case 1:return $(h.type,!0);case 31:return I("Activity");default:return""}}function J(h){try{var y="",_=null;do y+=H(h,_),_=h,h=h.return;while(h);return y}catch(j){return` +`);for(V=j=0;jV||lt[j]!==vt[V]){var Rt=` +`+lt[j].replace(" at new "," at ");return h.displayName&&Rt.includes("")&&(Rt=Rt.replace("",h.displayName)),Rt}while(1<=j&&0<=V);break}}}finally{G=!1,Error.prepareStackTrace=_}return(_=h?h.displayName||h.name:"")?I(_):""}function W(h,y){switch(h.tag){case 26:case 27:case 5:return I(h.type);case 16:return I("Lazy");case 13:return h.child!==y&&y!==null?I("Suspense Fallback"):I("Suspense");case 19:return I("SuspenseList");case 0:case 15:return q(h.type,!1);case 11:return q(h.type.render,!1);case 1:return q(h.type,!0);case 31:return I("Activity");default:return""}}function te(h){try{var y="",_=null;do y+=W(h,_),_=h,h=h.return;while(h);return y}catch(j){return` Error generating stack: `+j.message+` -`+j.stack}}var ie=Object.prototype.hasOwnProperty,be=t.unstable_scheduleCallback,pe=t.unstable_cancelCallback,he=t.unstable_shouldYield,q=t.unstable_requestPaint,R=t.unstable_now,te=t.unstable_getCurrentPriorityLevel,ge=t.unstable_ImmediatePriority,ke=t.unstable_UserBlockingPriority,je=t.unstable_NormalPriority,Ae=t.unstable_LowPriority,ye=t.unstable_IdlePriority,Re=t.log,Be=t.unstable_setDisableYieldValue,Ce=null,ze=null;function Me(h){if(typeof Re=="function"&&Be(h),ze&&typeof ze.setStrictMode=="function")try{ze.setStrictMode(Ce,h)}catch{}}var Oe=Math.clz32?Math.clz32:ve,Ge=Math.log,et=Math.LN2;function ve(h){return h>>>=0,h===0?32:31-(Ge(h)/et|0)|0}var Xe=256,Pe=262144,Ve=4194304;function we(h){var y=h&42;if(y!==0)return y;switch(h&-h){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return h&261888;case 262144:case 524288:case 1048576:case 2097152:return h&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return h&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return h}}function de(h,y,_){var j=h.pendingLanes;if(j===0)return 0;var G=0,Z=h.suspendedLanes,Ne=h.pingedLanes;h=h.warmLanes;var qe=j&134217727;return qe!==0?(j=qe&~Z,j!==0?G=we(j):(Ne&=qe,Ne!==0?G=we(Ne):_||(_=qe&~h,_!==0&&(G=we(_))))):(qe=j&~Z,qe!==0?G=we(qe):Ne!==0?G=we(Ne):_||(_=j&~h,_!==0&&(G=we(_)))),G===0?0:y!==0&&y!==G&&(y&Z)===0&&(Z=G&-G,_=y&-y,Z>=_||Z===32&&(_&4194048)!==0)?y:G}function Se(h,y){return(h.pendingLanes&~(h.suspendedLanes&~h.pingedLanes)&y)===0}function $e(h,y){switch(h){case 1:case 2:case 4:case 8:case 64:return y+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return y+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function st(){var h=Ve;return Ve<<=1,(Ve&62914560)===0&&(Ve=4194304),h}function He(h){for(var y=[],_=0;31>_;_++)y.push(h);return y}function rt(h,y){h.pendingLanes|=y,y!==268435456&&(h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0)}function ht(h,y,_,j,G,Z){var Ne=h.pendingLanes;h.pendingLanes=_,h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0,h.expiredLanes&=_,h.entangledLanes&=_,h.errorRecoveryDisabledLanes&=_,h.shellSuspendCounter=0;var qe=h.entanglements,lt=h.expirationTimes,vt=h.hiddenUpdates;for(_=Ne&~_;0<_;){var Rt=31-Oe(_),Ut=1<"u")return null;try{return h.activeElement||h.body}catch{return h.body}}var Tr=/[\n"\\]/g;function On(h){return h.replace(Tr,function(y){return"\\"+y.charCodeAt(0).toString(16)+" "})}function Ra(h,y,_,j,G,Z,Ne,qe){h.name="",Ne!=null&&typeof Ne!="function"&&typeof Ne!="symbol"&&typeof Ne!="boolean"?h.type=Ne:h.removeAttribute("type"),y!=null?Ne==="number"?(y===0&&h.value===""||h.value!=y)&&(h.value=""+ln(y)):h.value!==""+ln(y)&&(h.value=""+ln(y)):Ne!=="submit"&&Ne!=="reset"||h.removeAttribute("value"),y!=null?Br(h,Ne,ln(y)):_!=null?Br(h,Ne,ln(_)):j!=null&&h.removeAttribute("value"),G==null&&Z!=null&&(h.defaultChecked=!!Z),G!=null&&(h.checked=G&&typeof G!="function"&&typeof G!="symbol"),qe!=null&&typeof qe!="function"&&typeof qe!="symbol"&&typeof qe!="boolean"?h.name=""+ln(qe):h.removeAttribute("name")}function $n(h,y,_,j,G,Z,Ne,qe){if(Z!=null&&typeof Z!="function"&&typeof Z!="symbol"&&typeof Z!="boolean"&&(h.type=Z),y!=null||_!=null){if(!(Z!=="submit"&&Z!=="reset"||y!=null)){_n(h);return}_=_!=null?""+ln(_):"",y=y!=null?""+ln(y):_,qe||y===h.value||(h.value=y),h.defaultValue=y}j=j??G,j=typeof j!="function"&&typeof j!="symbol"&&!!j,h.checked=qe?h.checked:!!j,h.defaultChecked=!!j,Ne!=null&&typeof Ne!="function"&&typeof Ne!="symbol"&&typeof Ne!="boolean"&&(h.name=Ne),_n(h)}function Br(h,y,_){y==="number"&&br(h.ownerDocument)===h||h.defaultValue===""+_||(h.defaultValue=""+_)}function Kr(h,y,_,j){if(h=h.options,y){y={};for(var G=0;G<_.length;G++)y["$"+_[G]]=!0;for(_=0;_"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),zi=!1;if($o)try{var wl={};Object.defineProperty(wl,"passive",{get:function(){zi=!0}}),window.addEventListener("test",wl,wl),window.removeEventListener("test",wl,wl)}catch{zi=!1}var wo=null,ha=null,vc=null;function Im(){if(vc)return vc;var h,y=ha,_=y.length,j,G="value"in wo?wo.value:wo.textContent,Z=G.length;for(h=0;h<_&&y[h]===G[h];h++);var Ne=_-h;for(j=1;j<=Ne&&y[_-j]===G[Z-j];j++);return vc=G.slice(h,1=it),ct=" ",Mn=!1;function xr(h,y){switch(h){case"keyup":return Fn.indexOf(y.keyCode)!==-1;case"keydown":return y.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function lr(h){return h=h.detail,typeof h=="object"&&"data"in h?h.data:null}var Or=!1;function La(h,y){switch(h){case"compositionend":return lr(y);case"keypress":return y.which!==32?null:(Mn=!0,ct);case"textInput":return h=y.data,h===ct&&Mn?null:h;default:return null}}function un(h,y){if(Or)return h==="compositionend"||!kn&&xr(h,y)?(h=Im(),vc=ha=wo=null,Or=!1,h):null;switch(h){case"paste":return null;case"keypress":if(!(y.ctrlKey||y.altKey||y.metaKey)||y.ctrlKey&&y.altKey){if(y.char&&1=y)return{node:_,offset:y-h};h=j}e:{for(;_;){if(_.nextSibling){_=_.nextSibling;break e}_=_.parentNode}_=void 0}_=Yp(_)}}function Jy(h,y){return h&&y?h===y?!0:h&&h.nodeType===3?!1:y&&y.nodeType===3?Jy(h,y.parentNode):"contains"in h?h.contains(y):h.compareDocumentPosition?!!(h.compareDocumentPosition(y)&16):!1:!1}function YS(h){h=h!=null&&h.ownerDocument!=null&&h.ownerDocument.defaultView!=null?h.ownerDocument.defaultView:window;for(var y=br(h.document);y instanceof h.HTMLIFrameElement;){try{var _=typeof y.contentWindow.location.href=="string"}catch{_=!1}if(_)h=y.contentWindow;else break;y=br(h.document)}return y}function EA(h){var y=h&&h.nodeName&&h.nodeName.toLowerCase();return y&&(y==="input"&&(h.type==="text"||h.type==="search"||h.type==="tel"||h.type==="url"||h.type==="password")||y==="textarea"||h.contentEditable==="true")}var Fce=$o&&"documentMode"in document&&11>=document.documentMode,eb=null,DA=null,Zy=null,FA=!1;function L5(h,y,_){var j=_.window===_?_.document:_.nodeType===9?_:_.ownerDocument;FA||eb==null||eb!==br(j)||(j=eb,"selectionStart"in j&&EA(j)?j={start:j.selectionStart,end:j.selectionEnd}:(j=(j.ownerDocument&&j.ownerDocument.defaultView||window).getSelection(),j={anchorNode:j.anchorNode,anchorOffset:j.anchorOffset,focusNode:j.focusNode,focusOffset:j.focusOffset}),Zy&&Vm(Zy,j)||(Zy=j,j=O_(DA,"onSelect"),0>=Ne,G-=Ne,ld=1<<32-Oe(y)+G|_<Qn?(vr=vn,vn=null):vr=vn.sibling;var Dr=kt(gt,vn,yt[Qn],Ot);if(Dr===null){vn===null&&(vn=vr);break}h&&vn&&Dr.alternate===null&&y(gt,vn),pt=Z(Dr,pt,Qn),Er===null?An=Dr:Er.sibling=Dr,Er=Dr,vn=vr}if(Qn===yt.length)return _(gt,vn),Sr&&du(gt,Qn),An;if(vn===null){for(;QnQn?(vr=vn,vn=null):vr=vn.sibling;var hh=kt(gt,vn,Dr.value,Ot);if(hh===null){vn===null&&(vn=vr);break}h&&vn&&hh.alternate===null&&y(gt,vn),pt=Z(hh,pt,Qn),Er===null?An=hh:Er.sibling=hh,Er=hh,vn=vr}if(Dr.done)return _(gt,vn),Sr&&du(gt,Qn),An;if(vn===null){for(;!Dr.done;Qn++,Dr=yt.next())Dr=Ut(gt,Dr.value,Ot),Dr!==null&&(pt=Z(Dr,pt,Qn),Er===null?An=Dr:Er.sibling=Dr,Er=Dr);return Sr&&du(gt,Qn),An}for(vn=j(vn);!Dr.done;Qn++,Dr=yt.next())Dr=At(vn,gt,Qn,Dr.value,Ot),Dr!==null&&(h&&Dr.alternate!==null&&vn.delete(Dr.key===null?Qn:Dr.key),pt=Z(Dr,pt,Qn),Er===null?An=Dr:Er.sibling=Dr,Er=Dr);return h&&vn.forEach(function(eue){return y(gt,eue)}),Sr&&du(gt,Qn),An}function Qr(gt,pt,yt,Ot){if(typeof yt=="object"&&yt!==null&&yt.type===v&&yt.key===null&&(yt=yt.props.children),typeof yt=="object"&&yt!==null){switch(yt.$$typeof){case f:e:{for(var An=yt.key;pt!==null;){if(pt.key===An){if(An=yt.type,An===v){if(pt.tag===7){_(gt,pt.sibling),Ot=G(pt,yt.props.children),Ot.return=gt,gt=Ot;break e}}else if(pt.elementType===An||typeof An=="object"&&An!==null&&An.$$typeof===F&&sf(An)===pt.type){_(gt,pt.sibling),Ot=G(pt,yt.props),iv(Ot,yt),Ot.return=gt,gt=Ot;break e}_(gt,pt);break}else y(gt,pt);pt=pt.sibling}yt.type===v?(Ot=ef(yt.props.children,gt.mode,Ot,yt.key),Ot.return=gt,gt=Ot):(Ot=t_(yt.type,yt.key,yt.props,null,gt.mode,Ot),iv(Ot,yt),Ot.return=gt,gt=Ot)}return Ne(gt);case x:e:{for(An=yt.key;pt!==null;){if(pt.key===An)if(pt.tag===4&&pt.stateNode.containerInfo===yt.containerInfo&&pt.stateNode.implementation===yt.implementation){_(gt,pt.sibling),Ot=G(pt,yt.children||[]),Ot.return=gt,gt=Ot;break e}else{_(gt,pt);break}else y(gt,pt);pt=pt.sibling}Ot=BA(yt,gt.mode,Ot),Ot.return=gt,gt=Ot}return Ne(gt);case F:return yt=sf(yt),Qr(gt,pt,yt,Ot)}if(ne(yt))return gn(gt,pt,yt,Ot);if(O(yt)){if(An=O(yt),typeof An!="function")throw Error(r(150));return yt=An.call(yt),In(gt,pt,yt,Ot)}if(typeof yt.then=="function")return Qr(gt,pt,l_(yt),Ot);if(yt.$$typeof===N)return Qr(gt,pt,a_(gt,yt),Ot);c_(gt,yt)}return typeof yt=="string"&&yt!==""||typeof yt=="number"||typeof yt=="bigint"?(yt=""+yt,pt!==null&&pt.tag===6?(_(gt,pt.sibling),Ot=G(pt,yt),Ot.return=gt,gt=Ot):(_(gt,pt),Ot=UA(yt,gt.mode,Ot),Ot.return=gt,gt=Ot),Ne(gt)):_(gt,pt)}return function(gt,pt,yt,Ot){try{av=0;var An=Qr(gt,pt,yt,Ot);return ub=null,An}catch(vn){if(vn===db||vn===s_)throw vn;var Er=Ko(29,vn,null,gt.mode);return Er.lanes=Ot,Er.return=gt,Er}}}var lf=iU(!0),sU=iU(!1),Ym=!1;function ZA(h){h.updateQueue={baseState:h.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function ej(h,y){h=h.updateQueue,y.updateQueue===h&&(y.updateQueue={baseState:h.baseState,firstBaseUpdate:h.firstBaseUpdate,lastBaseUpdate:h.lastBaseUpdate,shared:h.shared,callbacks:null})}function Qm(h){return{lane:h,tag:0,payload:null,callback:null,next:null}}function Jm(h,y,_){var j=h.updateQueue;if(j===null)return null;if(j=j.shared,(Lr&2)!==0){var G=j.pending;return G===null?y.next=y:(y.next=G.next,G.next=y),j.pending=y,y=e_(h),q5(h,null,_),y}return ZS(h,j,y,_),e_(h)}function sv(h,y,_){if(y=y.updateQueue,y!==null&&(y=y.shared,(_&4194048)!==0)){var j=y.lanes;j&=h.pendingLanes,_|=j,y.lanes=_,qt(h,_)}}function tj(h,y){var _=h.updateQueue,j=h.alternate;if(j!==null&&(j=j.updateQueue,_===j)){var G=null,Z=null;if(_=_.firstBaseUpdate,_!==null){do{var Ne={lane:_.lane,tag:_.tag,payload:_.payload,callback:null,next:null};Z===null?G=Z=Ne:Z=Z.next=Ne,_=_.next}while(_!==null);Z===null?G=Z=y:Z=Z.next=y}else G=Z=y;_={baseState:j.baseState,firstBaseUpdate:G,lastBaseUpdate:Z,shared:j.shared,callbacks:j.callbacks},h.updateQueue=_;return}h=_.lastBaseUpdate,h===null?_.firstBaseUpdate=y:h.next=y,_.lastBaseUpdate=y}var nj=!1;function ov(){if(nj){var h=cb;if(h!==null)throw h}}function lv(h,y,_,j){nj=!1;var G=h.updateQueue;Ym=!1;var Z=G.firstBaseUpdate,Ne=G.lastBaseUpdate,qe=G.shared.pending;if(qe!==null){G.shared.pending=null;var lt=qe,vt=lt.next;lt.next=null,Ne===null?Z=vt:Ne.next=vt,Ne=lt;var Rt=h.alternate;Rt!==null&&(Rt=Rt.updateQueue,qe=Rt.lastBaseUpdate,qe!==Ne&&(qe===null?Rt.firstBaseUpdate=vt:qe.next=vt,Rt.lastBaseUpdate=lt))}if(Z!==null){var Ut=G.baseState;Ne=0,Rt=vt=lt=null,qe=Z;do{var kt=qe.lane&-536870913,At=kt!==qe.lane;if(At?(yr&kt)===kt:(j&kt)===kt){kt!==0&&kt===lb&&(nj=!0),Rt!==null&&(Rt=Rt.next={lane:0,tag:qe.tag,payload:qe.payload,callback:null,next:null});e:{var gn=h,In=qe;kt=y;var Qr=_;switch(In.tag){case 1:if(gn=In.payload,typeof gn=="function"){Ut=gn.call(Qr,Ut,kt);break e}Ut=gn;break e;case 3:gn.flags=gn.flags&-65537|128;case 0:if(gn=In.payload,kt=typeof gn=="function"?gn.call(Qr,Ut,kt):gn,kt==null)break e;Ut=m({},Ut,kt);break e;case 2:Ym=!0}}kt=qe.callback,kt!==null&&(h.flags|=64,At&&(h.flags|=8192),At=G.callbacks,At===null?G.callbacks=[kt]:At.push(kt))}else At={lane:kt,tag:qe.tag,payload:qe.payload,callback:qe.callback,next:null},Rt===null?(vt=Rt=At,lt=Ut):Rt=Rt.next=At,Ne|=kt;if(qe=qe.next,qe===null){if(qe=G.shared.pending,qe===null)break;At=qe,qe=At.next,At.next=null,G.lastBaseUpdate=At,G.shared.pending=null}}while(!0);Rt===null&&(lt=Ut),G.baseState=lt,G.firstBaseUpdate=vt,G.lastBaseUpdate=Rt,Z===null&&(G.shared.lanes=0),rh|=Ne,h.lanes=Ne,h.memoizedState=Ut}}function oU(h,y){if(typeof h!="function")throw Error(r(191,h));h.call(y)}function lU(h,y){var _=h.callbacks;if(_!==null)for(h.callbacks=null,h=0;h<_.length;h++)oU(_[h],y)}var mb=K(null),d_=K(0);function cU(h,y){h=wu,me(d_,h),me(mb,y),wu=h|y.baseLanes}function rj(){me(d_,wu),me(mb,mb.current)}function aj(){wu=d_.current,ae(mb),ae(d_)}var Xo=K(null),Dl=null;function Zm(h){var y=h.alternate;me(ai,ai.current&1),me(Xo,h),Dl===null&&(y===null||mb.current!==null||y.memoizedState!==null)&&(Dl=h)}function ij(h){me(ai,ai.current),me(Xo,h),Dl===null&&(Dl=h)}function dU(h){h.tag===22?(me(ai,ai.current),me(Xo,h),Dl===null&&(Dl=h)):eh()}function eh(){me(ai,ai.current),me(Xo,Xo.current)}function Yo(h){ae(Xo),Dl===h&&(Dl=null),ae(ai)}var ai=K(0);function u_(h){for(var y=h;y!==null;){if(y.tag===13){var _=y.memoizedState;if(_!==null&&(_=_.dehydrated,_===null||uM(_)||mM(_)))return y}else if(y.tag===19&&(y.memoizedProps.revealOrder==="forwards"||y.memoizedProps.revealOrder==="backwards"||y.memoizedProps.revealOrder==="unstable_legacy-backwards"||y.memoizedProps.revealOrder==="together")){if((y.flags&128)!==0)return y}else if(y.child!==null){y.child.return=y,y=y.child;continue}if(y===h)break;for(;y.sibling===null;){if(y.return===null||y.return===h)return null;y=y.return}y.sibling.return=y.return,y=y.sibling}return null}var hu=0,Xn=null,Xr=null,mi=null,m_=!1,hb=!1,cf=!1,h_=0,cv=0,pb=null,$ce=0;function $a(){throw Error(r(321))}function sj(h,y){if(y===null)return!1;for(var _=0;_Z?Z:8;var Ne=re.T,qe={};re.T=qe,wj(h,!1,y,_);try{var lt=G(),vt=re.S;if(vt!==null&&vt(qe,lt),lt!==null&&typeof lt=="object"&&typeof lt.then=="function"){var Rt=qce(lt,j);uv(h,y,Rt,Zo(h))}else uv(h,y,j,Zo(h))}catch(Ut){uv(h,y,{then:function(){},status:"rejected",reason:Ut},Zo())}finally{ee.p=Z,Ne!==null&&qe.types!==null&&(Ne.types=qe.types),re.T=Ne}}function Xce(){}function yj(h,y,_,j){if(h.tag!==5)throw Error(r(476));var G=UU(h).queue;zU(h,G,y,ue,_===null?Xce:function(){return BU(h),_(j)})}function UU(h){var y=h.memoizedState;if(y!==null)return y;y={memoizedState:ue,baseState:ue,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:pu,lastRenderedState:ue},next:null};var _={};return y.next={memoizedState:_,baseState:_,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:pu,lastRenderedState:_},next:null},h.memoizedState=y,h=h.alternate,h!==null&&(h.memoizedState=y),y}function BU(h){var y=UU(h);y.next===null&&(y=h.alternate.memoizedState),uv(h,y.next.queue,{},Zo())}function vj(){return ds(Tv)}function HU(){return ii().memoizedState}function qU(){return ii().memoizedState}function Yce(h){for(var y=h.return;y!==null;){switch(y.tag){case 24:case 3:var _=Zo();h=Qm(_);var j=Jm(y,h,_);j!==null&&(To(j,y,_),sv(j,y,_)),y={cache:XA()},h.payload=y;return}y=y.return}}function Qce(h,y,_){var j=Zo();_={lane:j,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null},y_(h)?VU(y,_):(_=OA(h,y,_,j),_!==null&&(To(_,h,j),GU(_,y,j)))}function $U(h,y,_){var j=Zo();uv(h,y,_,j)}function uv(h,y,_,j){var G={lane:j,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null};if(y_(h))VU(y,G);else{var Z=h.alternate;if(h.lanes===0&&(Z===null||Z.lanes===0)&&(Z=y.lastRenderedReducer,Z!==null))try{var Ne=y.lastRenderedState,qe=Z(Ne,_);if(G.hasEagerState=!0,G.eagerState=qe,_o(qe,Ne))return ZS(h,y,G,0),oa===null&&JS(),!1}catch{}if(_=OA(h,y,G,j),_!==null)return To(_,h,j),GU(_,y,j),!0}return!1}function wj(h,y,_,j){if(j={lane:2,revertLane:Zj(),gesture:null,action:j,hasEagerState:!1,eagerState:null,next:null},y_(h)){if(y)throw Error(r(479))}else y=OA(h,_,j,2),y!==null&&To(y,h,2)}function y_(h){var y=h.alternate;return h===Xn||y!==null&&y===Xn}function VU(h,y){hb=m_=!0;var _=h.pending;_===null?y.next=y:(y.next=_.next,_.next=y),h.pending=y}function GU(h,y,_){if((_&4194048)!==0){var j=y.lanes;j&=h.pendingLanes,_|=j,y.lanes=_,qt(h,_)}}var mv={readContext:ds,use:f_,useCallback:$a,useContext:$a,useEffect:$a,useImperativeHandle:$a,useLayoutEffect:$a,useInsertionEffect:$a,useMemo:$a,useReducer:$a,useRef:$a,useState:$a,useDebugValue:$a,useDeferredValue:$a,useTransition:$a,useSyncExternalStore:$a,useId:$a,useHostTransitionStatus:$a,useFormState:$a,useActionState:$a,useOptimistic:$a,useMemoCache:$a,useCacheRefresh:$a};mv.useEffectEvent=$a;var WU={readContext:ds,use:f_,useCallback:function(h,y){return Zs().memoizedState=[h,y===void 0?null:y],h},useContext:ds,useEffect:jU,useImperativeHandle:function(h,y,_){_=_!=null?_.concat([h]):null,b_(4194308,4,FU.bind(null,y,h),_)},useLayoutEffect:function(h,y){return b_(4194308,4,h,y)},useInsertionEffect:function(h,y){b_(4,2,h,y)},useMemo:function(h,y){var _=Zs();y=y===void 0?null:y;var j=h();if(cf){Me(!0);try{h()}finally{Me(!1)}}return _.memoizedState=[j,y],j},useReducer:function(h,y,_){var j=Zs();if(_!==void 0){var G=_(y);if(cf){Me(!0);try{_(y)}finally{Me(!1)}}}else G=y;return j.memoizedState=j.baseState=G,h={pending:null,lanes:0,dispatch:null,lastRenderedReducer:h,lastRenderedState:G},j.queue=h,h=h.dispatch=Qce.bind(null,Xn,h),[j.memoizedState,h]},useRef:function(h){var y=Zs();return h={current:h},y.memoizedState=h},useState:function(h){h=pj(h);var y=h.queue,_=$U.bind(null,Xn,y);return y.dispatch=_,[h.memoizedState,_]},useDebugValue:bj,useDeferredValue:function(h,y){var _=Zs();return xj(_,h,y)},useTransition:function(){var h=pj(!1);return h=zU.bind(null,Xn,h.queue,!0,!1),Zs().memoizedState=h,[!1,h]},useSyncExternalStore:function(h,y,_){var j=Xn,G=Zs();if(Sr){if(_===void 0)throw Error(r(407));_=_()}else{if(_=y(),oa===null)throw Error(r(349));(yr&127)!==0||pU(j,y,_)}G.memoizedState=_;var Z={value:_,getSnapshot:y};return G.queue=Z,jU(gU.bind(null,j,Z,h),[h]),j.flags|=2048,fb(9,{destroy:void 0},fU.bind(null,j,Z,_,y),null),_},useId:function(){var h=Zs(),y=oa.identifierPrefix;if(Sr){var _=cd,j=ld;_=(j&~(1<<32-Oe(j)-1)).toString(32)+_,y="_"+y+"R_"+_,_=h_++,0<_&&(y+="H"+_.toString(32)),y+="_"}else _=$ce++,y="_"+y+"r_"+_.toString(32)+"_";return h.memoizedState=y},useHostTransitionStatus:vj,useFormState:NU,useActionState:NU,useOptimistic:function(h){var y=Zs();y.memoizedState=y.baseState=h;var _={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return y.queue=_,y=wj.bind(null,Xn,!0,_),_.dispatch=y,[h,y]},useMemoCache:uj,useCacheRefresh:function(){return Zs().memoizedState=Yce.bind(null,Xn)},useEffectEvent:function(h){var y=Zs(),_={impl:h};return y.memoizedState=_,function(){if((Lr&2)!==0)throw Error(r(440));return _.impl.apply(void 0,arguments)}}},Sj={readContext:ds,use:f_,useCallback:LU,useContext:ds,useEffect:gj,useImperativeHandle:RU,useInsertionEffect:EU,useLayoutEffect:DU,useMemo:IU,useReducer:g_,useRef:AU,useState:function(){return g_(pu)},useDebugValue:bj,useDeferredValue:function(h,y){var _=ii();return OU(_,Xr.memoizedState,h,y)},useTransition:function(){var h=g_(pu)[0],y=ii().memoizedState;return[typeof h=="boolean"?h:dv(h),y]},useSyncExternalStore:hU,useId:HU,useHostTransitionStatus:vj,useFormState:CU,useActionState:CU,useOptimistic:function(h,y){var _=ii();return yU(_,Xr,h,y)},useMemoCache:uj,useCacheRefresh:qU};Sj.useEffectEvent=MU;var KU={readContext:ds,use:f_,useCallback:LU,useContext:ds,useEffect:gj,useImperativeHandle:RU,useInsertionEffect:EU,useLayoutEffect:DU,useMemo:IU,useReducer:hj,useRef:AU,useState:function(){return hj(pu)},useDebugValue:bj,useDeferredValue:function(h,y){var _=ii();return Xr===null?xj(_,h,y):OU(_,Xr.memoizedState,h,y)},useTransition:function(){var h=hj(pu)[0],y=ii().memoizedState;return[typeof h=="boolean"?h:dv(h),y]},useSyncExternalStore:hU,useId:HU,useHostTransitionStatus:vj,useFormState:TU,useActionState:TU,useOptimistic:function(h,y){var _=ii();return Xr!==null?yU(_,Xr,h,y):(_.baseState=h,[h,_.queue.dispatch])},useMemoCache:uj,useCacheRefresh:qU};KU.useEffectEvent=MU;function _j(h,y,_,j){y=h.memoizedState,_=_(j,y),_=_==null?y:m({},y,_),h.memoizedState=_,h.lanes===0&&(h.updateQueue.baseState=_)}var kj={enqueueSetState:function(h,y,_){h=h._reactInternals;var j=Zo(),G=Qm(j);G.payload=y,_!=null&&(G.callback=_),y=Jm(h,G,j),y!==null&&(To(y,h,j),sv(y,h,j))},enqueueReplaceState:function(h,y,_){h=h._reactInternals;var j=Zo(),G=Qm(j);G.tag=1,G.payload=y,_!=null&&(G.callback=_),y=Jm(h,G,j),y!==null&&(To(y,h,j),sv(y,h,j))},enqueueForceUpdate:function(h,y){h=h._reactInternals;var _=Zo(),j=Qm(_);j.tag=2,y!=null&&(j.callback=y),y=Jm(h,j,_),y!==null&&(To(y,h,_),sv(y,h,_))}};function XU(h,y,_,j,G,Z,Ne){return h=h.stateNode,typeof h.shouldComponentUpdate=="function"?h.shouldComponentUpdate(j,Z,Ne):y.prototype&&y.prototype.isPureReactComponent?!Vm(_,j)||!Vm(G,Z):!0}function YU(h,y,_,j){h=y.state,typeof y.componentWillReceiveProps=="function"&&y.componentWillReceiveProps(_,j),typeof y.UNSAFE_componentWillReceiveProps=="function"&&y.UNSAFE_componentWillReceiveProps(_,j),y.state!==h&&kj.enqueueReplaceState(y,y.state,null)}function df(h,y){var _=y;if("ref"in y){_={};for(var j in y)j!=="ref"&&(_[j]=y[j])}if(h=h.defaultProps){_===y&&(_=m({},_));for(var G in h)_[G]===void 0&&(_[G]=h[G])}return _}function QU(h){QS(h)}function JU(h){console.error(h)}function ZU(h){QS(h)}function v_(h,y){try{var _=h.onUncaughtError;_(y.value,{componentStack:y.stack})}catch(j){setTimeout(function(){throw j})}}function eB(h,y,_){try{var j=h.onCaughtError;j(_.value,{componentStack:_.stack,errorBoundary:y.tag===1?y.stateNode:null})}catch(G){setTimeout(function(){throw G})}}function Nj(h,y,_){return _=Qm(_),_.tag=3,_.payload={element:null},_.callback=function(){v_(h,y)},_}function tB(h){return h=Qm(h),h.tag=3,h}function nB(h,y,_,j){var G=_.type.getDerivedStateFromError;if(typeof G=="function"){var Z=j.value;h.payload=function(){return G(Z)},h.callback=function(){eB(y,_,j)}}var Ne=_.stateNode;Ne!==null&&typeof Ne.componentDidCatch=="function"&&(h.callback=function(){eB(y,_,j),typeof G!="function"&&(ah===null?ah=new Set([this]):ah.add(this));var qe=j.stack;this.componentDidCatch(j.value,{componentStack:qe!==null?qe:""})})}function Jce(h,y,_,j,G){if(_.flags|=32768,j!==null&&typeof j=="object"&&typeof j.then=="function"){if(y=_.alternate,y!==null&&ob(y,_,G,!0),_=Xo.current,_!==null){switch(_.tag){case 31:case 13:return Dl===null?E_():_.alternate===null&&Va===0&&(Va=3),_.flags&=-257,_.flags|=65536,_.lanes=G,j===o_?_.flags|=16384:(y=_.updateQueue,y===null?_.updateQueue=new Set([j]):y.add(j),Yj(h,j,G)),!1;case 22:return _.flags|=65536,j===o_?_.flags|=16384:(y=_.updateQueue,y===null?(y={transitions:null,markerInstances:null,retryQueue:new Set([j])},_.updateQueue=y):(_=y.retryQueue,_===null?y.retryQueue=new Set([j]):_.add(j)),Yj(h,j,G)),!1}throw Error(r(435,_.tag))}return Yj(h,j,G),E_(),!1}if(Sr)return y=Xo.current,y!==null?((y.flags&65536)===0&&(y.flags|=256),y.flags|=65536,y.lanes=G,j!==$A&&(h=Error(r(422),{cause:j}),tv(Al(h,_)))):(j!==$A&&(y=Error(r(423),{cause:j}),tv(Al(y,_))),h=h.current.alternate,h.flags|=65536,G&=-G,h.lanes|=G,j=Al(j,_),G=Nj(h.stateNode,j,G),tj(h,G),Va!==4&&(Va=2)),!1;var Z=Error(r(520),{cause:j});if(Z=Al(Z,_),vv===null?vv=[Z]:vv.push(Z),Va!==4&&(Va=2),y===null)return!0;j=Al(j,_),_=y;do{switch(_.tag){case 3:return _.flags|=65536,h=G&-G,_.lanes|=h,h=Nj(_.stateNode,j,h),tj(_,h),!1;case 1:if(y=_.type,Z=_.stateNode,(_.flags&128)===0&&(typeof y.getDerivedStateFromError=="function"||Z!==null&&typeof Z.componentDidCatch=="function"&&(ah===null||!ah.has(Z))))return _.flags|=65536,G&=-G,_.lanes|=G,G=tB(G),nB(G,h,_,j),tj(_,G),!1}_=_.return}while(_!==null);return!1}var Cj=Error(r(461)),hi=!1;function us(h,y,_,j){y.child=h===null?sU(y,null,_,j):lf(y,h.child,_,j)}function rB(h,y,_,j,G){_=_.render;var Z=y.ref;if("ref"in j){var Ne={};for(var qe in j)qe!=="ref"&&(Ne[qe]=j[qe])}else Ne=j;return rf(y),j=oj(h,y,_,Ne,Z,G),qe=lj(),h!==null&&!hi?(cj(h,y,G),fu(h,y,G)):(Sr&&qe&&HA(y),y.flags|=1,us(h,y,j,G),y.child)}function aB(h,y,_,j,G){if(h===null){var Z=_.type;return typeof Z=="function"&&!zA(Z)&&Z.defaultProps===void 0&&_.compare===null?(y.tag=15,y.type=Z,iB(h,y,Z,j,G)):(h=t_(_.type,null,j,y,y.mode,G),h.ref=y.ref,h.return=y,y.child=h)}if(Z=h.child,!Fj(h,G)){var Ne=Z.memoizedProps;if(_=_.compare,_=_!==null?_:Vm,_(Ne,j)&&h.ref===y.ref)return fu(h,y,G)}return y.flags|=1,h=cu(Z,j),h.ref=y.ref,h.return=y,y.child=h}function iB(h,y,_,j,G){if(h!==null){var Z=h.memoizedProps;if(Vm(Z,j)&&h.ref===y.ref)if(hi=!1,y.pendingProps=j=Z,Fj(h,G))(h.flags&131072)!==0&&(hi=!0);else return y.lanes=h.lanes,fu(h,y,G)}return Pj(h,y,_,j,G)}function sB(h,y,_,j){var G=j.children,Z=h!==null?h.memoizedState:null;if(h===null&&y.stateNode===null&&(y.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),j.mode==="hidden"){if((y.flags&128)!==0){if(Z=Z!==null?Z.baseLanes|_:_,h!==null){for(j=y.child=h.child,G=0;j!==null;)G=G|j.lanes|j.childLanes,j=j.sibling;j=G&~Z}else j=0,y.child=null;return oB(h,y,Z,_,j)}if((_&536870912)!==0)y.memoizedState={baseLanes:0,cachePool:null},h!==null&&i_(y,Z!==null?Z.cachePool:null),Z!==null?cU(y,Z):rj(),dU(y);else return j=y.lanes=536870912,oB(h,y,Z!==null?Z.baseLanes|_:_,_,j)}else Z!==null?(i_(y,Z.cachePool),cU(y,Z),eh(),y.memoizedState=null):(h!==null&&i_(y,null),rj(),eh());return us(h,y,G,_),y.child}function hv(h,y){return h!==null&&h.tag===22||y.stateNode!==null||(y.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),y.sibling}function oB(h,y,_,j,G){var Z=QA();return Z=Z===null?null:{parent:ui._currentValue,pool:Z},y.memoizedState={baseLanes:_,cachePool:Z},h!==null&&i_(y,null),rj(),dU(y),h!==null&&ob(h,y,j,!0),y.childLanes=G,null}function w_(h,y){return y=__({mode:y.mode,children:y.children},h.mode),y.ref=h.ref,h.child=y,y.return=h,y}function lB(h,y,_){return lf(y,h.child,null,_),h=w_(y,y.pendingProps),h.flags|=2,Yo(y),y.memoizedState=null,h}function Zce(h,y,_){var j=y.pendingProps,G=(y.flags&128)!==0;if(y.flags&=-129,h===null){if(Sr){if(j.mode==="hidden")return h=w_(y,j),y.lanes=536870912,hv(null,h);if(ij(y),(h=xa)?(h=v8(h,El),h=h!==null&&h.data==="&"?h:null,h!==null&&(y.memoizedState={dehydrated:h,treeContext:Gm!==null?{id:ld,overflow:cd}:null,retryLane:536870912,hydrationErrors:null},_=V5(h),_.return=y,y.child=_,cs=y,xa=null)):h=null,h===null)throw Km(y);return y.lanes=536870912,null}return w_(y,j)}var Z=h.memoizedState;if(Z!==null){var Ne=Z.dehydrated;if(ij(y),G)if(y.flags&256)y.flags&=-257,y=lB(h,y,_);else if(y.memoizedState!==null)y.child=h.child,y.flags|=128,y=null;else throw Error(r(558));else if(hi||ob(h,y,_,!1),G=(_&h.childLanes)!==0,hi||G){if(j=oa,j!==null&&(Ne=Vt(j,_),Ne!==0&&Ne!==Z.retryLane))throw Z.retryLane=Ne,Zp(h,Ne),To(j,h,Ne),Cj;E_(),y=lB(h,y,_)}else h=Z.treeContext,xa=Fl(Ne.nextSibling),cs=y,Sr=!0,Wm=null,El=!1,h!==null&&K5(y,h),y=w_(y,j),y.flags|=4096;return y}return h=cu(h.child,{mode:j.mode,children:j.children}),h.ref=y.ref,y.child=h,h.return=y,h}function S_(h,y){var _=y.ref;if(_===null)h!==null&&h.ref!==null&&(y.flags|=4194816);else{if(typeof _!="function"&&typeof _!="object")throw Error(r(284));(h===null||h.ref!==_)&&(y.flags|=4194816)}}function Pj(h,y,_,j,G){return rf(y),_=oj(h,y,_,j,void 0,G),j=lj(),h!==null&&!hi?(cj(h,y,G),fu(h,y,G)):(Sr&&j&&HA(y),y.flags|=1,us(h,y,_,G),y.child)}function cB(h,y,_,j,G,Z){return rf(y),y.updateQueue=null,_=mU(y,j,_,G),uU(h),j=lj(),h!==null&&!hi?(cj(h,y,Z),fu(h,y,Z)):(Sr&&j&&HA(y),y.flags|=1,us(h,y,_,Z),y.child)}function dB(h,y,_,j,G){if(rf(y),y.stateNode===null){var Z=rb,Ne=_.contextType;typeof Ne=="object"&&Ne!==null&&(Z=ds(Ne)),Z=new _(j,Z),y.memoizedState=Z.state!==null&&Z.state!==void 0?Z.state:null,Z.updater=kj,y.stateNode=Z,Z._reactInternals=y,Z=y.stateNode,Z.props=j,Z.state=y.memoizedState,Z.refs={},ZA(y),Ne=_.contextType,Z.context=typeof Ne=="object"&&Ne!==null?ds(Ne):rb,Z.state=y.memoizedState,Ne=_.getDerivedStateFromProps,typeof Ne=="function"&&(_j(y,_,Ne,j),Z.state=y.memoizedState),typeof _.getDerivedStateFromProps=="function"||typeof Z.getSnapshotBeforeUpdate=="function"||typeof Z.UNSAFE_componentWillMount!="function"&&typeof Z.componentWillMount!="function"||(Ne=Z.state,typeof Z.componentWillMount=="function"&&Z.componentWillMount(),typeof Z.UNSAFE_componentWillMount=="function"&&Z.UNSAFE_componentWillMount(),Ne!==Z.state&&kj.enqueueReplaceState(Z,Z.state,null),lv(y,j,Z,G),ov(),Z.state=y.memoizedState),typeof Z.componentDidMount=="function"&&(y.flags|=4194308),j=!0}else if(h===null){Z=y.stateNode;var qe=y.memoizedProps,lt=df(_,qe);Z.props=lt;var vt=Z.context,Rt=_.contextType;Ne=rb,typeof Rt=="object"&&Rt!==null&&(Ne=ds(Rt));var Ut=_.getDerivedStateFromProps;Rt=typeof Ut=="function"||typeof Z.getSnapshotBeforeUpdate=="function",qe=y.pendingProps!==qe,Rt||typeof Z.UNSAFE_componentWillReceiveProps!="function"&&typeof Z.componentWillReceiveProps!="function"||(qe||vt!==Ne)&&YU(y,Z,j,Ne),Ym=!1;var kt=y.memoizedState;Z.state=kt,lv(y,j,Z,G),ov(),vt=y.memoizedState,qe||kt!==vt||Ym?(typeof Ut=="function"&&(_j(y,_,Ut,j),vt=y.memoizedState),(lt=Ym||XU(y,_,lt,j,kt,vt,Ne))?(Rt||typeof Z.UNSAFE_componentWillMount!="function"&&typeof Z.componentWillMount!="function"||(typeof Z.componentWillMount=="function"&&Z.componentWillMount(),typeof Z.UNSAFE_componentWillMount=="function"&&Z.UNSAFE_componentWillMount()),typeof Z.componentDidMount=="function"&&(y.flags|=4194308)):(typeof Z.componentDidMount=="function"&&(y.flags|=4194308),y.memoizedProps=j,y.memoizedState=vt),Z.props=j,Z.state=vt,Z.context=Ne,j=lt):(typeof Z.componentDidMount=="function"&&(y.flags|=4194308),j=!1)}else{Z=y.stateNode,ej(h,y),Ne=y.memoizedProps,Rt=df(_,Ne),Z.props=Rt,Ut=y.pendingProps,kt=Z.context,vt=_.contextType,lt=rb,typeof vt=="object"&&vt!==null&&(lt=ds(vt)),qe=_.getDerivedStateFromProps,(vt=typeof qe=="function"||typeof Z.getSnapshotBeforeUpdate=="function")||typeof Z.UNSAFE_componentWillReceiveProps!="function"&&typeof Z.componentWillReceiveProps!="function"||(Ne!==Ut||kt!==lt)&&YU(y,Z,j,lt),Ym=!1,kt=y.memoizedState,Z.state=kt,lv(y,j,Z,G),ov();var At=y.memoizedState;Ne!==Ut||kt!==At||Ym||h!==null&&h.dependencies!==null&&r_(h.dependencies)?(typeof qe=="function"&&(_j(y,_,qe,j),At=y.memoizedState),(Rt=Ym||XU(y,_,Rt,j,kt,At,lt)||h!==null&&h.dependencies!==null&&r_(h.dependencies))?(vt||typeof Z.UNSAFE_componentWillUpdate!="function"&&typeof Z.componentWillUpdate!="function"||(typeof Z.componentWillUpdate=="function"&&Z.componentWillUpdate(j,At,lt),typeof Z.UNSAFE_componentWillUpdate=="function"&&Z.UNSAFE_componentWillUpdate(j,At,lt)),typeof Z.componentDidUpdate=="function"&&(y.flags|=4),typeof Z.getSnapshotBeforeUpdate=="function"&&(y.flags|=1024)):(typeof Z.componentDidUpdate!="function"||Ne===h.memoizedProps&&kt===h.memoizedState||(y.flags|=4),typeof Z.getSnapshotBeforeUpdate!="function"||Ne===h.memoizedProps&&kt===h.memoizedState||(y.flags|=1024),y.memoizedProps=j,y.memoizedState=At),Z.props=j,Z.state=At,Z.context=lt,j=Rt):(typeof Z.componentDidUpdate!="function"||Ne===h.memoizedProps&&kt===h.memoizedState||(y.flags|=4),typeof Z.getSnapshotBeforeUpdate!="function"||Ne===h.memoizedProps&&kt===h.memoizedState||(y.flags|=1024),j=!1)}return Z=j,S_(h,y),j=(y.flags&128)!==0,Z||j?(Z=y.stateNode,_=j&&typeof _.getDerivedStateFromError!="function"?null:Z.render(),y.flags|=1,h!==null&&j?(y.child=lf(y,h.child,null,G),y.child=lf(y,null,_,G)):us(h,y,_,G),y.memoizedState=Z.state,h=y.child):h=fu(h,y,G),h}function uB(h,y,_,j){return tf(),y.flags|=256,us(h,y,_,j),y.child}var Tj={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function Aj(h){return{baseLanes:h,cachePool:eU()}}function jj(h,y,_){return h=h!==null?h.childLanes&~_:0,y&&(h|=Jo),h}function mB(h,y,_){var j=y.pendingProps,G=!1,Z=(y.flags&128)!==0,Ne;if((Ne=Z)||(Ne=h!==null&&h.memoizedState===null?!1:(ai.current&2)!==0),Ne&&(G=!0,y.flags&=-129),Ne=(y.flags&32)!==0,y.flags&=-33,h===null){if(Sr){if(G?Zm(y):eh(),(h=xa)?(h=v8(h,El),h=h!==null&&h.data!=="&"?h:null,h!==null&&(y.memoizedState={dehydrated:h,treeContext:Gm!==null?{id:ld,overflow:cd}:null,retryLane:536870912,hydrationErrors:null},_=V5(h),_.return=y,y.child=_,cs=y,xa=null)):h=null,h===null)throw Km(y);return mM(h)?y.lanes=32:y.lanes=536870912,null}var qe=j.children;return j=j.fallback,G?(eh(),G=y.mode,qe=__({mode:"hidden",children:qe},G),j=ef(j,G,_,null),qe.return=y,j.return=y,qe.sibling=j,y.child=qe,j=y.child,j.memoizedState=Aj(_),j.childLanes=jj(h,Ne,_),y.memoizedState=Tj,hv(null,j)):(Zm(y),Mj(y,qe))}var lt=h.memoizedState;if(lt!==null&&(qe=lt.dehydrated,qe!==null)){if(Z)y.flags&256?(Zm(y),y.flags&=-257,y=Ej(h,y,_)):y.memoizedState!==null?(eh(),y.child=h.child,y.flags|=128,y=null):(eh(),qe=j.fallback,G=y.mode,j=__({mode:"visible",children:j.children},G),qe=ef(qe,G,_,null),qe.flags|=2,j.return=y,qe.return=y,j.sibling=qe,y.child=j,lf(y,h.child,null,_),j=y.child,j.memoizedState=Aj(_),j.childLanes=jj(h,Ne,_),y.memoizedState=Tj,y=hv(null,j));else if(Zm(y),mM(qe)){if(Ne=qe.nextSibling&&qe.nextSibling.dataset,Ne)var vt=Ne.dgst;Ne=vt,j=Error(r(419)),j.stack="",j.digest=Ne,tv({value:j,source:null,stack:null}),y=Ej(h,y,_)}else if(hi||ob(h,y,_,!1),Ne=(_&h.childLanes)!==0,hi||Ne){if(Ne=oa,Ne!==null&&(j=Vt(Ne,_),j!==0&&j!==lt.retryLane))throw lt.retryLane=j,Zp(h,j),To(Ne,h,j),Cj;uM(qe)||E_(),y=Ej(h,y,_)}else uM(qe)?(y.flags|=192,y.child=h.child,y=null):(h=lt.treeContext,xa=Fl(qe.nextSibling),cs=y,Sr=!0,Wm=null,El=!1,h!==null&&K5(y,h),y=Mj(y,j.children),y.flags|=4096);return y}return G?(eh(),qe=j.fallback,G=y.mode,lt=h.child,vt=lt.sibling,j=cu(lt,{mode:"hidden",children:j.children}),j.subtreeFlags=lt.subtreeFlags&65011712,vt!==null?qe=cu(vt,qe):(qe=ef(qe,G,_,null),qe.flags|=2),qe.return=y,j.return=y,j.sibling=qe,y.child=j,hv(null,j),j=y.child,qe=h.child.memoizedState,qe===null?qe=Aj(_):(G=qe.cachePool,G!==null?(lt=ui._currentValue,G=G.parent!==lt?{parent:lt,pool:lt}:G):G=eU(),qe={baseLanes:qe.baseLanes|_,cachePool:G}),j.memoizedState=qe,j.childLanes=jj(h,Ne,_),y.memoizedState=Tj,hv(h.child,j)):(Zm(y),_=h.child,h=_.sibling,_=cu(_,{mode:"visible",children:j.children}),_.return=y,_.sibling=null,h!==null&&(Ne=y.deletions,Ne===null?(y.deletions=[h],y.flags|=16):Ne.push(h)),y.child=_,y.memoizedState=null,_)}function Mj(h,y){return y=__({mode:"visible",children:y},h.mode),y.return=h,h.child=y}function __(h,y){return h=Ko(22,h,null,y),h.lanes=0,h}function Ej(h,y,_){return lf(y,h.child,null,_),h=Mj(y,y.pendingProps.children),h.flags|=2,y.memoizedState=null,h}function hB(h,y,_){h.lanes|=y;var j=h.alternate;j!==null&&(j.lanes|=y),WA(h.return,y,_)}function Dj(h,y,_,j,G,Z){var Ne=h.memoizedState;Ne===null?h.memoizedState={isBackwards:y,rendering:null,renderingStartTime:0,last:j,tail:_,tailMode:G,treeForkCount:Z}:(Ne.isBackwards=y,Ne.rendering=null,Ne.renderingStartTime=0,Ne.last=j,Ne.tail=_,Ne.tailMode=G,Ne.treeForkCount=Z)}function pB(h,y,_){var j=y.pendingProps,G=j.revealOrder,Z=j.tail;j=j.children;var Ne=ai.current,qe=(Ne&2)!==0;if(qe?(Ne=Ne&1|2,y.flags|=128):Ne&=1,me(ai,Ne),us(h,y,j,_),j=Sr?ev:0,!qe&&h!==null&&(h.flags&128)!==0)e:for(h=y.child;h!==null;){if(h.tag===13)h.memoizedState!==null&&hB(h,_,y);else if(h.tag===19)hB(h,_,y);else if(h.child!==null){h.child.return=h,h=h.child;continue}if(h===y)break e;for(;h.sibling===null;){if(h.return===null||h.return===y)break e;h=h.return}h.sibling.return=h.return,h=h.sibling}switch(G){case"forwards":for(_=y.child,G=null;_!==null;)h=_.alternate,h!==null&&u_(h)===null&&(G=_),_=_.sibling;_=G,_===null?(G=y.child,y.child=null):(G=_.sibling,_.sibling=null),Dj(y,!1,G,_,Z,j);break;case"backwards":case"unstable_legacy-backwards":for(_=null,G=y.child,y.child=null;G!==null;){if(h=G.alternate,h!==null&&u_(h)===null){y.child=G;break}h=G.sibling,G.sibling=_,_=G,G=h}Dj(y,!0,_,null,Z,j);break;case"together":Dj(y,!1,null,null,void 0,j);break;default:y.memoizedState=null}return y.child}function fu(h,y,_){if(h!==null&&(y.dependencies=h.dependencies),rh|=y.lanes,(_&y.childLanes)===0)if(h!==null){if(ob(h,y,_,!1),(_&y.childLanes)===0)return null}else return null;if(h!==null&&y.child!==h.child)throw Error(r(153));if(y.child!==null){for(h=y.child,_=cu(h,h.pendingProps),y.child=_,_.return=y;h.sibling!==null;)h=h.sibling,_=_.sibling=cu(h,h.pendingProps),_.return=y;_.sibling=null}return y.child}function Fj(h,y){return(h.lanes&y)!==0?!0:(h=h.dependencies,!!(h!==null&&r_(h)))}function ede(h,y,_){switch(y.tag){case 3:D(y,y.stateNode.containerInfo),Xm(y,ui,h.memoizedState.cache),tf();break;case 27:case 5:U(y);break;case 4:D(y,y.stateNode.containerInfo);break;case 10:Xm(y,y.type,y.memoizedProps.value);break;case 31:if(y.memoizedState!==null)return y.flags|=128,ij(y),null;break;case 13:var j=y.memoizedState;if(j!==null)return j.dehydrated!==null?(Zm(y),y.flags|=128,null):(_&y.child.childLanes)!==0?mB(h,y,_):(Zm(y),h=fu(h,y,_),h!==null?h.sibling:null);Zm(y);break;case 19:var G=(h.flags&128)!==0;if(j=(_&y.childLanes)!==0,j||(ob(h,y,_,!1),j=(_&y.childLanes)!==0),G){if(j)return pB(h,y,_);y.flags|=128}if(G=y.memoizedState,G!==null&&(G.rendering=null,G.tail=null,G.lastEffect=null),me(ai,ai.current),j)break;return null;case 22:return y.lanes=0,sB(h,y,_,y.pendingProps);case 24:Xm(y,ui,h.memoizedState.cache)}return fu(h,y,_)}function fB(h,y,_){if(h!==null)if(h.memoizedProps!==y.pendingProps)hi=!0;else{if(!Fj(h,_)&&(y.flags&128)===0)return hi=!1,ede(h,y,_);hi=(h.flags&131072)!==0}else hi=!1,Sr&&(y.flags&1048576)!==0&&W5(y,ev,y.index);switch(y.lanes=0,y.tag){case 16:e:{var j=y.pendingProps;if(h=sf(y.elementType),y.type=h,typeof h=="function")zA(h)?(j=df(h,j),y.tag=1,y=dB(null,y,h,j,_)):(y.tag=0,y=Pj(null,y,h,j,_));else{if(h!=null){var G=h.$$typeof;if(G===P){y.tag=11,y=rB(null,y,h,j,_);break e}else if(G===T){y.tag=14,y=aB(null,y,h,j,_);break e}}throw y=L(h)||h,Error(r(306,y,""))}}return y;case 0:return Pj(h,y,y.type,y.pendingProps,_);case 1:return j=y.type,G=df(j,y.pendingProps),dB(h,y,j,G,_);case 3:e:{if(D(y,y.stateNode.containerInfo),h===null)throw Error(r(387));j=y.pendingProps;var Z=y.memoizedState;G=Z.element,ej(h,y),lv(y,j,null,_);var Ne=y.memoizedState;if(j=Ne.cache,Xm(y,ui,j),j!==Z.cache&&KA(y,[ui],_,!0),ov(),j=Ne.element,Z.isDehydrated)if(Z={element:j,isDehydrated:!1,cache:Ne.cache},y.updateQueue.baseState=Z,y.memoizedState=Z,y.flags&256){y=uB(h,y,j,_);break e}else if(j!==G){G=Al(Error(r(424)),y),tv(G),y=uB(h,y,j,_);break e}else for(h=y.stateNode.containerInfo,h.nodeType===9?h=h.body:h=h.nodeName==="HTML"?h.ownerDocument.body:h,xa=Fl(h.firstChild),cs=y,Sr=!0,Wm=null,El=!0,_=sU(y,null,j,_),y.child=_;_;)_.flags=_.flags&-3|4096,_=_.sibling;else{if(tf(),j===G){y=fu(h,y,_);break e}us(h,y,j,_)}y=y.child}return y;case 26:return S_(h,y),h===null?(_=C8(y.type,null,y.pendingProps,null))?y.memoizedState=_:Sr||(_=y.type,h=y.pendingProps,j=z_(B.current).createElement(_),j[jt]=y,j[Jt]=h,ms(j,_,h),nt(j),y.stateNode=j):y.memoizedState=C8(y.type,h.memoizedProps,y.pendingProps,h.memoizedState),null;case 27:return U(y),h===null&&Sr&&(j=y.stateNode=_8(y.type,y.pendingProps,B.current),cs=y,El=!0,G=xa,lh(y.type)?(hM=G,xa=Fl(j.firstChild)):xa=G),us(h,y,y.pendingProps.children,_),S_(h,y),h===null&&(y.flags|=4194304),y.child;case 5:return h===null&&Sr&&((G=j=xa)&&(j=jde(j,y.type,y.pendingProps,El),j!==null?(y.stateNode=j,cs=y,xa=Fl(j.firstChild),El=!1,G=!0):G=!1),G||Km(y)),U(y),G=y.type,Z=y.pendingProps,Ne=h!==null?h.memoizedProps:null,j=Z.children,lM(G,Z)?j=null:Ne!==null&&lM(G,Ne)&&(y.flags|=32),y.memoizedState!==null&&(G=oj(h,y,Vce,null,null,_),Tv._currentValue=G),S_(h,y),us(h,y,j,_),y.child;case 6:return h===null&&Sr&&((h=_=xa)&&(_=Mde(_,y.pendingProps,El),_!==null?(y.stateNode=_,cs=y,xa=null,h=!0):h=!1),h||Km(y)),null;case 13:return mB(h,y,_);case 4:return D(y,y.stateNode.containerInfo),j=y.pendingProps,h===null?y.child=lf(y,null,j,_):us(h,y,j,_),y.child;case 11:return rB(h,y,y.type,y.pendingProps,_);case 7:return us(h,y,y.pendingProps,_),y.child;case 8:return us(h,y,y.pendingProps.children,_),y.child;case 12:return us(h,y,y.pendingProps.children,_),y.child;case 10:return j=y.pendingProps,Xm(y,y.type,j.value),us(h,y,j.children,_),y.child;case 9:return G=y.type._context,j=y.pendingProps.children,rf(y),G=ds(G),j=j(G),y.flags|=1,us(h,y,j,_),y.child;case 14:return aB(h,y,y.type,y.pendingProps,_);case 15:return iB(h,y,y.type,y.pendingProps,_);case 19:return pB(h,y,_);case 31:return Zce(h,y,_);case 22:return sB(h,y,_,y.pendingProps);case 24:return rf(y),j=ds(ui),h===null?(G=QA(),G===null&&(G=oa,Z=XA(),G.pooledCache=Z,Z.refCount++,Z!==null&&(G.pooledCacheLanes|=_),G=Z),y.memoizedState={parent:j,cache:G},ZA(y),Xm(y,ui,G)):((h.lanes&_)!==0&&(ej(h,y),lv(y,null,null,_),ov()),G=h.memoizedState,Z=y.memoizedState,G.parent!==j?(G={parent:j,cache:j},y.memoizedState=G,y.lanes===0&&(y.memoizedState=y.updateQueue.baseState=G),Xm(y,ui,j)):(j=Z.cache,Xm(y,ui,j),j!==G.cache&&KA(y,[ui],_,!0))),us(h,y,y.pendingProps.children,_),y.child;case 29:throw y.pendingProps}throw Error(r(156,y.tag))}function gu(h){h.flags|=4}function Rj(h,y,_,j,G){if((y=(h.mode&32)!==0)&&(y=!1),y){if(h.flags|=16777216,(G&335544128)===G)if(h.stateNode.complete)h.flags|=8192;else if(HB())h.flags|=8192;else throw of=o_,JA}else h.flags&=-16777217}function gB(h,y){if(y.type!=="stylesheet"||(y.state.loading&4)!==0)h.flags&=-16777217;else if(h.flags|=16777216,!M8(y))if(HB())h.flags|=8192;else throw of=o_,JA}function k_(h,y){y!==null&&(h.flags|=4),h.flags&16384&&(y=h.tag!==22?st():536870912,h.lanes|=y,yb|=y)}function pv(h,y){if(!Sr)switch(h.tailMode){case"hidden":y=h.tail;for(var _=null;y!==null;)y.alternate!==null&&(_=y),y=y.sibling;_===null?h.tail=null:_.sibling=null;break;case"collapsed":_=h.tail;for(var j=null;_!==null;)_.alternate!==null&&(j=_),_=_.sibling;j===null?y||h.tail===null?h.tail=null:h.tail.sibling=null:j.sibling=null}}function ya(h){var y=h.alternate!==null&&h.alternate.child===h.child,_=0,j=0;if(y)for(var G=h.child;G!==null;)_|=G.lanes|G.childLanes,j|=G.subtreeFlags&65011712,j|=G.flags&65011712,G.return=h,G=G.sibling;else for(G=h.child;G!==null;)_|=G.lanes|G.childLanes,j|=G.subtreeFlags,j|=G.flags,G.return=h,G=G.sibling;return h.subtreeFlags|=j,h.childLanes=_,y}function tde(h,y,_){var j=y.pendingProps;switch(qA(y),y.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return ya(y),null;case 1:return ya(y),null;case 3:return _=y.stateNode,j=null,h!==null&&(j=h.memoizedState.cache),y.memoizedState.cache!==j&&(y.flags|=2048),mu(ui),M(),_.pendingContext&&(_.context=_.pendingContext,_.pendingContext=null),(h===null||h.child===null)&&(sb(y)?gu(y):h===null||h.memoizedState.isDehydrated&&(y.flags&256)===0||(y.flags|=1024,VA())),ya(y),null;case 26:var G=y.type,Z=y.memoizedState;return h===null?(gu(y),Z!==null?(ya(y),gB(y,Z)):(ya(y),Rj(y,G,null,j,_))):Z?Z!==h.memoizedState?(gu(y),ya(y),gB(y,Z)):(ya(y),y.flags&=-16777217):(h=h.memoizedProps,h!==j&&gu(y),ya(y),Rj(y,G,h,j,_)),null;case 27:if(Y(y),_=B.current,G=y.type,h!==null&&y.stateNode!=null)h.memoizedProps!==j&&gu(y);else{if(!j){if(y.stateNode===null)throw Error(r(166));return ya(y),null}h=xe.current,sb(y)?X5(y):(h=_8(G,j,_),y.stateNode=h,gu(y))}return ya(y),null;case 5:if(Y(y),G=y.type,h!==null&&y.stateNode!=null)h.memoizedProps!==j&&gu(y);else{if(!j){if(y.stateNode===null)throw Error(r(166));return ya(y),null}if(Z=xe.current,sb(y))X5(y);else{var Ne=z_(B.current);switch(Z){case 1:Z=Ne.createElementNS("http://www.w3.org/2000/svg",G);break;case 2:Z=Ne.createElementNS("http://www.w3.org/1998/Math/MathML",G);break;default:switch(G){case"svg":Z=Ne.createElementNS("http://www.w3.org/2000/svg",G);break;case"math":Z=Ne.createElementNS("http://www.w3.org/1998/Math/MathML",G);break;case"script":Z=Ne.createElement("div"),Z.innerHTML=" +