From 65f4d1912b9e31cfc393cfaa0fafd28bf3d9cab6 Mon Sep 17 00:00:00 2001 From: Shiv Tyagi Date: Sat, 25 Jul 2026 13:50:53 +0530 Subject: [PATCH 01/19] build_manager: shorten build ids and name archives vehicle-board-id Co-authored-by: Cursor --- build_manager/manager.py | 15 +++++++++------ build_manager/progress_updater.py | 4 +++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/build_manager/manager.py b/build_manager/manager.py index 60bbc2e..befc8e7 100644 --- a/build_manager/manager.py +++ b/build_manager/manager.py @@ -200,13 +200,12 @@ def __generate_build_id(self, build_info: BuildInfo) -> str: build_info (BuildInfo): The build information object. Returns: - str: The generated build ID (64 characters). + str: The generated build ID (8 characters). """ h = hashlib.md5( f"{build_info}-{time.time_ns()}".encode() - ).hexdigest() - bid = f"{build_info.vehicle_id}-{build_info.board}-{h}" - return bid + ).hexdigest()[:8] + return h def submit_build(self, build_info: BuildInfo) -> str: @@ -460,19 +459,23 @@ def get_build_log_path(self, build_id: str) -> str: 'build.log' ) - def get_build_archive_path(self, build_id: str) -> str: + def get_build_archive_path( + self, build_id: str, vehicle_id: str, board: str + ) -> str: """ Return the path to the build archive. Parameters: build_id (str): The ID of the build. + vehicle_id (str): The vehicle identifier. + board (str): The board identifier. Returns: str: The path to the build archive. """ return os.path.join( self.get_build_artifacts_dir_path(build_id), - f"{build_id}.tar.gz" + f"{vehicle_id}-{board}-{build_id}.tar.gz" ) @staticmethod diff --git a/build_manager/progress_updater.py b/build_manager/progress_updater.py index ee15504..efda989 100644 --- a/build_manager/progress_updater.py +++ b/build_manager/progress_updater.py @@ -186,7 +186,9 @@ def __refresh_running_build_state(self, build_id: str) -> BuildState: # Builder ships the archive post completion # This is irrespective of SUCCESS or FAILURE if not os.path.exists( - bm.get_singleton().get_build_archive_path(build_id) + bm.get_singleton().get_build_archive_path( + build_id, build_info.vehicle_id, build_info.board + ) ): return BuildState.RUNNING From d89285c55e046d77521b8538fef062c75ee4ae73 Mon Sep 17 00:00:00 2001 From: Shiv Tyagi Date: Sat, 25 Jul 2026 13:50:53 +0530 Subject: [PATCH 02/19] builder: use renamed archive path and match tar inner folder Co-authored-by: Cursor --- builder/builder.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/builder/builder.py b/builder/builder.py index 7a16cb1..c687e9a 100644 --- a/builder/builder.py +++ b/builder/builder.py @@ -226,7 +226,9 @@ def __generate_archive(self, build_id: str) -> None: build_id (str): Unique identifier for the build. """ build_info = bm.get_singleton().get_build_info(build_id) - archive_path = bm.get_singleton().get_build_archive_path(build_id) + archive_path = bm.get_singleton().get_build_archive_path( + build_id, build_info.vehicle_id, build_info.board + ) files_to_include = [] @@ -261,10 +263,11 @@ def __generate_archive(self, build_id: str) -> None: ) files_to_include.append(extra_hwdef_path_abs) - # create archive + # create archive (inner folder matches download basename) + folder_name = Path(archive_path).name.removesuffix(".tar.gz") with tarfile.open(archive_path, "w:gz") as tar: for file in files_to_include: - arcname = f"{build_id}/{os.path.basename(file)}" + arcname = f"{folder_name}/{os.path.basename(file)}" self.logger.debug(f"Added {file} as {arcname}") tar.add(file, arcname=arcname) self.logger.info(f"Generated {archive_path}.") From d922c9a8341bed44ae81adfcd3cce9e4f1e32038 Mon Sep 17 00:00:00 2001 From: Shiv Tyagi Date: Sat, 25 Jul 2026 13:50:53 +0530 Subject: [PATCH 03/19] web: serve artifact download using archive basename Co-authored-by: Cursor --- web/api/v1/builds.py | 3 ++- web/services/builds.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/web/api/v1/builds.py b/web/api/v1/builds.py index 2c68c6a..9f33077 100644 --- a/web/api/v1/builds.py +++ b/web/api/v1/builds.py @@ -1,3 +1,4 @@ +import os from typing import List, Optional from fastapi import ( APIRouter, @@ -219,5 +220,5 @@ async def download_artifact( return FileResponse( path=artifact_path, media_type='application/gzip', - filename=f"{build_id}.tar.gz" + filename=os.path.basename(artifact_path) ) diff --git a/web/services/builds.py b/web/services/builds.py index 9cc6b67..64af64d 100644 --- a/web/services/builds.py +++ b/web/services/builds.py @@ -282,7 +282,9 @@ def get_artifact_path(self, build_id: str) -> Optional[str]: ]: return None - artifact_path = self.manager.get_build_archive_path(build_id) + artifact_path = self.manager.get_build_archive_path( + build_id, build_info.vehicle_id, build_info.board + ) if os.path.exists(artifact_path): return artifact_path From 4a77ba3cfa67f7c6b2e44317a3c876741933edc5 Mon Sep 17 00:00:00 2001 From: Shiv Tyagi Date: Sat, 25 Jul 2026 14:53:42 +0530 Subject: [PATCH 04/19] build_manager: store selected features as API labels Co-authored-by: Cursor --- build_manager/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build_manager/manager.py b/build_manager/manager.py index befc8e7..9a623dd 100644 --- a/build_manager/manager.py +++ b/build_manager/manager.py @@ -61,7 +61,7 @@ def __init__(self, source commit to build on. git_hash (str): The git commit hash to build on. board (str): Board to build for. - selected_features (set): Set of features selected for the build. + selected_features (set): Set of feature API labels/IDs for the build. """ self.vehicle_id = vehicle_id self.version_id = version_id From 5d18e3f97531681a789464713945676fce83427f Mon Sep 17 00:00:00 2001 From: Shiv Tyagi Date: Sat, 25 Jul 2026 14:53:42 +0530 Subject: [PATCH 05/19] builder: resolve feature labels to defines for extra_hwdef Co-authored-by: Cursor --- builder/builder.py | 41 ++++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/builder/builder.py b/builder/builder.py index c687e9a..dced6d2 100644 --- a/builder/builder.py +++ b/builder/builder.py @@ -17,6 +17,27 @@ CBS_BUILD_TIMEOUT_SEC = int(os.getenv('CBS_BUILD_TIMEOUT_SEC', 900)) +def resolve_feature_defines(selected_labels, all_features): + """ + Map API feature labels to preprocessor defines for extra_hwdef. + + Returns: + tuple: (enabled_defines, disabled_defines, all_defines, unknown_labels) + """ + label_to_define = { + feature.label: feature.define for feature in all_features + } + all_defines = set(label_to_define.values()) + selected = set(selected_labels) + known = set(label_to_define) + unknown_labels = selected.difference(known) + enabled_defines = { + label_to_define[label] for label in known.intersection(selected) + } + disabled_defines = all_defines.difference(enabled_defines) + return enabled_defines, disabled_defines, all_defines, unknown_labels + + class Builder: """ Processes build requests, perform builds and ship build artifacts @@ -102,22 +123,24 @@ def __generate_extrahwdef(self, build_id: str) -> None: ) build_info = bm.get_singleton().get_build_info(build_id) - selected_features = build_info.selected_features + selected_labels = build_info.selected_features self.logger.debug( - f"Selected features for {build_id}: {selected_features}" + f"Selected feature labels for {build_id}: {selected_labels}" ) all_features = apfetch.get_singleton().get_build_options_at_commit( remote=build_info.remote_info.name, commit_ref=build_info.git_hash, ) - all_defines = { - feature.define - for feature in all_features - } - enabled_defines = selected_features.intersection(all_defines) - disabled_defines = all_defines.difference(enabled_defines) + enabled_defines, disabled_defines, all_defines, unknown_labels = ( + resolve_feature_defines(selected_labels, all_features) + ) + if unknown_labels: + self.logger.warning( + f"Unknown feature labels not found in build options; " + f"skipping: {sorted(unknown_labels)}" + ) self.logger.info(f"Enabled defines for {build_id}: {enabled_defines}") - self.logger.info(f"Disabled defines for {build_id}: {enabled_defines}") + self.logger.info(f"Disabled defines for {build_id}: {disabled_defines}") with open(self.__get_path_to_extra_hwdef(build_id), "w") as f: # Undefine all defines at the beginning From b7daf38462d303708b9c900c611865324894fb64 Mon Sep 17 00:00:00 2001 From: Shiv Tyagi Date: Sat, 25 Jul 2026 14:53:42 +0530 Subject: [PATCH 06/19] web: submit and return feature labels without define mapping Co-authored-by: Cursor --- web/services/builds.py | 79 ++---------------------------------------- 1 file changed, 2 insertions(+), 77 deletions(-) diff --git a/web/services/builds.py b/web/services/builds.py index 64af64d..53bdfa1 100644 --- a/web/services/builds.py +++ b/web/services/builds.py @@ -103,38 +103,6 @@ def create_build( commit_ref=commit_ref ) - # Map feature labels (IDs from API) to defines - # (required by build manager) - selected_feature_defines = set() - if build_request.selected_features: - # Get build options to map labels to defines - with self.repo.get_checkout_lock(): - options = ( - self.ap_src_metadata_fetcher - .get_build_options_at_commit( - remote=remote_name, - commit_ref=commit_ref - ) - ) - - # Create label to define mapping - label_to_define = { - option.label: option.define for option in options - } - - # Map each selected feature label to its define - for feature_label in build_request.selected_features: - if feature_label in label_to_define: - selected_feature_defines.add( - label_to_define[feature_label] - ) - else: - logger.warning( - f"Feature label '{feature_label}' not found in " - f"build options for {vehicle_id} {remote_name} " - f"{commit_ref}" - ) - # Create build info build_info = build_manager.BuildInfo( vehicle_id=vehicle_id, @@ -142,7 +110,7 @@ def create_build( remote_info=remote_info, git_hash=git_hash, board=board_name, - selected_features=selected_feature_defines + selected_features=set(build_request.selected_features), ) # Submit build @@ -317,49 +285,6 @@ def _build_info_to_output( url=build_info.remote_info.url ) - # Map feature defines back to labels for API response - selected_feature_labels = [] - if build_info.selected_features: - try: - # Get build options to map defines back to labels - with self.repo.get_checkout_lock(): - options = ( - self.ap_src_metadata_fetcher - .get_build_options_at_commit( - remote=build_info.remote_info.name, - commit_ref=build_info.git_hash - ) - ) - - # Create define to label mapping - define_to_label = { - option.define: option.label for option in options - } - - # Map each selected feature define to its label - for feature_define in build_info.selected_features: - if feature_define in define_to_label: - selected_feature_labels.append( - define_to_label[feature_define] - ) - else: - # Fallback: use define if label not found - logger.warning( - f"Feature define '{feature_define}' not " - f"found in build options for build " - f"{build_id}" - ) - selected_feature_labels.append(feature_define) - except Exception as e: - logger.error( - f"Error mapping feature defines to labels for " - f"build {build_id}: {e}" - ) - # Fallback: use defines as-is - selected_feature_labels = list( - build_info.selected_features - ) - vehicle = self.vehicles_manager.get_vehicle_by_id( build_info.vehicle_id ) @@ -379,7 +304,7 @@ def _build_info_to_output( remote_info=remote_info, git_hash=build_info.git_hash ), - selected_features=selected_feature_labels, + selected_features=list(build_info.selected_features), progress=progress, time_created=build_info.time_created, ) From be7f66e00698dd0763a1516296f4abe5762de7e7 Mon Sep 17 00:00:00 2001 From: Shiv Tyagi Date: Sat, 25 Jul 2026 14:53:42 +0530 Subject: [PATCH 07/19] tests: cover label storage and resolve_feature_defines Co-authored-by: Cursor --- tests/builder/test_resolve_feature_defines.py | 56 +++++++++++++++ tests/web/test_builds_service.py | 72 ++++--------------- 2 files changed, 68 insertions(+), 60 deletions(-) create mode 100644 tests/builder/test_resolve_feature_defines.py diff --git a/tests/builder/test_resolve_feature_defines.py b/tests/builder/test_resolve_feature_defines.py new file mode 100644 index 0000000..c9bca81 --- /dev/null +++ b/tests/builder/test_resolve_feature_defines.py @@ -0,0 +1,56 @@ +"""Tests for builder label to define resolution.""" +from unittest.mock import Mock + +from builder.builder import resolve_feature_defines + + +def _option(label, define): + opt = Mock() + opt.label = label + opt.define = define + return opt + + +def test_resolve_feature_defines_maps_labels(): + features = [ + _option("HAL_LOGGING_ENABLED", "HAL_LOGGING_ENABLED_DEFINE"), + _option("HAL_WITH_EKF3", "HAL_WITH_EKF3_DEFINE"), + ] + + enabled, disabled, all_defines, unknown = resolve_feature_defines( + ["HAL_LOGGING_ENABLED"], + features, + ) + + assert enabled == {"HAL_LOGGING_ENABLED_DEFINE"} + assert disabled == {"HAL_WITH_EKF3_DEFINE"} + assert all_defines == { + "HAL_LOGGING_ENABLED_DEFINE", + "HAL_WITH_EKF3_DEFINE", + } + assert unknown == set() + + +def test_resolve_feature_defines_returns_unknown_labels(): + features = [_option("HAL_LOGGING_ENABLED", "HAL_LOGGING_ENABLED_DEFINE")] + + enabled, disabled, all_defines, unknown = resolve_feature_defines( + ["COMPLETELY_UNKNOWN_FEATURE"], + features, + ) + + assert enabled == set() + assert disabled == all_defines == {"HAL_LOGGING_ENABLED_DEFINE"} + assert unknown == {"COMPLETELY_UNKNOWN_FEATURE"} + + +def test_resolve_feature_defines_empty_selection_disables_all(): + features = [_option("HAL_LOGGING_ENABLED", "HAL_LOGGING_ENABLED_DEFINE")] + + enabled, disabled, all_defines, unknown = resolve_feature_defines( + [], features + ) + + assert enabled == set() + assert disabled == all_defines == {"HAL_LOGGING_ENABLED_DEFINE"} + assert unknown == set() diff --git a/tests/web/test_builds_service.py b/tests/web/test_builds_service.py index 8270ec2..bbe6aa4 100644 --- a/tests/web/test_builds_service.py +++ b/tests/web/test_builds_service.py @@ -79,7 +79,7 @@ def make_build_info( remote_info=ManagerRemoteInfo(name=remote_name, url=remote_url), git_hash=git_hash, board=board, - selected_features=selected_features or set(), + selected_features=set(selected_features) if selected_features is not None else set(), ) info.progress = bm.BuildProgress(state=state, percent=percent) return info @@ -242,17 +242,12 @@ def test_create_build_raises_value_error_when_board_not_in_version( with pytest.raises(ValueError, match="Invalid board for this version"): service.create_build(request) - def test_create_build_maps_feature_labels_to_defines( + def test_create_build_stores_feature_labels( self, service, - mock_ap_src_metadata_fetcher, mock_build_manager, ): - """Selected feature labels are translated to defines before build submission.""" - opt = Mock() - opt.label = "HAL_LOGGING_ENABLED" - opt.define = "HAL_LOGGING_ENABLED_DEFINE" - mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt] + """Selected feature labels are stored on BuildInfo as-is.""" request = BuildRequest( vehicle_id="copter", board_id="MatekH743", @@ -263,37 +258,14 @@ def test_create_build_maps_feature_labels_to_defines( service.create_build(request) submitted: bm.BuildInfo = mock_build_manager.submit_build.call_args[1]["build_info"] - assert "HAL_LOGGING_ENABLED_DEFINE" in submitted.selected_features - - def test_create_build_ignores_unknown_feature_labels( - self, - service, - mock_ap_src_metadata_fetcher, - mock_build_manager, - ): - """Unknown feature labels are silently skipped (not added to defines set).""" - opt = Mock() - opt.label = "HAL_LOGGING_ENABLED" - opt.define = "HAL_LOGGING_ENABLED_DEFINE" - mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt] - request = BuildRequest( - vehicle_id="copter", - board_id="MatekH743", - version_id="copter-4.5.0-stable", - selected_features=["COMPLETELY_UNKNOWN_FEATURE"], - ) - - service.create_build(request) - - submitted: bm.BuildInfo = mock_build_manager.submit_build.call_args[1]["build_info"] - assert len(submitted.selected_features) == 0 + assert submitted.selected_features == {"HAL_LOGGING_ENABLED"} - def test_create_build_no_features_submits_empty_set( + def test_create_build_no_features_submits_empty_list( self, service, mock_build_manager, ): - """When selected_features is empty, build is submitted with an empty set.""" + """When selected_features is empty, build is submitted with an empty list.""" request = BuildRequest( vehicle_id="copter", board_id="MatekH743", @@ -304,7 +276,7 @@ def test_create_build_no_features_submits_empty_set( service.create_build(request) submitted: bm.BuildInfo = mock_build_manager.submit_build.call_args[1]["build_info"] - assert len(submitted.selected_features) == 0 + assert submitted.selected_features == set() # Tests for list_builds @@ -546,40 +518,20 @@ def test_get_build_output_has_correct_vehicle_and_board( assert result.vehicle.id == "plane" assert result.board.id == "CubeOrange" - def test_get_build_maps_feature_defines_to_labels( + def test_get_build_uses_stored_feature_labels( self, service, mock_build_manager, - mock_ap_src_metadata_fetcher, ): - """Feature defines in BuildInfo are mapped back to labels in the output.""" + """API output uses feature labels stored on BuildInfo at submit time.""" mock_build_manager.build_exists.return_value = True mock_build_manager.get_build_info.return_value = make_build_info( - selected_features={"HAL_LOGGING_ENABLED_DEFINE"} - ) - opt = Mock() - opt.define = "HAL_LOGGING_ENABLED_DEFINE" - opt.label = "HAL_LOGGING_ENABLED" - mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt] - - result = service.get_build("build-abc123") - - assert "HAL_LOGGING_ENABLED" in result.selected_features - - def test_get_build_falls_back_to_define_when_label_not_found( - self, - service, - mock_build_manager, - ): - """When a define has no matching label, the define itself is used as fallback.""" - mock_build_manager.build_exists.return_value = True - mock_build_manager.get_build_info.return_value = make_build_info( - selected_features={"ORPHANED_DEFINE"} + selected_features=["HAL_LOGGING_ENABLED"], ) result = service.get_build("build-abc123") - assert "ORPHANED_DEFINE" in result.selected_features + assert result.selected_features == ["HAL_LOGGING_ENABLED"] def test_get_build_no_selected_features_returns_empty_list( self, @@ -589,7 +541,7 @@ def test_get_build_no_selected_features_returns_empty_list( """When a build has no selected features, the output list is empty.""" mock_build_manager.build_exists.return_value = True mock_build_manager.get_build_info.return_value = make_build_info( - selected_features=set() + selected_features=[], ) result = service.get_build("build-abc123") From f0f405f8f23a49458d2796b28cd00ce6bda75872 Mon Sep 17 00:00:00 2001 From: Shiv Tyagi Date: Sat, 25 Jul 2026 14:53:57 +0530 Subject: [PATCH 08/19] schemas: add custombuild config schema Co-authored-by: Cursor --- schemas/config/0.0.1.json | 51 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 schemas/config/0.0.1.json diff --git a/schemas/config/0.0.1.json b/schemas/config/0.0.1.json new file mode 100644 index 0000000..9685d42 --- /dev/null +++ b/schemas/config/0.0.1.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "BuildConfig", + "description": "CustomBuild configuration file schema v0.0.1", + "type": "object", + "required": ["config_version", "vehicle", "version", "board", "selected_features"], + "additionalProperties": false, + "properties": { + "config_version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "description": "Semver config format version" + }, + "vehicle": { + "type": "object", + "required": ["id", "name"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + }, + "version": { + "type": "object", + "required": ["id", "name", "type", "remote_name"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "type": { "type": "string" }, + "remote_name": { "type": "string" } + } + }, + "board": { + "type": "object", + "required": ["id", "name"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + }, + "selected_features": { + "type": "array", + "items": { "type": "string" } + }, + "use_default_features": { + "type": "boolean" + } + } +} From 6fa34a43b44c4e04f5e72ad4a999488048c06063 Mon Sep 17 00:00:00 2001 From: Shiv Tyagi Date: Sat, 25 Jul 2026 14:53:57 +0530 Subject: [PATCH 09/19] build_config: add shared config YAML helpers Co-authored-by: Cursor --- build_config/__init__.py | 23 +++++++++++ build_config/config.py | 88 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 build_config/__init__.py create mode 100644 build_config/config.py diff --git a/build_config/__init__.py b/build_config/__init__.py new file mode 100644 index 0000000..de96a99 --- /dev/null +++ b/build_config/__init__.py @@ -0,0 +1,23 @@ +"""Shared CustomBuild YAML config generation and validation.""" + +from build_config.config import ( + CONFIG_VERSION, + CONFIG_FILENAME, + build_config_dict, + config_dict_from_build_info, + dump_config_yaml, + schema_path_for_version, + validate_config_dict, + write_config_yaml, +) + +__all__ = [ + "CONFIG_VERSION", + "CONFIG_FILENAME", + "build_config_dict", + "config_dict_from_build_info", + "dump_config_yaml", + "schema_path_for_version", + "validate_config_dict", + "write_config_yaml", +] diff --git a/build_config/config.py b/build_config/config.py new file mode 100644 index 0000000..672d337 --- /dev/null +++ b/build_config/config.py @@ -0,0 +1,88 @@ +"""Serialize and validate CustomBuild config YAML (schemas/config).""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Mapping, Sequence + +import yaml +from jsonschema import Draft202012Validator + +CONFIG_VERSION = "0.0.1" +CONFIG_FILENAME = "custombuild.yaml" + +_SCHEMAS_DIR = Path(__file__).resolve().parent.parent / "schemas" / "config" + + +def schema_path_for_version(version: str = CONFIG_VERSION) -> Path: + path = _SCHEMAS_DIR / f"{version}.json" + if not path.is_file(): + raise FileNotFoundError(f"No config schema for version '{version}' at {path}") + return path + + +def validate_config_dict(config: Mapping[str, Any]) -> None: + version = config.get("config_version") + if not isinstance(version, str): + raise ValueError("Invalid or missing config_version") + schema = json.loads(schema_path_for_version(version).read_text(encoding="utf-8")) + Draft202012Validator(schema).validate(dict(config)) + + +def build_config_dict( + *, + vehicle_id: str, + vehicle_name: str, + version_id: str, + version_name: str, + version_type: str, + remote_name: str, + board_id: str, + board_name: str, + selected_features: Sequence[str], + config_version: str = CONFIG_VERSION, +) -> dict[str, Any]: + """Build a schema-compliant config dict (selected_features are API labels).""" + return { + "config_version": config_version, + "vehicle": {"id": vehicle_id, "name": vehicle_name}, + "version": { + "id": version_id, + "name": version_name, + "type": version_type, + "remote_name": remote_name, + }, + "board": {"id": board_id, "name": board_name}, + "selected_features": list(selected_features), + } + + +def dump_config_yaml(config: Mapping[str, Any]) -> str: + validate_config_dict(config) + return yaml.safe_dump( + dict(config), + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + ) + + +def write_config_yaml(path: Path | str, config: Mapping[str, Any]) -> None: + path = Path(path) + path.write_text(dump_config_yaml(config), encoding="utf-8") + + +def config_dict_from_build_info(build_info: Any) -> dict[str, Any]: + """Build config from BuildInfo fields set at submit time.""" + return build_config_dict( + vehicle_id=build_info.vehicle_id, + vehicle_name=build_info.vehicle_name, + version_id=build_info.version_id, + version_name=build_info.version_name, + version_type=build_info.version_type, + remote_name=build_info.remote_info.name, + board_id=build_info.board, + board_name=build_info.board_name, + selected_features=list(build_info.selected_features), + ) From 73bea96083e45dd5c91ed905db674d6433dcbc3d Mon Sep 17 00:00:00 2001 From: Shiv Tyagi Date: Sat, 25 Jul 2026 14:53:57 +0530 Subject: [PATCH 10/19] build_manager: store display fields on BuildInfo for config YAML Co-authored-by: Cursor --- build_manager/manager.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/build_manager/manager.py b/build_manager/manager.py index 9a623dd..50af92d 100644 --- a/build_manager/manager.py +++ b/build_manager/manager.py @@ -48,7 +48,11 @@ def __init__(self, remote_info: RemoteInfo, git_hash: str, board: str, - selected_features: set) -> None: + selected_features: set, + vehicle_name: str, + board_name: str, + version_name: str, + version_type: str) -> None: """ Initialize build information object including vehicle, remote, git hash, selected features, and progress of the build. @@ -62,6 +66,10 @@ def __init__(self, git_hash (str): The git commit hash to build on. board (str): Board to build for. selected_features (set): Set of feature API labels/IDs for the build. + vehicle_name (str): Display name for rebuild config YAML. + board_name (str): Display name for rebuild config YAML. + version_name (str): Display name for rebuild config YAML. + version_type (str): Release type for rebuild config YAML. """ self.vehicle_id = vehicle_id self.version_id = version_id @@ -69,6 +77,10 @@ def __init__(self, self.git_hash = git_hash self.board = board self.selected_features = selected_features + self.vehicle_name = vehicle_name + self.board_name = board_name + self.version_name = version_name + self.version_type = version_type self.progress = BuildProgress( state=BuildState.PENDING, percent=0 @@ -84,9 +96,13 @@ def to_dict(self) -> dict: 'git_hash': self.git_hash, 'board': self.board, 'selected_features': list(self.selected_features), + 'vehicle_name': self.vehicle_name, + 'board_name': self.board_name, + 'version_name': self.version_name, + 'version_type': self.version_type, 'progress': self.progress.to_dict(), 'time_created': self.time_created, - 'time_started': getattr(self, 'time_started', None), + 'time_started': self.time_started, } From 230049cd31579d3453f6ec432a319f582fced1f6 Mon Sep 17 00:00:00 2001 From: Shiv Tyagi Date: Sat, 25 Jul 2026 14:54:15 +0530 Subject: [PATCH 11/19] builder: pack custombuild.yaml into the build archive Co-authored-by: Cursor --- builder/builder.py | 12 ++++++++++++ builder/requirements.txt | 1 + 2 files changed, 13 insertions(+) diff --git a/builder/builder.py b/builder/builder.py index dced6d2..7ac2780 100644 --- a/builder/builder.py +++ b/builder/builder.py @@ -13,6 +13,11 @@ VehiclesManager as vehm ) from pathlib import Path +from build_config import ( + CONFIG_FILENAME, + config_dict_from_build_info, + write_config_yaml, +) CBS_BUILD_TIMEOUT_SEC = int(os.getenv('CBS_BUILD_TIMEOUT_SEC', 900)) @@ -286,6 +291,13 @@ def __generate_archive(self, build_id: str) -> None: ) files_to_include.append(extra_hwdef_path_abs) + # include rebuild config YAML (Builder is sole canonical author) + config_path = Path( + self.__get_path_to_build_dir(build_id) + ) / CONFIG_FILENAME + write_config_yaml(config_path, config_dict_from_build_info(build_info)) + files_to_include.append(str(config_path.resolve())) + # create archive (inner folder matches download basename) folder_name = Path(archive_path).name.removesuffix(".tar.gz") with tarfile.open(archive_path, "w:gz") as tar: diff --git a/builder/requirements.txt b/builder/requirements.txt index 3c8b6d3..d238f48 100644 --- a/builder/requirements.txt +++ b/builder/requirements.txt @@ -3,3 +3,4 @@ redis==5.2.1 dill==0.3.8 requests==2.31.0 packaging==25.0 +PyYAML==6.0.2 From 39cd8a83eb3144685ec3ccea9984160ab1f96906 Mon Sep 17 00:00:00 2001 From: Shiv Tyagi Date: Sat, 25 Jul 2026 14:54:15 +0530 Subject: [PATCH 12/19] web: add builds config download and wire submit-time display metadata Co-authored-by: Cursor --- web/api/v1/builds.py | 50 +++++++++++++++++++++++++++++- web/requirements.txt | 1 + web/schemas/builds.py | 6 +++- web/services/builds.py | 70 ++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 123 insertions(+), 4 deletions(-) diff --git a/web/api/v1/builds.py b/web/api/v1/builds.py index 9f33077..cd8e651 100644 --- a/web/api/v1/builds.py +++ b/web/api/v1/builds.py @@ -9,7 +9,7 @@ Depends, Request ) -from fastapi.responses import FileResponse, PlainTextResponse +from fastapi.responses import FileResponse, PlainTextResponse, Response from web.schemas import ( BuildRequest, @@ -222,3 +222,51 @@ async def download_artifact( media_type='application/gzip', filename=os.path.basename(artifact_path) ) + + +@router.get( + "/{build_id}/config", + responses={ + 404: { + "description": ( + "Build not found or config could not be generated" + ) + } + } +) +async def download_config( + build_id: str = Path(..., description="Unique build identifier"), + service: BuildsService = Depends(get_builds_service) +): + """ + Download the CustomBuild config YAML for a build. + + Generated from build metadata via the shared build_config module + (same shape as the YAML packed into the archive by Builder). + + Args: + build_id: The unique build identifier + + Returns: + YAML config file + + Raises: + 404: Build not found or config could not be generated + """ + result = service.get_build_config_yaml(build_id) + if not result: + raise HTTPException( + status_code=404, + detail=( + f"Config not available for build '{build_id}'. " + "Build may not exist or metadata is incomplete." + ) + ) + yaml_text, filename = result + return Response( + content=yaml_text, + media_type="application/yaml", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"' + }, + ) diff --git a/web/requirements.txt b/web/requirements.txt index 1862bbd..3f28e21 100644 --- a/web/requirements.txt +++ b/web/requirements.txt @@ -9,3 +9,4 @@ packaging==25.0 jinja2==3.1.2 python-multipart==0.0.6 slowapi==0.1.9 +PyYAML==6.0.2 diff --git a/web/schemas/builds.py b/web/schemas/builds.py index fc7e8a3..b654210 100644 --- a/web/schemas/builds.py +++ b/web/schemas/builds.py @@ -1,4 +1,4 @@ -from typing import List, Literal +from typing import List, Literal, Optional from pydantic import BaseModel, Field from web.schemas.vehicles import VehicleBase, BoardBase, RemoteInfo @@ -47,6 +47,10 @@ class BuildSubmitResponse(BaseModel): class BuildVersionInfo(BaseModel): """Version information for a build.""" id: str = Field(..., description="Version ID used for this build") + name: Optional[str] = Field(None, description="Version display name") + type: Optional[Literal["beta", "stable", "latest", "tag"]] = Field( + None, description="Version type classification" + ) remote_info: RemoteInfo = Field( ..., description="Source repository information" ) diff --git a/web/services/builds.py b/web/services/builds.py index 53bdfa1..6edce32 100644 --- a/web/services/builds.py +++ b/web/services/builds.py @@ -65,6 +65,10 @@ def create_build( if not vehicle_id: raise ValueError("vehicle_id is required") + vehicle = self.vehicles_manager.get_vehicle_by_id(vehicle_id) + if vehicle is None: + raise ValueError("Invalid vehicle_id") + # Get version info using version_id version_info = self.versions_manager.get_version_info( vehicle_id=vehicle_id, @@ -103,6 +107,11 @@ def create_build( commit_ref=commit_ref ) + if version_info.release_type == "latest": + version_display_name = "master" + else: + version_display_name = version_info.version_number + # Create build info build_info = build_manager.BuildInfo( vehicle_id=vehicle_id, @@ -111,6 +120,10 @@ def create_build( git_hash=git_hash, board=board_name, selected_features=set(build_request.selected_features), + vehicle_name=vehicle.name, + board_name=board_name, + version_name=version_display_name, + version_type=version_info.release_type, ) # Submit build @@ -258,6 +271,39 @@ def get_artifact_path(self, build_id: str) -> Optional[str]: return None + def get_build_config_yaml(self, build_id: str) -> Optional[tuple]: + """ + Generate custombuild.yaml from BuildInfo. + + Args: + build_id: The unique build identifier + + Returns: + (yaml_text, download_filename) or None if unavailable + """ + from build_config import config_dict_from_build_info, dump_config_yaml + + if not self.manager.build_exists(build_id): + return None + + build_info = self.manager.get_build_info(build_id) + if build_info is None: + return None + + try: + yaml_text = dump_config_yaml(config_dict_from_build_info(build_info)) + except Exception as e: + logger.error( + f"Error generating config YAML for build {build_id}: {e}" + ) + return None + + filename = ( + f"custombuild-{build_info.vehicle_id}-" + f"{build_info.board}-{build_id}.yaml" + ) + return yaml_text, filename + def _build_info_to_output( self, build_id: str, @@ -288,19 +334,39 @@ def _build_info_to_output( vehicle = self.vehicles_manager.get_vehicle_by_id( build_info.vehicle_id ) + if vehicle is not None: + vehicle_name = vehicle.name + else: + vehicle_name = build_info.vehicle_name or "" + + v_info = self.versions_manager.get_version_info( + vehicle_id=build_info.vehicle_id, + version_id=build_info.version_id + ) + if v_info is not None: + version_type = v_info.release_type + if v_info.release_type == "latest": + version_name = "master" + else: + version_name = v_info.version_number + else: + version_name = build_info.version_name + version_type = build_info.version_type return BuildOut( build_id=build_id, vehicle=VehicleBase( id=build_info.vehicle_id, - name=vehicle.name + name=vehicle_name ), board=BoardBase( id=build_info.board, - name=build_info.board # Board name is same as board ID for now + name=build_info.board_name ), version=BuildVersionInfo( id=build_info.version_id, + name=version_name, + type=version_type, remote_info=remote_info, git_hash=build_info.git_hash ), From 0c302635703d8698b8171f5cb85e87fa1fe02961 Mon Sep 17 00:00:00 2001 From: Shiv Tyagi Date: Sat, 25 Jul 2026 14:54:54 +0530 Subject: [PATCH 13/19] tests: cover build_config helpers and builds config endpoint Co-authored-by: Cursor --- tests/build_config/test_build_config.py | 101 +++++++++++++++ tests/web/test_builds_api.py | 31 +++++ tests/web/test_builds_service.py | 161 +++++++++++++++++++++++- 3 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 tests/build_config/test_build_config.py diff --git a/tests/build_config/test_build_config.py b/tests/build_config/test_build_config.py new file mode 100644 index 0000000..0220c73 --- /dev/null +++ b/tests/build_config/test_build_config.py @@ -0,0 +1,101 @@ +"""Tests for shared build_config packaging helpers.""" +import tarfile +from pathlib import Path + +import pytest +import yaml + +from build_config import ( + CONFIG_FILENAME, + CONFIG_VERSION, + build_config_dict, + config_dict_from_build_info, + dump_config_yaml, + validate_config_dict, + write_config_yaml, +) +from build_manager import BuildInfo +from metadata_manager import RemoteInfo + + +def _valid_config(**overrides): + base = build_config_dict( + vehicle_id="copter", + vehicle_name="Copter", + version_id="ardupilot-Copter-4.5.0-abc", + version_name="4.5.0", + version_type="stable", + remote_name="ardupilot", + board_id="CubeOrange", + board_name="CubeOrange", + selected_features={"HAL_LOGGING_ENABLED"}, + ) + base.update(overrides) + return base + + +def test_validate_accepts_schema_compliant_config(): + validate_config_dict(_valid_config()) + + +def test_validate_rejects_missing_required_field(): + bad = _valid_config() + del bad["board"] + with pytest.raises(Exception): + validate_config_dict(bad) + + +def test_dump_and_write_roundtrip(tmp_path: Path): + config = _valid_config() + text = dump_config_yaml(config) + parsed = yaml.safe_load(text) + assert parsed["config_version"] == CONFIG_VERSION + assert parsed["vehicle"]["id"] == "copter" + + out = tmp_path / CONFIG_FILENAME + write_config_yaml(out, config) + assert out.is_file() + assert yaml.safe_load(out.read_text())["board"]["id"] == "CubeOrange" + + +def test_config_dict_from_build_info_uses_labels_and_names(): + info = BuildInfo( + vehicle_id="copter", + version_id="ver-1", + remote_info=RemoteInfo( + name="ardupilot", + url="https://github.com/ArduPilot/ardupilot.git", + ), + git_hash="abc123", + board="MatekH743", + selected_features={"HAL_LOGGING_ENABLED"}, + vehicle_name="Copter", + board_name="MatekH743", + version_name="4.5.0", + version_type="stable", + ) + config = config_dict_from_build_info(info) + validate_config_dict(config) + assert config["selected_features"] == ["HAL_LOGGING_ENABLED"] + assert config["version"]["name"] == "4.5.0" + assert config["version"]["remote_name"] == "ardupilot" + + +def test_packaged_yaml_inside_tar(tmp_path: Path): + """Simulate Builder archive membership for custombuild.yaml.""" + config = _valid_config() + config_path = tmp_path / CONFIG_FILENAME + write_config_yaml(config_path, config) + + archive = tmp_path / "copter-MatekH743-build1.tar.gz" + folder_name = archive.name.removesuffix(".tar.gz") + with tarfile.open(archive, "w:gz") as tar: + tar.add(config_path, arcname=f"{folder_name}/{CONFIG_FILENAME}") + + with tarfile.open(archive, "r:gz") as tar: + names = [m.name for m in tar.getmembers()] + assert f"{folder_name}/{CONFIG_FILENAME}" in names + extracted = tar.extractfile(f"{folder_name}/{CONFIG_FILENAME}") + assert extracted is not None + loaded = yaml.safe_load(extracted.read()) + assert loaded["vehicle"]["id"] == "copter" diff --git a/tests/web/test_builds_api.py b/tests/web/test_builds_api.py index cbd7a83..6cf4769 100644 --- a/tests/web/test_builds_api.py +++ b/tests/web/test_builds_api.py @@ -444,3 +444,34 @@ def test_get_artifact_method_not_allowed(self, client): for method in [client.post, client.put, client.patch, client.delete]: response = method("/api/v1/builds/build-abc123/artifact") assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + def test_get_config_returns_200_when_available(self, client): + """Returns YAML when the service provides packaged config.""" + mock_service = Mock() + mock_service.get_build_config_yaml.return_value = ( + "config_version: \"0.0.1\"\nvehicle:\n id: copter\n name: Copter\n", + "custombuild-copter-MatekH743-build-abc123.yaml", + ) + with self.override_builds_service(client, mock_service): + response = client.get("/api/v1/builds/build-abc123/config") + + assert response.status_code == status.HTTP_200_OK + assert "copter" in response.text + assert "attachment" in response.headers.get("content-disposition", "") + + def test_get_config_returns_404_when_not_available(self, client): + mock_service = Mock() + mock_service.get_build_config_yaml.return_value = None + with self.override_builds_service(client, mock_service): + response = client.get("/api/v1/builds/some-build-id/config") + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "some-build-id" in response.json()["detail"] + + def test_get_config_service_called_with_correct_build_id(self, client): + mock_service = Mock() + mock_service.get_build_config_yaml.return_value = None + with self.override_builds_service(client, mock_service): + client.get("/api/v1/builds/target-build/config") + + mock_service.get_build_config_yaml.assert_called_once_with("target-build") diff --git a/tests/web/test_builds_service.py b/tests/web/test_builds_service.py index bbe6aa4..3361b9a 100644 --- a/tests/web/test_builds_service.py +++ b/tests/web/test_builds_service.py @@ -70,6 +70,10 @@ def make_build_info( git_hash="abc123def456", board="MatekH743", selected_features=None, + vehicle_name=None, + board_name=None, + version_name="4.5.0", + version_type="stable", state=bm.BuildState.PENDING, percent=0, ): @@ -80,6 +84,10 @@ def make_build_info( git_hash=git_hash, board=board, selected_features=set(selected_features) if selected_features is not None else set(), + vehicle_name=vehicle_name or vehicle_id.capitalize(), + board_name=board_name or board, + version_name=version_name, + version_type=version_type, ) info.progress = bm.BuildProgress(state=state, percent=percent) return info @@ -161,6 +169,21 @@ def test_create_build_raises_value_error_for_missing_vehicle_id(self, service): with pytest.raises(ValueError, match="vehicle_id is required"): service.create_build(request) + def test_create_build_raises_value_error_for_invalid_vehicle( + self, service, mock_vehicles_manager + ): + """ValueError is raised when vehicle_id is not a known vehicle.""" + mock_vehicles_manager.get_vehicle_by_id.return_value = None + request = BuildRequest( + vehicle_id="unknown-vehicle", + board_id="MatekH743", + version_id="copter-4.5.0-stable", + selected_features=[], + ) + + with pytest.raises(ValueError, match="Invalid vehicle_id"): + service.create_build(request) + def test_create_build_raises_value_error_for_missing_board_id( self, service ): @@ -216,9 +239,9 @@ def test_create_build_raises_value_error_when_remote_not_found( """ValueError is raised when the remote is not found.""" mock_versions_manager.get_remote_info.return_value = None request = BuildRequest( - vehicle_id="some-vehicle", - board_id="some-board", - version_id="some-version", + vehicle_id="copter", + board_id="MatekH743", + version_id="copter-4.5.0-stable", selected_features=[], ) @@ -725,3 +748,135 @@ def test_get_artifact_path_uses_correct_build_id( service.get_artifact_path("my-target-build") mock_build_manager.build_exists.assert_called_once_with("my-target-build") + + def test_get_build_includes_version_name_and_type( + self, + service, + mock_build_manager, + mock_versions_manager, + ): + """version.name and version.type come from versions_manager.""" + mock_build_manager.build_exists.return_value = True + mock_build_manager.get_build_info.return_value = make_build_info() + mock_versions_manager.get_version_info.return_value = make_version_info( + release_type="stable", + version_number="4.5.0", + ) + + result = service.get_build("build-abc123") + + assert result.version.name == "4.5.0" + assert result.version.type == "stable" + mock_versions_manager.get_version_info.assert_called_with( + vehicle_id="copter", + version_id="copter-4.5.0-stable", + ) + + def test_get_build_maps_latest_version_name_to_master( + self, + service, + mock_build_manager, + mock_versions_manager, + ): + """latest release_type is exposed as display name 'master'.""" + mock_build_manager.build_exists.return_value = True + mock_build_manager.get_build_info.return_value = make_build_info( + version_id="copter-latest", + ) + mock_versions_manager.get_version_info.return_value = make_version_info( + release_type="latest", + version_number="NA", + ) + + result = service.get_build("build-abc123") + + assert result.version.name == "master" + assert result.version.type == "latest" + + def test_get_build_falls_back_to_stored_version_when_unknown( + self, + service, + mock_build_manager, + mock_versions_manager, + ): + """Missing live version info uses names stored on BuildInfo.""" + mock_build_manager.build_exists.return_value = True + mock_build_manager.get_build_info.return_value = make_build_info( + version_name="4.5.0", + version_type="stable", + ) + mock_versions_manager.get_version_info.return_value = None + + result = service.get_build("build-abc123") + + assert result.version.name == "4.5.0" + assert result.version.type == "stable" + + def test_get_build_falls_back_to_stored_vehicle_name_when_unknown( + self, + service, + mock_build_manager, + mock_vehicles_manager, + ): + """Missing live vehicle uses display name stored on BuildInfo, not id.""" + mock_build_manager.build_exists.return_value = True + mock_build_manager.get_build_info.return_value = make_build_info( + vehicle_id="retired-vehicle", + vehicle_name="Retired Vehicle", + ) + mock_vehicles_manager.get_vehicle_by_id.side_effect = lambda _vid: None + + result = service.get_build("build-abc123") + + assert result.vehicle.id == "retired-vehicle" + assert result.vehicle.name == "Retired Vehicle" + + def test_get_build_config_yaml_generates_from_build_info( + self, + service, + mock_build_manager, + ): + mock_build_manager.build_exists.return_value = True + mock_build_manager.get_build_info.return_value = make_build_info( + state=bm.BuildState.SUCCESS, + vehicle_id="copter", + board="MatekH743", + vehicle_name="Copter", + selected_features=["HAL_LOGGING_ENABLED"], + ) + + result = service.get_build_config_yaml("build-abc123") + + assert result is not None + yaml_text, filename = result + assert "HAL_LOGGING_ENABLED" in yaml_text + assert filename.endswith(".yaml") + + def test_get_build_config_yaml_returns_none_when_build_missing( + self, service, mock_build_manager + ): + mock_build_manager.build_exists.return_value = False + assert service.get_build_config_yaml("missing") is None + + def test_get_build_config_yaml_returns_none_when_info_missing( + self, service, mock_build_manager + ): + mock_build_manager.build_exists.return_value = True + mock_build_manager.get_build_info.return_value = None + assert service.get_build_config_yaml("build-abc123") is None + + def test_create_build_stores_display_metadata( + self, service, mock_build_manager + ): + service.create_build( + BuildRequest( + vehicle_id="copter", + board_id="MatekH743", + version_id="copter-4.5.0-stable", + selected_features=["HAL_LOGGING_ENABLED"], + ) + ) + submitted: bm.BuildInfo = mock_build_manager.submit_build.call_args[1]["build_info"] + assert submitted.selected_features == {"HAL_LOGGING_ENABLED"} + assert submitted.vehicle_name == "Copter" + assert submitted.version_type == "stable" From b22d62a3b322fca9b2497e7226f06669b966ee5f Mon Sep 17 00:00:00 2001 From: Shiv Tyagi Date: Sat, 25 Jul 2026 13:52:18 +0530 Subject: [PATCH 14/19] backend: rename web directory to backend Co-authored-by: Cursor --- .github/workflows/tests.yml | 4 ++-- {web => backend}/Dockerfile | 2 +- {web => backend}/__init__.py | 0 {web => backend}/api/v1/__init__.py | 2 +- {web => backend}/api/v1/admin.py | 4 ++-- {web => backend}/api/v1/builds.py | 6 +++--- {web => backend}/api/v1/router.py | 2 +- {web => backend}/api/v1/vehicles.py | 4 ++-- {web => backend}/core/__init__.py | 4 ++-- {web => backend}/core/config.py | 0 {web => backend}/core/limiter.py | 2 +- {web => backend}/core/logging_config.py | 0 {web => backend}/core/startup.py | 0 {web => backend}/docker-entrypoint.sh | 0 {web => backend}/main.py | 17 +++++++++-------- {web => backend}/requirements.txt | 0 {web => backend}/schemas/__init__.py | 6 +++--- {web => backend}/schemas/admin.py | 0 {web => backend}/schemas/builds.py | 2 +- {web => backend}/schemas/vehicles.py | 0 backend/services/__init__.py | 15 +++++++++++++++ {web => backend}/services/admin.py | 2 +- {web => backend}/services/builds.py | 4 ++-- {web => backend}/services/vehicles.py | 9 +++------ .../static/images/ardupilot_logo.png | Bin {web => backend}/static/images/bg.png | Bin .../static/images/button-closed.png | Bin {web => backend}/static/images/button-open.png | Bin {web => backend}/static/images/button.png | Bin {web => backend}/static/images/logo.png | Bin {web => backend}/static/js/add_build.js | 0 {web => backend}/static/js/index.js | 0 {web => backend}/static/styles/main.css | 0 {web => backend}/templates/add_build.html | 0 {web => backend}/templates/index.html | 0 {web => backend}/ui/__init__.py | 2 +- {web => backend}/ui/router.py | 0 tests/{web => backend}/conftest.py | 4 ++-- tests/{web => backend}/test_admin_api.py | 2 +- tests/{web => backend}/test_admin_service.py | 2 +- tests/{web => backend}/test_builds_api.py | 6 +++--- tests/{web => backend}/test_builds_service.py | 4 ++-- tests/{web => backend}/test_config.py | 2 +- tests/{web => backend}/test_vehicles_api.py | 6 +++--- .../{web => backend}/test_vehicles_service.py | 6 +++--- web/services/__init__.py | 15 --------------- 46 files changed, 66 insertions(+), 68 deletions(-) rename {web => backend}/Dockerfile (96%) rename {web => backend}/__init__.py (100%) rename {web => backend}/api/v1/__init__.py (51%) rename {web => backend}/api/v1/admin.py (95%) rename {web => backend}/api/v1/builds.py (97%) rename {web => backend}/api/v1/router.py (88%) rename {web => backend}/api/v1/vehicles.py (98%) rename {web => backend}/core/__init__.py (50%) rename {web => backend}/core/config.py (100%) rename {web => backend}/core/limiter.py (94%) rename {web => backend}/core/logging_config.py (100%) rename {web => backend}/core/startup.py (100%) rename {web => backend}/docker-entrypoint.sh (100%) rename {web => backend}/main.py (89%) rename {web => backend}/requirements.txt (100%) rename {web => backend}/schemas/__init__.py (89%) rename {web => backend}/schemas/admin.py (100%) rename {web => backend}/schemas/builds.py (97%) rename {web => backend}/schemas/vehicles.py (100%) create mode 100644 backend/services/__init__.py rename {web => backend}/services/admin.py (97%) rename {web => backend}/services/builds.py (99%) rename {web => backend}/services/vehicles.py (97%) rename {web => backend}/static/images/ardupilot_logo.png (100%) rename {web => backend}/static/images/bg.png (100%) rename {web => backend}/static/images/button-closed.png (100%) rename {web => backend}/static/images/button-open.png (100%) rename {web => backend}/static/images/button.png (100%) rename {web => backend}/static/images/logo.png (100%) rename {web => backend}/static/js/add_build.js (100%) rename {web => backend}/static/js/index.js (100%) rename {web => backend}/static/styles/main.css (100%) rename {web => backend}/templates/add_build.html (100%) rename {web => backend}/templates/index.html (100%) rename {web => backend}/ui/__init__.py (64%) rename {web => backend}/ui/router.py (100%) rename tests/{web => backend}/conftest.py (98%) rename tests/{web => backend}/test_admin_api.py (99%) rename tests/{web => backend}/test_admin_service.py (97%) rename tests/{web => backend}/test_builds_api.py (99%) rename tests/{web => backend}/test_builds_service.py (99%) rename tests/{web => backend}/test_config.py (99%) rename tests/{web => backend}/test_vehicles_api.py (99%) rename tests/{web => backend}/test_vehicles_service.py (99%) delete mode 100644 web/services/__init__.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0dcf695..c8f7620 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-24.04 env: - PYTHONPATH: ${{ github.workspace }}:${{ github.workspace }}/web + PYTHONPATH: ${{ github.workspace }}:${{ github.workspace }}/backend steps: - name: Checkout code @@ -27,7 +27,7 @@ jobs: python -m pip install --upgrade pip pip install pytest pip install -r tests/requirements.txt - pip install -r web/requirements.txt + pip install -r backend/requirements.txt - name: Run tests run: | diff --git a/web/Dockerfile b/backend/Dockerfile similarity index 96% rename from web/Dockerfile rename to backend/Dockerfile index fa3c68c..4feb961 100644 --- a/web/Dockerfile +++ b/backend/Dockerfile @@ -10,7 +10,7 @@ RUN groupadd -g 999 ardupilot && \ chown ardupilot:ardupilot /app COPY --chown=ardupilot:ardupilot . /app -WORKDIR /app/web +WORKDIR /app/backend RUN pip install --no-cache-dir -r requirements.txt ENV PYTHONPATH=/app diff --git a/web/__init__.py b/backend/__init__.py similarity index 100% rename from web/__init__.py rename to backend/__init__.py diff --git a/web/api/v1/__init__.py b/backend/api/v1/__init__.py similarity index 51% rename from web/api/v1/__init__.py rename to backend/api/v1/__init__.py index 81d5e30..a818a3d 100644 --- a/web/api/v1/__init__.py +++ b/backend/api/v1/__init__.py @@ -1,4 +1,4 @@ """API v1 module.""" -from web.api.v1.router import router +from backend.api.v1.router import router __all__ = ["router"] diff --git a/web/api/v1/admin.py b/backend/api/v1/admin.py similarity index 95% rename from web/api/v1/admin.py rename to backend/api/v1/admin.py index f1a63a0..ab5e5e7 100644 --- a/web/api/v1/admin.py +++ b/backend/api/v1/admin.py @@ -1,8 +1,8 @@ from fastapi import APIRouter, HTTPException, Depends, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from web.schemas import RefreshVersionsResponse -from web.services.admin import get_admin_service, AdminService +from backend.schemas import RefreshVersionsResponse +from backend.services.admin import get_admin_service, AdminService router = APIRouter(prefix="/admin", tags=["admin"]) diff --git a/web/api/v1/builds.py b/backend/api/v1/builds.py similarity index 97% rename from web/api/v1/builds.py rename to backend/api/v1/builds.py index cd8e651..26b4211 100644 --- a/web/api/v1/builds.py +++ b/backend/api/v1/builds.py @@ -11,13 +11,13 @@ ) from fastapi.responses import FileResponse, PlainTextResponse, Response -from web.schemas import ( +from backend.schemas import ( BuildRequest, BuildSubmitResponse, BuildOut, ) -from web.services.builds import get_builds_service, BuildsService -from web.core.limiter import limiter +from backend.services.builds import get_builds_service, BuildsService +from backend.core.limiter import limiter router = APIRouter(prefix="/builds", tags=["builds"]) diff --git a/web/api/v1/router.py b/backend/api/v1/router.py similarity index 88% rename from web/api/v1/router.py rename to backend/api/v1/router.py index fa99a71..be1debc 100644 --- a/web/api/v1/router.py +++ b/backend/api/v1/router.py @@ -6,7 +6,7 @@ """ from fastapi import APIRouter -from web.api.v1 import vehicles, builds, admin +from backend.api.v1 import vehicles, builds, admin # Create the main v1 router router = APIRouter(prefix="/v1") diff --git a/web/api/v1/vehicles.py b/backend/api/v1/vehicles.py similarity index 98% rename from web/api/v1/vehicles.py rename to backend/api/v1/vehicles.py index 01380bf..47f7ff1 100644 --- a/web/api/v1/vehicles.py +++ b/backend/api/v1/vehicles.py @@ -1,14 +1,14 @@ from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Path -from web.schemas import ( +from backend.schemas import ( VehicleBase, VersionOut, BoardOut, StandardArtifactOut, FeatureOut, ) -from web.services.vehicles import get_vehicles_service, VehiclesService +from backend.services.vehicles import get_vehicles_service, VehiclesService router = APIRouter(prefix="/vehicles", tags=["vehicles"]) diff --git a/web/core/__init__.py b/backend/core/__init__.py similarity index 50% rename from web/core/__init__.py rename to backend/core/__init__.py index 91590a5..6b70b63 100644 --- a/web/core/__init__.py +++ b/backend/core/__init__.py @@ -1,8 +1,8 @@ """ Core application components. """ -from web.core.config import get_settings -from web.core.startup import initialize_application +from backend.core.config import get_settings +from backend.core.startup import initialize_application __all__ = [ "get_settings", diff --git a/web/core/config.py b/backend/core/config.py similarity index 100% rename from web/core/config.py rename to backend/core/config.py diff --git a/web/core/limiter.py b/backend/core/limiter.py similarity index 94% rename from web/core/limiter.py rename to backend/core/limiter.py index 831dedf..0d55188 100644 --- a/web/core/limiter.py +++ b/backend/core/limiter.py @@ -4,7 +4,7 @@ from slowapi.errors import RateLimitExceeded from slowapi import Limiter from slowapi.util import get_remote_address -from web.core.config import get_settings +from backend.core.config import get_settings logger = logging.getLogger(__name__) diff --git a/web/core/logging_config.py b/backend/core/logging_config.py similarity index 100% rename from web/core/logging_config.py rename to backend/core/logging_config.py diff --git a/web/core/startup.py b/backend/core/startup.py similarity index 100% rename from web/core/startup.py rename to backend/core/startup.py diff --git a/web/docker-entrypoint.sh b/backend/docker-entrypoint.sh similarity index 100% rename from web/docker-entrypoint.sh rename to backend/docker-entrypoint.sh diff --git a/web/main.py b/backend/main.py similarity index 89% rename from web/main.py rename to backend/main.py index 827a1d7..f9dc654 100755 --- a/web/main.py +++ b/backend/main.py @@ -14,13 +14,13 @@ from slowapi.errors import RateLimitExceeded from slowapi.middleware import SlowAPIMiddleware -from web.api.v1 import router as v1_router -from web.ui import router as ui_router +from backend.api.v1 import router as v1_router +from backend.ui import router as ui_router -from web.core.config import get_settings -from web.core.startup import initialize_application -from web.core.logging_config import setup_logging -from web.core.limiter import limiter, rate_limit_exceeded_handler +from backend.core.config import get_settings +from backend.core.startup import initialize_application +from backend.core.logging_config import setup_logging +from backend.core.limiter import limiter, rate_limit_exceeded_handler import ap_git import build_manager @@ -134,6 +134,7 @@ async def lifespan(app: FastAPI): title="CustomBuild API", description="API for ArduPilot Custom Firmware Builder", version="1.0.0", + openapi_url="/api/openapi.json", docs_url="/api/docs", redoc_url="/api/redoc", lifespan=lifespan, @@ -169,8 +170,8 @@ async def health_check(): parser.add_argument( "--port", type=int, - default=int(os.getenv("WEB_PORT", 8080)), - help="Port to run the server on (default: 8080 or WEB_PORT env var)" + default=int(os.getenv("BACKEND_PORT", 8080)), + help="Port to run the server on (default: 8080 or BACKEND_PORT env var)" ) args = parser.parse_args() diff --git a/web/requirements.txt b/backend/requirements.txt similarity index 100% rename from web/requirements.txt rename to backend/requirements.txt diff --git a/web/schemas/__init__.py b/backend/schemas/__init__.py similarity index 89% rename from web/schemas/__init__.py rename to backend/schemas/__init__.py index 7d31acf..eca63a6 100644 --- a/web/schemas/__init__.py +++ b/backend/schemas/__init__.py @@ -6,12 +6,12 @@ """ # Admin schemas -from web.schemas.admin import ( +from backend.schemas.admin import ( RefreshVersionsResponse, ) # Build schemas -from web.schemas.builds import ( +from backend.schemas.builds import ( BuildVersionInfo, RemoteInfo, BuildProgress, @@ -21,7 +21,7 @@ ) # Vehicle schemas -from web.schemas.vehicles import ( +from backend.schemas.vehicles import ( VehicleBase, VersionBase, VersionOut, diff --git a/web/schemas/admin.py b/backend/schemas/admin.py similarity index 100% rename from web/schemas/admin.py rename to backend/schemas/admin.py diff --git a/web/schemas/builds.py b/backend/schemas/builds.py similarity index 97% rename from web/schemas/builds.py rename to backend/schemas/builds.py index b654210..54a3a2c 100644 --- a/web/schemas/builds.py +++ b/backend/schemas/builds.py @@ -1,7 +1,7 @@ from typing import List, Literal, Optional from pydantic import BaseModel, Field -from web.schemas.vehicles import VehicleBase, BoardBase, RemoteInfo +from backend.schemas.vehicles import VehicleBase, BoardBase, RemoteInfo # --- Build Progress --- diff --git a/web/schemas/vehicles.py b/backend/schemas/vehicles.py similarity index 100% rename from web/schemas/vehicles.py rename to backend/schemas/vehicles.py diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..fab43c7 --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1,15 @@ +""" +Business logic services for the application. +""" +from backend.services.vehicles import get_vehicles_service, VehiclesService +from backend.services.builds import get_builds_service, BuildsService +from backend.services.admin import get_admin_service, AdminService + +__all__ = [ + "get_vehicles_service", + "VehiclesService", + "get_builds_service", + "BuildsService", + "get_admin_service", + "AdminService", +] diff --git a/web/services/admin.py b/backend/services/admin.py similarity index 97% rename from web/services/admin.py rename to backend/services/admin.py index e8d1745..3b677a8 100644 --- a/web/services/admin.py +++ b/backend/services/admin.py @@ -5,7 +5,7 @@ from typing import List from fastapi import Depends, Request -from web.core.config import get_settings, Settings +from backend.core.config import get_settings, Settings logger = logging.getLogger(__name__) diff --git a/web/services/builds.py b/backend/services/builds.py similarity index 99% rename from web/services/builds.py rename to backend/services/builds.py index 6edce32..28f81dc 100644 --- a/web/services/builds.py +++ b/backend/services/builds.py @@ -6,7 +6,7 @@ from fastapi import Request from typing import List, Optional -from web.schemas import ( +from backend.schemas import ( BuildRequest, BuildSubmitResponse, BuildOut, @@ -14,7 +14,7 @@ RemoteInfo, BuildVersionInfo, ) -from web.schemas.vehicles import VehicleBase, BoardBase +from backend.schemas.vehicles import VehicleBase, BoardBase # Import external modules # pylint: disable=wrong-import-position diff --git a/web/services/vehicles.py b/backend/services/vehicles.py similarity index 97% rename from web/services/vehicles.py rename to backend/services/vehicles.py index ebde485..74adb29 100644 --- a/web/services/vehicles.py +++ b/backend/services/vehicles.py @@ -7,7 +7,7 @@ from metadata_manager.firmware_server.index import latest_features_txt_url from metadata_manager.versions_manager.providers import OFFICIAL_REMOTE_NAME -from web.schemas import ( +from backend.schemas import ( VehicleBase, RemoteInfo, VersionOut, @@ -73,12 +73,9 @@ def get_versions( continue if version_info.release_type == "latest": - title = f"Latest ({version_info.remote_info.name})" + title = "master" else: - rel_type = version_info.release_type - ver_num = version_info.version_number - remote = version_info.remote_info.name - title = f"{rel_type} {ver_num} ({remote})" + title = version_info.version_number versions.append(VersionOut( id=version_info.version_id, diff --git a/web/static/images/ardupilot_logo.png b/backend/static/images/ardupilot_logo.png similarity index 100% rename from web/static/images/ardupilot_logo.png rename to backend/static/images/ardupilot_logo.png diff --git a/web/static/images/bg.png b/backend/static/images/bg.png similarity index 100% rename from web/static/images/bg.png rename to backend/static/images/bg.png diff --git a/web/static/images/button-closed.png b/backend/static/images/button-closed.png similarity index 100% rename from web/static/images/button-closed.png rename to backend/static/images/button-closed.png diff --git a/web/static/images/button-open.png b/backend/static/images/button-open.png similarity index 100% rename from web/static/images/button-open.png rename to backend/static/images/button-open.png diff --git a/web/static/images/button.png b/backend/static/images/button.png similarity index 100% rename from web/static/images/button.png rename to backend/static/images/button.png diff --git a/web/static/images/logo.png b/backend/static/images/logo.png similarity index 100% rename from web/static/images/logo.png rename to backend/static/images/logo.png diff --git a/web/static/js/add_build.js b/backend/static/js/add_build.js similarity index 100% rename from web/static/js/add_build.js rename to backend/static/js/add_build.js diff --git a/web/static/js/index.js b/backend/static/js/index.js similarity index 100% rename from web/static/js/index.js rename to backend/static/js/index.js diff --git a/web/static/styles/main.css b/backend/static/styles/main.css similarity index 100% rename from web/static/styles/main.css rename to backend/static/styles/main.css diff --git a/web/templates/add_build.html b/backend/templates/add_build.html similarity index 100% rename from web/templates/add_build.html rename to backend/templates/add_build.html diff --git a/web/templates/index.html b/backend/templates/index.html similarity index 100% rename from web/templates/index.html rename to backend/templates/index.html diff --git a/web/ui/__init__.py b/backend/ui/__init__.py similarity index 64% rename from web/ui/__init__.py rename to backend/ui/__init__.py index 62fa8d7..c2574d3 100644 --- a/web/ui/__init__.py +++ b/backend/ui/__init__.py @@ -1,6 +1,6 @@ """ UI module for web interface routes. """ -from web.ui.router import router +from backend.ui.router import router __all__ = ["router"] diff --git a/web/ui/router.py b/backend/ui/router.py similarity index 100% rename from web/ui/router.py rename to backend/ui/router.py diff --git a/tests/web/conftest.py b/tests/backend/conftest.py similarity index 98% rename from tests/web/conftest.py rename to tests/backend/conftest.py index 302bdc8..c0c1fbe 100644 --- a/tests/web/conftest.py +++ b/tests/backend/conftest.py @@ -225,8 +225,8 @@ def app_with_mocked_dependencies( from fastapi import FastAPI from slowapi.middleware import SlowAPIMiddleware from slowapi.errors import RateLimitExceeded - from web.api.v1 import router as v1_router - from web.core.limiter import limiter, rate_limit_exceeded_handler + from backend.api.v1 import router as v1_router + from backend.core.limiter import limiter, rate_limit_exceeded_handler # Set environment variables for test configuration os.environ["CBS_BASEDIR"] = test_base_dir diff --git a/tests/web/test_admin_api.py b/tests/backend/test_admin_api.py similarity index 99% rename from tests/web/test_admin_api.py rename to tests/backend/test_admin_api.py index 9f24a8a..fdda058 100644 --- a/tests/web/test_admin_api.py +++ b/tests/backend/test_admin_api.py @@ -6,7 +6,7 @@ from fastapi import status -from web.core.config import get_settings +from backend.core.config import get_settings class TestAdminRefreshVersionsEndpoint: diff --git a/tests/web/test_admin_service.py b/tests/backend/test_admin_service.py similarity index 97% rename from tests/web/test_admin_service.py rename to tests/backend/test_admin_service.py index a54e864..13541b2 100644 --- a/tests/web/test_admin_service.py +++ b/tests/backend/test_admin_service.py @@ -4,7 +4,7 @@ import pytest from unittest.mock import Mock -from web.services.admin import AdminService +from backend.services.admin import AdminService class TestAdminService: diff --git a/tests/web/test_builds_api.py b/tests/backend/test_builds_api.py similarity index 99% rename from tests/web/test_builds_api.py rename to tests/backend/test_builds_api.py index 6cf4769..84fdd95 100644 --- a/tests/web/test_builds_api.py +++ b/tests/backend/test_builds_api.py @@ -5,7 +5,7 @@ from unittest.mock import Mock, patch, PropertyMock, MagicMock from fastapi import status -from web.schemas import ( +from backend.schemas import ( BuildRequest, BuildSubmitResponse, BuildOut, @@ -13,7 +13,7 @@ BuildVersionInfo, RemoteInfo, ) -from web.schemas.vehicles import VehicleBase, BoardBase +from backend.schemas.vehicles import VehicleBase, BoardBase class TestBuildsAPI: @@ -25,7 +25,7 @@ class TestBuildsAPI: @contextmanager def override_builds_service(client, mock_service): """Temporarily override the get_builds_service dependency.""" - from web.services.builds import get_builds_service + from backend.services.builds import get_builds_service client.app.dependency_overrides[get_builds_service] = lambda: mock_service try: yield diff --git a/tests/web/test_builds_service.py b/tests/backend/test_builds_service.py similarity index 99% rename from tests/web/test_builds_service.py rename to tests/backend/test_builds_service.py index 3361b9a..5f50427 100644 --- a/tests/web/test_builds_service.py +++ b/tests/backend/test_builds_service.py @@ -8,8 +8,8 @@ import build_manager as bm from metadata_manager import RemoteInfo as ManagerRemoteInfo from metadata_manager import RemoteInfo, VersionInfo -from web.schemas import BuildRequest -from web.services.builds import BuildsService +from backend.schemas import BuildRequest +from backend.services.builds import BuildsService @pytest.fixture diff --git a/tests/web/test_config.py b/tests/backend/test_config.py similarity index 99% rename from tests/web/test_config.py rename to tests/backend/test_config.py index 123a3f3..15e0a65 100644 --- a/tests/web/test_config.py +++ b/tests/backend/test_config.py @@ -4,7 +4,7 @@ import os from unittest.mock import patch -from web.core.config import Settings +from backend.core.config import Settings class TestSettings: diff --git a/tests/web/test_vehicles_api.py b/tests/backend/test_vehicles_api.py similarity index 99% rename from tests/web/test_vehicles_api.py rename to tests/backend/test_vehicles_api.py index 74b866b..2cd8e3d 100644 --- a/tests/web/test_vehicles_api.py +++ b/tests/backend/test_vehicles_api.py @@ -5,7 +5,7 @@ from unittest.mock import Mock from fastapi import status -from web.schemas import ( +from backend.schemas import ( VehicleBase, VersionOut, BoardOut, @@ -26,7 +26,7 @@ class TestVehiclesAPI: @contextmanager def override_vehicles_service(client, mock_service): """Temporarily override the get_vehicles_service dependency.""" - from web.services.vehicles import get_vehicles_service + from backend.services.vehicles import get_vehicles_service client.app.dependency_overrides[get_vehicles_service] = lambda: mock_service try: yield @@ -37,7 +37,7 @@ def override_vehicles_service(client, mock_service): def dummy_version(): return VersionOut( id="copter-4.5.0-stable", - name="stable 4.5.0 (ardupilot)", + name="4.5.0", type="stable", remote=RemoteInfo( name="ardupilot", diff --git a/tests/web/test_vehicles_service.py b/tests/backend/test_vehicles_service.py similarity index 99% rename from tests/web/test_vehicles_service.py rename to tests/backend/test_vehicles_service.py index cb42d6b..608c568 100644 --- a/tests/web/test_vehicles_service.py +++ b/tests/backend/test_vehicles_service.py @@ -10,7 +10,7 @@ Vehicle, VersionInfo, ) -from web.services.vehicles import VehiclesService +from backend.services.vehicles import VehiclesService @pytest.fixture @@ -370,7 +370,7 @@ def test_get_versions_latest_name_format( ] versions = service.get_versions("copter") - assert versions[0].name == "Latest (ardupilot)" + assert versions[0].name == "master" def test_get_versions_non_latest_name_format( self, service, mock_versions_manager @@ -386,7 +386,7 @@ def test_get_versions_non_latest_name_format( ] versions = service.get_versions("copter") - assert versions[0].name == "stable 4.5.0 (ardupilot)" + assert versions[0].name == "4.5.0" # Tests for get_version diff --git a/web/services/__init__.py b/web/services/__init__.py deleted file mode 100644 index dea8646..0000000 --- a/web/services/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Business logic services for the application. -""" -from web.services.vehicles import get_vehicles_service, VehiclesService -from web.services.builds import get_builds_service, BuildsService -from web.services.admin import get_admin_service, AdminService - -__all__ = [ - "get_vehicles_service", - "VehiclesService", - "get_builds_service", - "BuildsService", - "get_admin_service", - "AdminService", -] From 8f23f87d3cdf2866721eae0293aed6b05b2d0e3c Mon Sep 17 00:00:00 2001 From: Shiv Tyagi Date: Sat, 25 Jul 2026 13:52:18 +0530 Subject: [PATCH 15/19] frontend: add React UI Co-authored-by: Cursor --- frontend/.dockerignore | 4 + frontend/.eslintrc.cjs | 19 + frontend/Dockerfile | 25 + frontend/index.html | 17 + frontend/nginx.conf | 18 + frontend/package-lock.json | 4523 +++++++++++++++++ frontend/package.json | 37 + frontend/postcss.config.js | 6 + frontend/public/ardupilot_logo.png | Bin 0 -> 67402 bytes frontend/public/favicon.ico | Bin 0 -> 1150 bytes frontend/public/favicon.svg | 4 + frontend/src/App.tsx | 219 + frontend/src/api.ts | 91 + frontend/src/buildConfig.ts | 109 + frontend/src/components/BuildForm.tsx | 728 +++ frontend/src/components/BuildInfoModal.tsx | 410 ++ frontend/src/components/BuildsTable.tsx | 342 ++ frontend/src/components/CollapsibleBanner.tsx | 82 + .../src/components/ConfigConflictModal.tsx | 134 + frontend/src/components/ConfigDropZone.tsx | 101 + frontend/src/components/DoneStep.tsx | 128 + frontend/src/components/ErrorBanner.tsx | 11 + frontend/src/components/FeaturesGraphView.tsx | 1003 ++++ frontend/src/components/FeaturesModal.tsx | 576 +++ frontend/src/components/HeroPanel.tsx | 23 + frontend/src/components/ModalShell.tsx | 115 + .../src/components/StandardArtifactsGrid.tsx | 69 + frontend/src/components/StepComponents.tsx | 419 ++ frontend/src/components/ThemeToggle.tsx | 21 + frontend/src/components/Tooltip.tsx | 74 + frontend/src/components/buildStatus.tsx | 54 + frontend/src/featureDeps.ts | 156 + frontend/src/featureSearch.ts | 44 + frontend/src/hooks/useBuildPolling.ts | 71 + frontend/src/hooks/useConfigLoad.ts | 365 ++ frontend/src/index.css | 560 ++ frontend/src/main.tsx | 10 + frontend/src/types.ts | 105 + frontend/src/vite-env.d.ts | 1 + frontend/tailwind.config.js | 42 + frontend/tsconfig.json | 15 + frontend/vite.config.ts | 38 + 42 files changed, 10769 insertions(+) create mode 100644 frontend/.dockerignore create mode 100644 frontend/.eslintrc.cjs create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/ardupilot_logo.png create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/buildConfig.ts create mode 100644 frontend/src/components/BuildForm.tsx create mode 100644 frontend/src/components/BuildInfoModal.tsx create mode 100644 frontend/src/components/BuildsTable.tsx create mode 100644 frontend/src/components/CollapsibleBanner.tsx create mode 100644 frontend/src/components/ConfigConflictModal.tsx create mode 100644 frontend/src/components/ConfigDropZone.tsx create mode 100644 frontend/src/components/DoneStep.tsx create mode 100644 frontend/src/components/ErrorBanner.tsx create mode 100644 frontend/src/components/FeaturesGraphView.tsx create mode 100644 frontend/src/components/FeaturesModal.tsx create mode 100644 frontend/src/components/HeroPanel.tsx create mode 100644 frontend/src/components/ModalShell.tsx create mode 100644 frontend/src/components/StandardArtifactsGrid.tsx create mode 100644 frontend/src/components/StepComponents.tsx create mode 100644 frontend/src/components/ThemeToggle.tsx create mode 100644 frontend/src/components/Tooltip.tsx create mode 100644 frontend/src/components/buildStatus.tsx create mode 100644 frontend/src/featureDeps.ts create mode 100644 frontend/src/featureSearch.ts create mode 100644 frontend/src/hooks/useBuildPolling.ts create mode 100644 frontend/src/hooks/useConfigLoad.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/types.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..5b08070 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.local diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..1c3b8eb --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,19 @@ +module.exports = { + root: true, + env: { browser: true, es2022: true }, + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint', 'react-hooks'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + rules: { + '@typescript-eslint/no-unused-vars': ['warn', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }], + '@typescript-eslint/no-explicit-any': 'off', + }, + ignorePatterns: ['dist/', 'node_modules/'], +}; diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..8a84a6e --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,25 @@ +# Stage 1: Build the React app +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm ci + +COPY frontend/ ./ +# Shared config schemas (single source of truth at repo root) +COPY schemas/ /schemas/ + +RUN npm run build + +# Stage 2: Serve with nginx +FROM nginx:1.27-alpine + +COPY --from=builder /app/dist /usr/share/nginx/html + +# nginx config that handles SPA routing (serves index.html for all routes) +COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..956d50d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + + ArduPilot CustomBuild + + + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..f78284e --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8080/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..26b843f --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4523 @@ +{ + "name": "custombuild-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "custombuild-frontend", + "version": "0.1.0", + "dependencies": { + "@xyflow/react": "^12.11.2", + "ajv": "^8.20.0", + "clsx": "^2.1.1", + "js-yaml": "^4.1.1", + "lucide-react": "^0.441.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^20.19.43", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^8.18.0", + "@typescript-eslint/parser": "^8.18.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "eslint": "^8.57.1", + "eslint-plugin-react-hooks": "^5.1.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.11", + "typescript": "^5.5.3", + "vite": "^5.4.8" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "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.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "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.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "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/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "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.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "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/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "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/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "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==", + "devOptional": 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==", + "devOptional": 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==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", + "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/type-utils": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.64.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz", + "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz", + "integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.64.0", + "@typescript-eslint/types": "^8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz", + "integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz", + "integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz", + "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", + "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz", + "integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.64.0", + "@typescript-eslint/tsconfig-utils": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz", + "integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz", + "integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, + "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/@xyflow/react": { + "version": "12.11.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", + "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.79", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.79", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", + "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "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.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "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/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "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/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "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/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.353", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "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/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "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/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "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/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "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/lucide-react": { + "version": "0.441.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.441.0.tgz", + "integrity": "sha512-0vfExYtvSDhkC2lqg0zYVW1Uu9GsI4knuV9GP9by5z0Xhc4Zi5RejTxfz9LsjRmCyWVzHCJvxGKZWcRyvQCWVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "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": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "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/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "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.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.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/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/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/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "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/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.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", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "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" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..3928cfe --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,37 @@ +{ + "name": "custombuild-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "lint": "eslint src --ext .ts,.tsx" + }, + "dependencies": { + "@xyflow/react": "^12.11.2", + "ajv": "^8.20.0", + "clsx": "^2.1.1", + "js-yaml": "^4.1.1", + "lucide-react": "^0.441.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^20.19.43", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^8.18.0", + "@typescript-eslint/parser": "^8.18.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "eslint": "^8.57.1", + "eslint-plugin-react-hooks": "^5.1.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.11", + "typescript": "^5.5.3", + "vite": "^5.4.8" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..d41ad63 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/ardupilot_logo.png b/frontend/public/ardupilot_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ed67b531b915a8a53018ee1574f8a7af878001ac GIT binary patch literal 67402 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 uKOyW-wf(VtYu{Z!KPsaQrqP9 z`IL+ab$Yu}mQpG!kSUqjoEK6`*8Dp+;l(nbUgV$mS3V+m|xksvzW3TtcZ?XkSP zwI=@&w8r#>vs{|JNU1pM zak+ez#l?Bbm3bB_B`Wgmp;DyKmd6PPX@R-o z6|A`jWRD%k_U*t4`EjEbj^tqamXv!31G^YKF~FDgPn1fRsns6xETJQPDX23=WmI}*vJ4I8(-<`?cw&y5_j(3!(M$({@e`R*Q)G!1-Ys7IChg) zFQeCNJSN76dH>-Zuj?;at}f#~dP8pF6j9MMKmJIjy6WW-m)slmjUHnogXrhF)UKtk zpMx(T_3Rt_>d-nq>3QjX{i@foscCmtfy15cgo8d>J3A;04Y9xd0B$7kM_r?r_6Xa4 pBGO-dPE_h1v3y>? + + AP + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..2f8b34f --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,219 @@ +import { useState, useRef, useEffect } from 'react'; +import { Menu, X } from 'lucide-react'; +import { BuildForm } from './components/BuildForm'; +import { BuildsTable } from './components/BuildsTable'; +import { HeroPanel } from './components/HeroPanel'; +import { ThemeToggle } from './components/ThemeToggle'; +import { type BuildConfig, configFromQueryParams } from './buildConfig'; + +const THEME_KEY = 'custombuild-theme'; + +const NAV_LINKS = [ + { href: 'https://ardupilot.org', label: 'ardupilot.org', external: true }, + { href: 'https://github.com/ArduPilot/CustomBuild', label: 'GitHub', external: true }, + { href: 'https://ardupilot.org/copter/docs/common-custom-firmware.html', label: 'Help', external: true }, + { href: '/api/docs', label: 'API Docs', external: false }, +] as const; + +function readInitialDark(): boolean { + try { + const stored = localStorage.getItem(THEME_KEY); + if (stored === 'dark') return true; + if (stored === 'light') return false; + } catch { /* ignore */ } + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} + +function prefersReducedMotion(): boolean { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +export default function App() { + const [pendingConfig, setPendingConfig] = useState(null); + const [isDark, setIsDark] = useState(readInitialDark); + const [themeUserSet, setThemeUserSet] = useState(() => { + try { return localStorage.getItem(THEME_KEY) !== null; } + catch { return false; } + }); + const [menuOpen, setMenuOpen] = useState(false); + const formRef = useRef(null); + + useEffect(() => { + const root = document.documentElement; + if (isDark) { + root.classList.remove('light'); + } else { + root.classList.add('light'); + } + }, [isDark]); + + useEffect(() => { + if (themeUserSet) return; + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const onChange = (e: MediaQueryListEvent) => setIsDark(e.matches); + mq.addEventListener('change', onChange); + return () => mq.removeEventListener('change', onChange); + }, [themeUserSet]); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const config = configFromQueryParams(params); + if (config) { + setPendingConfig(config); + window.history.replaceState({}, '', window.location.pathname); + } + }, []); + + function toggleTheme() { + setIsDark(d => { + const next = !d; + try { localStorage.setItem(THEME_KEY, next ? 'dark' : 'light'); } + catch { /* ignore */ } + return next; + }); + setThemeUserSet(true); + } + + function handleRebuild(config: BuildConfig) { + setPendingConfig(config); + formRef.current?.scrollIntoView({ + behavior: prefersReducedMotion() ? 'auto' : 'smooth', + block: 'center', + }); + } + + const linkClass = 'text-sm text-gray-500 hover:text-gray-300 transition-colors'; + + return ( +
    +
    + +
    +
    +
    + ArduPilot +
    +
    + + + +
    +
    + {menuOpen && ( + + )} +
    + +
    +
    +
    + +
    + +
    +

    + ArduPilot +

    +

    + CustomBuild +

    +

    + Build exactly the firmware you need. Choose your vehicle, board, and feature set, + and we'll compile it for you. +

    +

    + Versatile · Trusted · Open +

    +
    + +
    + setPendingConfig(null)} + /> +
    +
    + +
    +
    + All Builds +
    +
    +
    + +
    + +
    + + +
    + ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..90b3ec9 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,91 @@ +import type { + Vehicle, Version, Board, Feature, Build, + BuildRequest, BuildSubmitResponse, StandardArtifact, +} from './types'; +import { parseConfigYaml, type BuildConfig } from './buildConfig'; + +const API_BASE = '/api/v1'; + +function apiUrl(...segments: string[]): string { + const path = segments.map(encodeURIComponent).join('/'); + return `${API_BASE}/${path}`; +} + +export function buildArtifactUrl(buildId: string): string { + return apiUrl('builds', buildId, 'artifact'); +} + +function buildConfigUrl(buildId: string): string { + return apiUrl('builds', buildId, 'config'); +} + +export async function fetchBuildConfig(buildId: string): Promise { + const res = await fetch(buildConfigUrl(buildId)); + if (!res.ok) throw new Error(`Config unavailable (${res.status})`); + return parseConfigYaml(await res.text()); +} + +export function commitUrl(remoteUrl: string, gitHash: string): string { + return `${remoteUrl.replace(/\.git$/, '')}/commit/${gitHash}`; +} + +async function apiFetch(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`API error ${res.status}: ${await res.text()}`); + return res.json() as Promise; +} + +export async function fetchVehicles(): Promise { + return apiFetch(apiUrl('vehicles')); +} + +export async function fetchVersions(vehicleId: string): Promise { + return apiFetch(apiUrl('vehicles', vehicleId, 'versions')); +} + +export async function fetchBoards(vehicleId: string, versionId: string): Promise { + return apiFetch(apiUrl('vehicles', vehicleId, 'versions', versionId, 'boards')); +} + +export async function fetchFeatures(vehicleId: string, versionId: string, boardId: string): Promise { + return apiFetch( + apiUrl('vehicles', vehicleId, 'versions', versionId, 'boards', boardId, 'features'), + ); +} + +export async function fetchBuilds(limit = 10, offset = 0): Promise { + return apiFetch(`${apiUrl('builds')}?limit=${limit}&offset=${offset}`); +} + +export async function fetchBuild(buildId: string): Promise { + return apiFetch(apiUrl('builds', buildId)); +} + +export async function submitBuild(req: BuildRequest): Promise { + const res = await fetch(apiUrl('builds'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Submit error ${res.status}: ${await res.text()}`); + return res.json() as Promise; +} + +export async function fetchBuildLogs(buildId: string): Promise { + const res = await fetch(apiUrl('builds', buildId, 'logs')); + if (!res.ok) throw new Error('Logs unavailable'); + return res.text(); +} + +export async function fetchStandardArtifacts( + vehicleId: string, + versionId: string, + boardId: string, +): Promise { + const res = await fetch( + apiUrl('vehicles', vehicleId, 'versions', versionId, 'boards', boardId, 'standard_artifacts'), + ); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`API error ${res.status}: ${await res.text()}`); + return res.json() as Promise; +} diff --git a/frontend/src/buildConfig.ts b/frontend/src/buildConfig.ts new file mode 100644 index 0000000..130ee7b --- /dev/null +++ b/frontend/src/buildConfig.ts @@ -0,0 +1,109 @@ +import { load as yamlLoad } from 'js-yaml'; +import Ajv, { type ValidateFunction } from 'ajv/dist/2020'; + +const CONFIG_VERSION = '0.0.1'; + +interface BuildConfigVehicle { + id: string; + name: string; +} + +interface BuildConfigFirmwareVersion { + id: string; + name: string; + type: string; + remote_name: string; +} + +interface BuildConfigBoard { + id: string; + name: string; +} + +export interface BuildConfig { + config_version: string; + vehicle: BuildConfigVehicle; + version?: BuildConfigFirmwareVersion; + board?: BuildConfigBoard; + selected_features: string[]; + /** When true the config loader applies board defaults instead of selected_features */ + use_default_features?: boolean; +} + +/** Build a partial config from URL query parameters. Absent/empty params leave fields undefined. */ +export function configFromQueryParams(params: URLSearchParams): BuildConfig | null { + const vehicleId = params.get('vehicle_id'); + if (!vehicleId) return null; + + const versionId = params.get('version_id'); + const boardId = params.get('board_id'); + + return { + config_version: CONFIG_VERSION, + vehicle: { id: vehicleId, name: vehicleId }, + version: versionId + ? { id: versionId, name: versionId, type: 'tag', remote_name: '' } + : undefined, + board: boardId + ? { id: boardId, name: boardId } + : undefined, + selected_features: [], + use_default_features: true, + }; +} + +const schemaCache = new Map>(); +const validatorCache = new Map(); +const ajv = new Ajv({ allErrors: true }); + +async function fetchSchema(version: string): Promise> { + const cached = schemaCache.get(version); + if (cached) return cached; + const res = await fetch(`/schemas/config/${version}.json`); + if (!res.ok) + throw new Error(`No schema found for config version "${version}". The config file may be too new or unsupported.`); + const schema = await res.json() as Record; + schemaCache.set(version, schema); + return schema; +} + +async function getValidator(version: string): Promise { + const cached = validatorCache.get(version); + if (cached) return cached; + const schema = await fetchSchema(version); + const validate = ajv.compile(schema); + validatorCache.set(version, validate); + return validate; +} + +async function validateConfig(raw: unknown): Promise { + if (typeof raw !== 'object' || raw === null) + throw new Error('Config must be a YAML object'); + + const obj = raw as Record; + if (typeof obj.config_version !== 'string' || !obj.config_version.match(/^\d+\.\d+\.\d+$/)) + throw new Error('Invalid or missing "config_version" field (expected semver, e.g. "0.0.1")'); + + const validate = await getValidator(obj.config_version); + + if (!validate(raw)) { + const messages = (validate.errors ?? []) + .map(e => ` - ${e.instancePath || '(root)'} ${e.message}`) + .join('\n'); + throw new Error(`Config validation failed:\n${messages}`); + } + + return raw as BuildConfig; +} + +export async function parseConfigYaml(yamlText: string): Promise { + let raw: unknown; + try { + raw = yamlLoad(yamlText); + } catch (e: unknown) { + throw new Error( + `YAML parse error: ${e instanceof Error ? e.message : String(e)}` + ); + } + return validateConfig(raw); +} diff --git a/frontend/src/components/BuildForm.tsx b/frontend/src/components/BuildForm.tsx new file mode 100644 index 0000000..c42ec92 --- /dev/null +++ b/frontend/src/components/BuildForm.tsx @@ -0,0 +1,728 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { + Sliders, ChevronRight, Terminal, + ArrowRight, RefreshCw, PackageOpen, +} from 'lucide-react'; +import clsx from 'clsx'; +import type { + Vehicle, Version, Board, Feature, FormStep, StandardArtifact, BuildState, +} from '../types'; +import { + fetchVehicles, submitBuild, fetchStandardArtifacts, +} from '../api'; +import { + StepHeader, SearchableDropdown, VersionSelector, FormSection, ChosenPill, LoadingFiller, VehicleSelector, +} from './StepComponents'; +import { FeaturesModal } from './FeaturesModal'; +import { BuildInfoModal } from './BuildInfoModal'; +import { ConfigDropZone } from './ConfigDropZone'; +import { ConfigConflictModal } from './ConfigConflictModal'; +import { CollapsibleBanner } from './CollapsibleBanner'; +import { ErrorBanner } from './ErrorBanner'; +import { StandardArtifactsGrid } from './StandardArtifactsGrid'; +import { DoneStep } from './DoneStep'; +import { parseConfigYaml, type BuildConfig } from '../buildConfig'; +import { useBuildPolling } from '../hooks/useBuildPolling'; +import { useConfigLoad, type ConfigPhase } from '../hooks/useConfigLoad'; + +const STEPS = [ + { id: 'vehicle', label: 'Vehicle' }, + { id: 'version', label: 'Version' }, + { id: 'board', label: 'Board' }, +]; + +function headerStep(step: FormStep): string { + if (['choice', 'standard-files', 'features', 'building', 'done'].includes(step)) return '__done__'; + return step; +} + +function versionSupportsStandardArtifacts(version: Version): boolean { + return version.remote.name === 'ardupilot'; +} + +const TYPE_ORDER: Record = { stable: 0, beta: 1, latest: 2, tag: 3 }; + +function sortVersions(vs: Version[]): Version[] { + return [...vs].sort((a, b) => { + const typeDiff = TYPE_ORDER[a.type] - TYPE_ORDER[b.type]; + if (typeDiff !== 0) return typeDiff; + if (a.type === 'stable' || a.type === 'beta') { + const aParts = a.name.split('.').map(Number); + const bParts = b.name.split('.').map(Number); + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + const diff = (bParts[i] ?? 0) - (aParts[i] ?? 0); + if (diff !== 0) return diff; + } + } + return 0; + }); +} + +function versionBadge(t: Version['type']) { + return ({ + stable: 'badge-green', beta: 'badge-yellow', latest: 'badge-blue', tag: 'badge-gray', + } as const)[t]; +} + +interface BuildFormProps { + initialConfig?: BuildConfig | null; + onConsumeInitialConfig?: () => void; +} + +export function BuildForm({ initialConfig, onConsumeInitialConfig }: BuildFormProps) { + const [step, setStep] = useState('vehicle'); + const [vehicles, setVehicles] = useState([]); + const [versions, setVersions] = useState([]); + const [boards, setBoards] = useState([]); + const [features, setFeatures] = useState([]); + + const [vehicle, setVehicle] = useState(null); + const [version, setVersion] = useState(null); + const [board, setBoard] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [showFeatsModal, setShowFeatsModal] = useState(false); + const [showBuildModal, setShowBuildModal] = useState(false); + + const [buildId, setBuildId] = useState(null); + const [buildProgress, setBuildProgress] = useState(0); + const [buildState, setBuildState] = useState('PENDING'); + + const [standardArtifacts, setStandardArtifacts] = useState(null); + const [standardFilesLoading, setStandardFilesLoading] = useState(false); + + const [vehiclesLoading, setVehiclesLoading] = useState(false); + const [versionsLoading, setVersionsLoading] = useState(false); + const [boardsLoading, setBoardsLoading] = useState(false); + const [featuresLoading, setFeaturesLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const [error, setError] = useState(null); + + const [unavailableFeatures, setUnavailableFeatures] = useState([]); + const [dismissedUnavailable, setDismissedUnavailable] = useState(false); + const [autoAddedFeatures, setAutoAddedFeatures] = useState([]); + const [dismissedAutoAdded, setDismissedAutoAdded] = useState(false); + + const [featureConfigParseError, setFeatureConfigParseError] = useState(null); + + const [configPhase, setConfigPhase] = useState({ phase: 'idle' }); + const [configParseError, setConfigParseError] = useState(null); + + const loadGenRef = useRef(0); + const vehiclesRef = useRef([]); + const versionsRef = useRef([]); + const boardsRef = useRef([]); + const featuresRef = useRef([]); + const resetRef = useRef<() => void>(() => {}); + + useEffect(() => { vehiclesRef.current = vehicles; }, [vehicles]); + useEffect(() => { versionsRef.current = versions; }, [versions]); + useEffect(() => { boardsRef.current = boards; }, [boards]); + useEffect(() => { featuresRef.current = features; }, [features]); + + function bumpLoadGen(): number { + return ++loadGenRef.current; + } + + const { startPolling, stopBuildPolling } = useBuildPolling({ + setBuildProgress, + setBuildState, + setStep, + }); + + const reset = useCallback(() => { + bumpLoadGen(); + stopBuildPolling(); + setStep('vehicle'); + setVehicle(null); setVersion(null); setBoard(null); + setSelected(new Set()); setBuildId(null); + setBuildProgress(0); setBuildState('PENDING'); + setVersionsLoading(false); setBoardsLoading(false); + setFeaturesLoading(false); setSubmitting(false); + setError(null); + setUnavailableFeatures([]); setDismissedUnavailable(false); + setAutoAddedFeatures([]); setDismissedAutoAdded(false); + setFeatureConfigParseError(null); + setConfigPhase({ phase: 'idle' }); setConfigParseError(null); + setStandardArtifacts(null); setStandardFilesLoading(false); + }, [stopBuildPolling]); + + resetRef.current = reset; + + const { + startConfigLoad, + applyConfigFeatures, + handleConflictSelect, + handleConflictCancel, + loadVersions, + loadBoards, + loadDefaultFeatures, + } = useConfigLoad({ + loadGenRef, + bumpLoadGen, + reset: () => resetRef.current(), + vehiclesRef, + versionsRef, + boardsRef, + vehicle, + version, + setConfigPhase, + setError, + setVehicles, + setVersions, + setBoards, + setFeatures, + setVehicle, + setVersion, + setBoard, + setSelected, + setStep, + setVersionsLoading, + setBoardsLoading, + setFeaturesLoading, + setUnavailableFeatures, + setDismissedUnavailable, + setAutoAddedFeatures, + setDismissedAutoAdded, + }); + + function goToStep(next: FormStep) { + bumpLoadGen(); + setVersionsLoading(false); + setBoardsLoading(false); + setFeaturesLoading(false); + setStandardFilesLoading(false); + setError(null); + setFeatureConfigParseError(null); + setShowFeatsModal(false); + + if (next === 'vehicle') { + setVersion(null); + setBoard(null); + setVersions([]); + setBoards([]); + setFeatures([]); + setSelected(new Set()); + setStandardArtifacts(null); + setUnavailableFeatures([]); + setDismissedUnavailable(false); + setAutoAddedFeatures([]); + setDismissedAutoAdded(false); + } else if (next === 'version') { + setBoard(null); + setBoards([]); + setFeatures([]); + setSelected(new Set()); + setStandardArtifacts(null); + setUnavailableFeatures([]); + setDismissedUnavailable(false); + setAutoAddedFeatures([]); + setDismissedAutoAdded(false); + } else if (next === 'board') { + setFeatures([]); + setSelected(new Set()); + setStandardArtifacts(null); + setUnavailableFeatures([]); + setDismissedUnavailable(false); + setAutoAddedFeatures([]); + setDismissedAutoAdded(false); + } + + setStep(next); + } + + useEffect(() => { + const gen = bumpLoadGen(); + setVehiclesLoading(true); + fetchVehicles() + .then(data => { + if (gen !== loadGenRef.current) return; + setVehicles(data); + }) + .catch(() => { + if (gen !== loadGenRef.current) return; + setError('Failed to fetch vehicles from server'); + }) + .finally(() => { + if (gen !== loadGenRef.current) return; + setVehiclesLoading(false); + }); + }, []); + + const startConfigLoadRef = useRef(startConfigLoad); + startConfigLoadRef.current = startConfigLoad; + + useEffect(() => { + if (!initialConfig) return; + onConsumeInitialConfig?.(); + startConfigLoadRef.current(initialConfig); + }, [initialConfig, onConsumeInitialConfig]); + + const handleFileDrop = useCallback((yamlText: string) => { + setConfigParseError(null); + parseConfigYaml(yamlText) + .then(config => startConfigLoadRef.current(config)) + .catch((e: unknown) => setConfigParseError(e instanceof Error ? e.message : 'Invalid config file')); + }, []); + + const applyConfigFeaturesRef = useRef(applyConfigFeatures); + applyConfigFeaturesRef.current = applyConfigFeatures; + + const handleFeatureConfigDrop = useCallback((yamlText: string) => { + setFeatureConfigParseError(null); + parseConfigYaml(yamlText) + .then(config => { + setUnavailableFeatures([]); setDismissedUnavailable(false); + setAutoAddedFeatures([]); setDismissedAutoAdded(false); + applyConfigFeaturesRef.current(config, featuresRef.current); + }) + .catch((e: unknown) => + setFeatureConfigParseError(e instanceof Error ? e.message : 'Invalid config file'), + ); + }, []); + + function selectVehicle(id: string) { + const v = vehicles.find(x => x.id === id)!; + setStep('version'); + void loadVersions(v); + } + + function selectVersion(id: string) { + const v = versions.find(x => x.id === id)!; + setStandardArtifacts(null); + setStep('board'); + void loadBoards(vehicle!, v); + } + + function selectBoard(id: string) { + const b = boards.find(x => x.id === id)!; + setBoard(b); + setError(null); + setStep('choice'); + } + + function chooseCustom() { + void loadDefaultFeatures(vehicle!, version!, board!); + } + + function chooseStandard() { + const gen = bumpLoadGen(); + setStandardArtifacts(null); + setStandardFilesLoading(true); + setError(null); + setStep('standard-files'); + fetchStandardArtifacts(vehicle!.id, version!.id, board!.id) + .then(result => { + if (gen !== loadGenRef.current) return; + setStandardArtifacts(result); + }) + .catch(() => { + if (gen !== loadGenRef.current) return; + setStandardArtifacts(null); + setError('Failed to fetch standard build artifacts'); + }) + .finally(() => { + if (gen !== loadGenRef.current) return; + setStandardFilesLoading(false); + }); + } + + async function startBuild() { + try { + setSubmitting(true); + setError(null); + const res = await submitBuild({ + vehicle_id: vehicle!.id, + board_id: board!.id, + version_id: version!.id, + selected_features: Array.from(selected), + }); + setBuildId(res.build_id); + setBuildProgress(0); + setBuildState('PENDING'); + setStep('building'); + startPolling(res.build_id); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Build submit failed'); + } finally { + setSubmitting(false); + } + } + + const isConfigLoading = configPhase.phase === 'loading'; + const failedBuild = buildState === 'FAILURE' || buildState === 'ERROR' || buildState === 'TIMED_OUT'; + + return ( + <> +
    +
    +
    + goToStep(id as FormStep)) : undefined} + /> +
    + {step !== 'vehicle' && ( + + )} +
    + + {isConfigLoading && ( +
    + + Loading config… +
    + )} + + {step === 'vehicle' && ( +
    + + ({ id: v.id, name: v.name }))} + selected={vehicle?.id} + onSelect={selectVehicle} + /> + + +
    +
    + or +
    +
    + + + + +
    + )} + + {step === 'version' && ( +
    + goToStep('vehicle')} /> + + ({ + id: v.id, name: v.name, + type: v.type, badge: v.type, + badgeColor: versionBadge(v.type), + remote: v.remote.name !== 'ardupilot' ? v.remote.name : undefined, + }))} + selected={version?.id} + onSelect={selectVersion} + /> + + +
    + )} + + {step === 'board' && ( +
    + goToStep('vehicle')} /> + goToStep('version')} /> + + ({ id: b.id, name: b.name }))} + selected={board?.id} + onSelect={selectBoard} + placeholder="Choose a board…" + /> + + + +
    + )} + + {step === 'choice' && ( +
    + goToStep('vehicle')} /> + goToStep('version')} /> + goToStep('board')} /> + +
    + {version && versionSupportsStandardArtifacts(version) && ( + + )} + +
    +
    + +
    + )} + + {step === 'standard-files' && ( +
    + goToStep('vehicle')} /> + goToStep('version')} /> + goToStep('board')} /> + {standardFilesLoading ? ( + +
    + {[1, 2, 3, 4, 5, 6, 7, 8].map(i => ( +
    + ))} +
    + + ) : standardArtifacts === null || standardArtifacts.length === 0 ? ( + +
    + + +
    +
    + ) : ( + + )} +
    + )} + + {step === 'features' && ( +
    + goToStep('vehicle')} /> + goToStep('version')} /> + goToStep('board')} /> + + {autoAddedFeatures.length > 0 && !dismissedAutoAdded && ( + 1 ? 's were' : ' was'} auto-selected to satisfy dependencies not listed in your config:`} + items={autoAddedFeatures} + onDismiss={() => setDismissedAutoAdded(true)} + /> + )} + + {unavailableFeatures.length > 0 && !dismissedUnavailable && ( + 1 ? 's' : ''} from your config ${unavailableFeatures.length > 1 ? 'are' : 'is'} unavailable for this board/version combination and ${unavailableFeatures.length > 1 ? 'have' : 'has'} been skipped:`} + items={unavailableFeatures} + onDismiss={() => setDismissedUnavailable(true)} + /> + )} + + + {featuresLoading ? ( +
    +
    +
    +
    +
    +
    +
    + ) : ( +
    +
    +
    + {selected.size} / {features.length} features selected +
    +

    + {unavailableFeatures.length > 0 + ? 'Config applied with adjustments — review below' + : 'Defaults pre-applied based on your board'} +

    +
    + +
    + )} + + +
    +
    + + or load feature selection from config + +
    +
    + + {featuresLoading ? ( +
    +
    +
    +
    +
    + ) : ( + + Applies feature list only · vehicle/version/board in the file are ignored ·{' '} + .yaml /{' '} + .yml + + } + /> + )} + + + + + {featuresLoading ? ( + <> +
    + + + ) : ( + + )} +
    + )} + + {step === 'building' && ( +
    +
    + + + + +
    + + +
    +
    + {buildState} + + {buildProgress}% + +
    +
    +
    +
    + {buildProgress === 0 ? ( +

    + + {buildState === 'PENDING' + ? 'Your build is in the queue and will start soon…' + : 'Setting up the build environment…'} +

    + ) : ( +

    + Compiling {vehicle?.name} with {selected.size} feature overrides for {board?.name}… +

    + )} +
    + + + +
    + )} + + {step === 'done' && ( +
    + setShowBuildModal(true)} + onRebuild={startConfigLoad} + /> +
    + )} +
    + + {configPhase.phase === 'conflict' && ( + handleConflictSelect(id, configPhase)} + onCancel={handleConflictCancel} + /> + )} + + {showFeatsModal && ( + { setSelected(sel); setShowFeatsModal(false); }} + onClose={() => setShowFeatsModal(false)} + /> + )} + + {showBuildModal && buildId && ( + setShowBuildModal(false)} + /> + )} + + ); +} diff --git a/frontend/src/components/BuildInfoModal.tsx b/frontend/src/components/BuildInfoModal.tsx new file mode 100644 index 0000000..193d6d4 --- /dev/null +++ b/frontend/src/components/BuildInfoModal.tsx @@ -0,0 +1,410 @@ +import { useEffect, useRef, useState } from 'react'; +import { + X, Download, Terminal, Info, Package, Loader2, Clock, +} from 'lucide-react'; +import clsx from 'clsx'; +import type { Build, BuildState } from '../types'; +import { fetchBuild, fetchBuildLogs, buildArtifactUrl, commitUrl } from '../api'; +import { ModalShell } from './ModalShell'; +import { StateBadge, VersionTypeBadge, formatAge, isTerminal } from './buildStatus'; + +function colorize(line: string) { + if (line.startsWith('[SUCCESS]')) return 'text-emerald-400'; + if (line.startsWith('[ERROR]') || line.startsWith('[FAIL]')) return 'text-red-400'; + if (line.startsWith('[WARN]')) return 'text-orange-400'; + if (line.startsWith('[INFO]')) return 'text-blue-400'; + return 'text-gray-400'; +} + +type Tab = 'details' | 'logs'; + +interface BuildInfoModalProps { + buildId: string; + initialTab?: Tab; + initialFeaturesExpanded?: boolean; + onClose: () => void; +} + +export function BuildInfoModal({ buildId, initialTab = 'details', initialFeaturesExpanded = false, onClose }: BuildInfoModalProps) { + const [tab, setTab] = useState(initialTab); + const [build, setBuild] = useState(null); + const [loadingBuild, setLoadingBuild] = useState(true); + const [logs, setLogs] = useState(''); + const [logsLoading, setLogsLoading] = useState(false); + const [logsError, setLogsError] = useState(null); + + const pollRef = useRef>(); + const logPollRef = useRef>(); + const buildStateRef = useRef(); + + useEffect(() => { + buildStateRef.current = build?.progress.state; + }, [build?.progress.state]); + + useEffect(() => { + let alive = true; + setLoadingBuild(true); + setBuild(null); + + const clearPoll = () => { + clearTimeout(pollRef.current); + pollRef.current = undefined; + }; + + const schedulePoll = () => { + clearPoll(); + pollRef.current = setTimeout(async () => { + if (!alive) return; + try { + const updated = await fetchBuild(buildId); + if (!alive) return; + setBuild(updated); + if (isTerminal(updated.progress.state)) { + clearPoll(); + return; + } + } catch { /* ignore */ } + if (alive) schedulePoll(); + }, 2000); + }; + + async function load() { + try { + const b = await fetchBuild(buildId); + if (!alive) return; + setBuild(b); + setLoadingBuild(false); + if (!isTerminal(b.progress.state)) schedulePoll(); + } catch { + if (alive) setLoadingBuild(false); + } + } + + load(); + return () => { + alive = false; + clearPoll(); + }; + }, [buildId]); + + // Logs fetching / polling — serialized self-scheduling + useEffect(() => { + if (tab !== 'logs') { + clearTimeout(logPollRef.current); + logPollRef.current = undefined; + return; + } + + let alive = true; + setLogsError(null); + + const clearLogPoll = () => { + clearTimeout(logPollRef.current); + logPollRef.current = undefined; + }; + + const scheduleLogPoll = () => { + clearLogPoll(); + const state = buildStateRef.current; + if (state && isTerminal(state)) return; + logPollRef.current = setTimeout(runLogFetch, 3000); + }; + + async function runLogFetch() { + if (!alive) return; + setLogsLoading(true); + try { + const l = await fetchBuildLogs(buildId); + if (!alive) return; + setLogs(l); + setLogsError(null); + } catch { + if (!alive) return; + setLogsError('Failed to refresh logs'); + } finally { + if (alive) setLogsLoading(false); + } + if (alive) scheduleLogPoll(); + } + + runLogFetch(); + return () => { + alive = false; + clearLogPoll(); + }; + }, [tab, buildId]); + + useEffect(() => { + const state = build?.progress.state; + if (state && isTerminal(state)) { + clearTimeout(logPollRef.current); + logPollRef.current = undefined; + } + }, [build?.progress.state]); + + const state = build?.progress.state ?? 'PENDING'; + const terminal = isTerminal(state); + + return ( + +
    +
    + +

    Build Info

    + {buildId} + {build && } +
    + +
    + +
    + setTab('details')}> + + Details + + setTab('logs')}> + + Logs + {!terminal && build && ( + + )} + +
    + +
    + {loadingBuild ? ( +
    + + Loading… +
    + ) : tab === 'details' ? ( + + ) : ( + + )} +
    + +
    + {!terminal && build ? ( +
    +
    +
    +
    + + {build.progress.percent}% + +
    + ) : ( +
    + )} + + +
    + + ); +} + +function TabButton({ active, onClick, children }: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function FeaturesList({ features, initialExpanded = false }: { features: string[]; initialExpanded?: boolean }) { + const [showAll, setShowAll] = useState(initialExpanded); + const CAP = 20; + const visible = showAll ? features : features.slice(0, CAP); + const overflow = features.length - CAP; + return ( +
    +

    Selected features

    +
    + {visible.map(f => ( + + {f} + + ))} + {!showAll && overflow > 0 && ( + + )} + {showAll && overflow > 0 && ( + + )} +
    +
    + ); +} + +function DetailsPane({ build, initialFeaturesExpanded }: { build: Build | null; initialFeaturesExpanded?: boolean }) { + if (!build) return

    No data

    ; + + const rows: [string, React.ReactNode][] = [ + ['Vehicle', build.vehicle.name], + ['Board', build.board.name], + ['Version', ( + + {build.version.name ?? build.version.id} + {build.version.type && } + + )], + ['Git hash', ( + + {build.version.git_hash.substring(0, 8)} + + )], + ['Created', {formatAge(build.time_created)}], + ['Progress', `${build.progress.percent}%`], + ['Features', build.selected_features.length === 0 + ? none + : {build.selected_features.length} selected + ], + ]; + + return ( +
    + {rows.map(([label, value]) => ( +
    + {label} + {value} +
    + ))} + + {build.selected_features.length > 0 && ( + + )} +
    + ); +} + +function LogsPane({ logs, loading, error }: { + logs: string; + loading: boolean; + error: string | null; +}) { + const containerRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + const autoScrollRef = useRef(true); + + const NEAR_BOTTOM = 120; + const MAX_LINES = 2000; + + const allLines = logs ? logs.split('\n') : []; + const omitted = Math.max(0, allLines.length - MAX_LINES); + const lines = omitted > 0 ? allLines.slice(-MAX_LINES) : allLines; + + const scrollToBottom = () => { + const el = containerRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + autoScrollRef.current = true; + setAutoScroll(true); + }; + + const handleScroll = () => { + const el = containerRef.current; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM; + if (autoScrollRef.current !== atBottom) { + autoScrollRef.current = atBottom; + setAutoScroll(atBottom); + } + }; + + useEffect(() => { + const el = containerRef.current; + if (!el || !autoScrollRef.current) return; + el.scrollTop = el.scrollHeight; + }, [logs]); + + return ( +
    + {error && ( +
    + {error} +
    + )} +
    + {loading && !logs ? ( +
    + + Fetching logs… +
    + ) : ( + <> + {omitted > 0 && ( +
    + … {omitted.toLocaleString()} earlier lines omitted +
    + )} + {lines.map((line, i) => ( +
    {line ||
    }
    + ))} + + )} +
    + {!autoScroll && ( + + )} +
    + ); +} diff --git a/frontend/src/components/BuildsTable.tsx b/frontend/src/components/BuildsTable.tsx new file mode 100644 index 0000000..cd123fb --- /dev/null +++ b/frontend/src/components/BuildsTable.tsx @@ -0,0 +1,342 @@ +import { useState, useEffect, useRef } from 'react'; +import { + ChevronLeft, ChevronRight, + Clock, RotateCcw, Info, Download, Loader2, +} from 'lucide-react'; +import clsx from 'clsx'; +import type { Build } from '../types'; +import { fetchBuilds, commitUrl, buildArtifactUrl, fetchBuildConfig } from '../api'; +import { BuildInfoModal } from './BuildInfoModal'; +import type { BuildConfig } from '../buildConfig'; +import { Tooltip } from './Tooltip'; +import { + NON_TERMINAL_BUILD_STATES, + isTerminal, + StateBadge, + VersionTypeBadge, + formatAge, +} from './buildStatus'; + +const PAGE_SIZE = 5; +const POLL_INTERVAL_MS = 4000; + +// Fixed tracks so header/rows share identical widths. Actions must fit 3 icon buttons +// (~26px each + gaps); too-narrow tracks overflow and look like header/row drift. +const COLS = 'grid-cols-[140px_130px_140px_minmax(220px,1fr)_110px_96px_128px]'; +const TABLE_MIN = 'min-w-[1100px]'; + + +function VersionCell({ version }: { version: Build['version'] }) { + const label = version.name ?? version.id; + return ( +
    +
    + {label} + {version.type && } +
    + + {version.git_hash.slice(0, 10)} + +
    + ); +} + +interface BuildsTableProps { + onRebuild?: (config: BuildConfig) => void; +} + +export function BuildsTable({ onRebuild }: BuildsTableProps = {}) { + const [builds, setBuilds] = useState([]); + const [page, setPage] = useState(0); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(true); + const [selectedBuildId, setSelectedBuildId] = useState(null); + const [modalInitialTab, setModalInitialTab] = useState<'details' | 'logs'>('details'); + const [modalFeaturesExpanded, setModalFeaturesExpanded] = useState(false); + const [rebuildLoadingId, setRebuildLoadingId] = useState(null); + const [rebuildError, setRebuildError] = useState(null); + const sectionRef = useRef(null); + const pollTimerRef = useRef | null>(null); + const pageRef = useRef(page); + const fetchGenRef = useRef(0); + const inFlightRef = useRef(false); + pageRef.current = page; + + function openBuild(b: Build) { + const tab = NON_TERMINAL_BUILD_STATES.includes(b.progress.state) ? 'logs' : 'details'; + setModalInitialTab(tab); + setModalFeaturesExpanded(false); + setSelectedBuildId(b.build_id); + } + + function openBuildFeatures(b: Build) { + setModalInitialTab('details'); + setModalFeaturesExpanded(true); + setSelectedBuildId(b.build_id); + } + + async function handleRebuildClick(buildId: string) { + if (!onRebuild || rebuildLoadingId) return; + setRebuildError(null); + setRebuildLoadingId(buildId); + try { + const config = await fetchBuildConfig(buildId); + onRebuild(config); + } catch (e: unknown) { + setRebuildError( + e instanceof Error ? e.message : `Failed to load config for ${buildId}`, + ); + } finally { + setRebuildLoadingId(null); + } + } + + function doFetch(p: number, isInitial = false) { + if (!isInitial && inFlightRef.current) return; + + const gen = ++fetchGenRef.current; + inFlightRef.current = true; + if (isInitial) setLoading(true); + + fetchBuilds(PAGE_SIZE + 1, p * PAGE_SIZE) + .then(data => { + if (gen !== fetchGenRef.current) return; + setHasMore(data.length > PAGE_SIZE); + setBuilds(data.slice(0, PAGE_SIZE)); + }) + .finally(() => { + if (gen === fetchGenRef.current) { + inFlightRef.current = false; + if (isInitial) setLoading(false); + } + }); + } + + const doFetchRef = useRef(doFetch); + doFetchRef.current = doFetch; + + useEffect(() => { + inFlightRef.current = false; + doFetchRef.current(pageRef.current, true); + }, [page]); + + useEffect(() => { + const el = sectionRef.current; + if (!el) return; + + function startPolling() { + if (pollTimerRef.current) return; + pollTimerRef.current = setInterval(() => { + doFetchRef.current(pageRef.current); + }, POLL_INTERVAL_MS); + } + + function stopPolling() { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) startPolling(); + else stopPolling(); + }, + { threshold: 0.1 } + ); + observer.observe(el); + + return () => { observer.disconnect(); stopPolling(); }; + }, []); + + return ( + <> +
    +
    +

    Recent Builds

    +

    All builds across the server

    +
    + +
    + {rebuildError && ( +
    + {rebuildError} +
    + )} +
    +
    +
    + {([ + { top: 'Build ID' }, + { top: 'Vehicle', btm: 'Board' }, + { top: 'Version', btm: 'Git SHA' }, + { top: 'Features' }, + { top: 'Status' }, + { top: 'Age' }, + { top: 'Actions' }, + ] as { top: string; btm?: string }[]).map(({ top, btm }) => ( +
    + + {top}{btm && /} + + {btm && {btm}} +
    + ))} +
    + + {loading ? ( +
    + {Array.from({ length: PAGE_SIZE }).map((_, i) => ( +
    + {Array.from({ length: 7 }).map((_, j) => ( +
    + ))} +
    + ))} +
    + ) : builds.length === 0 ? ( +
    No builds found
    + ) : ( +
    + {builds.map(b => { + const terminal = isTerminal(b.progress.state); + return ( +
    +
    + +
    + +
    +
    {b.vehicle.name}
    +
    {b.board.name}
    +
    + + + +
    + {b.selected_features.length === 0 ? ( + none + ) : ( +
    + {b.selected_features.slice(0, 5).map(f => ( + + {f} + + ))} + {b.selected_features.length > 5 && ( + + )} +
    + )} +
    + +
    + +
    + +
    + + {formatAge(b.time_created)} +
    + +
    + + + + + + + + + +
    +
    + ); + })} +
    + )} +
    +
    + + {(page > 0 || hasMore) && ( +
    + + Page {page + 1} + +
    + + +
    +
    + )} +
    +
    + + {selectedBuildId && ( + setSelectedBuildId(null)} + /> + )} + + ); +} diff --git a/frontend/src/components/CollapsibleBanner.tsx b/frontend/src/components/CollapsibleBanner.tsx new file mode 100644 index 0000000..439bdb5 --- /dev/null +++ b/frontend/src/components/CollapsibleBanner.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react'; +import { AlertTriangle, XCircle, ChevronDown, ChevronUp } from 'lucide-react'; +import clsx from 'clsx'; + +interface CollapsibleBannerProps { + items: string[]; + message: string; + color: 'yellow' | 'blue'; + onDismiss: () => void; + collapseAfter?: number; +} + +const COLORS = { + yellow: { + border: 'border-yellow-400/30', + bg: 'bg-yellow-400/5', + icon: 'text-yellow-400', + heading: 'text-yellow-300', + expand: 'text-yellow-400 hover:text-yellow-300', + }, + blue: { + border: 'border-blue-400/30', + bg: 'bg-blue-400/5', + icon: 'text-blue-400', + heading: 'text-blue-300', + expand: 'text-blue-400 hover:text-blue-300', + }, +}; + +export function CollapsibleBanner({ + items, + message, + color, + onDismiss, + collapseAfter = 4, +}: CollapsibleBannerProps) { + const [expanded, setExpanded] = useState(false); + const c = COLORS[color]; + const collapsible = items.length > collapseAfter; + const visible = expanded || !collapsible ? items : items.slice(0, collapseAfter); + + return ( +
    +
    +
    + +
    +

    {message}

    +
      + {visible.map(f => ( +
    • • {f}
    • + ))} +
    + {collapsible && ( + + )} +
    +
    + +
    +
    + ); +} diff --git a/frontend/src/components/ConfigConflictModal.tsx b/frontend/src/components/ConfigConflictModal.tsx new file mode 100644 index 0000000..2d0fa5f --- /dev/null +++ b/frontend/src/components/ConfigConflictModal.tsx @@ -0,0 +1,134 @@ +import { useState } from 'react'; +import { AlertTriangle, X, ChevronRight } from 'lucide-react'; +import clsx from 'clsx'; +import { ModalShell } from './ModalShell'; + +export type ConflictKind = 'vehicle' | 'version' | 'board'; + +interface Option { + id: string; + name: string; + badge?: string; +} + +interface ConfigConflictModalProps { + kind: ConflictKind; + requested: { id: string; name: string }; + available: Option[]; + onSelect: (id: string) => void; + onCancel: () => void; +} + +const LABELS: Record = { + vehicle: 'Vehicle', + version: 'Firmware version', + board: 'Board', +}; + +export function ConfigConflictModal({ + kind, + requested, + available, + onSelect, + onCancel, +}: ConfigConflictModalProps) { + const [selected, setSelected] = useState(null); + const [query, setQuery] = useState(''); + + const q = query.toLowerCase(); + const filtered = available.filter(o => + o.name.toLowerCase().includes(q) || o.id.toLowerCase().includes(q), + ); + + const label = LABELS[kind]; + + return ( + +
    +
    + +
    +

    + {label} not available +

    +

    + The config references{' '} + + {requested.name} + {' '} + ({requested.id}), which is not currently listed on the + server. Select an alternative to continue. +

    +
    +
    + +
    + +
    + setQuery(e.target.value)} + placeholder={`Search ${label.toLowerCase()}…`} + className="w-full bg-surface-3 border border-surface-4 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 outline-none focus:ring-1 focus:ring-yellow-400/50 shrink-0" + /> + +
    + {filtered.length === 0 ? ( +

    + No results +

    + ) : ( + filtered.map(opt => ( + + )) + )} +
    +
    + +
    + + +
    +
    + ); +} diff --git a/frontend/src/components/ConfigDropZone.tsx b/frontend/src/components/ConfigDropZone.tsx new file mode 100644 index 0000000..38d5a60 --- /dev/null +++ b/frontend/src/components/ConfigDropZone.tsx @@ -0,0 +1,101 @@ +import { useState, useRef, useCallback, useId, type ReactNode } from 'react'; +import { UploadCloud, FileText, AlertTriangle } from 'lucide-react'; +import clsx from 'clsx'; + +interface ConfigDropZoneProps { + onLoad: (yamlText: string, fileName: string) => void; + title?: string; + hint?: ReactNode; +} + +export function ConfigDropZone({ + onLoad, + title = 'Load config file', + hint, +}: ConfigDropZoneProps) { + const secondaryHint = hint ?? ( + <> + Drag & drop or click · .yaml /{' '} + .yml + + ); + const [dragging, setDragging] = useState(false); + const [error, setError] = useState(null); + const onLoadRef = useRef(onLoad); + onLoadRef.current = onLoad; + const inputId = useId(); + + const readFile = useCallback((file: File) => { + if (!file.name.match(/\.(yaml|yml)$/i)) { + setError('Only .yaml or .yml files are supported'); + return; + } + setError(null); + const reader = new FileReader(); + reader.onload = e => onLoadRef.current((e.target?.result as string) ?? '', file.name); + reader.onerror = () => setError('Failed to read file'); + reader.readAsText(file); + }, []); + + const onDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setDragging(false); + const file = e.dataTransfer.files[0]; + if (file) readFile(file); + }, [readFile]); + + const onDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setDragging(true); + }; + const onDragLeave = () => setDragging(false); + + const onInputChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) readFile(file); + e.target.value = ''; + }; + + return ( +
    + + {error && ( +

    + + {error} +

    + )} +
    + ); +} diff --git a/frontend/src/components/DoneStep.tsx b/frontend/src/components/DoneStep.tsx new file mode 100644 index 0000000..7d74544 --- /dev/null +++ b/frontend/src/components/DoneStep.tsx @@ -0,0 +1,128 @@ +import { + CheckCircle2, XCircle, AlertTriangle, + Download, Copy, Terminal, +} from 'lucide-react'; +import clsx from 'clsx'; +import type { Vehicle, Version, Board, BuildState } from '../types'; +import { buildArtifactUrl } from '../api'; +import type { BuildConfig } from '../buildConfig'; + +interface DoneStepProps { + buildState: BuildState; + buildId: string | null; + vehicle: Vehicle | null; + version: Version | null; + board: Board | null; + selected: Set; + onViewLogs: () => void; + onRebuild: (config: BuildConfig) => void; +} + +export function DoneStep({ + buildState, buildId, vehicle, version, board, selected, + onViewLogs, onRebuild, +}: DoneStepProps) { + const isSuccess = buildState === 'SUCCESS'; + const isTimeout = buildState === 'TIMED_OUT'; + const icon = isSuccess + ? + : isTimeout + ? + : ; + const message = isSuccess ? 'Build complete!' : isTimeout ? 'Build timed out' : 'Build failed'; + + function makeConfig(): BuildConfig | null { + if (!vehicle || !version || !board) return null; + return { + config_version: '0.0.1', + vehicle: { id: vehicle.id, name: vehicle.name }, + version: { + id: version.id, + name: version.name, + type: version.type, + remote_name: version.remote.name, + }, + board: { id: board.id, name: board.name }, + selected_features: Array.from(selected), + }; + } + + return ( +
    +
    + {icon} +
    +
    {message}
    +
    + {buildId && ( + Build ID: {buildId} + )} + {vehicle && ( + Vehicle: {vehicle.name} + )} + {vehicle && board && ·} + {board && ( + Board: {board.name} + )} + {board && buildId && ·} +
    +
    +
    + +
    + + + {isSuccess && ( + + )} + + {isSuccess ? ( + + + Download Bundle + + ) : ( + + )} +
    +
    + ); +} diff --git a/frontend/src/components/ErrorBanner.tsx b/frontend/src/components/ErrorBanner.tsx new file mode 100644 index 0000000..0726c3a --- /dev/null +++ b/frontend/src/components/ErrorBanner.tsx @@ -0,0 +1,11 @@ +import { AlertTriangle } from 'lucide-react'; + +export function ErrorBanner({ message }: { message: string | null }) { + if (!message) return null; + return ( +

    + + {message} +

    + ); +} diff --git a/frontend/src/components/FeaturesGraphView.tsx b/frontend/src/components/FeaturesGraphView.tsx new file mode 100644 index 0000000..a0a06aa --- /dev/null +++ b/frontend/src/components/FeaturesGraphView.tsx @@ -0,0 +1,1003 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type MouseEvent as ReactMouseEvent, +} from 'react'; +import { createPortal } from 'react-dom'; +import { + ReactFlow, + Background, + BaseEdge, + MarkerType, + Position, + Handle, + getBezierPath, + useReactFlow, + ReactFlowProvider, + useNodesState, + useEdgesState, + useStore, + type Node, + type Edge, + type EdgeProps, + type NodeProps, + BackgroundVariant, +} from '@xyflow/react'; +import { + Search, X, Check, EyeOff, Eye, ZoomIn, ZoomOut, LayoutGrid, Scan, +} from 'lucide-react'; +import clsx from 'clsx'; +import type { Feature } from '../types'; +import { + collectTransitiveDeps, + collectTransitiveDependents, + reducedDependencyEdges, +} from '../featureDeps'; +import { searchFeatures } from '../featureSearch'; +import '@xyflow/react/dist/style.css'; + +const NODE_WIDTH = 220; +const NODE_HEIGHT = 64; +const MIN_ZOOM = 0.02; +const MAX_ZOOM = 2; +const RANK_SEP = 180; +const NODE_GAP = 24; +const ROOT_GAP = 40; +const STANDALONE_GAP_Y = 48; +const FIT_PADDING = 0.12; +const INFO_CARD_DELAY_MS = 500; +const INFO_CARD_WIDTH = 280; +const INFO_CARD_GAP = 8; +const INFO_CARD_EST_H = 148; + +interface FeatureNodeData extends Record { + feature: Feature; + searchHighlight: boolean; + focused: boolean; // Subtree-view root + dimmed: boolean; + treeActive: boolean; + onFocus: (id: string) => void; + onShowAll: () => void; +} + +interface FeatureEdgeData extends Record { + treeActive: boolean; + dimmed: boolean; +} + +const HANDLE_CLASS = '!w-2 !h-2 !bg-gray-500 !border-0 !pointer-events-none'; + +interface SelectionCtx { + selected: Set; + onToggle: (id: string) => void; +} + +const FeatureGraphSelectionContext = createContext(null); + +function useSelection() { + const ctx = useContext(FeatureGraphSelectionContext); + if (!ctx) throw new Error('FeatureGraphSelectionContext missing'); + return ctx; +} + +function FeatureEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + data, + markerEnd, + style, +}: EdgeProps>) { + const treeActive = data?.treeActive ?? false; + const dimmed = data?.dimmed ?? false; + + const [path] = getBezierPath({ + sourceX, + sourceY, + sourcePosition: Position.Left, + targetX, + targetY, + targetPosition: Position.Right, + }); + const stroke = treeActive + ? 'rgb(250, 204, 21)' + : 'rgb(107, 114, 128)'; + + return ( + + ); +} + +function FeatureGraphNode({ data }: NodeProps>) { + const { + feature, searchHighlight, focused, dimmed, treeActive, onFocus, onShowAll, + } = data; + const { selected, onToggle } = useSelection(); + const checked = selected.has(feature.id); + + function handleToggle(e: ReactMouseEvent) { + e.preventDefault(); + e.stopPropagation(); + onToggle(feature.id); + } + + function handleCardClick(e: ReactMouseEvent) { + e.preventDefault(); + e.stopPropagation(); + if (focused) onShowAll(); + else onFocus(feature.id); + } + + return ( +
    e.stopPropagation()} + onPointerDown={(e: ReactMouseEvent) => e.stopPropagation()} + > + +
    + +
    +
    + {feature.name} +
    +
    + {feature.category.name} +
    +
    +
    + +
    + ); +} + +const nodeTypes = { feature: FeatureGraphNode }; +const edgeTypes = { feature: FeatureEdge }; + +function isStandalone( + id: string, + forwardDeps: Map>, + reverseDeps: Map>, +): boolean { + const outs = forwardDeps.get(id); + const ins = reverseDeps.get(id); + return (!outs || outs.size === 0) && (!ins || ins.size === 0); +} + +/** Ancestors + self + descendants for hover/focus trees. */ +function relatedTreeIds( + id: string, + forwardDeps: Map>, + reverseDeps: Map>, + featureMap: Map, +): Set { + const deps = collectTransitiveDeps(id, forwardDeps, featureMap); + const dependents = collectTransitiveDependents(id, reverseDeps); + const set = new Set([id]); + deps.forEach(d => set.add(d)); + dependents.forEach(d => set.add(d)); + return set; +} + +/** Longest-path column rank: roots = 0; else 1 + max(dep ranks). */ +function computeRanks( + connectedIds: Set, + forwardDeps: Map>, +): Map { + const ranks = new Map(); + const visiting = new Set(); + + function rankOf(id: string): number { + const cached = ranks.get(id); + if (cached !== undefined) return cached; + if (visiting.has(id)) return 0; + visiting.add(id); + + let maxDep = -1; + for (const depId of forwardDeps.get(id) ?? []) { + if (!connectedIds.has(depId)) continue; + maxDep = Math.max(maxDep, rankOf(depId)); + } + visiting.delete(id); + + const r = maxDep < 0 ? 0 : maxDep + 1; + ranks.set(id, r); + return r; + } + + for (const id of connectedIds) rankOf(id); + return ranks; +} + +/** + * Primary layout parent = dependency with maximum rank (deepest / rightmost). + * Matches longest-path columns so packing stays under the deepest dep. + */ +function buildLayoutChildren( + connectedIds: Set, + forwardDeps: Map>, + ranks: Map, +): { children: Map; roots: string[] } { + const children = new Map(); + const roots: string[] = []; + + const sortedIds = [...connectedIds].sort((a, b) => a.localeCompare(b)); + + for (const id of sortedIds) { + const inSetDeps = [...(forwardDeps.get(id) ?? [])].filter(d => connectedIds.has(d)); + if (inSetDeps.length === 0) { + roots.push(id); + continue; + } + + // Prefer deepest (highest rank) dependency as layout parent + inSetDeps.sort((a, b) => { + const rd = (ranks.get(b) ?? 0) - (ranks.get(a) ?? 0); + return rd !== 0 ? rd : a.localeCompare(b); + }); + const parent = inSetDeps[0]; + if (!children.has(parent)) children.set(parent, []); + children.get(parent)!.push(id); + } + + for (const [, kids] of children) { + kids.sort((a, b) => a.localeCompare(b)); + } + roots.sort((a, b) => a.localeCompare(b)); + + return { children, roots }; +} + +function computeSubtreeHeights( + roots: string[], + children: Map, +): Map { + const heights = new Map(); + + function heightOf(id: string): number { + const cached = heights.get(id); + if (cached !== undefined) return cached; + const kids = children.get(id) ?? []; + let h: number; + if (kids.length === 0) { + h = NODE_HEIGHT; + } else { + const sum = kids.reduce((acc, k) => acc + heightOf(k), 0); + h = Math.max(sum + NODE_GAP * (kids.length - 1), NODE_HEIGHT); + } + heights.set(id, h); + return h; + } + + for (const r of roots) heightOf(r); + return heights; +} + +function placeTree( + id: string, + top: number, + ranks: Map, + children: Map, + heights: Map, + positions: Map, +): void { + const kids = children.get(id) ?? []; + const blockH = heights.get(id) ?? NODE_HEIGHT; + const rank = ranks.get(id) ?? 0; + const x = rank * (NODE_WIDTH + RANK_SEP); + + if (kids.length === 0) { + positions.set(id, { x, y: top }); + return; + } + + let cursor = top; + for (const kid of kids) { + placeTree(kid, cursor, ranks, children, heights, positions); + cursor += (heights.get(kid) ?? NODE_HEIGHT) + NODE_GAP; + } + + const firstKid = positions.get(kids[0])!; + const lastKid = positions.get(kids[kids.length - 1])!; + const y = (firstKid.y + lastKid.y + NODE_HEIGHT) / 2 - NODE_HEIGHT / 2; + const minY = top; + const maxY = top + blockH - NODE_HEIGHT; + positions.set(id, { x, y: Math.min(maxY, Math.max(minY, y)) }); +} + +function buildLayout( + features: Feature[], + forwardDeps: Map>, + reverseDeps: Map>, + hideStandalone: boolean, +): { + positions: Map; + visible: Feature[]; + edgePairs: Array<{ source: string; target: string }>; +} { + const connected = features.filter(f => !isStandalone(f.id, forwardDeps, reverseDeps)); + const standalones = hideStandalone + ? [] + : features.filter(f => isStandalone(f.id, forwardDeps, reverseDeps)); + + const connectedIds = new Set(connected.map(f => f.id)); + const positions = new Map(); + // Graph edges: covering only (selection still uses full forwardDeps) + const edgePairs = reducedDependencyEdges(connectedIds, forwardDeps); + + if (connected.length > 0) { + const ranks = computeRanks(connectedIds, forwardDeps); + const { children, roots } = buildLayoutChildren(connectedIds, forwardDeps, ranks); + const heights = computeSubtreeHeights(roots, children); + + let cursor = 0; + for (const root of roots) { + placeTree(root, cursor, ranks, children, heights, positions); + cursor += (heights.get(root) ?? NODE_HEIGHT) + ROOT_GAP; + } + + for (const id of connectedIds) { + if (!positions.has(id)) { + positions.set(id, { x: 0, y: cursor }); + cursor += NODE_HEIGHT + NODE_GAP; + } + } + } + + if (standalones.length > 0) { + let maxY = 0; + if (positions.size > 0) { + maxY = Math.max(...[...positions.values()].map(p => p.y + NODE_HEIGHT)); + } + const startY = positions.size > 0 ? maxY + STANDALONE_GAP_Y : 0; + standalones + .slice() + .sort((a, b) => a.name.localeCompare(b.name)) + .forEach((f, i) => { + positions.set(f.id, { + x: 0, + y: startY + i * (NODE_HEIGHT + NODE_GAP), + }); + }); + } + + return { positions, visible: [...connected, ...standalones], edgePairs }; +} + +function buildFlowElements( + visible: Feature[], + positions: Map, + edgePairs: Array<{ source: string; target: string }>, + searchHighlightId: string | null, + focusedId: string | null, + hoverTree: Set | null, + onFocus: (id: string) => void, + onShowAll: () => void, +): { nodes: Node[]; edges: Edge[] } { + const hovering = hoverTree !== null && hoverTree.size > 0; + + const nodes: Node[] = visible.map(f => { + const pos = positions.get(f.id)!; + const inTree = hovering && hoverTree!.has(f.id); + return { + id: f.id, + type: 'feature', + position: pos, + className: 'nopan nodrag', + data: { + feature: f, + searchHighlight: searchHighlightId === f.id, + focused: focusedId === f.id, + dimmed: hovering && !inTree, + treeActive: !!inTree, + onFocus, + onShowAll, + }, + }; + }); + + const edges: Edge[] = edgePairs.map(({ source, target }) => { + const inTree = hovering && hoverTree!.has(source) && hoverTree!.has(target); + const dimmed = hovering && !inTree; + const stroke = inTree ? 'rgb(250, 204, 21)' : 'rgb(107, 114, 128)'; + return { + id: `${source}->${target}`, + source, + target, + type: 'feature', + data: { + treeActive: !!inTree, + dimmed, + }, + markerEnd: { + type: MarkerType.ArrowClosed, + width: 16, + height: 16, + color: stroke, + }, + }; + }); + + return { nodes, edges }; +} + +function ZoomSlider() { + const { zoomTo, zoomIn, zoomOut, fitView } = useReactFlow(); + const zoom = useStore(s => s.transform[2]); + + return ( +
    + + zoomTo(Number(e.target.value), { duration: 0 })} + className="features-zoom-slider" + aria-label="Zoom" + style={{ writingMode: 'vertical-lr', direction: 'rtl', height: 100, width: 14 }} + /> + + + {Math.round(zoom * 100)}% + + +
    + ); +} + +interface FeatureInfoCardState { + feature: Feature; + top: number; + left: number; +} + +function placeInfoTooltip( + nodeRect: DOMRect, + cardW: number, + cardH: number, +): { top: number; left: number } { + return { + top: nodeRect.top - INFO_CARD_GAP - cardH, + left: nodeRect.left + nodeRect.width / 2 - cardW / 2, + }; +} + +function FeatureInfoCard({ feature, top, left }: FeatureInfoCardState) { + const showName = feature.name !== feature.id; + const caretBorder = 'rgb(var(--s4))'; + + return createPortal( +
    +
    +
    + {feature.id} +
    + {showName && ( +
    {feature.name}
    + )} +
    + + {feature.category.name} + + + default {feature.default.enabled ? 'on' : 'off'} + +
    +

    + {feature.description?.trim() || 'No description'} +

    +
    +
    +
    , + document.body, + ); +} + +interface FeaturesGraphViewProps { + features: Feature[]; + selected: Set; + forwardDeps: Map>; + reverseDeps: Map>; + featureMap: Map; + onToggle: (id: string) => void; +} + +function FeaturesGraphCanvas({ + features, + selected, + forwardDeps, + reverseDeps, + featureMap, + onToggle, +}: FeaturesGraphViewProps) { + const { fitView } = useReactFlow(); + const [hideStandalone, setHideStandalone] = useState(true); + const [search, setSearch] = useState(''); + const [searchHighlightId, setSearchHighlightId] = useState(null); + const [hoveredId, setHoveredId] = useState(null); + const [focusedId, setFocusedId] = useState(null); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [infoCard, setInfoCard] = useState(null); + const highlightTimeoutRef = useRef | null>(null); + const searchWrapRef = useRef(null); + const hoverLeaveTimer = useRef | null>(null); + const infoCardTimer = useRef | null>(null); + + const clearInfoCard = useCallback(() => { + if (infoCardTimer.current) { + clearTimeout(infoCardTimer.current); + infoCardTimer.current = null; + } + setInfoCard(null); + }, []); + + const onToggleRef = useRef(onToggle); + onToggleRef.current = onToggle; + const stableToggle = useCallback((id: string) => onToggleRef.current(id), []); + + const onFocusRef = useRef((id: string) => setFocusedId(id)); + onFocusRef.current = (id: string) => { + setFocusedId(id); + setHoveredId(null); + clearInfoCard(); + }; + const stableFocus = useCallback((id: string) => onFocusRef.current(id), []); + + const showAll = useCallback(() => { + setFocusedId(null); + setHoveredId(null); + clearInfoCard(); + }, [clearInfoCard]); + + const selectionValue = useMemo( + () => ({ selected, onToggle: stableToggle }), + [selected, stableToggle], + ); + + const layoutFeatures = useMemo(() => { + if (!focusedId) return features; + const tree = relatedTreeIds(focusedId, forwardDeps, reverseDeps, featureMap); + return features.filter(f => tree.has(f.id)); + }, [features, focusedId, forwardDeps, reverseDeps, featureMap]); + + const layout = useMemo( + () => buildLayout( + layoutFeatures, + forwardDeps, + reverseDeps, + // Focus already scopes the set; keep standalones so a lone focused + // feature still appears. + focusedId ? false : hideStandalone, + ), + [layoutFeatures, forwardDeps, reverseDeps, hideStandalone, focusedId], + ); + + const hoverTree = useMemo(() => { + if (!hoveredId) return null; + return relatedTreeIds(hoveredId, forwardDeps, reverseDeps, featureMap); + }, [hoveredId, forwardDeps, reverseDeps, featureMap]); + + const flowElements = useMemo( + () => + buildFlowElements( + layout.visible, + layout.positions, + layout.edgePairs, + searchHighlightId, + focusedId, + hoverTree, + stableFocus, + showAll, + ), + [ + layout, + searchHighlightId, + focusedId, + hoverTree, + stableFocus, + showAll, + ], + ); + + const [nodes, setNodes, onNodesChange] = useNodesState>( + flowElements.nodes, + ); + const [edges, setEdges, onEdgesChange] = useEdgesState>( + flowElements.edges, + ); + const [graphReady, setGraphReady] = useState(false); + const initialFitDone = useRef(false); + + useEffect(() => { + setNodes(flowElements.nodes); + setEdges(flowElements.edges); + }, [flowElements, setNodes, setEdges]); + + useEffect(() => { + if (layout.visible.length === 0) { + setGraphReady(true); + return; + } + + let cancelled = false; + // Wait until RF has committed node measurements, then fit without animating + // the first paint (avoids zoom-1 → fitView flicker). + const id = requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (cancelled) return; + const animate = initialFitDone.current; + fitView({ + padding: FIT_PADDING, + duration: animate ? 300 : 0, + minZoom: MIN_ZOOM, + maxZoom: MAX_ZOOM, + }); + initialFitDone.current = true; + setGraphReady(true); + }); + }); + return () => { + cancelled = true; + cancelAnimationFrame(id); + }; + }, [layout, focusedId, fitView]); + + useEffect(() => { + return () => { + if (infoCardTimer.current) clearTimeout(infoCardTimer.current); + if (hoverLeaveTimer.current) clearTimeout(hoverLeaveTimer.current); + if (highlightTimeoutRef.current) clearTimeout(highlightTimeoutRef.current); + }; + }, []); + + useEffect(() => { + function onDocClick(e: MouseEvent) { + if (!searchWrapRef.current?.contains(e.target as HTMLElement)) { + setDropdownOpen(false); + } + } + document.addEventListener('mousedown', onDocClick); + return () => document.removeEventListener('mousedown', onDocClick); + }, []); + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key !== 'Escape') return; + if (search || dropdownOpen) return; + if (focusedId) { + e.stopPropagation(); + setFocusedId(null); + } + } + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [focusedId, search, dropdownOpen]); + + const searchPool = layout.visible; + + const matches = useMemo( + () => (search.trim() ? searchFeatures(searchPool, search, 8) : []), + [search, searchPool], + ); + + const zoomToFeature = useCallback( + (id: string) => { + if (highlightTimeoutRef.current) clearTimeout(highlightTimeoutRef.current); + setSearchHighlightId(id); + setDropdownOpen(false); + + requestAnimationFrame(() => { + fitView({ + nodes: [{ id }], + duration: 450, + maxZoom: 1.4, + padding: 0.55, + }); + }); + + highlightTimeoutRef.current = setTimeout(() => setSearchHighlightId(null), 2500); + }, + [fitView], + ); + + function clearSearch() { + setSearch(''); + setSearchHighlightId(null); + setDropdownOpen(false); + if (highlightTimeoutRef.current) clearTimeout(highlightTimeoutRef.current); + fitView({ + padding: FIT_PADDING, + duration: 400, + minZoom: MIN_ZOOM, + maxZoom: MAX_ZOOM, + }); + } + + function handleSearchKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' && matches.length > 0) { + e.preventDefault(); + const first = matches[0]; + setSearch(first.name); + zoomToFeature(first.id); + } else if (e.key === 'Escape' && (search || dropdownOpen)) { + e.stopPropagation(); + if (search) clearSearch(); + else setDropdownOpen(false); + } + } + + function handleNodeMouseEnter(e: React.MouseEvent, node: Node) { + if (hoverLeaveTimer.current) clearTimeout(hoverLeaveTimer.current); + setHoveredId(node.id); + + clearInfoCard(); + const feature = featureMap.get(node.id); + if (!feature) return; + + const el = (e.target as HTMLElement).closest('.react-flow__node'); + const nodeRect = el?.getBoundingClientRect(); + infoCardTimer.current = setTimeout(() => { + const nr = nodeRect ?? new DOMRect(e.clientX, e.clientY, 0, 0); + const placed = placeInfoTooltip(nr, INFO_CARD_WIDTH, INFO_CARD_EST_H); + setInfoCard({ feature, ...placed }); + }, INFO_CARD_DELAY_MS); + } + + function handleNodeMouseLeave() { + if (hoverLeaveTimer.current) clearTimeout(hoverLeaveTimer.current); + hoverLeaveTimer.current = setTimeout(() => setHoveredId(null), 80); + clearInfoCard(); + } + + return ( + +
    + + + + +
    + {focusedId && ( + + )} + {!focusedId && ( + + )} + +
    + +
    +
    + + { + setSearch(e.target.value); + setDropdownOpen(true); + }} + onFocus={() => setDropdownOpen(true)} + onKeyDown={handleSearchKeyDown} + /> + {search && ( + + )} +
    + {dropdownOpen && matches.length > 0 && ( +
      + {matches.map(f => ( +
    • + +
    • + ))} +
    + )} + {dropdownOpen && search.trim() && matches.length === 0 && ( +
    + No features match +
    + )} +
    + + {nodes.length === 0 && ( +
    +

    + {hideStandalone && !focusedId + ? 'No connected features — turn on “Show standalone features” to see all.' + : 'No features to display'} +

    +
    + )} + + {infoCard && } +
    +
    + ); +} + +export function FeaturesGraphView(props: FeaturesGraphViewProps) { + return ( +
    + + + +
    + ); +} diff --git a/frontend/src/components/FeaturesModal.tsx b/frontend/src/components/FeaturesModal.tsx new file mode 100644 index 0000000..999f698 --- /dev/null +++ b/frontend/src/components/FeaturesModal.tsx @@ -0,0 +1,576 @@ +import { useEffect, useRef, useMemo, useState, useCallback } from 'react'; +import { + X, Search, Check, Minus, SlidersHorizontal, AlertTriangle, ChevronDown, ChevronUp, List, Network, +} from 'lucide-react'; +import clsx from 'clsx'; +import type { Feature } from '../types'; +import { + buildFeatureMaps, + collectTransitiveDeps, + collectTransitiveDependents, +} from '../featureDeps'; +import { searchFeatures } from '../featureSearch'; +import { ModalShell } from './ModalShell'; +import { FeaturesGraphView } from './FeaturesGraphView'; + +type ViewMode = 'list' | 'graph'; + +interface FeaturesModalProps { + features: Feature[]; + selected: Set; + onDone: (selected: Set) => void; + onClose: () => void; +} + +export function FeaturesModal({ features, selected, onDone, onClose }: FeaturesModalProps) { + const [localSelected, setLocalSelected] = useState>(new Set(selected)); + const [viewMode, setViewMode] = useState('list'); + const [search, setSearch] = useState(''); + const [highlightedCat, setHighlightedCat] = useState(null); + const [pendingUncheck, setPendingUncheck] = useState(null); + const catFirstRefsMap = useRef>(new Map()); + const featureListRef = useRef(null); + const highlightTimeoutRef = useRef | null>(null); + + const { forwardDeps, reverseDeps, featureMap } = useMemo( + () => buildFeatureMaps(features), + [features], + ); + + const collectAllDeps = useCallback( + (id: string): Set => collectTransitiveDeps(id, forwardDeps, featureMap), + [forwardDeps, featureMap], + ); + + const collectAllDependents = useCallback( + (id: string): Set => collectTransitiveDependents(id, reverseDeps), + [reverseDeps], + ); + + const grouped = features.reduce>((acc, f) => { + if (!acc[f.category.id]) acc[f.category.id] = { catName: f.category.name, features: [] }; + acc[f.category.id].features.push(f); + return acc; + }, {}); + + const firstFeaturePerCat = useMemo(() => { + const map = new Map(); + features.forEach(f => { if (!map.has(f.category.id)) map.set(f.category.id, f.id); }); + return map; + }, [features]); + + const visibleFeatures = useMemo( + () => (search.trim() ? searchFeatures(features, search) : features), + [features, search], + ); + + function toggle(id: string) { + if (localSelected.has(id)) { + if (pendingUncheck === id) { + const dependents = collectAllDependents(id); + setLocalSelected(prev => { + const next = new Set(prev); + next.delete(id); + dependents.forEach(d => next.delete(d)); + return next; + }); + setPendingUncheck(null); + } else { + const dependents = collectAllDependents(id); + const activeDependents = [...dependents].filter(d => localSelected.has(d)); + if (activeDependents.length > 0) { + setPendingUncheck(id); + } else { + setLocalSelected(prev => { const next = new Set(prev); next.delete(id); return next; }); + setPendingUncheck(null); + } + } + } else { + const deps = collectAllDeps(id); + setLocalSelected(prev => { + const next = new Set(prev); + next.add(id); + deps.forEach(d => next.add(d)); + return next; + }); + if (pendingUncheck === id) setPendingUncheck(null); + } + } + + /** Graph view: deps are visible, so uncheck immediately removes dependents too. */ + function toggleGraph(id: string) { + if (localSelected.has(id)) { + const dependents = collectAllDependents(id); + setLocalSelected(prev => { + const next = new Set(prev); + next.delete(id); + dependents.forEach(d => next.delete(d)); + return next; + }); + if (pendingUncheck === id) setPendingUncheck(null); + } else { + const deps = collectAllDeps(id); + setLocalSelected(prev => { + const next = new Set(prev); + next.add(id); + deps.forEach(d => next.add(d)); + return next; + }); + if (pendingUncheck === id) setPendingUncheck(null); + } + } + + function cancelPendingUncheck() { + setPendingUncheck(null); + } + + function scrollToCategory(catId: string) { + const el = catFirstRefsMap.current.get(catId); + const container = featureListRef.current; + if (el && container) { + const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const top = el.getBoundingClientRect().top + - container.getBoundingClientRect().top + + container.scrollTop; + container.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' }); + } + if (highlightTimeoutRef.current) clearTimeout(highlightTimeoutRef.current); + setHighlightedCat(catId); + highlightTimeoutRef.current = setTimeout(() => setHighlightedCat(null), 1800); + } + + function toggleAllInCat(catId: string, catFeatures: Feature[]) { + const ids = catFeatures.map(f => f.id); + const allSelected = ids.every(id => localSelected.has(id)); + + if (allSelected) { + const toRemove = new Set(ids); + ids.forEach(id => collectAllDependents(id).forEach(d => toRemove.add(d))); + setLocalSelected(prev => { + const next = new Set(prev); + toRemove.forEach(id => next.delete(id)); + return next; + }); + if (pendingUncheck && toRemove.has(pendingUncheck)) setPendingUncheck(null); + } else { + const toAdd = new Set(ids); + ids.forEach(id => collectAllDeps(id).forEach(d => toAdd.add(d))); + setLocalSelected(prev => { + const next = new Set(prev); + toAdd.forEach(id => next.add(id)); + return next; + }); + } + } + + function toggleAll() { + const allSelected = features.every(f => localSelected.has(f.id)); + setLocalSelected(allSelected ? new Set() : new Set(features.map(f => f.id))); + setPendingUncheck(null); + } + + const totalCount = features.length; + const selectedCount = localSelected.size; + + return ( + +
    +
    + +

    Select Features

    + {selectedCount} / {totalCount} enabled +
    + + +
    +
    + +
    + + {viewMode === 'list' ? ( +
    +
    +
    +
    + 0 && selectedCount < totalCount} + onToggle={toggleAll} + label="Toggle all features" + /> + All Features +
    +
    +
    +
    + {Object.entries(grouped).map(([catId, { catName, features: catFeatures }]) => { + const selCount = catFeatures.filter(f => localSelected.has(f.id)).length; + return ( + toggleAllInCat(catId, catFeatures)} + /> + ); + })} +
    +
    +
    + +
    +
    +
    + + setSearch(e.target.value)} + /> + {search && ( + + )} +
    +
    + +
    + {visibleFeatures.length === 0 ? ( +

    No features match

    + ) : ( +
    + {visibleFeatures.map(f => { + const activeDependents = pendingUncheck === f.id + ? [...collectAllDependents(f.id)].filter(d => localSelected.has(d)).map(d => featureMap.get(d)!).filter(Boolean) + : []; + return ( + { + if (el) catFirstRefsMap.current.set(f.category.id, el); + else catFirstRefsMap.current.delete(f.category.id); + } + : undefined + } + /> + ); + })} +
    + )} +
    +
    +
    + ) : ( + + )} + +
    + + +
    +
    + ); +} + +function CategoryCard({ + catId, catName, total, selectedCount, isHighlighted, onScrollTo, onToggleAll, +}: { + catId: string; + catName: string; + total: number; + selectedCount: number; + isHighlighted: boolean; + onScrollTo: (id: string) => void; + onToggleAll: () => void; +}) { + const hasSelection = selectedCount > 0; + const allSelected = selectedCount === total; + const indeterminate = hasSelection && !allSelected; + return ( +
    onScrollTo(catId)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onScrollTo(catId); + } + }} + className={clsx( + 'shrink-0 md:w-full rounded-xl px-3.5 py-3 border transition-all duration-150 bg-surface-2 cursor-pointer', + isHighlighted + ? 'border-yellow-400 shadow-[0_0_8px_rgba(250,204,21,0.35)]' + : 'border-surface-4 hover:border-yellow-400/40 hover:bg-hover', + )} + > +
    + + + {catName} + + + {selectedCount}/{total} + +
    +
    + ); +} + +function TriStateCheckbox({ checked, indeterminate, onToggle, label }: { + checked: boolean; + indeterminate: boolean; + onToggle: () => void; + label: string; +}) { + const ariaChecked: boolean | 'mixed' = checked ? true : indeterminate ? 'mixed' : false; + return ( + + ); +} + +function FeatureRow({ feature, checked, onToggle, catHighlighted, innerRef, pendingUncheck, activeDependents, onCancelPendingUncheck }: { + feature: Feature; + checked: boolean; + onToggle: (id: string) => void; + catHighlighted?: boolean; + innerRef?: (el: HTMLElement | null) => void; + pendingUncheck?: boolean; + activeDependents?: Feature[]; + onCancelPendingUncheck?: () => void; +}) { + const [expandedDeps, setExpandedDeps] = useState(false); + const rowRef = useRef(null); + + // Collapse when row scrolls out of view + useEffect(() => { + if (!pendingUncheck) return; + const el = rowRef.current; + if (!el) return; + const observer = new IntersectionObserver( + ([entry]) => { if (!entry.isIntersecting) onCancelPendingUncheck?.(); }, + { threshold: 0 } + ); + observer.observe(el); + return () => observer.disconnect(); + }, [pendingUncheck, onCancelPendingUncheck]); + + useEffect(() => { + if (!pendingUncheck) setExpandedDeps(false); + }, [pendingUncheck]); + + const TREE_LIMIT = 4; + const shown = activeDependents?.slice(0, expandedDeps ? undefined : TREE_LIMIT) ?? []; + const hiddenCount = (activeDependents?.length ?? 0) - TREE_LIMIT; + + function setRef(el: HTMLDivElement | null) { + rowRef.current = el; + innerRef?.(el); + } + + return ( +
    + + + {pendingUncheck && activeDependents && activeDependents.length > 0 && ( +
    +
    + + + Disabling this will also disable {activeDependents.length} dependent{activeDependents.length !== 1 ? 's' : ''}: + +
    +
    + {shown.map((dep, i) => { + const isLast = i === shown.length - 1 && (expandedDeps || hiddenCount <= 0); + return ( +
    + {isLast ? '└──' : '├──'} + {dep.name} + {dep.category.name} +
    + ); + })} + {!expandedDeps && hiddenCount > 0 && ( +
    + └── + +
    + )} + {expandedDeps && hiddenCount > 0 && ( + + )} +
    +
    + + +
    +
    + )} +
    + ); +} diff --git a/frontend/src/components/HeroPanel.tsx b/frontend/src/components/HeroPanel.tsx new file mode 100644 index 0000000..8561a63 --- /dev/null +++ b/frontend/src/components/HeroPanel.tsx @@ -0,0 +1,23 @@ +export function HeroPanel() { + return ( +
    +

    + ArduPilot +

    +
    + + CustomBuild + +
    + +

    + Build exactly the firmware you need. Choose your vehicle, board, and feature set, + and we'll compile it for you. +

    + +

    + Versatile · Trusted · Open +

    +
    + ); +} diff --git a/frontend/src/components/ModalShell.tsx b/frontend/src/components/ModalShell.tsx new file mode 100644 index 0000000..11efa08 --- /dev/null +++ b/frontend/src/components/ModalShell.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import clsx from 'clsx'; + +const FOCUSABLE = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +interface ModalShellProps { + children: ReactNode; + panelClassName?: string; + backdropStyle?: CSSProperties; + onClose?: () => void; + /** Separate absolute backdrop layer (conflict modal layout). */ + separateBackdrop?: boolean; + ariaLabelledBy?: string; +} + +export function ModalShell({ + children, + panelClassName, + backdropStyle = { background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)' }, + onClose, + separateBackdrop = false, + ariaLabelledBy, +}: ModalShellProps) { + const closeRef = useRef(onClose); + closeRef.current = onClose; + const panelRef = useRef(null); + const previousFocusRef = useRef(null); + + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = prev; }; + }, []); + + useEffect(() => { + previousFocusRef.current = document.activeElement as HTMLElement | null; + const panel = panelRef.current; + if (!panel) return; + + const focusables = () => + Array.from(panel.querySelectorAll(FOCUSABLE)).filter( + el => !el.hasAttribute('disabled') && el.offsetParent !== null, + ); + + const initial = focusables(); + (initial[0] ?? panel).focus(); + + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.stopPropagation(); + closeRef.current?.(); + return; + } + if (e.key !== 'Tab' || !panelRef.current) return; + + const nodes = focusables(); + if (nodes.length === 0) { + e.preventDefault(); + panelRef.current.focus(); + return; + } + const first = nodes[0]; + const last = nodes[nodes.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + previousFocusRef.current?.focus?.(); + }; + }, []); + + function handleBackdropClick() { + closeRef.current?.(); + } + + return createPortal( +
    + {separateBackdrop && ( +
    + )} +
    e.stopPropagation()} + > + {children} +
    +
    , + document.body, + ); +} diff --git a/frontend/src/components/StandardArtifactsGrid.tsx b/frontend/src/components/StandardArtifactsGrid.tsx new file mode 100644 index 0000000..257caf2 --- /dev/null +++ b/frontend/src/components/StandardArtifactsGrid.tsx @@ -0,0 +1,69 @@ +import { ArrowDownToLine, Cpu, FileText } from 'lucide-react'; +import clsx from 'clsx'; +import type { StandardArtifact } from '../types'; +import { FormSection } from './StepComponents'; + +const FIRMWARE_FORMATS = new Set(['bin', 'apj', 'elf', 'hex', '']); + +const FORMAT_BADGE: Record = { + bin: 'bg-orange-500/20 text-orange-300 border border-orange-500/30', + apj: 'bg-sky-500/20 text-sky-300 border border-sky-500/30', + elf: 'bg-violet-500/20 text-violet-300 border border-violet-500/30', + hex: 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30', + '': 'bg-orange-500/20 text-orange-300 border border-orange-500/30', +}; +const DEFAULT_BADGE = 'bg-gray-500/20 text-gray-400 border border-gray-500/30'; + +function getBaseName(name: string) { + const dot = name.lastIndexOf('.'); + return dot === -1 ? name : name.slice(0, dot); +} + +function ArtifactCard({ artifact }: { artifact: StandardArtifact }) { + const format = artifact.format.toLowerCase(); + const isFirmware = FIRMWARE_FORMATS.has(format); + const formatLabel = format.toUpperCase() || 'BIN'; + const badgeCls = FORMAT_BADGE[format] ?? DEFAULT_BADGE; + return ( + + + {formatLabel} + +
    + {isFirmware + ? + : } +
    + + {getBaseName(artifact.name)} + + +
    + ); +} + +export function StandardArtifactsGrid({ artifacts }: { artifacts: StandardArtifact[] }) { + const firmwareArtifacts = artifacts.filter(a => + FIRMWARE_FORMATS.has(a.format.toLowerCase()), + ); + const otherArtifacts = artifacts.filter(a => + !FIRMWARE_FORMATS.has(a.format.toLowerCase()), + ); + + return ( + +
    + {firmwareArtifacts.map(a => )} + {otherArtifacts.map(a => )} +
    +
    + ); +} diff --git a/frontend/src/components/StepComponents.tsx b/frontend/src/components/StepComponents.tsx new file mode 100644 index 0000000..8715283 --- /dev/null +++ b/frontend/src/components/StepComponents.tsx @@ -0,0 +1,419 @@ +import { type ReactNode, useState, useRef, useEffect } from 'react'; +import { Check, ChevronRight, ChevronDown, ChevronUp } from 'lucide-react'; +import clsx from 'clsx'; + +interface Step { + id: string; + label: string; +} + +interface StepHeaderProps { + steps: Step[]; + currentStep: string; + onStepClick?: (id: string) => void; +} + +export function StepHeader({ steps, currentStep, onStepClick }: StepHeaderProps) { + const currentIdx = steps.findIndex(s => s.id === currentStep); + // If currentStep is not in the list (post-flow), treat all as done + const effectiveIdx = currentIdx === -1 ? steps.length : currentIdx; + + return ( +
    + {steps.map((step, idx) => { + const done = idx < effectiveIdx; + const active = idx === effectiveIdx; + const clickable = done && !!onStepClick; + const className = clsx( + 'flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-mono font-medium transition-all duration-300 border', + done && 'bg-yellow-400 border-yellow-400 text-black', + active && 'bg-transparent border-yellow-400 text-yellow-400', + !done && !active && 'bg-transparent border-surface-4 text-gray-500', + clickable && 'cursor-pointer hover:bg-yellow-300 hover:border-yellow-300', + ); + const content = ( + <> + {done ? ( + + ) : ( + {idx + 1} + )} + {step.label} + + ); + return ( +
    + {clickable ? ( + + ) : ( +
    + {content} +
    + )} + {idx < steps.length - 1 && ( + + )} +
    + ); + })} +
    + ); +} + +interface Option { + id: string; + name: string; +} + +interface SearchableDropdownProps { + options: Option[]; + selected?: string; + onSelect: (id: string) => void; + placeholder?: string; + loading?: boolean; +} + +export function SearchableDropdown({ options, selected, onSelect, placeholder = 'Select…', loading }: SearchableDropdownProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const ref = useRef(null); + + const selectedOpt = options.find(o => o.id === selected); + const filtered = options.filter(o => o.name.toLowerCase().includes(query.toLowerCase())); + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + setQuery(''); + } + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, []); + + if (loading) return
    ; + + return ( +
    + + + {open && ( +
    +
    + setQuery(e.target.value)} + placeholder="Search…" + className="w-full bg-surface-3 border border-surface-4 rounded px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 outline-none focus:ring-1 focus:ring-yellow-400/50" + /> +
    +
    + {filtered.length === 0 ? ( +

    No results

    + ) : filtered.map(opt => ( + + ))} +
    +
    + )} +
    + ); +} + +const FILLER_MESSAGES: Record<'boards' | 'features', string> = { + boards: 'Loading available boards…', + features: 'Loading features…', +}; + +export function LoadingFiller({ kind, loading }: { kind: 'boards' | 'features'; loading: boolean }) { + if (!loading) return null; + + return ( +

    + {FILLER_MESSAGES[kind]} +

    + ); +} + +interface VersionSelectorOption { + id: string; + name: string; + type: 'stable' | 'beta' | 'latest' | 'tag'; + badge?: string; + badgeColor?: string; + remote?: string; +} + +interface VersionSelectorProps { + options: VersionSelectorOption[]; + selected?: string; + onSelect: (id: string) => void; + loading?: boolean; +} + +const QUICK_TYPES = [ + { type: 'stable' as const, label: 'Stable', color: 'badge-green' }, + { type: 'beta' as const, label: 'Beta', color: 'badge-yellow' }, + { type: 'latest' as const, label: 'Latest', color: 'badge-blue' }, +]; + +export function VersionSelector({ options, selected, onSelect, loading }: VersionSelectorProps) { + const selectedOpt = options.find(o => o.id === selected); + + // One representative per quick type (first match) + const quickOptions = QUICK_TYPES.map(qt => ({ + ...qt, + version: options.find(o => o.type === qt.type), + })).filter(qt => qt.version); + + // Non-quick = selected but not the exact pill representative for its type + const quickIds = new Set(quickOptions.map(qt => qt.version!.id)); + const isNonQuick = !!selectedOpt && !quickIds.has(selectedOpt.id); + + const [showMore, setShowMore] = useState(isNonQuick); + const [query, setQuery] = useState(''); + const selectedRowRef = useRef(null); + + // Scroll the pre-selected item into view when the dropdown opens on return + useEffect(() => { + if (showMore && isNonQuick && selectedRowRef.current) { + selectedRowRef.current.scrollIntoView({ block: 'nearest' }); + } + }, [showMore, isNonQuick]); + + const filtered = options.filter(o => o.name.toLowerCase().includes(query.toLowerCase())); + + function toggleMore() { + setShowMore(v => !v); + if (showMore) setQuery(''); + } + + function handleSelect(id: string) { + onSelect(id); + if (quickIds.has(id)) { + setShowMore(false); + setQuery(''); + } + } + + if (loading) return
    ; + + return ( +
    +
    + {quickOptions.map(({ type, label, color, version }) => ( + + ))} + + +
    + + {showMore && ( +
    +
    + setQuery(e.target.value)} + placeholder="Search versions…" + className="w-full bg-surface-3 border border-surface-4 rounded px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 outline-none focus:ring-1 focus:ring-yellow-400/50" + /> +
    +
    + {filtered.length === 0 ? ( +

    No results

    + ) : filtered.map(opt => ( + + ))} +
    +
    + )} +
    + ); +} + +const PROMOTED_VEHICLE_IDS = ['copter', 'plane', 'rover'] as const; + +const VEHICLE_ICONS: Record = { + copter: , + plane: , + rover: , + sub: , + tracker: , + heli: , + blimp: , + 'ap-periph': , +}; + +const DEFAULT_VEHICLE_ICON = ; + +interface VehicleSelectorProps { + options: { id: string; name: string }[]; + selected?: string; + onSelect: (id: string) => void; + loading?: boolean; +} + +export function VehicleSelector({ options, selected, onSelect, loading }: VehicleSelectorProps) { + if (loading) { + return ( +
    + {[...Array(8)].map((_, i) => ( +
    + ))} +
    + ); + } + + const promotedIds = new Set(PROMOTED_VEHICLE_IDS as readonly string[]); + const promoted = (PROMOTED_VEHICLE_IDS as readonly string[]) + .map(id => options.find(o => o.id === id)) + .filter(Boolean) as { id: string; name: string }[]; + const rest = options.filter(o => !promotedIds.has(o.id)); + const sorted = [...promoted, ...rest]; + + return ( +
    + {sorted.map(v => { + const isSelected = selected === v.id; + return ( + + ); + })} +
    + ); +} + +export function FormSection({ label, children }: { label: string; children: ReactNode }) { + return ( +
    +

    {label}

    + {children} +
    + ); +} + +export function ChosenPill({ label, value, onEdit }: { label: string; value: string; onEdit?: () => void }) { + return ( +
    + {label}: + {value} + {onEdit && ( + + )} +
    + ); +} diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..bdf8251 --- /dev/null +++ b/frontend/src/components/ThemeToggle.tsx @@ -0,0 +1,21 @@ +import { Sun, Moon } from 'lucide-react'; + +interface ThemeToggleProps { + isDark: boolean; + onToggle: () => void; +} + +export function ThemeToggle({ isDark, onToggle }: ThemeToggleProps) { + return ( + + ); +} diff --git a/frontend/src/components/Tooltip.tsx b/frontend/src/components/Tooltip.tsx new file mode 100644 index 0000000..ef4a76e --- /dev/null +++ b/frontend/src/components/Tooltip.tsx @@ -0,0 +1,74 @@ +import { useState, useRef, useEffect, useId, cloneElement, type ReactElement } from 'react'; +import { createPortal } from 'react-dom'; + +interface TooltipProps { + text: string; + children: ReactElement; +} + +export function Tooltip({ text, children }: TooltipProps) { + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + const wrapperRef = useRef(null); + const tooltipId = useId(); + + function updatePos() { + if (!wrapperRef.current) return; + const r = wrapperRef.current.getBoundingClientRect(); + const left = Math.min( + Math.max(r.left + r.width / 2, 48), + window.innerWidth - 48, + ); + setPos({ top: r.top - 8, left }); + } + + useEffect(() => { + if (!pos) return; + const onReposition = () => updatePos(); + window.addEventListener('scroll', onReposition, true); + window.addEventListener('resize', onReposition); + return () => { + window.removeEventListener('scroll', onReposition, true); + window.removeEventListener('resize', onReposition); + }; + }, [pos]); + + const child = cloneElement(children, { + 'aria-describedby': pos ? tooltipId : undefined, + } as Record); + + return ( + <> + setPos(null)} + onFocus={e => { + if ((e.target as HTMLElement).matches(':focus-visible')) updatePos(); + }} + onBlur={() => setPos(null)} + onPointerDown={() => setPos(null)} + onClick={() => setPos(null)} + > + {child} + + {pos && createPortal( +