diff --git a/CODEOWNERS b/CODEOWNERS index 5e3a2b92..174adc20 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -15,6 +15,7 @@ go-libs @nfx @alexott ip_access_list_analyzer @alexott judge-builder @smoorjani @alkispoly-db ka-chat-bot @taiga-db +marketplace-provisioning @michellejm metascan @nfx @alexott runtime-packages @nfx @alexott sql_migration_copilot @robertwhiffin diff --git a/marketplace-provisioning/.gitignore b/marketplace-provisioning/.gitignore new file mode 100644 index 00000000..de802cd5 --- /dev/null +++ b/marketplace-provisioning/.gitignore @@ -0,0 +1,30 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ + +# Node +node_modules +npm-debug.log* + +# SQLite +*.db +*.db-wal +*.db-shm + +# Secrets / environment +.env + +# IDE +.idea/ +.vscode/ +*.swp + +# Databricks +.databricks/ + +# OS +.DS_Store +Thumbs.db diff --git a/marketplace-provisioning/README.md b/marketplace-provisioning/README.md new file mode 100644 index 00000000..830bfe7c --- /dev/null +++ b/marketplace-provisioning/README.md @@ -0,0 +1,219 @@ +--- +title: "Marketplace Provisioning App" +language: python +author: "Michelle McSweeney" +date: 2026-04-20 + +tags: +- marketplace +- genie +- databricks-apps +- free-edition +- provisioning +--- + +# Marketplace Provisioning App + +## Goal + +Provision Genie spaces and AI/BI dashboards for non-technical users on Databricks Free Edition — without requiring them to run a notebook. + +## Approach: Marketplace App (ephemeral provisioner) + +1. User clicks "Get" on a Marketplace App listing +2. App installs and runs in their workspace +3. App's service principal creates Genie spaces + dashboards via REST API +4. App grants permissions to workspace users +5. User deletes the app (frees up the single Free Edition app slot) + +### Why this approach + +| Alternative considered | Why it doesn't work | +|---|---| +| Marketplace data product | Only supports data assets (tables, volumes, models) — not Genie spaces or dashboards | +| DABS | Requires CLI installation + auth — not viable for non-technical users | +| "Open in Databricks" | Engineering done but not launched — no timeline | +| Hosted external web app | Major build (OAuth integration), unclear Free Edition support | + +### Why a Marketplace App is feasible for us + +Marketplace Apps are first-party only (must be in `databricks` or `databricks-labs` GitHub org). This is fine since this is a Databricks-internal project. Requires going through the Marketplace listing SOP with the Marketplace team. + +## API Details + +### Genie Space Creation + +- **Endpoint**: `POST /api/2.0/genie/spaces` (GA since March 2026) +- **Python SDK**: `w.genie.create_space(warehouse_id, serialized_space, title, parent_path)` +- **Key payload**: `serialized_space` JSON string containing tables, instructions, sample questions, example SQL, join specs +- **Limits**: Max 30 tables/space, 100 instructions, 100 example SQL queries +- **Permissions after creation**: Must use `PUT /api/2.0/permissions/genie/{space_id}` (Genie API has no built-in permissions management) + +#### Serialized Space Structure + +```json +{ + "version": 2, + "config": { + "sample_questions": [{"id": "<32-char-hex>", "question": ["..."]}] + }, + "data_sources": { + "tables": [ + { + "identifier": "catalog.schema.table", + "description": ["..."], + "column_configs": [{"column_name": "...", "description": ["..."], "synonyms": [...]}] + } + ] + }, + "instructions": { + "text_instructions": [{"id": "<32-char-hex>", "content": ["..."]}], + "example_question_sqls": [{"id": "<32-char-hex>", "question": ["..."], "sql": ["..."]}] + } +} +``` + +IDs must be 32-character lowercase hex. Collections must be sorted alphabetically by identifier. + +### Dashboard Creation + Publishing + +- **Create draft**: `POST /api/2.0/lakeview/dashboards` +- **Publish**: `POST /api/2.0/lakeview/dashboards/{dashboard_id}/published` +- **Python SDK**: `w.lakeview.create(dashboard)` then `w.lakeview.publish(dashboard_id, embed_credentials, warehouse_id)` + +#### Create Request + +```python +from databricks.sdk import WorkspaceClient +from databricks.sdk.service.dashboards import Dashboard + +w = WorkspaceClient() + +dashboard = w.lakeview.create( + dashboard=Dashboard( + display_name="My Dashboard", + serialized_dashboard='{"pages": [...], "datasets": [...]}', + warehouse_id="", + parent_path="/Users/user@example.com" + ) +) + +w.lakeview.publish( + dashboard_id=dashboard.dashboard_id, + embed_credentials=True, + warehouse_id="" +) +``` + +#### Serialized Dashboard Structure + +```json +{ + "pages": [ + { + "name": "", + "displayName": "Page Title", + "layout": [ + { + "widget": {"name": "", "queries": [...], "spec": {...}}, + "position": {"x": 0, "y": 0, "width": 6, "height": 4} + } + ] + } + ], + "datasets": [ + { + "name": "", + "displayName": "Dataset Name", + "query": "SELECT * FROM catalog.schema.table" + } + ] +} +``` + +No formal JSON schema exists — reverse-engineer by exporting a prototype dashboard. + +#### embed_credentials + +- `true` ("Shared data permission"): Viewers query using publisher's credentials. Better performance (shared cache). Publisher must have SELECT on all tables. +- `false` ("Individual data permission"): Each viewer needs their own table access. +- **Known issue**: Delta Sharing tables do NOT work with `embed_credentials: true`. + +### Permissions Management + +After creating objects, the app must grant access: + +- **Dashboards**: `PUT /api/2.0/permissions/dashboards/{workspace_object_id}` +- **Genie spaces**: `PUT /api/2.0/permissions/genie/{space_id}` + +The service principal is the owner by default — other users have no access until explicitly granted. + +## Free Edition Constraints + +| Constraint | Impact | +|---|---| +| 1 app per account | App must be deleted after provisioning to free the slot | +| Apps auto-stop after 24 hours | Fine — provisioning takes seconds | +| SP creation reported as unreliable | **Biggest risk** — must validate early | +| Serverless SQL warehouse (quota-limited) | Genie + dashboards need a warehouse — should work | +| Genie available on Free Edition | Confirmed via Slack (with some bugs tracked) | +| Dashboards available on Free Edition | Confirmed via docs and blog posts | + +## Build Plan + +### Step 1: Validate (do this first) + +- [ ] Confirm a Marketplace App can install and run its service principal on Free Edition +- [ ] Confirm the SP can create a Genie space on Free Edition +- [ ] Confirm the SP can create + publish a dashboard on Free Edition +- [ ] Confirm the SP can grant permissions to workspace users + +### Step 2: Build prototype + +- [ ] Create target Genie space + dashboard manually in a dev workspace +- [ ] Export `serialized_space` and `serialized_dashboard` JSON via API +- [ ] Build minimal app (Streamlit/Flask) that provisions from bundled configs +- [ ] App flow: startup -> detect warehouse -> create Genie space -> create + publish dashboard -> grant permissions -> show "Done" screen with links + +### Step 3: Marketplace listing + +- [ ] Host app in `databricks` or `databricks-labs` GitHub org +- [ ] Go through Marketplace listing SOP +- [ ] Contacts: Jianyu Zhou (Marketplace Apps eng), DJ Sharkey (Marketplace), Tia Chang (Marketplace PM) + +### Step 4: Self-cleanup + +- [ ] Test whether the app can call `DELETE /api/2.0/apps/{app_name}` on itself +- [ ] Fallback: show "Done — you can delete this app" with instructions + +## Key Contacts + +| Person | Role | Relevance | +|---|---|---| +| Jianyu Zhou | Marketplace Apps engineer | Marketplace App listing process | +| DJ Sharkey | Marketplace | Listing process | +| Tia Chang | Marketplace PM | Feature requests, listing approval | +| Naim Achahboun | Apps team | Recommended DABS pattern, Apps technical questions | +| Will Valori | Free Edition PM | Free Edition capabilities/limitations | +| Miranda Luna | PM, AI/BI Genie | Genie API, limits, roadmap | +| Hanlin Sun | PM/Eng, AI/BI Genie | Pricing, API limits | + +## Relevant Slack Channels + +- `#ai-bi-genie` — Genie questions +- `#eng-databricks-apps` — Databricks Apps + Genie resources +- `#free-edition` — Free Edition feature availability +- `#apa-agent-bricks` — Agent Bricks + Genie integration + +## Research Sources + +- [Genie API reference](https://docs.databricks.com/api/workspace/genie) +- [Genie Space Import/Export guide](https://docs.google.com/document/d/14hsOeDAtylMlSKVkakbYMGBIFr_SihR-Rrij5NCnT4k) +- [Dashboard CRUD API tutorial](https://docs.databricks.com/aws/en/dashboards/tutorials/dashboard-crud-api) +- [Lakeview API reference](https://docs.databricks.com/api/workspace/lakeview/create) +- [Dashboard permissions](https://docs.databricks.com/aws/en/dashboards/tutorials/manage-permissions) +- [Python SDK - Genie](https://databricks-sdk-py.readthedocs.io/en/latest/workspace/dashboards/genie.html) +- [Python SDK - Lakeview](https://databricks-sdk-py.readthedocs.io/en/latest/workspace/dashboards/lakeview.html) +- [Apps FAQ (go/apps/faq)](https://go/apps/faq) +- [AI/BI FAQ (go/aibi/faq)](https://docs.google.com/document/d/1vjcYSiTilHDRppuas9Kh8uAw_TOFC-62U2-nweRmx_I) +- [Google Doc with full research](https://docs.google.com/document/d/17UNJYk2jk_T21RtTMM7Elky_9Id2wGmmmb6jAi5VE9A/edit) diff --git a/marketplace-provisioning/SECURITY.md b/marketplace-provisioning/SECURITY.md new file mode 100644 index 00000000..93d398aa --- /dev/null +++ b/marketplace-provisioning/SECURITY.md @@ -0,0 +1,73 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in this project, please report it responsibly. + +**Do not open a public GitHub issue for security vulnerabilities.** + +Instead, please email [security@databricks.com](mailto:security@databricks.com) with: + +- A description of the vulnerability +- Steps to reproduce the issue +- Any potential impact assessment + +You will receive an acknowledgment within 48 hours and a detailed response within 5 business days. + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| Latest | Yes | + +## Authentication Model + +This app uses the **Databricks App service principal** for all Databricks +operations (SQL statements, Files API, Genie space CRUD, SCIM lookups, +permission grants). Credentials are obtained from the +`databricks.sdk.WorkspaceClient` at runtime — the app never reads a +`DATABRICKS_TOKEN` environment variable and never accepts a PAT from the +user. + +### Why service-principal-only (no on-behalf-of-user) + +Provisioning a scenario requires privileged operations that an unprivileged +end user typically cannot perform in a shared workspace: + +- Creating Unity Catalog catalogs, schemas, volumes, and tables +- Uploading CSV/Parquet data into managed volumes +- Granting `USE_CATALOG`, `USE_SCHEMA`, and `SELECT` to the end user +- Creating Genie spaces and granting the user `CAN_RUN` + +The Marketplace deployment targets Databricks Free Edition workspaces where +the installing user *is* the workspace admin, so the app service principal +runs with admin-equivalent authority by design. Users only receive the +minimum grants needed to query their provisioned scenario. + +### What the app never does + +- Does not store or log user tokens, session cookies, or PATs +- Does not accept credentials via the UI or API payloads +- Does not call external (non-Databricks) services +- Does not persist customer data beyond the user's own workspace + +## Input Validation + +- All Unity Catalog identifiers (catalog, schema, table, column names) are + validated against a strict identifier regex before being interpolated + into SQL. See `provisioner._check_ident` / `_check_dotted_ident`. +- Parameterized SQL bindings are used for all user-supplied values. +- Mystery text is length-capped and passed through a word-boundary + profanity filter. + +## Security Practices + +- Dependencies are tracked in `requirements.txt` (Python) and + `package.json` (Node.js). +- No user credentials are stored by the application. +- The app uses SQLite for transient game state only (reset on each + deployment). +- The in-memory provisioning-status dict is capped to prevent unbounded + growth. +- Exception messages surfaced to the UI are sanitized; full tracebacks are + only written to server logs. diff --git a/marketplace-provisioning/app.py b/marketplace-provisioning/app.py new file mode 100644 index 00000000..225b7dc5 --- /dev/null +++ b/marketplace-provisioning/app.py @@ -0,0 +1,648 @@ +""" +Data Detective Marketplace App — FastAPI Backend +================================================= +Game + Genie space provisioning for Free Edition workspaces. + +When a player selects a mystery scenario, the app provisions the +data tables and Genie space into their workspace automatically. + +Scoring (max 500): + - Clues & evidence (max 5 × 30): +150 + - Root cause deduction (keyword-overlap scored): up to +100 + - Business recommendation (keyword-overlap scored): up to +250 +""" + +import json +import logging +import os +import re as _re +import sqlite3 +import threading +import time as _time +import uuid +from contextlib import asynccontextmanager, contextmanager +from datetime import datetime, timezone +from typing import Optional + +from fastapi import FastAPI, HTTPException +from fastapi.responses import StreamingResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from provisioner import provision_scenario +from scenarios.registry import MYSTERIES, MYSTERY_ANSWER_KEYS, SCENARIOS + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") +log = logging.getLogger("data-detective") + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +DB_PATH = os.environ.get("DB_PATH", "game.db") +MAX_EVIDENCE = 10 +SCORED_EVIDENCE = 5 + +POINTS_ROOT_CAUSE = 100 +POINTS_PER_EVIDENCE = 30 +POINTS_RECOMMENDATION_MAX = 250 + +MAX_EVIDENCE_ITEM_BYTES = 1_500_000 # ~1.5MB per item; fits a base64-encoded image +MAX_SUBMISSION_TOTAL_BYTES = 6_000_000 # ~6MB total per submission +MAX_TEXT_FIELD_LEN = 5000 # solution / recommendation +LEADERBOARD_FORM_URL = os.environ.get("LEADERBOARD_FORM_URL", "").strip() +PRE_PROVISION_ALL = os.environ.get("PRE_PROVISION_ALL", "").lower() in ("1", "true", "yes") + +# Total provisioning steps as reported by provisioner.provision_scenario +TOTAL_STEPS = 8 + + +# --------------------------------------------------------------------------- +# Database helpers +# --------------------------------------------------------------------------- + +def _get_conn() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +@contextmanager +def get_db(): + conn = _get_conn() + try: + yield conn + conn.commit() + finally: + conn.close() + + +def init_db(): + with get_db() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, + nickname TEXT NOT NULL, + mystery TEXT NOT NULL DEFAULT '', + genie_url TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS submissions ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES accounts(id), + solution TEXT NOT NULL DEFAULT '', + recommendation TEXT NOT NULL DEFAULT '', + submitted_at TEXT NOT NULL, + score REAL, + root_cause_score REAL DEFAULT 0, + evidence_score REAL DEFAULT 0, + recommendation_score REAL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS evidence ( + id TEXT PRIMARY KEY, + submission_id TEXT NOT NULL REFERENCES submissions(id), + field_order INTEGER NOT NULL, + type TEXT NOT NULL CHECK (type IN ('text', 'image', 'csv')), + content TEXT NOT NULL DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS provisioned_spaces ( + scenario TEXT PRIMARY KEY, + catalog_name TEXT NOT NULL, + genie_space_id TEXT NOT NULL, + genie_url TEXT NOT NULL, + provisioned_at TEXT NOT NULL, + provisioned_by TEXT NOT NULL + ); + """) + + +init_db() + +# --------------------------------------------------------------------------- +# Provisioning state (in-memory, transient) +# --------------------------------------------------------------------------- + +_MAX_STATUS_ENTRIES = 1000 +_provisioning_status: dict[str, dict] = {} +_provisioning_status_lock = threading.Lock() + + +def _set_provisioning_status(account_id: str, status: dict): + with _provisioning_status_lock: + # Cap dict size; drop oldest insertion (dicts preserve insertion order in 3.7+). + while len(_provisioning_status) >= _MAX_STATUS_ENTRIES and account_id not in _provisioning_status: + _provisioning_status.pop(next(iter(_provisioning_status))) + _provisioning_status[account_id] = status + + +def _get_provisioning_status(account_id: str) -> Optional[dict]: + with _provisioning_status_lock: + return _provisioning_status.get(account_id) + + +# --------------------------------------------------------------------------- +# Scoring functions (keyword-based) +# --------------------------------------------------------------------------- + +_STOPWORDS = frozenset({ + "the", "a", "an", "is", "was", "were", "are", "of", "in", "to", + "and", "or", "that", "this", "it", "for", "on", "with", "as", + "at", "by", "from", "be", "been", "being", "has", "had", "have", + "do", "does", "did", "not", "but", "if", "so", "no", "its", + "they", "their", "them", "we", "our", "he", "she", "his", "her", +}) + + +def _keyword_overlap(submission: str, reference: str) -> float: + """Return fraction of reference keywords found in submission (0.0–1.0).""" + ref_words = set(_re.findall(r'[a-z]+', reference.lower())) - _STOPWORDS + sub_words = set(_re.findall(r'[a-z]+', submission.lower())) - _STOPWORDS + if not ref_words: + return 0.0 + return len(ref_words & sub_words) / len(ref_words) + + +def _score_root_cause(submission_solution: str, correct_root_cause: str) -> int: + if not correct_root_cause.strip() or not submission_solution.strip(): + return 0 + overlap = _keyword_overlap(submission_solution, correct_root_cause) + return min(int(overlap * POINTS_ROOT_CAUSE), POINTS_ROOT_CAUSE) + + +def _score_evidence(evidence_rows: list) -> int: + non_empty = [e for e in evidence_rows if e["content"].strip()][:SCORED_EVIDENCE] + return len(non_empty) * POINTS_PER_EVIDENCE + + +def _score_recommendation(recommendation: str, root_cause: str, scoring_context: str) -> int: + if not recommendation.strip(): + return 0 + # Score against both root cause and scoring context for broader keyword coverage + combined_reference = f"{root_cause} {scoring_context}" + overlap = _keyword_overlap(recommendation, combined_reference) + return min(int(overlap * POINTS_RECOMMENDATION_MAX), POINTS_RECOMMENDATION_MAX) + + +# --------------------------------------------------------------------------- +# Profanity filter +# --------------------------------------------------------------------------- + +_PROFANE_WORDS = { + "ass", "asshole", "bastard", "bitch", "bullshit", "cock", "crap", "cunt", + "damn", "dick", "douchebag", "fag", "faggot", "fuck", "fucker", "fucking", + "goddamn", "hell", "jackass", "motherfucker", "nigger", "nigga", "penis", + "piss", "prick", "pussy", "shit", "shitty", "slut", "twat", "vagina", + "whore", "wanker", "retard", "retarded", +} + + +def _contains_profanity(text: str) -> bool: + # Word-boundary match only — substring matching produced false positives + # like "hello" → "hell" or "class" → "ass". + normalized = _re.sub(r"[^a-z]", " ", text.lower()) + words = normalized.split() + return any(w in _PROFANE_WORDS for w in words) + + + +# --------------------------------------------------------------------------- +# Pydantic models +# --------------------------------------------------------------------------- + +class CreateAccount(BaseModel): + nickname: str = Field(..., min_length=1, max_length=50) + mystery: str = Field(..., min_length=1) + + +class EvidenceItemModel(BaseModel): + field_order: int = Field(..., ge=1, le=MAX_EVIDENCE) + type: str = Field(..., pattern="^(text|image|csv)$") + content: str = Field(default="", max_length=MAX_EVIDENCE_ITEM_BYTES) + + +class CreateSubmission(BaseModel): + account_id: str + solution: str = Field(default="", max_length=MAX_TEXT_FIELD_LEN) + recommendation: str = Field(default="", max_length=MAX_TEXT_FIELD_LEN) + evidence: list[EvidenceItemModel] = [] + + +# --------------------------------------------------------------------------- +# Background provisioning +# --------------------------------------------------------------------------- + +def _run_provisioning(account_id: str, mystery: str): + """Run provisioning in a background thread.""" + scenario = SCENARIOS.get(mystery) + if not scenario: + _set_provisioning_status(account_id, { + "step": "error", + "message": f"Unknown scenario: {mystery}", + "progress": 0, + "total": TOTAL_STEPS, + "error": True, + }) + return + + def progress_callback(step: str, message: str, progress: int, total: int): + status = { + "step": step, + "message": message, + "progress": progress, + "total": total, + } + _set_provisioning_status(account_id, status) + + try: + result = provision_scenario(scenario["key"], progress_callback) + + now = datetime.now(timezone.utc).isoformat() + with get_db() as conn: + # Update the account with the Genie URL + conn.execute( + "UPDATE accounts SET genie_url = ? WHERE id = ?", + (result["genie_url"], account_id), + ) + # Record in provisioned_spaces (for future idempotency) + conn.execute(""" + INSERT OR REPLACE INTO provisioned_spaces + (scenario, catalog_name, genie_space_id, genie_url, provisioned_at, provisioned_by) + VALUES (?, ?, ?, ?, ?, ?) + """, (mystery, result["catalog_name"], result["genie_space_id"], + result["genie_url"], now, account_id)) + + _set_provisioning_status(account_id, { + "step": "done", + "message": "Your case is ready!", + "progress": TOTAL_STEPS, + "total": TOTAL_STEPS, + "genie_url": result["genie_url"], + }) + + except Exception as e: + log.exception(f"Provisioning failed for {account_id}") + _set_provisioning_status(account_id, { + "step": "error", + "message": "Provisioning failed. Check the app logs for details.", + "progress": 0, + "total": TOTAL_STEPS, + "error": True, + }) + + +# --------------------------------------------------------------------------- +# API sub-application +# --------------------------------------------------------------------------- + +api = FastAPI() + + +# -- Config ----------------------------------------------------------------- + +@api.get("/config") +def get_config(): + """Return non-sensitive workspace info for the frontend.""" + host = os.environ.get("DATABRICKS_HOST", "") + if host and not host.startswith("http"): + host = f"https://{host}" + return { + "workspace_host": host, + "leaderboard_form_url": LEADERBOARD_FORM_URL, + } + + +# -- Accounts --------------------------------------------------------------- + +@api.post("/accounts") +def create_account(body: CreateAccount): + if _contains_profanity(body.nickname): + raise HTTPException(400, "Nickname contains inappropriate language. Please choose another.") + if body.mystery not in MYSTERIES: + raise HTTPException(400, f"Invalid mystery. Choose one of: {', '.join(MYSTERIES)}") + + account_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc).isoformat() + + # Check if this scenario is already provisioned on this workspace + with get_db() as conn: + existing = conn.execute( + "SELECT genie_url FROM provisioned_spaces WHERE scenario = ?", + (body.mystery,), + ).fetchone() + + genie_url = existing["genie_url"] if existing else "" + provisioning_status = "completed" if existing else "pending" + + with get_db() as conn: + conn.execute( + "INSERT INTO accounts (id, nickname, mystery, genie_url, created_at) VALUES (?, ?, ?, ?, ?)", + (account_id, body.nickname.strip(), body.mystery, genie_url, now), + ) + + # Start background provisioning if needed + if not existing: + _set_provisioning_status(account_id, { + "step": "pending", + "message": "Preparing your investigation...", + "progress": 0, + "total": TOTAL_STEPS, + }) + t = threading.Thread(target=_run_provisioning, args=(account_id, body.mystery), daemon=True) + t.start() + + return { + "id": account_id, + "nickname": body.nickname.strip(), + "mystery": body.mystery, + "provisioning_status": provisioning_status, + "genie_url": genie_url or None, + "created_at": now, + } + + +@api.get("/accounts/{account_id}") +def get_account(account_id: str): + with get_db() as conn: + row = conn.execute( + "SELECT id, nickname, mystery, genie_url, created_at FROM accounts WHERE id = ?", + (account_id,), + ).fetchone() + if not row: + raise HTTPException(404, "Account not found") + result = dict(row) + # Prefer the latest URL from provisioned_spaces (updated on each startup) + with get_db() as conn: + ps = conn.execute( + "SELECT genie_url FROM provisioned_spaces WHERE scenario = ?", + (result["mystery"],), + ).fetchone() + if ps and ps["genie_url"]: + result["genie_url"] = ps["genie_url"] + return result + + +# -- Provisioning status (SSE) --------------------------------------------- + +@api.get("/provisioning/{account_id}/status") +def provisioning_status_sse(account_id: str): + """Server-Sent Events stream for provisioning progress.""" + def event_stream(): + last_step = None + timeout = 300 # 5 minute max + elapsed = 0 + while elapsed < timeout: + status = _get_provisioning_status(account_id) + if status and status.get("step") != last_step: + last_step = status["step"] + yield f"data: {json.dumps(status)}\n\n" + if status.get("step") in ("done", "error"): + return + _time.sleep(1) + elapsed += 1 + last = _get_provisioning_status(account_id) or {} + timeout_event = { + "step": "error", + "message": "Provisioning timed out", + "progress": last.get("progress", 0), + "total": last.get("total", TOTAL_STEPS), + "error": True, + } + yield f"data: {json.dumps(timeout_event)}\n\n" + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + +# -- Provisioning status (polling fallback) --------------------------------- + +@api.get("/provisioning/{account_id}/poll") +def provisioning_status_poll(account_id: str): + """Polling fallback for provisioning status.""" + status = _get_provisioning_status(account_id) + if not status: + # Check if already provisioned in DB + with get_db() as conn: + acct = conn.execute( + "SELECT genie_url FROM accounts WHERE id = ?", + (account_id,), + ).fetchone() + if acct and acct["genie_url"]: + return { + "step": "done", + "message": "Your case is ready!", + "progress": 8, + "total": TOTAL_STEPS, + "genie_url": acct["genie_url"], + } + raise HTTPException(404, "No provisioning status found") + return status + + +# -- Submissions ------------------------------------------------------------ + +@api.post("/submissions") +def create_submission(body: CreateSubmission): + with get_db() as conn: + acct = conn.execute("SELECT id, mystery FROM accounts WHERE id = ?", + (body.account_id,)).fetchone() + if not acct: + raise HTTPException(404, "Account not found") + + if len(body.evidence) > MAX_EVIDENCE: + raise HTTPException(400, f"Maximum {MAX_EVIDENCE} evidence fields allowed") + + total_evidence_bytes = sum(len(e.content) for e in body.evidence) + if total_evidence_bytes > MAX_SUBMISSION_TOTAL_BYTES: + raise HTTPException( + 413, + f"Submission too large: {total_evidence_bytes} bytes " + f"exceeds limit of {MAX_SUBMISSION_TOTAL_BYTES} bytes", + ) + + submission_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc).isoformat() + mystery = acct["mystery"] + + # Preliminary score: evidence count only + non_empty_evidence = [e for e in body.evidence if e.content.strip()][:SCORED_EVIDENCE] + ev_score = len(non_empty_evidence) * POINTS_PER_EVIDENCE + score = ev_score + + conn.execute( + "INSERT INTO submissions (id, account_id, solution, recommendation, submitted_at, score, root_cause_score, evidence_score, recommendation_score) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (submission_id, body.account_id, body.solution, body.recommendation, now, score, 0, ev_score, 0), + ) + for ev in body.evidence: + conn.execute( + "INSERT INTO evidence (id, submission_id, field_order, type, content) VALUES (?, ?, ?, ?, ?)", + (str(uuid.uuid4()), submission_id, ev.field_order, ev.type, ev.content), + ) + + # Score immediately (keyword-based, no LLM delay) + rc_score = 0 + rec_score = 0 + if mystery in MYSTERY_ANSWER_KEYS: + answer_key = MYSTERY_ANSWER_KEYS[mystery] + rc_score = _score_root_cause(body.solution, answer_key["root_cause"]) + rec_score = _score_recommendation(body.recommendation, answer_key["root_cause"], answer_key["scoring_context"]) + + total = rc_score + ev_score + rec_score + with get_db() as conn2: + conn2.execute( + "UPDATE submissions SET score = ?, root_cause_score = ?, evidence_score = ?, recommendation_score = ? WHERE id = ?", + (total, rc_score, ev_score, rec_score, submission_id), + ) + + return { + "id": submission_id, + "score": total, + "root_cause_score": rc_score, + "evidence_score": ev_score, + "recommendation_score": rec_score, + "submitted_at": now, + } + + +@api.get("/submissions/{account_id}") +def get_latest_submission(account_id: str): + with get_db() as conn: + row = conn.execute( + "SELECT id, solution, recommendation, submitted_at, score, root_cause_score, evidence_score, recommendation_score FROM submissions WHERE account_id = ? ORDER BY submitted_at DESC LIMIT 1", + (account_id,), + ).fetchone() + if not row: + return None + submission = dict(row) + evidence_rows = conn.execute( + "SELECT field_order, type, content FROM evidence WHERE submission_id = ? ORDER BY field_order", + (submission["id"],), + ).fetchall() + submission["evidence"] = [dict(e) for e in evidence_rows] + return submission + + +# -- Self-scoring ----------------------------------------------------------- + +@api.post("/score/{account_id}") +def score_submission(account_id: str): + """Score the latest submission for this account.""" + with get_db() as conn: + acct = conn.execute("SELECT mystery FROM accounts WHERE id = ?", + (account_id,)).fetchone() + if not acct: + raise HTTPException(404, "Account not found") + + mystery = acct["mystery"] + if mystery not in MYSTERY_ANSWER_KEYS: + raise HTTPException(400, "No answer key available for this mystery") + + answer_key = MYSTERY_ANSWER_KEYS[mystery] + root_cause = answer_key["root_cause"] + scoring_context = answer_key["scoring_context"] + + sub = conn.execute( + "SELECT id, solution, recommendation FROM submissions WHERE account_id = ? ORDER BY submitted_at DESC LIMIT 1", + (account_id,), + ).fetchone() + if not sub: + raise HTTPException(400, "No submission found. Submit your answer first.") + + evidence_rows = conn.execute( + "SELECT field_order, type, content FROM evidence WHERE submission_id = ? ORDER BY field_order", + (sub["id"],), + ).fetchall() + + # Score (keyword-based, instant) + rc_score = _score_root_cause(sub["solution"], root_cause) + ev_score = _score_evidence(evidence_rows) + rec_score = _score_recommendation(sub["recommendation"], root_cause, scoring_context) + total = rc_score + ev_score + rec_score + + with get_db() as conn: + conn.execute( + "UPDATE submissions SET score = ?, root_cause_score = ?, evidence_score = ?, recommendation_score = ? WHERE id = ?", + (total, rc_score, ev_score, rec_score, sub["id"]), + ) + + return { + "score": total, + "root_cause_score": rc_score, + "evidence_score": ev_score, + "recommendation_score": rec_score, + } + + +# -- Answer reveal ---------------------------------------------------------- + +@api.get("/answer/{mystery}") +def get_answer(mystery: str): + """Return the correct answer for a mystery.""" + if mystery not in MYSTERY_ANSWER_KEYS: + raise HTTPException(404, "Unknown mystery") + + key = MYSTERY_ANSWER_KEYS[mystery] + return { + "mystery": mystery, + "root_cause": key["root_cause"], + "sample_recommendation": key.get("sample_recommendation", ""), + "query_path": key.get("query_path", []), + } + + +# --------------------------------------------------------------------------- +# Startup: optional pre-provisioning +# --------------------------------------------------------------------------- + +def _provision_all_at_startup(): + """Provision every scenario on app startup. Opt-in only (PRE_PROVISION_ALL).""" + for mystery_name, scenario in SCENARIOS.items(): + key = scenario["key"] + try: + log.info(f"[startup] Provisioning {mystery_name}...") + result = provision_scenario(key) + now = datetime.now(timezone.utc).isoformat() + with get_db() as conn: + conn.execute(""" + INSERT OR REPLACE INTO provisioned_spaces + (scenario, catalog_name, genie_space_id, genie_url, provisioned_at, provisioned_by) + VALUES (?, ?, ?, ?, ?, ?) + """, (mystery_name, result["catalog_name"], result["genie_space_id"], + result["genie_url"], now, "startup")) + log.info(f"[startup] {mystery_name} ready: {result['genie_url']}") + except Exception as e: + log.error(f"[startup] Failed to provision {mystery_name}: {e}") + + +@asynccontextmanager +async def lifespan(app): + if PRE_PROVISION_ALL: + log.info("PRE_PROVISION_ALL=true — pre-provisioning every scenario at startup") + t = threading.Thread(target=_provision_all_at_startup, daemon=True) + t.start() + else: + log.info("Default mode: scenarios are provisioned on-demand when a player picks one") + yield + + +# --------------------------------------------------------------------------- +# Main application — mount API then static files +# --------------------------------------------------------------------------- + +app = FastAPI(lifespan=lifespan) +app.mount("/api", api) + +static_dir = os.path.join(os.path.dirname(__file__), "static") + +if os.path.isdir(static_dir): + app.mount("/", StaticFiles(directory=static_dir, html=True), name="static") diff --git a/marketplace-provisioning/app.yaml b/marketplace-provisioning/app.yaml new file mode 100644 index 00000000..140b7a0c --- /dev/null +++ b/marketplace-provisioning/app.yaml @@ -0,0 +1 @@ +command: ['uvicorn', 'app:app', '--host', '0.0.0.0', '--port', '8000'] diff --git a/marketplace-provisioning/databricks.yml b/marketplace-provisioning/databricks.yml new file mode 100644 index 00000000..2e1e914b --- /dev/null +++ b/marketplace-provisioning/databricks.yml @@ -0,0 +1,13 @@ +bundle: + name: data-detective + +targets: + dev: + default: true + +resources: + apps: + data_detective: + name: data-detective + description: "Data Detective — investigate business mysteries using Genie AI spaces" + source_code_path: . diff --git a/marketplace-provisioning/frontend/index.html b/marketplace-provisioning/frontend/index.html new file mode 100644 index 00000000..fd44b084 --- /dev/null +++ b/marketplace-provisioning/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Data Detective + + +
+ + + diff --git a/marketplace-provisioning/frontend/package-lock.json b/marketplace-provisioning/frontend/package-lock.json new file mode 100644 index 00000000..3adb9edb --- /dev/null +++ b/marketplace-provisioning/frontend/package-lock.json @@ -0,0 +1,1846 @@ +{ + "name": "bia-game-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bia-game-frontend", + "version": "1.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", + "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001778", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz", + "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/marketplace-provisioning/frontend/package.json b/marketplace-provisioning/frontend/package.json new file mode 100644 index 00000000..b08d6e3e --- /dev/null +++ b/marketplace-provisioning/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "bia-game-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.0" + } +} diff --git a/marketplace-provisioning/frontend/src/App.tsx b/marketplace-provisioning/frontend/src/App.tsx new file mode 100644 index 00000000..72cfb572 --- /dev/null +++ b/marketplace-provisioning/frontend/src/App.tsx @@ -0,0 +1,151 @@ +import { useState, useEffect, useCallback } from "react"; +import { api } from "./api"; +import NicknameEntry from "./components/NicknameEntry"; +import ProvisioningStatus from "./components/ProvisioningStatus"; +import QuestionView from "./components/QuestionView"; +import CaseFile from "./components/CaseFile"; +type Page = "nickname" | "provisioning" | "question" | "casefile"; + +const SESSION_KEY = "dd_session"; + +interface Session { + accountId: string; + nickname: string; + mystery: string; + genieUrl: string; +} + +function loadSession(): Session | null { + try { + const raw = localStorage.getItem(SESSION_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (parsed.accountId && parsed.nickname && parsed.mystery) return parsed; + } catch {} + return null; +} + +function saveSession(session: Session) { + localStorage.setItem(SESSION_KEY, JSON.stringify(session)); +} + +export default function App() { + const saved = loadSession(); + const [page, setPage] = useState(saved ? "question" : "nickname"); + const [accountId, setAccountId] = useState(saved?.accountId || ""); + const [nickname, setNickname] = useState(saved?.nickname || ""); + const [mystery, setMystery] = useState(saved?.mystery || ""); + const [genieUrl, setGenieUrl] = useState(saved?.genieUrl || ""); + + function handleAccountCreated( + id: string, + nick: string, + myst: string, + provisioningStatus: string, + gUrl: string | null + ) { + setAccountId(id); + setNickname(nick); + setMystery(myst); + + if (provisioningStatus === "completed" && gUrl) { + setGenieUrl(gUrl); + saveSession({ accountId: id, nickname: nick, mystery: myst, genieUrl: gUrl }); + setPage("question"); + } else { + saveSession({ accountId: id, nickname: nick, mystery: myst, genieUrl: "" }); + setPage("provisioning"); + } + } + + const handleProvisioningComplete = useCallback((gUrl: string) => { + setGenieUrl(gUrl); + const session: Session = { accountId, nickname, mystery, genieUrl: gUrl }; + saveSession(session); + setPage("question"); + }, [accountId, nickname, mystery]); + + function handleSwitchMystery() { + localStorage.removeItem(SESSION_KEY); + setAccountId(""); + setMystery(""); + setGenieUrl(""); + setPage("nickname"); + } + + // Verify account still exists on load; silently re-create if DB was reset + useEffect(() => { + if (!accountId || !nickname || !mystery) return; + api.getAccount(accountId).catch(async () => { + try { + const acct = await api.createAccount(nickname, mystery); + setAccountId(acct.id); + const gUrl = acct.genie_url || genieUrl; + if (gUrl) setGenieUrl(gUrl); + saveSession({ accountId: acct.id, nickname, mystery, genieUrl: gUrl }); + } catch { + // nickname conflict — append a random suffix and retry + try { + const retryNick = nickname + Math.floor(Math.random() * 1000); + const acct = await api.createAccount(retryNick, mystery); + setAccountId(acct.id); + setNickname(retryNick); + const gUrl = acct.genie_url || genieUrl; + if (gUrl) setGenieUrl(gUrl); + saveSession({ accountId: acct.id, nickname: retryNick, mystery, genieUrl: gUrl }); + } catch { + handleSwitchMystery(); + } + } + }); + }, [accountId]); + + return ( +
+ {page !== "nickname" && page !== "provisioning" && ( +
+ + + +
+ )} + + {page === "nickname" && ( + + )} + {page === "provisioning" && ( + + )} + {page === "question" && ( + + )} + {page === "casefile" && ( + + )} +
+ ); +} diff --git a/marketplace-provisioning/frontend/src/api.ts b/marketplace-provisioning/frontend/src/api.ts new file mode 100644 index 00000000..b4e09edd --- /dev/null +++ b/marketplace-provisioning/frontend/src/api.ts @@ -0,0 +1,65 @@ +const BASE = "/api"; + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + headers: { "Content-Type": "application/json" }, + ...options, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.detail || `Request failed: ${res.status}`); + } + return res.json(); +} + +export const api = { + createAccount: (nickname: string, mystery: string) => + request<{ + id: string; + nickname: string; + mystery: string; + provisioning_status: string; + genie_url: string | null; + }>("/accounts", { + method: "POST", + body: JSON.stringify({ nickname, mystery }), + }), + + getAccount: (accountId: string) => + request<{ id: string; nickname: string; mystery: string; genie_url: string }>( + `/accounts/${accountId}` + ), + + submitAnswer: (data: { + account_id: string; + solution: string; + recommendation: string; + evidence: { field_order: number; type: string; content: string }[]; + }) => + request<{ id: string; score: number; root_cause_score: number; evidence_score: number; recommendation_score: number }>("/submissions", { + method: "POST", + body: JSON.stringify(data), + }), + + getLatestSubmission: (accountId: string) => + request(`/submissions/${accountId}`), + + scoreMySubmission: (accountId: string) => + request<{ + score: number; + root_cause_score: number; + evidence_score: number; + recommendation_score: number; + }>(`/score/${accountId}`, { method: "POST" }), + + getAnswer: (mystery: string) => + request<{ + mystery: string; + root_cause: string; + sample_recommendation: string; + query_path: string[]; + }>(`/answer/${encodeURIComponent(mystery)}`), + + getConfig: () => + request<{ workspace_host: string; leaderboard_form_url: string }>("/config"), +}; diff --git a/marketplace-provisioning/frontend/src/components/CaseFile.tsx b/marketplace-provisioning/frontend/src/components/CaseFile.tsx new file mode 100644 index 00000000..b92e0b0b --- /dev/null +++ b/marketplace-provisioning/frontend/src/components/CaseFile.tsx @@ -0,0 +1,139 @@ +import { useState, useEffect } from "react"; +import { api } from "../api"; +import { Submission, EvidenceItem } from "../types"; + +interface Props { + accountId: string; + nickname: string; + mystery: string; +} + +export default function CaseFile({ accountId, nickname, mystery }: Props) { + const [submission, setSubmission] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + async function load() { + try { + const data = await api.getLatestSubmission(accountId); + if (!cancelled) setSubmission(data); + } catch {} + if (!cancelled) setLoading(false); + } + load(); + return () => { cancelled = true; }; + }, [accountId]); + + if (loading) return

Opening case file...

; + + if (!submission) { + return ( +
+

Case File

+

+ No evidence submitted yet. Return to the investigation and submit your findings. +

+
+ ); + } + + const evidence = submission.evidence || []; + + return ( +
+
+
+

♦ ♦ ♦

+

Case File

+

+ Detective: {nickname} +

+

+ Mystery: {mystery} +

+

♦ ♦ ♦

+
+
+ + {/* Evidence Board */} + {evidence.length > 0 && ( +
+

Collected Evidence

+
+ {evidence.map((ev: EvidenceItem, idx: number) => ( +
+
+ + Clue #{idx + 1} + + + {ev.type} + {idx >= 5 && " (unscored)"} + +
+ {ev.type === "text" && ( +

{ev.content}

+ )} + {ev.type === "image" && ev.content && ( + {`Clue + )} + {ev.type === "csv" && ( +
+                    {ev.content}
+                  
+ )} +
+ ))} +
+
+ )} + + {/* Deduction */} + {submission.solution && ( +
+

Your Deduction

+

{submission.solution}

+
+ )} + + {/* Recommendation */} + {submission.recommendation && ( +
+

Business Recommendation

+

{submission.recommendation}

+
+ )} + + {/* Score */} +
+

Current Score

+

+ {submission.score} / 500 +

+
+
+ ); +} diff --git a/marketplace-provisioning/frontend/src/components/EvidenceField.tsx b/marketplace-provisioning/frontend/src/components/EvidenceField.tsx new file mode 100644 index 00000000..f8bc2fc7 --- /dev/null +++ b/marketplace-provisioning/frontend/src/components/EvidenceField.tsx @@ -0,0 +1,219 @@ +import { useState, useRef } from "react"; +import { EvidenceItem } from "../types"; + +interface Props { + index: number; + item: EvidenceItem; + onChange: (updated: EvidenceItem) => void; + onRemove: () => void; + canRemove: boolean; +} + +export default function EvidenceField({ index, item, onChange, onRemove, canRemove }: Props) { + const [locked, setLocked] = useState(false); + const [csvWarning, setCsvWarning] = useState(false); + const [dragging, setDragging] = useState(false); + const fileInputRef = useRef(null); + + function detectType(file: File): "image" | "csv" | "text" { + if (file.type.startsWith("image/")) return "image"; + if (file.type === "text/csv" || file.name.endsWith(".csv")) return "csv"; + return "text"; + } + + function processFile(file: File) { + if (file.size > 5 * 1024 * 1024) { + alert("File must be under 5MB"); + return; + } + const type = detectType(file); + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + onChange({ ...item, type, content: result, filename: file.name }); + if (type === "csv") { + const rows = result.trim().split("\n").length; + setCsvWarning(rows > 10); + } else { + setCsvWarning(false); + } + }; + if (type === "csv" || type === "text") { + reader.readAsText(file); + } else { + reader.readAsDataURL(file); + } + } + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (file) processFile(file); + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault(); + setDragging(false); + const file = e.dataTransfer.files?.[0]; + if (file) processFile(file); + } + + function handleDragOver(e: React.DragEvent) { + e.preventDefault(); + setDragging(true); + } + + function handleDragLeave(e: React.DragEvent) { + e.preventDefault(); + setDragging(false); + } + + function handleAdd() { + if (!item.content.trim()) return; + setLocked(true); + } + + function handleEdit() { + setLocked(false); + } + + const hasContent = item.content.trim().length > 0; + + const dropZoneStyle: React.CSSProperties = { + border: `2px dashed ${dragging ? "#d4a853" : "#4a3f35"}`, + borderRadius: 6, + padding: "20px 12px", + textAlign: "center", + cursor: "pointer", + background: dragging ? "rgba(212, 168, 83, 0.08)" : "transparent", + transition: "border-color 0.2s, background 0.2s", + }; + + return ( +
+
+ + Clue {index + 1} + {index >= 5 && (not scored)} + {locked && Added to case} + +
+ {canRemove && ( + + )} +
+
+ + {locked ? ( +
+ + {item.type === "text" + ? item.content.length > 80 ? item.content.slice(0, 80) + "..." : item.content + : item.type === "csv" + ? "CSV data attached" + : "Image attached"} + + +
+ ) : ( + <> + + + {item.type === "text" && !item.content.startsWith("data:") ? ( +
+