From 57af8a1c19cec0acad74aaf0dd12cb8dff66a1ef Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 29 Apr 2026 07:42:09 +0200 Subject: [PATCH] feat(projects): URL field + cover photo on project cards (#1155) Two new project fields: a free-text URL rendered as a one-click external-link button beside the project name on every card (opens in a new tab, click is e.stopPropagation()-guarded so it doesn't enter the project), and a cover photo that replaces the status-icon box with a square thumbnail. URL is plumbed through ProjectCreate/Update/Response/ListResponse, including from-template + create-template flows so it inherits between a project and its template. Cover photo is not inherited because the file would be shared on disk between 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. PATCH uses model_fields_set for the URL field so users can clear it by sending {"url": null}. Cover image storage: Project.cover_image_filename references a file Cover image storage: Project.cover_image_filename references a file inside the existing archives/projects/{id}/attachments/ dir, but it's tracked separately from the attachments JSON list so swap/delete on the cover doesn't perturb the user's other attachments. Three routes (POST/GET/DELETE /projects/{id}/cover-image) accept only .jpg/.jpeg/ .png/.gif/.webp (no SVG -- SVG can carry script payloads), replace in place (prior file deleted before the new one lands so repeat uploads can't accumulate orphans), and self-heal when a DB reference points at a vanished disk file by clearing the column and 404'ing. GET cover-image is gated by RequireCameraStreamTokenIfAuthEnabled (accepts ?token=... query string) -- not the bearer-token gate -- so requests work in both auth-on and auth-off configurations. The frontend wraps getProjectCoverImageUrl with withStreamToken(), matching the existing pattern from getArchiveThumbnail. Permissions: PROJECTS_UPDATE for upload/delete/PATCH, PROJECTS_READ gate is implicit via the stream-token credential. Migration: 2 idempotent ALTER TABLE projects ADD COLUMN. Localised across all 8 UI languages. --- CHANGELOG.md | 1 + README.md | 1 + backend/app/api/routes/projects.py | 165 +++++++++++- backend/app/core/database.py | 7 + backend/app/models/project.py | 8 + backend/app/schemas/project.py | 34 ++- .../tests/integration/test_projects_api.py | 147 +++++++++++ .../test_printer_manager_status_broadcast.py | 21 +- frontend/src/__tests__/api/client.test.ts | 34 ++- .../src/__tests__/pages/ProjectsPage.test.tsx | 83 ++++++ frontend/src/api/client.ts | 35 +++ frontend/src/i18n/locales/de.ts | 9 + frontend/src/i18n/locales/en.ts | 9 + frontend/src/i18n/locales/fr.ts | 9 + frontend/src/i18n/locales/it.ts | 9 + frontend/src/i18n/locales/ja.ts | 9 + frontend/src/i18n/locales/pt-BR.ts | 9 + frontend/src/i18n/locales/zh-CN.ts | 9 + frontend/src/i18n/locales/zh-TW.ts | 9 + frontend/src/pages/ProjectsPage.tsx | 157 +++++++++++- .../{index-CeqwraNQ.js => index-8qP2vyrm.js} | 236 +++++++++--------- static/index.html | 2 +- 22 files changed, 876 insertions(+), 127 deletions(-) rename static/assets/{index-CeqwraNQ.js => index-8qP2vyrm.js} (81%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c22ae025..ed5f1a7a6 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 +- **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. - **Multi-color slicing in the Slice modal, with per-plate filament discovery for unsliced project files** — Initial slice support assumed a single filament profile per slice; multi-color 3MFs were silently truncated to the first slot, producing wrong colours on every non-trivial print. The Slice modal now (1) opens a plate-picker step first when the source is a multi-plate 3MF, (2) renders one filament dropdown per AMS slot the picked plate actually uses, with each dropdown auto-populated against the user's local + standard presets by `(filament_type, filament_colour)` match, and (3) submits the user's picks as an ordered `filament_presets: PresetRef[]` array which is forwarded as repeated `filamentProfile` multipart parts to the slicer sidecar (the CLI joins them with `;` for `--load-filaments`). **Per-plate filament list source-of-truth chain**: for a sliced archive the modal reads `Metadata/slice_info.config` directly (existing path); for an unsliced project file (where `slice_info.config` is empty until Bambu Studio actually slices), the new `slice_preview` service runs a fast preview-slice via the sidecar's `slice_without_profiles` (the project's embedded settings drive the slice; we throw away the gcode and only parse the resulting slice_info), and the result is cached by `(kind, source_id, plate_id, content_hash)` with LRU eviction at 256 entries — repeat opens of the same plate are instant. If the sidecar isn't reachable the modal falls back to a heuristic that reads `Metadata/project_settings.config` for the AMS slot config and intersects it with the plate's painted-face data (`paint_color` quadtree leaves on per-object .model files, scanned with a 5% noise threshold to drop single-leaf edit accidents). **SliceModal-only tier priority is now `local → cloud → standard`** (was `cloud → local → standard`): imported profiles win because they carry parsed type/colour metadata in the response, while cloud entries don't (the per-preset detail endpoint rate-limits at ~10/sec per token and 50+ parallel fetches returned 429 on every request). The unified-listing endpoint's dedup pass now backfills metadata cross-tier — if a cloud entry wins dedup over a same-named local entry, the cloud entry inherits the local's `filament_type` / `filament_colour` so the Slice modal's metadata-aware pre-pick keeps working for users who have presets both cloud-synced and locally imported. Other consumers of `/slicer/presets` (Profiles page, etc.) retain the existing cloud-first dedup. **Sidecar** (orca-slicer-api fork, `bambuddy/profile-resolver` branch): `/slice` now accepts up to 16 repeated `filamentProfile` parts (was hard-capped at 1), the slicing service materializes each as `filament_N.json` and joins paths into a single `--load-filaments "a.json;b.json;c.json"` invocation; `/profiles/bundled` listing was extended with `filament_type` and `filament_colour` per leaf so the bundled tier carries metadata into the modal. **Sliced-archive card now reflects the actually-used filament list, not the project-wide AMS config**: `slice_and_persist_as_archive` previously copied `filament_type` and `filament_color` from the unsliced source archive verbatim, which inherited every project-wide AMS slot (16+ swatches on the card for a 2-color print). The new archive now reads those fields from the sliced output's `slice_info.config` via `ThreeMFParser` (which already gates on `used_g > 0`), falling back to the source archive's values only if parsing failed. **Backwards compatibility**: `SliceRequest` schema accepts three shapes — legacy `filament_preset_id: int`, source-aware singular `filament_preset: PresetRef`, multi-color array `filament_presets: list[PresetRef]` — the validator promotes any of them into a populated `filament_presets` list before the route handler runs, and stale browser tabs from before this change keep working unchanged. **Permissions**: no new endpoint paths added; the preview-slice runs inside `/filament-requirements` (gated on `LIBRARY_READ` / `ARCHIVES_READ`) and the multi-filament dispatch runs inside `POST /slice` (gated on `LIBRARY_UPLOAD`) — no auth surface widened. **Tests**: 6 schema tests for `SliceRequest` covering the multi-filament list shape and legacy-vs-new precedence; 9 unit tests for `slice_preview` covering happy path, content-hash invalidation, sidecar-failure no-cache-poison, concurrent-call thundering-herd guard via per-key `asyncio.Lock`, and LRU eviction-with-lock-cleanup; 15 unit tests for `extract_project_filaments_from_3mf` (5 cases) and `extract_plate_extruder_set_from_3mf` (10 cases including the 60/40 painted-threshold pin); a multi-filament wire-format test on `slice_with_profiles` pinning that N filament profiles produce N repeated multipart parts in submission order; 22 frontend SliceModal tests covering the plate picker step, multi-color rendering, metadata-aware pre-pick, manual slot override, archive-vs-library routing, and the new tier order. Localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation per the project's existing flow). diff --git a/README.md b/README.md index eb090ce53..2a1d7e415 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer - Track plates (print jobs) and parts separately - Auto-detect parts count from 3MF files - Color-coded project badges +- **Project URL + cover photo** — paste a MakerWorld/Printables/Thingiverse link and upload a hero image so each card is immediately recognisable; the URL renders as a one-click link beside the project name - Bulk assign archives via multi-select toolbar - Import/Export projects as ZIP (includes files) or JSON - Print or queue files from linked library folders directly in the project view (resulting archive auto-linked to the project) diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py index 6a18db0ba..22f4bc10a 100644 --- a/backend/app/api/routes/projects.py +++ b/backend/app/api/routes/projects.py @@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from backend.app.api.routes.library import get_library_dir -from backend.app.core.auth import RequirePermissionIfAuthEnabled +from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled from backend.app.core.config import settings from backend.app.core.database import get_db from backend.app.core.permissions import Permission @@ -255,6 +255,8 @@ async def list_projects( queue_count=queue_count, progress_percent=progress_percent, archives=archive_previews, + url=project.url, + cover_image_filename=project.cover_image_filename, ) ) @@ -289,6 +291,7 @@ async def create_project( priority=data.priority, budget=data.budget, parent_id=data.parent_id, + url=data.url, ) db.add(project) await db.flush() @@ -306,6 +309,8 @@ async def create_project( target_parts_count=project.target_parts_count, notes=project.notes, attachments=project.attachments, + url=project.url, + cover_image_filename=project.cover_image_filename, tags=project.tags, due_date=project.due_date, priority=project.priority, @@ -355,6 +360,8 @@ async def list_templates( queue_count=0, progress_percent=None, archives=[], + url=project.url, + cover_image_filename=project.cover_image_filename, ) ) @@ -391,6 +398,7 @@ async def create_project_from_template( budget=template.budget, is_template=False, template_source_id=template.id, + url=template.url, ) db.add(project) await db.flush() @@ -428,6 +436,8 @@ async def create_project_from_template( target_parts_count=project.target_parts_count, notes=project.notes, attachments=project.attachments, + url=project.url, + cover_image_filename=project.cover_image_filename, tags=project.tags, due_date=project.due_date, priority=project.priority, @@ -511,6 +521,8 @@ async def get_project( target_parts_count=project.target_parts_count, notes=project.notes, attachments=project.attachments, + url=project.url, + cover_image_filename=project.cover_image_filename, tags=project.tags, due_date=project.due_date, priority=project.priority, @@ -567,6 +579,9 @@ async def update_project( project.priority = data.priority if "budget" in data.model_fields_set: project.budget = data.budget + if "url" in data.model_fields_set: + # Pydantic validator already guarantees http(s) prefix or None. + project.url = data.url if data.parent_id is not None: # Verify parent exists and prevent circular reference if data.parent_id == project_id: @@ -603,6 +618,8 @@ async def update_project( target_parts_count=project.target_parts_count, notes=project.notes, attachments=project.attachments, + url=project.url, + cover_image_filename=project.cover_image_filename, tags=project.tags, due_date=project.due_date, priority=project.priority, @@ -768,6 +785,19 @@ def get_project_attachments_dir(project_id: int) -> Path: return base_dir / "projects" / str(project_id) / "attachments" +# Cover-image upload accepts only common web-renderable image types (#1155). +# Subset of ALLOWED_ATTACHMENT_EXTENSIONS minus .svg/.ico because those don't +# render well as a card thumbnail. +COVER_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} +COVER_IMAGE_CONTENT_TYPES = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", +} + + # Allowed file extensions for attachments ALLOWED_ATTACHMENT_EXTENSIONS = { # Images @@ -985,6 +1015,132 @@ async def delete_attachment( } +# ============ #1155: Cover image ============ + + +@router.post("/{project_id}/cover-image") +async def upload_project_cover_image( + project_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE), +): + """Upload (or replace) the project's cover image (#1155). + + Stored alongside other attachments but tracked via Project.cover_image_filename + so swap/delete operations don't touch the attachments list. Replaces any + existing cover image — the prior file is deleted on disk before the new one + lands so a stuck filesystem reference can't accumulate orphaned images. + """ + result = await db.execute(select(Project).where(Project.id == project_id)) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + original_name = file.filename or "cover" + ext = os.path.splitext(original_name)[1].lower() + if ext not in COVER_IMAGE_EXTENSIONS: + raise HTTPException( + status_code=400, + detail=f"Cover image must be one of {sorted(COVER_IMAGE_EXTENSIONS)}", + ) + + attachments_dir = get_project_attachments_dir(project_id) + attachments_dir.mkdir(parents=True, exist_ok=True) + + # Remove the previous cover-image file from disk first so we don't accumulate + # orphans when users repeatedly replace it. Best-effort: a missing/locked file + # shouldn't block a successful replacement. + if project.cover_image_filename: + old_path = attachments_dir / project.cover_image_filename + if old_path.exists(): + try: + os.remove(old_path) + except OSError as e: + logger.warning("Failed to delete old cover image %s: %s", old_path, e) + + unique_filename = f"cover_{uuid.uuid4().hex}{ext}" + file_path = attachments_dir / unique_filename + try: + with open(file_path, "wb") as f: + content = await file.read() + f.write(content) + except OSError as e: + logger.error("Failed to save cover image: %s", e) + raise HTTPException(status_code=500, detail="Failed to save cover image") + + project.cover_image_filename = unique_filename + db.add(project) + await db.flush() + await db.commit() + + return { + "status": "success", + "filename": unique_filename, + "size": len(content), + } + + +@router.get("/{project_id}/cover-image") +async def get_project_cover_image( + project_id: int, + db: AsyncSession = Depends(get_db), + _: None = RequireCameraStreamTokenIfAuthEnabled, +): + """Stream the project's cover image (#1155). + + Browsers can't attach `Authorization: Bearer ...` to `` requests, + so this route accepts the same `?token=` stream-credential as + /archives/{id}/thumbnail. The frontend wraps URLs with `withStreamToken`.""" + result = await db.execute(select(Project).where(Project.id == project_id)) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + if not project.cover_image_filename: + raise HTTPException(status_code=404, detail="No cover image set") + + file_path = get_project_attachments_dir(project_id) / project.cover_image_filename + if not file_path.exists(): + # DB references a file that vanished from disk — clear the dangling + # reference so future GETs get a clean 404 instead of repeatedly + # touching the filesystem. + logger.warning("Cover image file missing for project %s: %s", project_id, file_path) + project.cover_image_filename = None + await db.commit() + raise HTTPException(status_code=404, detail="Cover image file not found") + + ext = os.path.splitext(project.cover_image_filename)[1].lower() + media_type = COVER_IMAGE_CONTENT_TYPES.get(ext, "application/octet-stream") + return FileResponse(file_path, media_type=media_type) + + +@router.delete("/{project_id}/cover-image") +async def delete_project_cover_image( + project_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE), +): + """Remove the project's cover image (#1155).""" + result = await db.execute(select(Project).where(Project.id == project_id)) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + if project.cover_image_filename: + file_path = get_project_attachments_dir(project_id) / project.cover_image_filename + if file_path.exists(): + try: + os.remove(file_path) + except OSError as e: + logger.warning("Failed to delete cover image file %s: %s", file_path, e) + project.cover_image_filename = None + db.add(project) + await db.flush() + await db.commit() + + return {"status": "success"} + + # ============ Phase 7: BOM Endpoints ============ @@ -1213,6 +1369,7 @@ async def create_template_from_project( budget=source.budget, is_template=True, template_source_id=source.id, + url=source.url, ) db.add(template) await db.flush() @@ -1250,6 +1407,8 @@ async def create_template_from_project( target_parts_count=template.target_parts_count, notes=template.notes, attachments=template.attachments, + url=template.url, + cover_image_filename=template.cover_image_filename, tags=template.tags, due_date=template.due_date, priority=template.priority, @@ -1570,6 +1729,8 @@ async def import_project( target_parts_count=project.target_parts_count, notes=project.notes, attachments=project.attachments, + url=project.url, + cover_image_filename=project.cover_image_filename, tags=project.tags, due_date=project.due_date, priority=project.priority, @@ -1743,6 +1904,8 @@ async def import_project_file( target_parts_count=project.target_parts_count, notes=project.notes, attachments=project.attachments, + url=project.url, + cover_image_filename=project.cover_image_filename, tags=project.tags, due_date=project.due_date, priority=project.priority, diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 7d66b206b..d3b019b48 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -640,6 +640,13 @@ async def run_migrations(conn): # Migration: Add target_parts_count column to projects for tracking total parts needed await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER") + # Migration: Add url + cover_image_filename columns to projects (#1155). + # url: external link rendered next to the project name on the card. + # cover_image_filename: filename of the project's hero image inside the + # existing attachments dir; rendered as a thumbnail on the card. + 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: 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 diff --git a/backend/app/models/project.py b/backend/app/models/project.py index 04b38d887..7e59f4365 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -16,6 +16,14 @@ class Project(Base): description: Mapped[str | None] = mapped_column(Text, nullable=True) color: Mapped[str | None] = mapped_column(String(20), nullable=True) # Hex color for UI status: Mapped[str] = mapped_column(String(20), default="active") # active, completed, archived + + # External link rendered as a clickable icon next to the project name (#1155). + url: Mapped[str | None] = mapped_column(String(2048), nullable=True) + # Filename of the cover photo inside the project's attachments dir; serves as + # the card's hero image when set (#1155). The file lives alongside other + # attachments but is tracked here separately so users can manage one without + # the other. + cover_image_filename: Mapped[str | None] = mapped_column(String(255), nullable=True) target_count: Mapped[int | None] = mapped_column( Integer, nullable=True ) # Optional target number of prints (plates) diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 90e570491..500fc3fae 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -1,6 +1,21 @@ from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel, field_validator + + +def _validate_project_url(value: str | None) -> str | None: + """Reject anything that isn't an http(s) URL — the URL is rendered as a + clickable `` so a `javascript:` / `data:` / `file:` value would be + an XSS vector even with React's default escaping (#1155).""" + if value is None: + return value + trimmed = value.strip() + if not trimmed: + return None + lowered = trimmed.lower() + if not (lowered.startswith("http://") or lowered.startswith("https://")): + raise ValueError("url must start with http:// or https://") + return trimmed class ProjectCreate(BaseModel): @@ -17,6 +32,12 @@ class ProjectCreate(BaseModel): priority: str = "normal" budget: float | None = None parent_id: int | None = None # For sub-projects + url: str | None = None + + @field_validator("url") + @classmethod + def _check_url(cls, v: str | None) -> str | None: + return _validate_project_url(v) class ProjectUpdate(BaseModel): @@ -34,6 +55,12 @@ class ProjectUpdate(BaseModel): priority: str | None = None budget: float | None = None parent_id: int | None = None + url: str | None = None + + @field_validator("url") + @classmethod + def _check_url(cls, v: str | None) -> str | None: + return _validate_project_url(v) class ProjectStats(BaseModel): @@ -95,6 +122,8 @@ class ProjectResponse(BaseModel): created_at: datetime updated_at: datetime stats: ProjectStats | None = None + url: str | None = None + cover_image_filename: str | None = None class Config: from_attributes = True @@ -132,6 +161,9 @@ class ProjectListResponse(BaseModel): progress_percent: float | None = None # Preview of archives (up to 5) archives: list[ArchivePreview] = [] + # #1155: card-level metadata + url: str | None = None + cover_image_filename: str | None = None class Config: from_attributes = True diff --git a/backend/tests/integration/test_projects_api.py b/backend/tests/integration/test_projects_api.py index 97b83afa0..693c73d7b 100644 --- a/backend/tests/integration/test_projects_api.py +++ b/backend/tests/integration/test_projects_api.py @@ -113,6 +113,153 @@ class TestProjectsAPI: assert response.status_code == 404 +class TestProjectUrlAndCoverImage: + """Tests for #1155 — url field + cover image upload/get/delete.""" + + @pytest.fixture + async def project_factory(self, db_session): + async def _create(**kwargs): + from backend.app.models.project import Project + + defaults = {"name": "URL/Cover Project", "color": "#00ff00"} + defaults.update(kwargs) + project = Project(**defaults) + db_session.add(project) + await db_session.commit() + await db_session.refresh(project) + return project + + return _create + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_project_accepts_https_url(self, async_client: AsyncClient): + response = await async_client.post( + "/api/v1/projects/", + json={"name": "With URL", "url": "https://makerworld.com/models/12345"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["url"] == "https://makerworld.com/models/12345" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_project_rejects_javascript_url(self, async_client: AsyncClient): + # `` rendering would execute javascript: URLs — schema must reject. + response = await async_client.post( + "/api/v1/projects/", + json={"name": "Hostile", "url": "javascript:alert(1)"}, + ) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_project_rejects_data_url(self, async_client: AsyncClient): + response = await async_client.post( + "/api/v1/projects/", + json={"name": "Hostile", "url": "data:text/html,"}, + ) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_patch_project_clears_url_when_explicitly_null(self, async_client: AsyncClient, project_factory): + project = await project_factory(url="https://example.com") + response = await async_client.patch(f"/api/v1/projects/{project.id}", json={"url": None}) + assert response.status_code == 200 + assert response.json()["url"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_upload_cover_image_then_serve_then_delete(self, async_client: AsyncClient, project_factory): + project = await project_factory() + + # 1x1 PNG (smallest valid PNG bytes) + png_bytes = bytes.fromhex( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4" + "890000000d49444154789c63f80f00000100010000000000000049454e44ae42" + "6082" + ) + upload = await async_client.post( + f"/api/v1/projects/{project.id}/cover-image", + files={"file": ("cover.png", png_bytes, "image/png")}, + ) + assert upload.status_code == 200, upload.text + body = upload.json() + assert body["status"] == "success" + assert body["filename"].endswith(".png") + cover_filename = body["filename"] + + # GET should serve the bytes back + served = await async_client.get(f"/api/v1/projects/{project.id}/cover-image") + assert served.status_code == 200 + assert served.headers["content-type"] == "image/png" + assert served.content == png_bytes + + # Project response should reflect the cover_image_filename field + view = await async_client.get(f"/api/v1/projects/{project.id}") + assert view.json()["cover_image_filename"] == cover_filename + + # DELETE should clear the field + deleted = await async_client.delete(f"/api/v1/projects/{project.id}/cover-image") + assert deleted.status_code == 200 + view2 = await async_client.get(f"/api/v1/projects/{project.id}") + assert view2.json()["cover_image_filename"] is None + # And subsequent GET should 404 + served2 = await async_client.get(f"/api/v1/projects/{project.id}/cover-image") + assert served2.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_upload_cover_image_rejects_non_image(self, async_client: AsyncClient, project_factory): + project = await project_factory() + response = await async_client.post( + f"/api/v1/projects/{project.id}/cover-image", + files={"file": ("evil.exe", b"MZ\x00\x00", "application/octet-stream")}, + ) + assert response.status_code == 400 + + @pytest.mark.integration + def test_cover_image_get_uses_stream_token_gate(self): + """Regression guard: GET /projects/{id}/cover-image MUST be gated by + ``RequireCameraStreamTokenIfAuthEnabled`` (accepts ``?token=…`` query + string) rather than by the bearer-token gate, because browsers can't + attach an ``Authorization`` header to ```` requests. Swapping + back to the bearer gate would silently 401 every cover image when auth + is enabled.""" + from fastapi.routing import APIRoute + + from backend.app.api.routes.projects import router + + # Find the GET cover-image route. The router exposes path/methods/ + # dependencies via APIRoute objects. + + cover_get = None + for route in router.routes: + if isinstance(route, APIRoute) and route.path.endswith("/cover-image") and "GET" in route.methods: + cover_get = route + break + + assert cover_get is not None, "GET cover-image route missing" + + # The route's dependant tree includes a Depends(require_camera_stream_token_if_auth_enabled()) + # — its `call` is the inner check function returned by that factory. + # Walk the dependant tree and assert one of the dependencies came from + # the stream-token factory, NOT from require_permission_if_auth_enabled. + from backend.app.core.auth import ( + require_camera_stream_token_if_auth_enabled, + ) + + # The factory returns a fresh closure each call; the most reliable + # signature is the qualified name of the function in the closure chain. + expected_qualname = require_camera_stream_token_if_auth_enabled().__qualname__ + + gate_qualnames = [dep.call.__qualname__ for dep in cover_get.dependant.dependencies if dep.call] + assert expected_qualname in gate_qualnames, ( + f"GET cover-image route is not gated by RequireCameraStreamTokenIfAuthEnabled. Found: {gate_qualnames}" + ) + + class TestProjectPartsTracking: """Tests for project parts tracking feature.""" diff --git a/backend/tests/unit/test_printer_manager_status_broadcast.py b/backend/tests/unit/test_printer_manager_status_broadcast.py index 934a3ecf1..bc6c25c48 100644 --- a/backend/tests/unit/test_printer_manager_status_broadcast.py +++ b/backend/tests/unit/test_printer_manager_status_broadcast.py @@ -32,6 +32,23 @@ def manager(): return PrinterManager() +def _close_unawaited(coro): + """Side effect for mocked ``_schedule_async``. + + ``set_awaiting_plate_clear`` evaluates the coroutine expressions + ``self._persist_awaiting_plate_clear(...)`` and + ``self._broadcast_status_change(...)`` before passing them to + ``_schedule_async``. When that target is patched, the coroutine objects + leak — Python's ``__del__`` then emits ``coroutine was never awaited`` + during GC, and when GC runs late enough that warning hits the interpreter + shutdown path with ``KeyError: '__import__'``. Closing the coroutine here + prevents both. Returns ``None`` so the mock's call signature is unchanged. + """ + if asyncio.iscoroutine(coro): + coro.close() + return None + + def _fake_state(**overrides): """Minimal stand-in for a ``PrinterState`` — only the attributes ``printer_state_to_dict`` reads. We use a SimpleNamespace rather than @@ -58,7 +75,7 @@ class TestSchedulingFromSetAwaitingPlateClear: manager._loop = MagicMock() manager._loop.is_running.return_value = True - with patch.object(manager, "_schedule_async") as scheduled: + with patch.object(manager, "_schedule_async", side_effect=_close_unawaited) as scheduled: manager.set_awaiting_plate_clear(7, True) # Two coroutines: persist + broadcast. Order doesn't matter. @@ -96,7 +113,7 @@ class TestSchedulingFromSetAwaitingPlateClear: manager._loop = MagicMock() manager._loop.is_running.return_value = True - with patch.object(manager, "_schedule_async") as scheduled: + with patch.object(manager, "_schedule_async", side_effect=_close_unawaited) as scheduled: manager.set_awaiting_plate_clear(7, True) scheduled.reset_mock() manager.set_awaiting_plate_clear(7, False) diff --git a/frontend/src/__tests__/api/client.test.ts b/frontend/src/__tests__/api/client.test.ts index 8ec28c224..5604bc972 100644 --- a/frontend/src/__tests__/api/client.test.ts +++ b/frontend/src/__tests__/api/client.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; -import { setAuthToken, getAuthToken, api } from '../../api/client'; +import { setAuthToken, getAuthToken, api, setStreamToken } from '../../api/client'; // Mock sessionStorage (H-5: tokens are stored in sessionStorage, not localStorage) const sessionStorageMock = { @@ -297,3 +297,35 @@ describe('Printer control endpoints', () => { expect(capturedUrl).toContain('mode=heating'); }); }); + +// #1155 — `` can't carry an `Authorization: Bearer …` header, so the +// project cover-image URL must use the same stream-token pattern as +// /archives/{id}/thumbnail. A regression where `withStreamToken` is removed +// would break the modal preview AND the card thumbnail when auth is enabled. +describe('Project cover image URL (#1155)', () => { + afterEach(() => { + setStreamToken(null); + }); + + it('appends the stream token query string when one is set', () => { + setStreamToken('abc123'); + const url = api.getProjectCoverImageUrl(42); + expect(url).toContain('/projects/42/cover-image'); + expect(url).toContain('token=abc123'); + }); + + it('returns the bare URL when no stream token is set', () => { + setStreamToken(null); + const url = api.getProjectCoverImageUrl(42); + expect(url).toContain('/projects/42/cover-image'); + expect(url).not.toContain('token='); + }); + + it('URL-encodes a token containing query-string-unsafe characters', () => { + setStreamToken('a&b=c'); + const url = api.getProjectCoverImageUrl(7); + // Decoded back, the token must round-trip exactly. + const params = new URL(url, 'http://x').searchParams; + expect(params.get('token')).toBe('a&b=c'); + }); +}); diff --git a/frontend/src/__tests__/pages/ProjectsPage.test.tsx b/frontend/src/__tests__/pages/ProjectsPage.test.tsx index 2689732a8..13f7aa844 100644 --- a/frontend/src/__tests__/pages/ProjectsPage.test.tsx +++ b/frontend/src/__tests__/pages/ProjectsPage.test.tsx @@ -148,4 +148,87 @@ describe('ProjectsPage', () => { }); }); }); + + // #1155 — URL link icon + cover image thumbnail on project cards. + describe('URL link and cover image (#1155)', () => { + it('renders an external-link icon next to the project name when URL is set', async () => { + server.use( + http.get('/api/v1/projects/', () => + HttpResponse.json([ + { + ...mockProjects[0], + url: 'https://makerworld.com/models/12345', + cover_image_filename: null, + }, + ]) + ) + ); + + render(); + + const link = await screen.findByLabelText(/Open project URL/i); + expect(link).toBeInTheDocument(); + expect(link.getAttribute('href')).toBe('https://makerworld.com/models/12345'); + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toContain('noopener'); + }); + + it('does not render the link icon when URL is not set', async () => { + // Default fixture has no `url` field — verify the icon is absent. + render(); + + await waitFor(() => { + expect(screen.getByText('Functional Parts')).toBeInTheDocument(); + }); + expect(screen.queryByLabelText(/Open project URL/i)).not.toBeInTheDocument(); + }); + + it('clicking the URL link does not bubble to the card onClick', async () => { + server.use( + http.get('/api/v1/projects/', () => + HttpResponse.json([ + { + ...mockProjects[0], + url: 'https://example.com', + cover_image_filename: null, + }, + ]) + ) + ); + + const user = userEvent.setup(); + render(); + + const link = await screen.findByLabelText(/Open project URL/i); + // Prevent the underlying anchor from triggering jsdom navigation noise + // — we only need the propagation guard verified. + link.addEventListener('click', (e) => e.preventDefault(), { once: true }); + await user.click(link); + + // No navigate / detail-page transition should have happened. Card root + // is still rendered. + expect(screen.getByText('Functional Parts')).toBeInTheDocument(); + }); + + it('renders a cover image thumbnail when cover_image_filename is set', async () => { + server.use( + http.get('/api/v1/projects/', () => + HttpResponse.json([ + { + ...mockProjects[0], + url: null, + cover_image_filename: 'cover_abc.png', + }, + ]) + ) + ); + + render(); + + const img = await screen.findByAltText(/Project cover photo/i); + expect(img).toBeInTheDocument(); + // Card thumbnail uses the GET endpoint URL, project.id is 1. + expect(img.getAttribute('src')).toContain('/projects/1/cover-image'); + }); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c9e0a454c..299a49255 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -647,6 +647,8 @@ export interface Project { created_at: string; updated_at: string; stats?: ProjectStats; + url: string | null; // External link rendered next to project name on the card (#1155) + cover_image_filename: string | null; // Filename within project attachments dir (#1155) } export interface ProjectAttachment { @@ -682,6 +684,8 @@ export interface ProjectListItem { queue_count: number; progress_percent: number | null; // Plates progress archives: ArchivePreview[]; + url: string | null; // #1155 + cover_image_filename: string | null; // #1155 } export interface ProjectCreate { @@ -696,6 +700,7 @@ export interface ProjectCreate { priority?: string; budget?: number | null; parent_id?: number; + url?: string | null; // #1155 } export interface ProjectUpdate { @@ -711,6 +716,7 @@ export interface ProjectUpdate { priority?: string; budget?: number | null; parent_id?: number; + url?: string | null; // #1155 — explicit null clears the URL } // BOM Types - Tracks sourced/purchased parts (hardware, electronics, etc.) @@ -4615,6 +4621,35 @@ export const api = { { method: 'DELETE' } ), + // #1155: Cover image + // Browsers can't attach `Authorization: Bearer ...` to ``, so we + // append the stream-token query string the same way archive thumbnails do. + getProjectCoverImageUrl: (projectId: number) => + withStreamToken(`${API_BASE}/projects/${projectId}/cover-image`), + uploadProjectCoverImage: async ( + projectId: number, + file: File + ): Promise<{ status: string; filename: string; size: number }> => { + const formData = new FormData(); + formData.append('file', file); + const headers: Record = {}; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + const response = await fetch(`${API_BASE}/projects/${projectId}/cover-image`, { + method: 'POST', + headers, + body: formData, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || `HTTP ${response.status}`); + } + return response.json(); + }, + deleteProjectCoverImage: (projectId: number) => + request<{ status: string }>(`/projects/${projectId}/cover-image`, { method: 'DELETE' }), + // BOM (Bill of Materials) getProjectBOM: (projectId: number) => request(`/projects/${projectId}/bom`), diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 81e05ea14..212fb9a9b 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -3034,6 +3034,15 @@ export default { // Modal fields namePlaceholder: 'z.B. Voron 2.4 Build', descriptionPlaceholder: 'Optionale Beschreibung...', + urlLabel: 'URL', + urlPlaceholder: 'https://makerworld.com/...', + urlInvalid: 'URL muss mit http:// oder https:// beginnen', + openExternalUrl: 'Projekt-URL öffnen', + coverImageLabel: 'Titelbild', + coverImageAlt: 'Projekt-Titelbild', + coverImageUpload: 'Hochladen', + coverImageReplace: 'Ersetzen', + coverImageRemove: 'Entfernen', color: 'Farbe', targetPlates: 'Ziel-Platten', targetPlatesPlaceholder: 'z.B. 25', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 869a79a19..3d6798c49 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3037,6 +3037,15 @@ export default { // Modal fields namePlaceholder: 'e.g., Voron 2.4 Build', descriptionPlaceholder: 'Optional description...', + urlLabel: 'URL', + urlPlaceholder: 'https://makerworld.com/...', + urlInvalid: 'URL must start with http:// or https://', + openExternalUrl: 'Open project URL', + coverImageLabel: 'Cover photo', + coverImageAlt: 'Project cover photo', + coverImageUpload: 'Upload', + coverImageReplace: 'Replace', + coverImageRemove: 'Remove', color: 'Color', targetPlates: 'Target Plates', targetPlatesPlaceholder: 'e.g., 25', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index d7fa5f584..afab66270 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -2956,6 +2956,15 @@ export default { // Modal fields namePlaceholder: 'ex: Build Voron 2.4', descriptionPlaceholder: 'Description optionnelle...', + urlLabel: 'URL', + urlPlaceholder: 'https://makerworld.com/...', + urlInvalid: "L'URL doit commencer par http:// ou https://", + openExternalUrl: 'Ouvrir l’URL du projet', + coverImageLabel: 'Photo de couverture', + coverImageAlt: 'Photo de couverture du projet', + coverImageUpload: 'Téléverser', + coverImageReplace: 'Remplacer', + coverImageRemove: 'Supprimer', color: 'Couleur', targetPlates: 'Plateaux cibles', targetPlatesPlaceholder: 'ex: 25', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 29ef27187..19e6ed98c 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -2955,6 +2955,15 @@ export default { // Modal fields namePlaceholder: 'es., Build Voron 2.4', descriptionPlaceholder: 'Descrizione opzionale...', + urlLabel: 'URL', + urlPlaceholder: 'https://makerworld.com/...', + urlInvalid: "L'URL deve iniziare con http:// o https://", + openExternalUrl: 'Apri URL del progetto', + coverImageLabel: 'Immagine di copertina', + coverImageAlt: 'Immagine di copertina del progetto', + coverImageUpload: 'Carica', + coverImageReplace: 'Sostituisci', + coverImageRemove: 'Rimuovi', color: 'Colore', targetPlates: 'Piatti target', targetPlatesPlaceholder: 'es., 25', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 5bfb4ddb5..71cabf078 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -2994,6 +2994,15 @@ export default { // Modal fields namePlaceholder: 'プロジェクト名', descriptionPlaceholder: 'プロジェクトの説明(任意)', + urlLabel: 'URL', + urlPlaceholder: 'https://makerworld.com/...', + urlInvalid: 'URLはhttp://またはhttps://で始まる必要があります', + openExternalUrl: 'プロジェクトのURLを開く', + coverImageLabel: 'カバー画像', + coverImageAlt: 'プロジェクトのカバー画像', + coverImageUpload: 'アップロード', + coverImageReplace: '置き換える', + coverImageRemove: '削除', color: '色', targetPlates: '目標プレート数', targetPlatesPlaceholder: '例: 10', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index a006f3e3e..c5a928259 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -2969,6 +2969,15 @@ export default { // Modal fields namePlaceholder: 'ex., Voron 2.4 Build', descriptionPlaceholder: 'Descrição opcional...', + urlLabel: 'URL', + urlPlaceholder: 'https://makerworld.com/...', + urlInvalid: 'A URL deve começar com http:// ou https://', + openExternalUrl: 'Abrir URL do projeto', + coverImageLabel: 'Imagem de capa', + coverImageAlt: 'Imagem de capa do projeto', + coverImageUpload: 'Enviar', + coverImageReplace: 'Substituir', + coverImageRemove: 'Remover', color: 'Cor', targetPlates: 'Placas Alvo', targetPlatesPlaceholder: 'ex., 25', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 0117c190d..9c615450f 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -3021,6 +3021,15 @@ export default { // Modal fields namePlaceholder: '例如:Voron 2.4 构建', descriptionPlaceholder: '可选描述...', + urlLabel: '网址', + urlPlaceholder: 'https://makerworld.com/...', + urlInvalid: '网址必须以 http:// 或 https:// 开头', + openExternalUrl: '打开项目网址', + coverImageLabel: '封面图片', + coverImageAlt: '项目封面图片', + coverImageUpload: '上传', + coverImageReplace: '替换', + coverImageRemove: '移除', color: '颜色', targetPlates: '目标板数', targetPlatesPlaceholder: '例如:25', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index a9e7b01c6..b5fdb740e 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -3020,6 +3020,15 @@ export default { viewDetails: '檢視詳情', // Modal fields namePlaceholder: '例如:Voron 2.4 構建', + urlLabel: '網址', + urlPlaceholder: 'https://makerworld.com/...', + urlInvalid: '網址必須以 http:// 或 https:// 開頭', + openExternalUrl: '開啟專案網址', + coverImageLabel: '封面圖片', + coverImageAlt: '專案封面圖片', + coverImageUpload: '上傳', + coverImageReplace: '替換', + coverImageRemove: '移除', descriptionPlaceholder: '可選描述...', color: '顏色', targetPlates: '目標板數', diff --git a/frontend/src/pages/ProjectsPage.tsx b/frontend/src/pages/ProjectsPage.tsx index 7b5b632de..716b5e9a1 100644 --- a/frontend/src/pages/ProjectsPage.tsx +++ b/frontend/src/pages/ProjectsPage.tsx @@ -19,6 +19,9 @@ import { MoreVertical, Download, Upload, + ExternalLink, + Image as ImageIcon, + X, } from 'lucide-react'; import { api } from '../api/client'; import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport, Permission } from '../api/client'; @@ -62,9 +65,54 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb const [dueDate, setDueDate] = useState((project as ProjectListItem & { due_date?: string })?.due_date?.split('T')[0] || ''); const [priority, setPriority] = useState((project as ProjectListItem & { priority?: string })?.priority || 'normal'); const [budget, setBudget] = useState(project?.budget?.toString() || ''); + const [url, setUrl] = useState(project?.url || ''); + const [urlError, setUrlError] = useState(null); + const queryClient = useQueryClient(); + const [coverImageFilename, setCoverImageFilename] = useState(project?.cover_image_filename || null); + const coverFileInputRef = useRef(null); + const [coverUploading, setCoverUploading] = useState(false); + // Cache-bust the cover image URL when it changes mid-edit so the preview + // refreshes after upload/remove. + const [coverCacheKey, setCoverCacheKey] = useState(0); + + const handleCoverFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !project) return; + setCoverUploading(true); + try { + const result = await api.uploadProjectCoverImage(project.id, file); + setCoverImageFilename(result.filename); + setCoverCacheKey((k) => k + 1); + queryClient.invalidateQueries({ queryKey: ['projects'] }); + } catch { + // Upload failed — leave existing cover image in place. + } finally { + setCoverUploading(false); + if (coverFileInputRef.current) coverFileInputRef.current.value = ''; + } + }; + + const handleRemoveCover = async () => { + if (!project) return; + setCoverUploading(true); + try { + await api.deleteProjectCoverImage(project.id); + setCoverImageFilename(null); + setCoverCacheKey((k) => k + 1); + queryClient.invalidateQueries({ queryKey: ['projects'] }); + } finally { + setCoverUploading(false); + } + }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + const trimmedUrl = url.trim(); + if (trimmedUrl && !/^https?:\/\//i.test(trimmedUrl)) { + setUrlError(t('projects.urlInvalid')); + return; + } + setUrlError(null); onSave({ name: name.trim(), description: description.trim() || undefined, @@ -75,6 +123,10 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb due_date: dueDate || undefined, priority, budget: budget.trim() ? parseFloat(budget) : null, + // Pydantic accepts null to clear the URL; an empty string would fail the + // http(s) prefix validator. Use undefined for create (omit) and null for + // edit-with-cleared-value. + url: project ? (trimmedUrl || null) : (trimmedUrl || undefined), ...(project && { status }), }); }; @@ -116,6 +168,80 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb /> + {/* #1155: External URL */} +
+ + { setUrl(e.target.value); if (urlError) setUrlError(null); }} + className={`w-full bg-bambu-dark border rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none ${ + urlError ? 'border-red-500 focus:border-red-500' : 'border-bambu-dark-tertiary focus:border-bambu-green' + }`} + placeholder={t('projects.urlPlaceholder')} + maxLength={2048} + /> + {urlError &&

{urlError}

} +
+ + {/* #1155: Cover image — only available when editing an existing project, + since uploading needs a project_id. New projects can add it after save. */} + {project && ( +
+ +
+
+ {coverImageFilename ? ( + {t('projects.coverImageAlt')} + ) : ( + + )} +
+
+ + + {coverImageFilename && ( + + )} +
+
+
+ )} +
-
- -
+ {project.cover_image_filename ? ( + // #1155: cover photo replaces the status-icon box +
+ {t('projects.coverImageAlt')} +
+ ) : ( +
+ +
+ )}

{project.name}

+ {project.url && ( +
e.stopPropagation()} + title={project.url} + aria-label={t('projects.openExternalUrl')} + className="inline-flex items-center justify-center w-6 h-6 rounded bg-bambu-dark border border-bambu-dark-tertiary text-bambu-green hover:bg-bambu-green/10 hover:border-bambu-green transition-colors flex-shrink-0" + > + + + )} {project.target_parts_count ? ( = 100 diff --git a/static/assets/index-CeqwraNQ.js b/static/assets/index-8qP2vyrm.js similarity index 81% rename from static/assets/index-CeqwraNQ.js rename to static/assets/index-8qP2vyrm.js index ba7aa1260..74666d76c 100644 --- a/static/assets/index-CeqwraNQ.js +++ b/static/assets/index-8qP2vyrm.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 $(K){return typeof K=="object"&&K!==null&&K.$$typeof===t}function z(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?z(""+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,be){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,be)}}if(Q)return be=be(K),Q=xe===""?"."+L(K,0):xe,C(be)?(me="",Q!=null&&(me=Q.replace(X,"$&/")+"/"),re(be,ae,me,"",function(U){return U})):be!=null&&($(be)&&(be=E(be,me+(be.key==null||K&&K.key===be.key?"":(""+be.key).replace(X,"$&/")+"/")+Q)),ae.push(be)),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(be,me)?(re[se]=be,re[xe]=ue,se=xe):(re[se]=me,re[ae]=ue,se=ae);else if(xei(be,ue))re[se]=be,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,z());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?z():A=!1}}}var z;if(typeof N=="function")z=function(){N($)};else if(typeof MessageChannel<"u"){var X=new MessageChannel,L=X.port2;X.port1.onmessage=$,z=function(){L.postMessage(null)}}else z=function(){g($,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,z()))),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),be=K(null),B=K(null),Q=K(null);function D(h,y){switch(me(B,y),me(be,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(be),ae(B)}function U(h){h.memoizedState!==null&&me(Q,h);var y=xe.current,_=f8(y,h.type);y!==_&&(me(be,h),me(xe,_))}function Y(h){be.current===h&&(ae(xe),ae(be)),Q.current===h&&(ae(Q),Tv._currentValue=ue)}var I,ce;function O(h){if(I===void 0)try{throw Error()}catch(_){var y=_.stack.trim().match(/\n( *(at )?)/);I=y&&y[1]||"",ce=-1<_.stack.indexOf(` +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(` at`)?" ()":-1<_.stack.indexOf("@")?"@unknown:0:0":""}return` -`+I+h+ce}var W=!1;function V(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 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(` `),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:"")?O(_):""}function H(h,y){switch(h.tag){case 26:case 27:case 5:return O(h.type);case 16:return O("Lazy");case 13:return h.child!==y&&y!==null?O("Suspense Fallback"):O("Suspense");case 19:return O("SuspenseList");case 0:case 15:return V(h.type,!1);case 11:return V(h.type.render,!1);case 1:return V(h.type,!0);case 31:return O("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` +`+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` Error generating stack: `+j.message+` -`+j.stack}}var ie=Object.prototype.hasOwnProperty,ge=t.unstable_scheduleCallback,pe=t.unstable_cancelCallback,he=t.unstable_shouldYield,q=t.unstable_requestPaint,R=t.unstable_now,te=t.unstable_getCurrentPriorityLevel,fe=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 Ie=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-Ie(_),Ut=1<"u")return null;try{return h.activeElement||h.body}catch{return h.body}}var Tr=/[\n"\\]/g;function In(h){return h.replace(Tr,function(y){return"\\"+y.charCodeAt(0).toString(16)+" "})}function Fa(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)){Sn(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),Sn(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 Om(){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 Ir=!1;function Ra(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(Ir)return h==="compositionend"||!_n&&xr(h,y)?(h=Om(),vc=ha=wo=null,Ir=!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=I_(DA,"onSelect"),0>=Ne,G-=Ne,ld=1<<32-Ie(y)+G|_<Qn?(vr=vn,vn=null):vr=vn.sibling;var Er=kt(gt,vn,yt[Qn],It);if(Er===null){vn===null&&(vn=vr);break}h&&vn&&Er.alternate===null&&y(gt,vn),pt=Z(Er,pt,Qn),Mr===null?An=Er:Mr.sibling=Er,Mr=Er,vn=vr}if(Qn===yt.length)return _(gt,vn),Sr&&cu(gt,Qn),An;if(vn===null){for(;QnQn?(vr=vn,vn=null):vr=vn.sibling;var hh=kt(gt,vn,Er.value,It);if(hh===null){vn===null&&(vn=vr);break}h&&vn&&hh.alternate===null&&y(gt,vn),pt=Z(hh,pt,Qn),Mr===null?An=hh:Mr.sibling=hh,Mr=hh,vn=vr}if(Er.done)return _(gt,vn),Sr&&cu(gt,Qn),An;if(vn===null){for(;!Er.done;Qn++,Er=yt.next())Er=Ut(gt,Er.value,It),Er!==null&&(pt=Z(Er,pt,Qn),Mr===null?An=Er:Mr.sibling=Er,Mr=Er);return Sr&&cu(gt,Qn),An}for(vn=j(vn);!Er.done;Qn++,Er=yt.next())Er=At(vn,gt,Qn,Er.value,It),Er!==null&&(h&&Er.alternate!==null&&vn.delete(Er.key===null?Qn:Er.key),pt=Z(Er,pt,Qn),Mr===null?An=Er:Mr.sibling=Er,Mr=Er);return h&&vn.forEach(function(eue){return y(gt,eue)}),Sr&&cu(gt,Qn),An}function Qr(gt,pt,yt,It){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),It=G(pt,yt.props.children),It.return=gt,gt=It;break e}}else if(pt.elementType===An||typeof An=="object"&&An!==null&&An.$$typeof===F&&sf(An)===pt.type){_(gt,pt.sibling),It=G(pt,yt.props),iv(It,yt),It.return=gt,gt=It;break e}_(gt,pt);break}else y(gt,pt);pt=pt.sibling}yt.type===v?(It=ef(yt.props.children,gt.mode,It,yt.key),It.return=gt,gt=It):(It=t_(yt.type,yt.key,yt.props,null,gt.mode,It),iv(It,yt),It.return=gt,gt=It)}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),It=G(pt,yt.children||[]),It.return=gt,gt=It;break e}else{_(gt,pt);break}else y(gt,pt);pt=pt.sibling}It=BA(yt,gt.mode,It),It.return=gt,gt=It}return Ne(gt);case F:return yt=sf(yt),Qr(gt,pt,yt,It)}if(ne(yt))return gn(gt,pt,yt,It);if(z(yt)){if(An=z(yt),typeof An!="function")throw Error(r(150));return yt=An.call(yt),On(gt,pt,yt,It)}if(typeof yt.then=="function")return Qr(gt,pt,l_(yt),It);if(yt.$$typeof===N)return Qr(gt,pt,a_(gt,yt),It);c_(gt,yt)}return typeof yt=="string"&&yt!==""||typeof yt=="number"||typeof yt=="bigint"?(yt=""+yt,pt!==null&&pt.tag===6?(_(gt,pt.sibling),It=G(pt,yt),It.return=gt,gt=It):(_(gt,pt),It=UA(yt,gt.mode,It),It.return=gt,gt=It),Ne(gt)):_(gt,pt)}return function(gt,pt,yt,It){try{av=0;var An=Qr(gt,pt,yt,It);return ub=null,An}catch(vn){if(vn===db||vn===s_)throw vn;var Mr=Ko(29,vn,null,gt.mode);return Mr.lanes=It,Mr.return=gt,Mr}}}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,On=qe;kt=y;var Qr=_;switch(On.tag){case 1:if(gn=On.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=On.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=vu,me(d_,h),me(mb,y),vu=h|y.baseLanes}function rj(){me(d_,vu),me(mb,mb.current)}function aj(){vu=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 mu=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:hu,lastRenderedState:ue},next:null};var _={};return y.next={memoizedState:_,baseState:_,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:hu,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,_):(_=IA(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(_=IA(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=IA(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-Ie(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:OU,useReducer:g_,useRef:AU,useState:function(){return g_(hu)},useDebugValue:bj,useDeferredValue:function(h,y){var _=ii();return IU(_,Xr.memoizedState,h,y)},useTransition:function(){var h=g_(hu)[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:OU,useReducer:hj,useRef:AU,useState:function(){return hj(hu)},useDebugValue:bj,useDeferredValue:function(h,y){var _=ii();return Xr===null?xj(_,h,y):IU(_,Xr.memoizedState,h,y)},useTransition:function(){var h=hj(hu)[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),pu(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 pu(h,y,G)}return y.flags|=1,h=lu(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,pu(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=lu(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),pu(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),pu(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=pu(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=lu(lt,{mode:"hidden",children:j.children}),j.subtreeFlags=lt.subtreeFlags&65011712,vt!==null?qe=lu(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,_=lu(_,{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 pu(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,_=lu(h,h.pendingProps),y.child=_,_.return=y;h.sibling!==null;)h=h.sibling,_=_.sibling=lu(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=pu(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 pu(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=pu(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 fu(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),uu(ui),M(),_.pendingContext&&(_.context=_.pendingContext,_.pendingContext=null),(h===null||h.child===null)&&(sb(y)?fu(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?(fu(y),Z!==null?(ya(y),gB(y,Z)):(ya(y),Rj(y,G,null,j,_))):Z?Z!==h.memoizedState?(fu(y),ya(y),gB(y,Z)):(ya(y),y.flags&=-16777217):(h=h.memoizedProps,h!==j&&fu(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&&fu(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,fu(y))}return ya(y),null;case 5:if(Y(y),G=y.type,h!==null&&y.stateNode!=null)h.memoizedProps!==j&&fu(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=" +