From 6f5e267493c40bc04b371f39e7857a62837f88e1 Mon Sep 17 00:00:00 2001 From: Prabhaav Pillai Date: Wed, 2 Sep 2026 23:40:08 -0400 Subject: [PATCH] Mobile Friendly Galaxy --- scripts/host_tool_runner.sh | 41 + .../the_galaxy/assets/components/main.css | 13 + .../the_galaxy/assets/components/router.js | 5 + .../the_galaxy/assets/mobile/css/material.css | 1310 ++ .../the_galaxy/assets/mobile/index.html | 41 + .../system/the_galaxy/assets/mobile/js/api.js | 496 + .../system/the_galaxy/assets/mobile/js/app.js | 85 + .../assets/mobile/js/components/AppShell.js | 168 + .../mobile/js/components/BluetoothPanel.js | 134 + .../mobile/js/components/DevModeBanner.js | 25 + .../mobile/js/components/FavoritesEditor.js | 184 + .../mobile/js/components/GalaxyEmbed.js | 82 + .../mobile/js/components/GalaxyModal.js | 63 + .../mobile/js/components/GalaxySection.js | 25 + .../mobile/js/components/GalaxyToggleCard.js | 226 + .../mobile/js/components/ManeuverCard.js | 89 + .../mobile/js/components/ParamSections.js | 66 + .../mobile/js/components/SettingTree.js | 46 + .../mobile/js/components/WheelControls.js | 165 + .../assets/mobile/js/composables.js | 100 + .../the_galaxy/assets/mobile/js/params.js | 269 + .../the_galaxy/assets/mobile/js/store.js | 122 + .../the_galaxy/assets/mobile/js/views/Home.js | 11 + .../the_galaxy/assets/mobile/js/views/Logs.js | 246 + .../assets/mobile/js/views/Navigation.js | 89 + .../assets/mobile/js/views/Recordings.js | 305 + .../assets/mobile/js/views/Settings.js | 166 + .../assets/mobile/js/views/SystemTools.js | 261 + .../assets/mobile/js/views/ToolEmbed.js | 46 + .../assets/mobile/js/views/Tools.js | 39 + .../assets/mobile/js/views/Tuning.js | 15 + .../assets/mobile/js/views/Vehicle.js | 79 + .../the_galaxy/assets/mobile/manifest.json | 18 + .../assets/vendor/vue/vue.esm-browser.js | 16774 ++++++++++++++++ .../system/the_galaxy/templates/index.html | 9 + .../tests/test_device_settings_frontend.py | 53 + .../the_galaxy/tests/test_ui_vue_frontend.py | 437 + starpilot/system/the_galaxy/the_galaxy.py | 33 + starpilot/system/the_galaxy/utilities.py | 16 +- 39 files changed, 22344 insertions(+), 8 deletions(-) create mode 100644 starpilot/system/the_galaxy/assets/mobile/css/material.css create mode 100644 starpilot/system/the_galaxy/assets/mobile/index.html create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/api.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/app.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/components/BluetoothPanel.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/components/DevModeBanner.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/components/FavoritesEditor.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyEmbed.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyModal.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/components/GalaxySection.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyToggleCard.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/components/ManeuverCard.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/components/ParamSections.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/components/SettingTree.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/components/WheelControls.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/composables.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/params.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/store.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/views/Home.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/views/Logs.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/views/Navigation.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/views/Recordings.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/views/Settings.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/views/ToolEmbed.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/views/Tools.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/views/Tuning.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/views/Vehicle.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/manifest.json create mode 100644 starpilot/system/the_galaxy/assets/vendor/vue/vue.esm-browser.js create mode 100644 starpilot/system/the_galaxy/tests/test_device_settings_frontend.py create mode 100644 starpilot/system/the_galaxy/tests/test_ui_vue_frontend.py diff --git a/scripts/host_tool_runner.sh b/scripts/host_tool_runner.sh index 415c6a6169..10a00d1eb1 100755 --- a/scripts/host_tool_runner.sh +++ b/scripts/host_tool_runner.sh @@ -484,6 +484,47 @@ launch_c4() { run_in_worktree "${WORK_DIR}/scripts/launch_ui_c4_desktop.sh" "${jobs}" "$@" } +pick_free_galaxy_port() { + "${ROOT_DIR}/.venv/bin/python3" - <<'PY' +import socket + +# Desktop ZMQ hashes replay service names into ports 8023-65535. Keep Galaxy +# below that range so its HTTP server never steals a replay service port. +for port in range(4600, 8023): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + try: + sock.bind(("0.0.0.0", port)) + except OSError: + continue + print(port) + raise SystemExit(0) + +raise SystemExit("Unable to find a free local Galaxy port.") +PY +} + +launch_galaxy() { + sync_worktree + ensure_host_python_extensions + + local port + port="$(pick_free_galaxy_port)" + local galaxy_dir="${HOME}/.comma/starpilot/data/galaxy" + + echo "Starting local Galaxy session on port ${port}..." + ( + cd "${WORK_DIR}" + setup_build_env + export_workdir_pythonpath + export SP_GALAXY_DIR="${galaxy_dir}" + export SP_GALAXY_HOST="0.0.0.0" + export SP_GALAXY_PORT="${port}" + export SP_GALAXY_DEBUG="${SP_GALAXY_DEBUG:-1}" + export SP_GALAXY_RELOAD="${SP_GALAXY_RELOAD:-0}" + exec "${WORK_DIR}/.venv/bin/python3" -m openpilot.starpilot.system.the_galaxy.the_galaxy + ) +} + launch_onroad() { local jobs jobs="$(default_jobs)" diff --git a/starpilot/system/the_galaxy/assets/components/main.css b/starpilot/system/the_galaxy/assets/components/main.css index 7a360fa493..d93a2a886c 100644 --- a/starpilot/system/the_galaxy/assets/components/main.css +++ b/starpilot/system/the_galaxy/assets/components/main.css @@ -193,6 +193,19 @@ body { padding-left: var(--padding-lg); } +.embedded #sidebar, +.embedded #sidebar_shell, +.embedded #sidebarUnderlay { + display: none !important; +} +.embedded #menu_button { + display: none !important; +} +.embedded .content { + margin-left: 0 !important; + padding-left: var(--padding-lg); +} + /* ――― Headings ――― */ h1, h2, diff --git a/starpilot/system/the_galaxy/assets/components/router.js b/starpilot/system/the_galaxy/assets/components/router.js index bae0db76bf..8e92e4a643 100644 --- a/starpilot/system/the_galaxy/assets/components/router.js +++ b/starpilot/system/the_galaxy/assets/components/router.js @@ -4,8 +4,10 @@ import { hideSidebar } from "/assets/js/utils.js" import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-c4-hint-1" import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-live-15" import { WheelControls } from "/assets/components/tools/wheel_controls.js?v=controllers-2" +import { DoorControl } from "/assets/components/tools/doors.js" import { ErrorLogs } from "/assets/components/tools/error_logs.js" import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js" +import { TSKManager } from "/assets/components/tools/tsk_manager.js" import { GalaxyPairing } from "/assets/components/tools/galaxy.js" import { Home } from "/assets/components/home/home.js" import { LongitudinalManeuvers } from "/assets/components/tools/longitudinal_maneuvers.js" @@ -70,12 +72,15 @@ function Root() { let routes = [ createRoute("bluetooth", "/bluetooth", Bluetooth), createRoute("wheel_controls", "/wheel-controls", WheelControls), + createRoute("doors", "/manage_doors", DoorControl), + createRoute("tsk", "/manage_tsk", TSKManager), createRoute("device_settings", "/device_settings/:section?", DeviceSettings), createRoute("errorLogs", "/manage_error_logs", ErrorLogs), createRoute("galaxy", "/galaxy", GalaxyPairing), createRoute("navdestination", "/set_navigation_destination", NavDestination), createRoute("navkeys", "/manage_navigation_keys", NavKeys), createRoute("root", "/", Home), + createRoute("classicRoot", "/classic", Home), createRoute("routes", "/dashcam_routes", RouteRecordings), createRoute("screen_recordings", "/screen_recordings", ScreenRecordings), createRoute("sentry", "/sentry", SentryMode), diff --git a/starpilot/system/the_galaxy/assets/mobile/css/material.css b/starpilot/system/the_galaxy/assets/mobile/css/material.css new file mode 100644 index 0000000000..0a1c0f8341 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/css/material.css @@ -0,0 +1,1310 @@ +:root { + color-scheme: dark; + --primary: #9d72ff; + --primary-hover: #b38fff; + --primary-glow: rgba(157, 114, 255, 0.35); + --on-primary: #ffffff; + --primary-container: rgba(157, 114, 255, 0.18); + --on-primary-container: #ede4ff; + + --secondary: #4de8e8; + --secondary-glow: rgba(77, 232, 232, 0.3); + --on-secondary: #002b2b; + + --accent-rose: #ff60a8; + --accent-amber: #ffb865; + --error: #ff5277; + --on-error: #ffffff; + --warning: #ffb865; + --success: #4de8e8; + + --background: #070712; + --surface: rgba(18, 15, 32, 0.65); + --surface-container: rgba(26, 21, 46, 0.55); + --surface-container-high: rgba(36, 30, 62, 0.7); + --surface-container-low: rgba(14, 11, 26, 0.45); + + --on-surface: #f3f2f8; + --on-surface-variant: #9d9bb8; + --text-muted: #8b88a8; + --text-color: #f3f2f8; + + --glass-bg: rgba(22, 18, 38, 0.7); + --glass-bg-hover: rgba(35, 29, 60, 0.8); + --glass-active-bg: rgba(157, 114, 255, 0.22); + --glass-border: rgba(255, 255, 255, 0.12); + --glass-border-light: rgba(255, 255, 255, 0.2); + --glass-shadow: 0 8px 24px 0 rgba(0, 0, 0, 0.35); + --glass-blur: blur(4px); + --glass-text: #f3f2f8; + --glass-accent: #9d72ff; + + --outline: rgba(255, 255, 255, 0.08); + --outline-variant: rgba(255, 255, 255, 0.05); + + --radius-xs: 8px; + --radius-sm: 14px; + --radius-md: 18px; + --radius-lg: 24px; + --radius-xl: 32px; + --radius-full: 9999px; + + --elev-1: 0 4px 12px rgba(0, 0, 0, 0.2); + --elev-2: 0 6px 18px rgba(0, 0, 0, 0.28); + --elev-3: 0 12px 32px rgba(0, 0, 0, 0.4); + + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; + --sp-5: 24px; --sp-6: 32px; --sp-7: 40px; --sp-8: 48px; + + --font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --fs-xs: 0.75rem; --fs-sm: 0.875rem; --fs-base: 1rem; + --fs-lg: 1.2rem; --fs-xl: 1.45rem; --fs-xxl: 1.85rem; + --fw-normal: 400; --fw-medium: 500; --fw-bold: 600; + + --motion-fast: 150ms cubic-bezier(0.2, 0.9, 0.3, 1); + --motion-base: 240ms cubic-bezier(0.2, 0.9, 0.3, 1); + --motion-slow: 350ms cubic-bezier(0.16, 1, 0.3, 1); + + --appbar-height: 64px; + --bottomnav-height: 72px; + --touch-target: 44px; + --z-appbar: 100; + --z-drawer: 200; + --z-modal: 1200; + --z-snackbar: 1300; + + --scrollbar-track: rgba(255, 255, 255, 0.03); + --scrollbar-thumb: rgba(157, 114, 255, 0.4); + --scrollbar-thumb-hover: rgba(157, 114, 255, 0.7); + --scrollbar-border: rgba(157, 114, 255, 0.3); + + --gx-para-x: 0px; +} + +[data-theme="light"] { + color-scheme: light; + --primary: #7849e8; + --primary-hover: #6737d9; + --primary-glow: rgba(120, 73, 232, 0.25); + --on-primary: #ffffff; + --primary-container: rgba(120, 73, 232, 0.12); + --on-primary-container: #471fa8; + + --secondary: #0c9b9b; + --secondary-glow: rgba(12, 155, 155, 0.25); + --on-secondary: #ffffff; + --accent-rose: #e04b8f; + --accent-amber: #c9821f; + --error: #d63a62; + --on-error: #ffffff; + --warning: #c9821f; + --success: #0c9b9b; + + --background: #f2f4fc; + --surface: rgba(255, 255, 255, 0.85); + --surface-container: rgba(255, 255, 255, 0.75); + --surface-container-high: rgba(255, 255, 255, 0.95); + --surface-container-low: rgba(240, 243, 252, 0.75); + + --on-surface: #171526; + --on-surface-variant: #5f5b7d; + --text-muted: #6f6a8f; + --text-color: #171526; + + --glass-bg: rgba(255, 255, 255, 0.82); + --glass-bg-hover: rgba(255, 255, 255, 0.95); + --glass-active-bg: rgba(120, 73, 232, 0.15); + --glass-border: rgba(120, 73, 232, 0.28); + --glass-border-light: rgba(120, 73, 232, 0.4); + --glass-shadow: 0 8px 24px 0 rgba(80, 60, 140, 0.08); + --glass-text: #171526; + --glass-accent: #7849e8; + + --outline: rgba(120, 73, 232, 0.14); + --outline-variant: rgba(120, 73, 232, 0.08); + + --scrollbar-track: rgba(0, 0, 0, 0.05); + --scrollbar-thumb: #7849e8; + --scrollbar-thumb-hover: #5d31ca; + --scrollbar-border: #471fa8; +} + +* { + box-sizing: border-box; + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} + +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +*::-webkit-scrollbar-track { + background: var(--scrollbar-track); + border-radius: var(--radius-full); +} + +*::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border: 1px solid var(--scrollbar-border); + border-radius: var(--radius-full); +} + +*::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} + +html, body { + background: var(--background); + color: var(--on-surface); + font-family: var(--font-body); + margin: 0; + min-height: 100dvh; + padding: 0; + -webkit-tap-highlight-color: transparent; + text-rendering: optimizeSpeed; +} + +html { cursor: default; } + +body { + overscroll-behavior-y: contain; + padding-bottom: env(safe-area-inset-bottom); +} + +#galaxy-bg { + position: fixed; + inset: 0; + overflow: hidden; + z-index: 0; + background-color: var(--background); + background-image: + radial-gradient(ellipse at 15% 20%, rgba(157, 114, 255, 0.22) 0%, transparent 55%), + radial-gradient(ellipse at 85% 75%, rgba(85, 45, 180, 0.18) 0%, transparent 60%), + radial-gradient(circle at 75% 15%, rgba(77, 232, 232, 0.12) 0%, transparent 48%); + pointer-events: none; + transform: translateZ(0); + will-change: transform; +} + +#galaxy-bg::before { + content: ""; + position: absolute; + top: 0; left: 0; width: 100%; height: 200%; + background-image: + radial-gradient(1px 1px at 30px 40px, #ffffff, rgba(255,255,255,0)), + radial-gradient(1px 1px at 110px 220px, rgba(220, 200, 255, 0.8), rgba(255,255,255,0)), + radial-gradient(1.5px 1.5px at 200px 90px, #ffffff, rgba(255,255,255,0)), + radial-gradient(1.5px 1.5px at 310px 340px, rgba(160, 240, 255, 0.9), rgba(255,255,255,0)), + radial-gradient(1px 1px at 410px 150px, #ffffff, rgba(255,255,255,0)), + radial-gradient(1.5px 1.5px at 640px 110px, #ffffff, rgba(255,255,255,0)), + radial-gradient(1px 1px at 890px 180px, #ffffff, rgba(255,255,255,0)); + background-size: 800px 400px; + opacity: 0.5; + will-change: transform; + transform: translate3d(var(--gx-para-x), 0, 0); + animation: galaxy-drift-smooth 160s linear infinite; +} + +#galaxy-bg::after { + content: ""; + position: absolute; + top: 0; left: 0; width: 100%; height: 200%; + background-image: + radial-gradient(1.5px 1.5px at 80px 140px, #ffffff, rgba(255,255,255,0)), + radial-gradient(2px 2px at 240px 390px, rgba(190, 235, 255, 0.9), rgba(255,255,255,0)), + radial-gradient(1.5px 1.5px at 380px 60px, rgba(230, 195, 255, 0.85), rgba(255,255,255,0)), + radial-gradient(2px 2px at 560px 480px, #ffffff, rgba(255,255,255,0)), + radial-gradient(1.5px 1.5px at 730px 220px, rgba(255, 255, 255, 0.9), rgba(255,255,255,0)); + background-size: 900px 450px; + opacity: 0.6; + will-change: transform; + transform: translate3d(var(--gx-para-x), 0, 0); + animation: galaxy-drift-smooth 100s linear infinite; +} + +#galaxy-bg .galaxy-hero { + position: absolute; + width: 3px; + height: 3px; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 0 6px rgba(255, 255, 255, 0.9); + animation: galaxy-twinkle 6s ease-in-out infinite alternate; +} + +@keyframes galaxy-drift-smooth { + from { transform: translate3d(0, 0, 0); } + to { transform: translate3d(0, -400px, 0); } +} + +@keyframes galaxy-twinkle { + 0%, 100% { opacity: 0.3; transform: scale(0.9); } + 50% { opacity: 1; transform: scale(1.2); } +} + +@media (prefers-reduced-motion: reduce) { + #galaxy-bg, #galaxy-bg::before, #galaxy-bg::after, #galaxy-bg .galaxy-hero { + animation: none !important; + } +} + +[data-theme="light"] #galaxy-bg { + background-color: #eef1fb; + background-image: + radial-gradient(ellipse at 20% 20%, rgba(180, 145, 255, 0.28) 0%, transparent 55%), + radial-gradient(ellipse at 80% 80%, rgba(120, 220, 235, 0.22) 0%, transparent 60%), + radial-gradient(circle at 70% 15%, rgba(255, 170, 210, 0.18) 0%, transparent 48%); +} + +[data-theme="light"] #galaxy-bg::before, +[data-theme="light"] #galaxy-bg::after, +[data-theme="light"] #galaxy-bg .galaxy-hero { + opacity: 0.12; +} + +[v-cloak] { display: none !important; } +a { color: inherit; text-decoration: none; } +button { font-family: var(--font-body); } +ul { list-style: none; margin: 0; padding: 0; } + +:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 3px; +} + +.ripple-ripple { + animation: ripple-fade var(--motion-base); + background: radial-gradient(circle, rgba(255, 255, 255, 0.4) 10%, transparent 10%); + border-radius: 50%; + opacity: 0; + position: absolute; + transform: scale(10); + pointer-events: none; +} + +@keyframes ripple-fade { + from { opacity: 0.4; transform: scale(0); } + to { opacity: 0; transform: scale(10); } +} + +.gx-app { + display: flex; + flex-direction: column; + min-height: 100dvh; + position: relative; + z-index: 1; +} + +.gx-appbar { + align-items: center; + display: flex; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + padding-top: calc(env(safe-area-inset-top) + var(--sp-2)); + position: sticky; + top: 0; + z-index: var(--z-appbar); +} + +.gx-appbar__pill { + align-items: center; + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: var(--radius-full); + box-shadow: var(--glass-shadow); + color: var(--glass-text); + display: flex; + flex: 1; + gap: var(--sp-2); + min-width: 0; + padding: 6px 12px; + transition: background-color var(--motion-fast), border-color var(--motion-fast); +} + +.gx-appbar__back { + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: 50%; + box-shadow: var(--glass-shadow); + color: var(--glass-text); + flex: none; + height: var(--touch-target); + width: var(--touch-target); + transition: transform var(--motion-fast), background-color var(--motion-fast); +} + +.gx-appbar__back:active { transform: scale(0.92); } + +.gx-appbar__title { + font-size: var(--fs-lg); + font-weight: var(--fw-bold); + white-space: nowrap; + letter-spacing: -0.02em; +} + +.gx-appbar__title .gx-logo { + height: 28px; + margin-right: var(--sp-2); + vertical-align: middle; +} + +.gx-appbar__right { + align-items: center; + display: flex; + gap: var(--sp-2); + margin-left: auto; +} + +.gx-search { + background: var(--surface-container); + border: 1px solid var(--glass-border); + border-radius: var(--radius-full); + color: var(--on-surface); + font-size: var(--fs-sm); + height: 40px; + max-width: 420px; + outline: none; + padding: 0 var(--sp-4); + transition: border-color var(--motion-fast), box-shadow var(--motion-fast); +} + +.gx-search:focus { + background: var(--surface-container-high); + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--primary-glow); +} + +[data-theme="light"] .gx-search { + border-color: rgba(120, 73, 232, 0.3); +} + +.gx-icon-btn { + align-items: center; + background: transparent; + border: none; + border-radius: var(--radius-full); + color: inherit; + cursor: pointer; + display: inline-flex; + height: var(--touch-target); + justify-content: center; + min-height: var(--touch-target); + min-width: var(--touch-target); + position: relative; + transition: background-color var(--motion-fast), transform var(--motion-fast); + width: var(--touch-target); +} + +.gx-icon-btn:hover { background: var(--glass-bg-hover); } +.gx-icon-btn:active { transform: scale(0.9); } +.gx-icon-btn i { font-size: 1.3rem; } +.gx-back-btn { display: none; } +.gx-menu-btn { display: inline-flex; } + +.gx-theme-toggle { + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: 50%; + box-shadow: var(--glass-shadow); + color: var(--glass-text); + flex: none; + height: var(--touch-target); + width: var(--touch-target); + transition: transform var(--motion-fast), background-color var(--motion-fast), border-color var(--motion-fast); +} + +.gx-theme-toggle:active { transform: scale(0.92); } + +[data-theme="light"] .gx-theme-toggle { + border: 1.5px solid var(--primary); + box-shadow: 0 2px 8px rgba(120, 73, 232, 0.2); +} + +.gx-searchwrap { + align-items: center; + display: flex; + flex: 1; + min-width: 0; + position: relative; +} + +.gx-searchwrap .gx-search { + padding-right: var(--sp-5); + width: 100%; +} + +.gx-search-clear { + align-items: center; + background: transparent; + border: none; + border-radius: 50%; + color: var(--glass-text); + cursor: pointer; + display: inline-flex; + height: 28px; + justify-content: center; + position: absolute; + right: 4px; + transition: background-color var(--motion-fast); + width: 28px; +} + +.gx-search-clear:hover { background: var(--glass-active-bg); } +.gx-search-clear i { font-size: 1rem; } + +.gx-status-pill { + align-items: center; + background: var(--surface-container); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: var(--radius-full); + display: inline-flex; + font-size: var(--fs-xs); + font-weight: var(--fw-medium); + gap: var(--sp-2); + padding: 6px 12px; +} + +[data-theme="light"] .gx-status-pill { + border: 1px solid rgba(120, 73, 232, 0.3); +} + +.gx-status-dot { + border-radius: 50%; + height: 8px; + width: 8px; +} + +.gx-status-dot.online { + background: var(--success); + box-shadow: 0 0 8px var(--success); +} + +.gx-status-dot.offline { + background: var(--error); + box-shadow: 0 0 8px var(--error); +} + +.gx-content { + contain: layout style; + display: flex; + flex: 1; + flex-direction: column; + margin: 0 auto; + max-width: none; + padding: var(--sp-4) var(--sp-4) calc(var(--bottomnav-height) + var(--sp-5)); + width: 100%; +} + +.liquid-glass-nav { + position: fixed; + bottom: calc(18px + env(safe-area-inset-bottom, 0px)); + left: 50%; + transform: translateX(-50%); + z-index: 1000; + + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + + border-radius: var(--radius-full); + border: 1px solid var(--glass-border); + box-shadow: var(--glass-shadow); + + transition: transform var(--motion-base), background-color var(--motion-fast); + max-width: calc(100vw - 32px); +} + +[data-theme="light"] .liquid-glass-nav { + border: 1.5px solid var(--primary); +} + +.liquid-glass-nav .nav-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 8px 16px; + border-radius: var(--radius-full); + color: var(--on-surface-variant); + text-decoration: none; + font-size: 0.75rem; + font-weight: var(--fw-medium); + transition: color var(--motion-fast), background-color var(--motion-fast); + background: transparent; + border: none; + cursor: pointer; +} + +.liquid-glass-nav .nav-item i { + font-size: 1.3rem; + margin-bottom: 2px; + transition: transform var(--motion-fast); +} + +.liquid-glass-nav .nav-item:hover { + color: var(--on-surface); + background: rgba(255, 255, 255, 0.08); +} + +.liquid-glass-nav .nav-item:active i { + transform: scale(0.88); +} + +.liquid-glass-nav .nav-item.active { + background: var(--glass-active-bg); + color: var(--primary); + font-weight: var(--fw-bold); +} + +.liquid-glass-nav .nav-item.active i { + transform: translateY(-2px); +} + +.gx-card { + contain: layout style; + background: var(--surface-container); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + box-shadow: var(--elev-1); + margin-bottom: var(--sp-4); + overflow: hidden; + transition: transform var(--motion-fast), box-shadow var(--motion-fast); +} + +[data-theme="light"] .gx-card { + border: 1px solid rgba(120, 73, 232, 0.22); +} + +.gx-section__header { + align-items: center; + cursor: pointer; + display: flex; + gap: var(--sp-3); + min-height: 56px; + padding: var(--sp-3) var(--sp-4); + background: rgba(255, 255, 255, 0.02); +} + +.gx-section__header i { + color: var(--primary); + font-size: 1.35rem; +} + +.gx-section__title { + flex: 1; + font-weight: var(--fw-bold); + letter-spacing: -0.01em; +} + +.gx-section__count { + color: var(--text-muted); + font-size: var(--fs-xs); + background: rgba(255, 255, 255, 0.08); + padding: 2px 8px; + border-radius: var(--radius-full); +} + +[data-theme="light"] .gx-section__count { + background: rgba(120, 73, 232, 0.12); + color: var(--primary); + font-weight: var(--fw-bold); +} + +.gx-chevron { + color: var(--text-muted); + transition: transform var(--motion-base); +} + +.gx-chevron.open { transform: rotate(180deg); } + +.gx-row { + align-items: center; + border-top: 1px solid var(--outline); + display: flex; + gap: var(--sp-3); + min-height: 64px; + padding: var(--sp-3) var(--sp-4); + transition: background-color var(--motion-fast); +} + +.gx-row:hover { + background-color: rgba(255, 255, 255, 0.03); +} + +.gx-row__info { flex: 1; min-width: 0; } + +.gx-row__label { + display: block; + font-weight: var(--fw-medium); + font-size: var(--fs-base); +} + +.gx-row__desc { + color: var(--text-muted); + font-size: var(--fs-sm); + margin-top: 2px; + line-height: 1.4; +} + +.gx-row__value { + color: var(--primary); + font-weight: var(--fw-bold); + white-space: nowrap; +} + +.gx-row.disabled .gx-row__label, .gx-row.disabled .gx-row__desc { opacity: 0.45; } +.gx-row--favorites { align-items: stretch; flex-direction: column; } +.gx-row--favorites .gx-row__info { flex: none; } +.gx-row--stack { align-items: stretch; flex-direction: column; } +.gx-row--stack .gx-row__info { flex: none; } +.gx-row--stack .gx-field, +.gx-row--stack .gx-slider-row { width: 100%; } + +.gx-switch { + display: inline-flex; + position: relative; + cursor: pointer; +} + +.gx-switch input { height: 1px; opacity: 0; position: absolute; width: 1px; } + +.gx-switch__track { + background: rgba(255, 255, 255, 0.12); + border: 1px solid var(--glass-border); + border-radius: var(--radius-full); + height: 30px; + width: 52px; + transition: background-color var(--motion-base), border-color var(--motion-base); +} + +[data-theme="light"] .gx-switch__track { + background: rgba(0, 0, 0, 0.08); + border: 1.5px solid var(--primary); +} + +.gx-switch__thumb { + background: #ffffff; + border-radius: 50%; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.35); + height: 22px; + left: 4px; + position: absolute; + top: 4px; + transition: transform var(--motion-base), width var(--motion-fast); + width: 22px; +} + +[data-theme="light"] .gx-switch__thumb { + border: 1px solid rgba(120, 73, 232, 0.3); +} + +.gx-switch:active .gx-switch__thumb { + width: 26px; +} + +.gx-switch input:checked + .gx-switch__track { + background: var(--primary); + border-color: var(--primary); +} + +.gx-switch input:checked ~ .gx-switch__thumb { + transform: translateX(22px); +} + +.gx-switch:active input:checked ~ .gx-switch__thumb { + transform: translateX(18px); +} + +.gx-switch input:disabled + .gx-switch__track { opacity: 0.4; } + +.gx-slider-row { + align-items: stretch; + display: flex; + flex-direction: column; + gap: var(--sp-2); + width: 100%; +} + +.gx-slider-row .gx-row__value { text-align: left; min-width: 0; } +.gx-slider-row .gx-slider-reset { align-self: flex-end; } + +input[type="range"].gx-slider { + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.12); + border-radius: var(--radius-full); + cursor: pointer; + flex: 1; + height: 8px; + outline: none; + width: 100%; +} + +[data-theme="light"] input[type="range"].gx-slider { + background: rgba(120, 73, 232, 0.18); + border: 1px solid rgba(120, 73, 232, 0.3); +} + +input[type="range"].gx-slider::-webkit-slider-thumb { + -webkit-appearance: none; + background: #ffffff; + border: 2px solid var(--primary); + border-radius: 50%; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); + height: 22px; + width: 22px; + transition: transform var(--motion-fast); +} + +input[type="range"].gx-slider:active::-webkit-slider-thumb { + transform: scale(1.15); +} + +.gx-slider-reset { + background: var(--surface-container-high); + border: 1px solid var(--glass-border); + border-radius: var(--radius-full); + color: var(--on-surface); + cursor: pointer; + font-size: var(--fs-xs); + font-weight: var(--fw-medium); + min-height: 32px; + padding: 0 var(--sp-3); + transition: background-color var(--motion-fast); +} + +.gx-slider-reset:hover { background: var(--glass-bg-hover); } + +[data-theme="light"] .gx-slider-reset { + border: 1.5px solid var(--primary); +} + +/* Fields are fluid and shrinkable: never let a select/textarea blow past its + container (intrinsic width from options/content) or refuse to shrink inside + a flex row. Contexts that want a full-width field (e.g. stacked toggle rows, + .gx-field--full) still stretch explicitly. */ +.gx-field { + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + box-sizing: border-box; + color: var(--on-surface); + font-size: var(--fs-base); + flex-shrink: 1; + max-width: 100%; + min-height: 46px; + min-width: 0; + outline: none; + padding: 0 var(--sp-3); + transition: border-color var(--motion-fast), box-shadow var(--motion-fast); +} + +.gx-field--full { + display: block; + width: 100%; +} + +.gx-field:focus { + background: rgba(255, 255, 255, 0.1); + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--primary-glow); +} + +[data-theme="light"] .gx-field { + border: 1px solid rgba(120, 73, 232, 0.3); + background: rgba(255, 255, 255, 0.9); +} + +select.gx-field { background: var(--surface-container-high); } +textarea.gx-field { padding: var(--sp-3); border-radius: var(--radius-md); } + +input[type="color"].gx-color { + border-radius: var(--radius-full); + border: 1px solid var(--glass-border); + height: 40px; + padding: 2px; + width: 40px; +} + +[data-theme="light"] input[type="color"].gx-color { + border: 1.5px solid var(--primary); +} + +.gx-btn { + align-items: center; + background: var(--primary); + border: none; + border-radius: var(--radius-full); + box-shadow: 0 4px 12px var(--primary-glow); + color: var(--on-primary); + cursor: pointer; + display: inline-flex; + font-weight: var(--fw-bold); + gap: var(--sp-2); + justify-content: center; + min-height: 44px; + padding: 0 var(--sp-5); + position: relative; + transition: transform var(--motion-fast), background-color var(--motion-fast); +} + +.gx-btn:hover { background: var(--primary-hover); } +.gx-btn:active { transform: scale(0.96); } +.gx-btn:disabled { opacity: 0.4; box-shadow: none; cursor: not-allowed; } + +.gx-btn--text { + background: transparent; + box-shadow: none; + color: var(--primary); +} + +.gx-btn--text:hover { background: var(--glass-active-bg); box-shadow: none; } + +.gx-btn--outlined { + background: transparent; + border: 1px solid var(--glass-border); + box-shadow: none; + color: var(--on-surface); +} + +[data-theme="light"] .gx-btn--outlined { + border: 1.5px solid var(--primary); +} + +.gx-btn--outlined:hover { background: var(--glass-bg-hover); } + +.gx-btn--tonal { + background: var(--primary-container); + box-shadow: none; + color: var(--primary); +} + +.gx-btn--tonal:hover { background: rgba(157, 114, 255, 0.28); } +.gx-btn--block { width: 100%; } + +.gx-grid { + display: grid; + gap: var(--sp-3); + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); +} + +.gx-tile { + align-items: center; + background: var(--surface-container); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + color: var(--on-surface); + cursor: pointer; + display: flex; + flex-direction: column; + gap: var(--sp-2); + min-height: 100px; + padding: var(--sp-4); + text-align: center; + transition: transform var(--motion-fast), background-color var(--motion-fast); +} + +[data-theme="light"] .gx-tile { + border: 1px solid rgba(120, 73, 232, 0.25); +} + +.gx-tile:hover { + background: var(--glass-bg-hover); + transform: translateY(-2px); +} + +.gx-tile:active { transform: scale(0.96); } +.gx-tile i { color: var(--primary); font-size: 1.75rem; } +.gx-tile span { font-size: var(--fs-sm); font-weight: var(--fw-medium); } + +.gx-chip { + border-radius: var(--radius-full); + font-size: var(--fs-xs); + font-weight: var(--fw-bold); + padding: 3px 10px; +} + +.gx-chip--advanced { + background: rgba(255, 184, 101, 0.18); + border: 1px solid var(--warning); + color: var(--warning); +} + +.gx-chip--dev { + background: var(--primary-container); + border: 1px solid var(--primary); + color: var(--on-primary-container); +} + +.gx-chip--lock { + background: rgba(255, 82, 119, 0.18); + border: 1px solid var(--error); + color: var(--error); +} + +.gx-alert { + align-items: center; + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-left: 4px solid var(--warning); + border-radius: var(--radius-md); + display: flex; + flex-wrap: wrap; + gap: var(--sp-3); + margin-bottom: var(--sp-4); + padding: var(--sp-3) var(--sp-4); +} + +.gx-alert--info { + background: rgba(157, 114, 255, 0.12); + border-left-color: var(--primary); +} + +.gx-alert--warn { + background: rgba(255, 184, 101, 0.12); + border-left-color: var(--warning); +} + +.gx-alert__icon { font-size: 1.4rem; color: var(--primary); } +.gx-alert__body { flex: 1 1 220px; } +.gx-alert__body strong { display: block; font-weight: var(--fw-bold); } +.gx-alert__body span { color: var(--text-muted); font-size: var(--fs-sm); } + +.gx-underlay { + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + inset: 0; + position: fixed; + z-index: var(--z-drawer); +} + +.gx-drawer { + background: var(--surface-container-high); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border-right: 1px solid var(--glass-border); + box-shadow: var(--elev-3); + height: 100dvh; + left: 0; + max-width: 320px; + overflow-y: auto; + padding: env(safe-area-inset-top) var(--sp-3) var(--sp-5); + position: fixed; + top: 0; + transform: translateX(-100%); + transition: transform var(--motion-slow); + width: 84vw; + z-index: calc(var(--z-drawer) + 1); +} + +[data-theme="light"] .gx-drawer { + border-right: 1.5px solid var(--primary); +} + +.gx-drawer.open { transform: translateX(0); } + +.gx-drawer__header { + align-items: center; + display: flex; + gap: var(--sp-3); + margin-bottom: var(--sp-3); + padding: var(--sp-3) var(--sp-2); +} + +.gx-drawer__header img { height: 44px; width: 44px; } + +.gx-drawer__header .gx-drawer-title { + font-size: var(--fs-lg); + font-weight: var(--fw-bold); + letter-spacing: -0.02em; +} + +.gx-nav-section { margin-bottom: var(--sp-3); } + +.gx-nav-section__title { + color: var(--text-muted); + font-size: var(--fs-xs); + font-weight: var(--fw-bold); + letter-spacing: 0.08em; + padding: var(--sp-2); + text-transform: uppercase; +} + +.gx-nav-item { + align-items: center; + border-radius: var(--radius-full); + color: var(--on-surface); + cursor: pointer; + display: flex; + font-weight: var(--fw-medium); + gap: var(--sp-3); + min-height: 46px; + padding: 0 var(--sp-4); + position: relative; + transition: background-color var(--motion-fast); +} + +.gx-nav-item:hover { background: rgba(255, 255, 255, 0.08); } + +.gx-nav-item.active { + background: var(--glass-active-bg); + color: var(--primary); + font-weight: var(--fw-bold); +} + +[data-theme="light"] .gx-nav-item.active { + border: 1px solid rgba(120, 73, 232, 0.35); +} + +.gx-nav-item i { font-size: 1.3rem; width: 24px; } + +.gx-scrim { + align-items: center; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + display: flex; + inset: 0; + justify-content: center; + padding: var(--sp-4); + padding-top: max(var(--sp-4), calc(var(--appbar-height) + var(--sp-3))); + padding-bottom: max(var(--sp-4), calc(var(--bottomnav-height) + var(--sp-3))); + position: fixed; + z-index: var(--z-modal); +} + +.gx-dialog, .gx-sheet { + background: var(--surface-container-high); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: var(--radius-xl); + box-shadow: var(--elev-3); + box-sizing: border-box; + max-height: min(720px, calc(100dvh - var(--appbar-height) - var(--bottomnav-height))); + max-width: 560px; + overflow: auto; + overscroll-behavior: contain; + padding: var(--sp-5); + width: 100%; +} + +[data-theme="light"] .gx-dialog, [data-theme="light"] .gx-sheet { + border: 1.5px solid var(--primary); +} + +.gx-sheet { + position: relative; +} + +.gx-dialog__title, .gx-sheet__title { + font-size: var(--fs-lg); + font-weight: var(--fw-bold); + margin: 0 0 var(--sp-3); +} + +.gx-dialog__actions { + display: flex; + gap: var(--sp-2); + justify-content: flex-end; + margin-top: var(--sp-5); +} + +.gx-tree-node { position: relative; } + +.gx-tree-node--child { + border-left: 2px solid var(--glass-border); + margin-left: calc(var(--gx-depth, 1) * var(--sp-2)); + padding-left: var(--sp-2); +} + +.gx-tree-node--child .gx-row { border-top-style: dashed; } + +.gx-tree-children { + background: var(--surface-container-low); + overflow: hidden; +} + +.gx-manage-btn { + align-items: center; + background: var(--surface-container); + border: 1px solid var(--glass-border); + border-radius: var(--radius-full); + color: var(--on-surface); + cursor: pointer; + display: inline-flex; + font-size: var(--fs-sm); + gap: var(--sp-1); + margin: var(--sp-1) var(--sp-4) var(--sp-3); + min-height: 34px; + padding: 0 var(--sp-3); + transition: background-color var(--motion-fast); +} + +.gx-manage-btn:hover { background: var(--glass-bg-hover); } +.gx-manage-btn i { font-size: 0.8rem; } + +[data-theme="light"] .gx-manage-btn { + border: 1px solid var(--primary); +} + +.gx-terminal { + background: rgba(8, 6, 16, 0.85); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + color: #c9c6ec; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + line-height: 1.5; + margin: var(--sp-3); + max-height: 60vh; + min-height: 160px; + overflow: auto; + padding: var(--sp-3); + white-space: pre-wrap; +} + +.gx-video { + background: #000; + border-radius: var(--radius-md); + display: block; + margin: 0 auto; + max-height: 48dvh; + width: 100%; +} + +.gx-view, .gx-embed { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + width: 100%; +} + +.gx-embed__frame { + background: var(--surface); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + display: block; + flex: 1; + height: 100%; + min-height: 0; + width: 100%; +} + +.gx-loading, .gx-empty { + color: var(--text-muted); + padding: var(--sp-6); + text-align: center; +} + +.gx-loading::before { + animation: gx-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; + border: 3px solid rgba(255, 255, 255, 0.1); + border-radius: 50%; + border-top-color: var(--primary); + content: ""; + display: block; + height: 36px; + margin: 0 auto var(--sp-3); + width: 36px; +} + +@keyframes gx-spin { to { transform: rotate(360deg); } } + +.gx-spin { + animation: gx-spin 0.9s linear infinite; + display: inline-block; +} + +#snackbar_wrapper { + position: fixed; + top: calc(var(--sp-4) + env(safe-area-inset-top, 0px)); + left: 0; right: 0; + z-index: var(--z-snackbar); + display: flex; + flex-direction: column; + align-items: center; + pointer-events: none; + padding: 0 var(--sp-3); +} + +#snackbar_wrapper .snackbar { + pointer-events: auto; + max-width: 480px; + width: 100%; + background: var(--surface-container-high); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: var(--radius-full); + box-shadow: var(--elev-3); + padding: 12px 20px; + opacity: 0; + transform: translateY(-16px) scale(0.97); + transition: transform var(--motion-base), opacity var(--motion-base); +} + +[data-theme="light"] #snackbar_wrapper .snackbar { + border: 1.5px solid var(--primary); +} + +#snackbar_wrapper .snackbar.show { + opacity: 1; + transform: translateY(0) scale(1); +} + +.gx-fade-enter-active, .gx-fade-leave-active { transition: opacity var(--motion-base); } +.gx-fade-enter-from, .gx-fade-leave-to { opacity: 0; } + +.gx-slide-enter-active, .gx-slide-leave-active { + transition: transform var(--motion-base), opacity var(--motion-base); +} +.gx-slide-enter-from { opacity: 0; transform: translateY(16px); } +.gx-slide-leave-to { opacity: 0; transform: translateY(-16px); } + +.gx-collapse-enter-active, .gx-collapse-leave-active { + max-height: 999px; + overflow: hidden; + transition: max-height var(--motion-base), opacity var(--motion-base); +} +.gx-collapse-enter-from, .gx-collapse-leave-to { max-height: 0; opacity: 0; } + +@media (max-width: 767px) { + .liquid-glass-nav { display: flex; } + .gx-menu-btn { display: none; } + .gx-back-btn { display: inline-flex; } + .gx-appbar__search { flex: 1; min-width: 0; } + .gx-status-pill { display: none; } + .gx-content { padding-left: var(--sp-3); padding-right: var(--sp-3); } +} + +@media (min-width: 768px) { + .liquid-glass-nav { display: none; } + .gx-menu-btn { display: inline-flex; } + .gx-back-btn { display: none; } + .gx-content { padding-bottom: var(--sp-6); } +} \ No newline at end of file diff --git a/starpilot/system/the_galaxy/assets/mobile/index.html b/starpilot/system/the_galaxy/assets/mobile/index.html new file mode 100644 index 0000000000..53c373f546 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/index.html @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + Galaxy + + + + +
+ +
+ +
+ + + + diff --git a/starpilot/system/the_galaxy/assets/mobile/js/api.js b/starpilot/system/the_galaxy/assets/mobile/js/api.js new file mode 100644 index 0000000000..715f375dfc --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/api.js @@ -0,0 +1,496 @@ +export const LAYOUT_URL = "/assets/components/tools/device_settings_layout.json?v=settings-tier-1" + +async function handle(res) { + const data = await res.json().catch(() => ({})) + if (!res.ok) { + const err = new Error(data?.error || data?.message || res.statusText || "Request failed") + err.data = data + throw err + } + return data +} + +export const api = { + async postAction(endpoint) { + const res = await fetch(endpoint, { method: "POST" }) + return handle(res) + }, + + async getOptions(endpoint) { + const res = await fetch(endpoint) + return handle(res) + }, + + async getLayout() { + const res = await fetch(LAYOUT_URL, { cache: "no-store" }) + const data = await handle(res) + return (data || []) + .map((section) => ({ ...section, params: (section.params || []).filter((p) => p.key !== "Model") })) + .filter((section) => (section.params || []).length > 0) + }, + + async getParams() { + const res = await fetch("/api/params/all") + return handle(res) + }, + + async getDefaults() { + const res = await fetch("/api/params/defaults") + return res.ok ? handle(res) : {} + }, + + async updateParam({ key, value, label }) { + const body = { key, value } + if (label) body.label = label + const res = await fetch("/api/params", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + return handle(res) + }, + + async getFlmWorkspace() { + const res = await fetch("/api/flm/workspace", { cache: "no-store" }) + return res.ok ? handle(res) : null + }, + + async getFavoritesSlots() { + const res = await fetch("/api/favorites/slots", { cache: "no-store" }) + return handle(res) + }, + + async saveFavoritesSlots(slots) { + const res = await fetch("/api/favorites/slots", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ slots }), + }) + return handle(res) + }, + + async activateFavoriteAction(key) { + const res = await fetch("/api/favorites/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key }), + }) + return handle(res) + }, + + async getDeviceStatus() { + const res = await fetch("/api/device/status") + return res.ok ? handle(res) : null + }, + + async getStats() { + const res = await fetch("/api/stats") + return res.ok ? handle(res) : null + }, + + + async getRoutesStream({ onProgress, onRoutes, signal } = {}) { + const res = await fetch("/api/routes", { signal }) + if (!res.ok || !res.body) throw new Error(`Route request failed (${res.status})`) + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + while (true) { + const { value, done } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const events = buffer.split(/\r?\n\r?\n/) + buffer = events.pop() || "" + for (const event of events) { + const lines = event.split(/\r?\n/).filter((l) => l.startsWith("data:")) + if (!lines.length) continue + try { + const payload = JSON.parse(lines.map((l) => l.slice(5).trimStart()).join("\n")) + if (Number.isFinite(payload.progress)) onProgress?.(payload.progress) + onRoutes?.(Array.isArray(payload.routes) ? payload.routes : []) + } catch (e) { } + } + } + }, + + async getRoute(name) { + const res = await fetch(`/api/routes/${encodeURIComponent(name)}`) + return handle(res) + }, + + async deleteRoute(name) { + const res = await fetch(`/api/routes/${encodeURIComponent(name)}`, { method: "DELETE" }) + return handle(res) + }, + + async renameRoute(oldName, newName) { + const res = await fetch("/api/routes/rename", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ old: oldName, new: newName }), + }) + return handle(res) + }, + + async resetRouteName(name) { + const res = await fetch("/api/routes/reset_name", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }) + return handle(res) + }, + + async setRoutePreserved(name, preserved) { + const res = await fetch(`/api/routes/${encodeURIComponent(name)}/preserve`, { method: preserved ? "POST" : "DELETE" }) + return handle(res) + }, + + async deleteAllRoutes(includePreserved) { + const res = await fetch(`/api/routes/delete_all?include_preserved=${includePreserved}`, { method: "DELETE" }) + return handle(res) + }, + + async getRouteLogs(name) { + const res = await fetch(`/api/routes/${encodeURIComponent(name)}/logs`) + return handle(res) + }, + + async getScreenRecordings() { + const res = await fetch("/api/screen_recordings/list") + return handle(res) + }, + + async deleteScreenRecording(filename) { + const res = await fetch(`/api/screen_recordings/delete/${encodeURIComponent(filename)}`, { method: "DELETE" }) + return handle(res) + }, + + async deleteAllScreenRecordings() { + const res = await fetch("/api/screen_recordings/delete_all", { method: "DELETE" }) + return handle(res) + }, + + async renameScreenRecording(oldName, newName) { + const res = await fetch("/api/screen_recordings/rename", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ old: oldName, new: newName }), + }) + return handle(res) + }, + + + async getErrorLogs() { + const res = await fetch("/api/error_logs", { headers: { Accept: "application/json" } }) + return handle(res) + }, + + async getErrorLog(filename) { + const res = await fetch(`/api/error_logs/${encodeURIComponent(filename)}`) + return res.text() + }, + + async deleteErrorLog(filename) { + const res = await fetch(`/api/error_logs/${encodeURIComponent(filename)}`, { method: "DELETE" }) + return res.ok + }, + + async deleteAllErrorLogs() { + const res = await fetch("/api/error_logs/delete_all", { method: "DELETE" }) + return res.ok + }, + + async getTmuxLogs() { + const res = await fetch("/api/tmux_log/list") + return handle(res) + }, + + async tmuxCapture() { + const res = await fetch("/api/tmux_log/capture", { method: "POST" }) + return res.ok + }, + + async tmuxSnapshot() { + const res = await fetch("/api/tmux_log/snapshot") + return handle(res) + }, + + async deleteTmuxLog(filename) { + const res = await fetch(`/api/tmux_log/delete/${encodeURIComponent(filename)}`, { method: "DELETE" }) + return res.ok + }, + + async deleteAllTmuxLogs() { + const res = await fetch("/api/tmux_log/delete_all", { method: "DELETE" }) + return res.ok + }, + + async renameTmuxLog(oldName, newName) { + const res = await fetch(`/api/tmux_log/rename/${encodeURIComponent(oldName)}/${encodeURIComponent(newName)}`, { method: "PUT" }) + return res.ok + }, + + async runTroubleshoot() { + const res = await fetch("/api/troubleshoot", { method: "POST" }) + return handle(res) + }, + + async getTroubleshoot() { + const res = await fetch("/api/troubleshoot") + return res.ok ? handle(res) : null + }, + + async resetTroubleshoot() { + const res = await fetch("/api/troubleshoot/reset", { method: "POST" }) + return res.ok + }, + + + async getWheelControlsStatus() { + const res = await fetch("/api/wheel-controls/status", { cache: "no-store" }) + return handle(res) + }, + + async wheelControlsOp(operation, body = {}) { + const res = await fetch(`/api/wheel-controls/${operation}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + return handle(res) + }, + + async getBluetoothStatus() { + const res = await fetch("/api/bluetooth/status") + return handle(res) + }, + + async bluetoothOp(operation, body = {}) { + const res = await fetch(`/api/bluetooth/${operation}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + return handle(res) + }, + + async carFeaturesCheck(tool = "") { + const query = tool ? `?tool=${encodeURIComponent(tool)}` : "" + const res = await fetch(`/api/car_features_check${query}`) + return res.ok ? handle(res) : null + }, + + + async lateralManeuvers(action) { + const res = await fetch(`/api/lateral_maneuvers/${action}`, { method: "POST" }) + return handle(res) + }, + + async lateralManeuversStatus() { + const res = await fetch("/api/lateral_maneuvers/status") + return handle(res) + }, + + async longitudinalManeuvers(action) { + const res = await fetch(`/api/longitudinal_maneuvers/${action}`, { method: "POST" }) + return handle(res) + }, + + async longitudinalManeuversStatus() { + const res = await fetch("/api/longitudinal_maneuvers/status") + return handle(res) + }, + + + async getMapsStatus() { + const res = await fetch("/api/maps/status") + return handle(res) + }, + + async getMapsCatalog() { + const res = await fetch("/api/maps/catalog") + return handle(res) + }, + + async mapsOp(operation, body = {}) { + const res = await fetch(`/api/maps/${operation}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + return handle(res) + }, + + async getNavigation() { + const res = await fetch("/api/navigation") + return handle(res) + }, + + async setNavigation(body) { + const res = await fetch("/api/navigation", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + return handle(res) + }, + + async getNavigationKeys() { + const res = await fetch("/api/navigation_key") + return handle(res) + }, + + async setNavigationKey(body) { + const res = await fetch("/api/navigation_key", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + return handle(res) + }, + + async navigationFavorite(body) { + const res = await fetch("/api/navigation/favorite", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + return handle(res) + }, + + + async backupToggles() { + const res = await fetch("/api/toggles/backup", { method: "POST" }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data?.message || "Failed to create toggle backup.") + } + return res.blob() + }, + + async restoreToggles(data) { + const res = await fetch("/api/toggles/restore", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }) + return handle(res) + }, + + async resetTogglesDefault() { + const res = await fetch("/api/toggles/reset_default", { method: "POST" }) + return handle(res) + }, + + async getUpdateBranches() { + const res = await fetch("/api/update/branches") + return handle(res) + }, + + async getUpdateBranch() { + const res = await fetch("/api/update/branch") + return handle(res) + }, + + async setUpdateBranch(branch) { + const res = await fetch("/api/update/branch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ branch }), + }) + return handle(res) + }, + + async updateFast() { + const res = await fetch("/api/update/fast", { method: "POST" }) + return handle(res) + }, + + async getUpdateFastStatus() { + const res = await fetch("/api/update/fast/status") + return handle(res) + }, + + async updateRecover() { + const res = await fetch("/api/update/recover", { method: "POST" }) + return handle(res) + }, + + async updateRollback() { + const res = await fetch("/api/update/rollback", { method: "POST" }) + return handle(res) + }, + + async factoryReset() { + const res = await fetch("/api/update/factory_reset", { method: "POST" }) + return handle(res) + }, + + async getAgnosStatus() { + const res = await fetch("/api/update/agnos_status") + return res.ok ? handle(res) : null + }, + + + async getVasmConfig() { + const res = await fetch("/api/v_asm/config") + return handle(res) + }, + + async setVasmConfig(body) { + const res = await fetch("/api/v_asm/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + return handle(res) + }, + + async vasmSnapshot() { + const res = await fetch("/api/v_asm/snapshot") + return res.ok ? handle(res) : null + }, + + async getPipConfig() { + const res = await fetch("/api/pip_preview/config") + return handle(res) + }, + + async setPipConfig(body) { + const res = await fetch("/api/pip_preview/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + return handle(res) + }, + + async pipSnapshot() { + const res = await fetch("/api/pip_preview/snapshot") + return res.ok ? handle(res) : null + }, +} + +export function showSnackbar(message, level = "info") { + const wrapper = document.getElementById("snackbar_wrapper") + if (!wrapper) return + for (const el of Array.from(wrapper.children)) { + el.classList.remove("show") + el.remove() + } + const el = document.createElement("div") + el.className = "snackbar show" + el.style.background = level === "error" ? "var(--error)" : "var(--color-confirm, #8b6cc5)" + el.style.borderRadius = "var(--border-radius-base, 5px)" + el.style.color = "var(--text-color, #fff)" + el.style.margin = "0 auto var(--margin-base, 1rem)" + el.style.padding = "var(--padding-base, 1rem)" + el.style.textAlign = "center" + el.textContent = message + wrapper.appendChild(el) + setTimeout(() => { + el.classList.remove("show") + setTimeout(() => el.remove(), 500) + }, 2400) +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/app.js b/starpilot/system/the_galaxy/assets/mobile/js/app.js new file mode 100644 index 0000000000..d1e9733f99 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/app.js @@ -0,0 +1,85 @@ +import { createApp, h } from "vue" +import { AppShell } from "./components/AppShell.js" +import { Home } from "./views/Home.js" +import { Settings } from "./views/Settings.js" +import { Tools } from "./views/Tools.js" +import { Recordings } from "./views/Recordings.js" +import { Logs } from "./views/Logs.js" +import { Tuning } from "./views/Tuning.js" +import { Navigation } from "./views/Navigation.js" +import { Vehicle } from "./views/Vehicle.js" +import { SystemTools } from "./views/SystemTools.js" +import { ToolEmbed } from "./views/ToolEmbed.js" +import { store, initRouter, navigate } from "./store.js" +import { showSnackbar } from "./api.js" + +window.__galaxyVue = { createApp, h } + +window.addEventListener("message", (event) => { + const data = event?.data + if (!data || data.source !== "galaxy-embed" || typeof data.path !== "string") return + const current = store.params.src || "" + const target = data.path + if (target === current || target === "/" + current) return + navigate("/embed?src=" + encodeURIComponent(target)) +}) + +const VIEWS = { + "/": Home, + "/settings": Settings, + "/tools": Tools, + "/recordings": Recordings, + "/logs": Logs, + "/tuning": Tuning, + "/navigation": Navigation, + "/vehicle": Vehicle, + "/system": SystemTools, + "/embed": ToolEmbed, +} + +function resolveView(path) { + if (path === "/embed" || path.startsWith("/embed/")) return ToolEmbed + for (const [root, view] of Object.entries(VIEWS)) { + if (path === root || (root !== "/" && path.startsWith(root + "/"))) return view + } + if (path === "/") return Home + return ToolEmbed +} + +const app = createApp({ + name: "GalaxyApp", + errorCaptured(err) { + console.error("[galaxy-ui]", err) + showSnackbar("Something went wrong: " + (err?.message || err), "error") + return false + }, + computed: { + View() { + return resolveView(store.route) + }, + }, + render() { + return h(AppShell, null, { + default: () => h(this.View), + }) + }, +}) + +app.mount("#galaxy-app") + +initRouter() + +;(() => { + const bg = document.getElementById("galaxy-bg") + if (!bg) return + for (let i = 0; i < 14; i++) { + const s = document.createElement("i") + s.className = "galaxy-hero" + s.style.left = (Math.random() * 100).toFixed(2) + "%" + s.style.top = (Math.random() * 100).toFixed(2) + "%" + s.style.animationDelay = (Math.random() * 4).toFixed(2) + "s" + const size = Math.random() > 0.6 ? 3 : 2 + s.style.width = s.style.height = size + "px" + bg.appendChild(s) + } +})() diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js b/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js new file mode 100644 index 0000000000..91e9c0a58a --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js @@ -0,0 +1,168 @@ +import { store, navigate, goBack, toolHref, toggleTheme } from "../store.js" +import { api } from "../api.js" +import { usePolling } from "../composables.js" + +const NAV = { + recordings: [ + { name: "Recordings", link: "/recordings", icon: "bi-camera-reels" }, + ], + tools: [ + { name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle" }, + { name: "Tuning & Maneuvers", link: "/tuning", icon: "bi-sign-turn-right" }, + { name: "Navigation & Maps", link: "/navigation", icon: "bi-map" }, + { name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front" }, + { name: "V-ASM Spot Monitor", link: "/manage_v_asm", icon: "bi-bounding-box" }, + { name: "PiP Side Camera", link: "/manage_pip_sidecam", icon: "bi-camera-video" }, + { name: "System Tools", link: "/system", icon: "bi-arrow-repeat" }, + { name: "Galaxy", link: "/galaxy", icon: "bi-globe2" }, + { name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation" }, + { name: "Model Manager", link: "/manage_models", icon: "bi-cpu" }, + { name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" }, + { name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2" }, + { name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" }, + ], +} + +const BOTTOM_NAV = [ + { name: "Home", link: "/", icon: "bi-house-fill" }, + { name: "Settings", link: "/settings", icon: "bi-toggle-on" }, + { name: "Tools", link: "/tools", icon: "bi-tools" }, + { name: "Recordings", link: "/recordings", icon: "bi-camera-reels" }, +] + +export const AppShell = { + name: "AppShell", + data() { + return { store, BOTTOM_NAV, NAV } + }, + computed: { + online() { return store.online }, + statusLabel() { return store.online ? store.deviceStatus : "Offline" }, + isLight() { return store.theme === "light" }, + drawerOpen: { + get() { return store.drawerOpen }, + set(v) { store.drawerOpen = v }, + }, + activePath() { return store.route }, + search: { + get() { return store.search }, + set(v) { store.search = v }, + }, + }, + watch: { + "store.search"(q) { + if (q && store.route !== "/settings" && !store.route.startsWith("/settings/")) { + navigate("/settings") + } + }, + }, + methods: { + closeDrawer() { store.drawerOpen = false }, + back() { goBack() }, + async refreshStatus() { + try { + const payload = await api.getDeviceStatus() + if (!payload) throw new Error("no status") + store.online = true + store.deviceStatus = String(payload.status || "Parked") + } catch (e) { + store.online = false + } + }, + clearSearch() { + store.search = "" + this.$nextTick(() => { const el = this.$refs.searchInput; if (el) el.focus() }) + }, + themeToggle() { toggleTheme() }, + navTo(link) { + this.closeDrawer() + navigate(toolHref(link)) + }, + bottomNavTo(item) { + navigate(item.link) + }, + isActive(link) { + return this.activePath === link || (link !== "/" && this.activePath.startsWith(link)) + }, + }, + created() { + this.statusPoll = usePolling(() => this.refreshStatus(), { interval: 5000 }) + this.statusPoll.start() + }, + beforeUnmount() { + this.statusPoll?.destroy() + }, + template: ` +
+
+ +
+ + Galaxy +
+ + +
+
+ + + {{ statusLabel }} + +
+
+ +
+ + +
+
+ + +
+ +
+ + +
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/BluetoothPanel.js b/starpilot/system/the_galaxy/assets/mobile/js/components/BluetoothPanel.js new file mode 100644 index 0000000000..3e42d54128 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/BluetoothPanel.js @@ -0,0 +1,134 @@ +import { api, showSnackbar } from "../api.js" +import { usePolling } from "../composables.js" + +function address(device) { return String(device.address || "").toUpperCase() } + +export const BluetoothPanel = { + name: "BluetoothPanel", + data() { + return { + loading: true, busy: "", available: false, enabled: false, powered: false, discovering: false, + offroad: false, selectedAudio: "", pairingAddress: "", devices: [], prompt: null, pairValue: "", error: "", + } + }, + created() { this.poll = usePolling(() => this.refresh(), { interval: 2000 }); this.poll.start() }, + beforeUnmount() { this.poll?.destroy() }, + computed: { + known() { return this.devices.filter((d) => d.paired || d.trusted || d.connected) }, + availableDevices() { return this.devices.filter((d) => !d.paired && !d.trusted && !d.connected) }, + }, + methods: { + async refresh() { + try { + const p = await api.getBluetoothStatus() + this.available = !!p.available + this.enabled = !!p.enabled + this.powered = !!p.powered + this.discovering = !!p.discovering + this.offroad = !!p.offroad + this.selectedAudio = String(p.selected_audio || "") + this.pairingAddress = String(p.pairing_address || "") + this.devices = Array.isArray(p.devices) ? p.devices : [] + this.prompt = p.prompt || null + this.error = p.error || "" + } catch (e) { + this.available = false + this.error = e?.message || "Bluetooth service unavailable" + } finally { + this.loading = false + } + }, + async request(operation, body = {}) { + if (this.busy) return + this.busy = operation + try { + await api.bluetoothOp(operation, body) + this.error = "" + await this.refresh() + } catch (e) { + this.error = e?.message || "Bluetooth operation failed" + } finally { + this.busy = "" + } + }, + pair(d) { this.request("pair", { address: d.address }) }, + connect(d) { this.request(d.connected ? "disconnect" : "connect", { address: d.address }) }, + forget(d) { this.request("forget", { address: d.address }) }, + audio(d) { const isSel = this.selectedAudio.toUpperCase() === address(d); this.request("select_audio", { address: isSel ? "" : d.address }) }, + testAudio(d) { this.request("test_audio", { address: d.address }) }, + respondPairing(accepted) { + const prompt = this.prompt + if (!prompt || this.busy === "pairing_response") return + if (accepted && (prompt.kind === "pin" || prompt.kind === "passkey") && !this.pairValue.trim()) { + this.error = "Enter the value to continue pairing." + return + } + this.request("pairing_response", { prompt_id: prompt.id, accepted, value: this.pairValue.trim() }) + }, + isPairing(d) { return !!this.pairingAddress && this.pairingAddress.toUpperCase() === address(d) }, + statusOf(d) { + if (this.isPairing(d)) return "Pairing…" + if (d.connected) { + const audioSel = this.selectedAudio.toUpperCase() === address(d) + return audioSel ? "Connected · Audio output" : "Connected" + } + return d.paired ? "Saved" : "Ready to pair" + }, + offroadDisabled() { return !this.offroad || !!this.busy }, + needsPairValue() { return this.prompt && (this.prompt.kind === "pin" || this.prompt.kind === "passkey") }, + }, + template: ` +
+
+
+ Bluetooth {{ enabled ? 'On' : 'Off' }} + +
+

Scanning, pairing, and forgetting devices are available offroad only.

+

{{ error }}

+ +
+
Pairing request · {{ prompt.name }}
+
+

{{ prompt.kind === 'confirmation' ? 'Confirm the pairing request.' : prompt.kind === 'authorization' ? 'Allow this device to connect?' : prompt.kind === 'pin' ? 'Enter the PIN supplied by the device.' : 'Enter the device passkey.' }}

+ +
+ + +
+
+
+ +
+ + +
+ +

My Devices

+
No saved devices yet.
+
+
+ {{ d.name }} Connected + {{ d.audio && d.controller ? 'Audio · Controller' : d.audio ? 'Audio' : d.controller ? 'Controller' : 'Bluetooth' }} · {{ statusOf(d) }} +
+
+ + + + +
+
+ +

Available Devices

+
{{ discovering ? 'Searching for nearby devices…' : 'No nearby devices found.' }}
+
+
+ {{ d.name }} + {{ statusOf(d) }} +
+ +
+
+
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/DevModeBanner.js b/starpilot/system/the_galaxy/assets/mobile/js/components/DevModeBanner.js new file mode 100644 index 0000000000..ca365b2351 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/DevModeBanner.js @@ -0,0 +1,25 @@ +import { navigate } from "../store.js" + +export const DevModeBanner = { + name: "DevModeBanner", + props: { + hiddenCount: { type: Number, default: 0 }, + devModeOn: { type: Boolean, default: false }, + }, + computed: { + visible() { return !this.devModeOn && this.hiddenCount > 0 }, + }, + methods: { + unlock() { navigate("/settings/developer") }, + }, + template: ` +
+ +
+ {{ hiddenCount }} advanced setting{{ hiddenCount !== 1 ? "s" : "" }} hidden. + Advanced features are tucked away until you enable Developer Mode. +
+ +
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/FavoritesEditor.js b/starpilot/system/the_galaxy/assets/mobile/js/components/FavoritesEditor.js new file mode 100644 index 0000000000..3727afebd0 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/FavoritesEditor.js @@ -0,0 +1,184 @@ +import { api, showSnackbar } from "../api.js" + +const FAVORITE_COUNT = 3 +const ACTION_PREFIX = "__starpilot_favorite_action__:" + +function sortOptions(options) { + return (options || []).slice().sort((a, b) => + String(a?.label || a?.key || "").localeCompare(String(b?.label || b?.key || ""), undefined, { numeric: true, sensitivity: "base" }) + ) +} + +function defaultSlots() { + return [0, 1, 2].map(() => ({ enabled: false, show_onroad: false, key: null, label: "" })) +} + +function normalizeSlots(slots) { + const base = defaultSlots() + if (!Array.isArray(slots)) return base + slots.slice(0, FAVORITE_COUNT).forEach((slot, index) => { + if (!slot || typeof slot !== "object") return + const key = slot.key ? String(slot.key) : null + base[index] = { + enabled: !!slot.enabled, + show_onroad: !!slot.show_onroad, + key, + label: key ? String(slot.label || key) : "", + } + }) + return base +} + +export const FavoritesEditor = { + name: "FavoritesEditor", + data() { + return { + loading: true, + saving: false, + slots: [], + options: [], + values: {}, + filters: ["", "", ""], + } + }, + computed: { + optionByKey() { return new Map(this.options.map((o) => [o.key, o])) }, + quickFavorites() { + return this.slots + .map((slot, index) => { + const opt = this.optionByKey.get(slot.key || "") + return { index, slot, opt, checked: !!(slot.key && opt && !!this.values[slot.key]) } + }) + .filter((f) => f.slot.enabled && f.slot.key && f.opt) + }, + }, + methods: { + normalizeSlots, + filteredOptions(index) { + const q = (this.filters[index] || "").toLowerCase() + return this.options.filter((o) => + !q || [o.label, o.key, o.section, o.description].some((v) => String(v || "").toLowerCase().includes(q)) + ) + }, + isActionSlot(slot) { + const opt = this.optionByKey.get(slot.key || "") + return String(slot.key || "").startsWith(ACTION_PREFIX) || !!opt?.action + }, + async load() { + this.loading = true + try { + const data = await api.getFavoritesSlots() + this.options = sortOptions(data?.options) + this.slots = normalizeSlots(data?.slots) + this.values = { ...this.values, ...(data?.values || {}) } + } catch (e) { + showSnackbar("Failed to load favorite slots.", "error") + } finally { + this.loading = false + } + }, + async saveSlots() { + if (this.saving) return + this.saving = true + try { + const data = await api.saveFavoritesSlots(this.slots) + this.slots = normalizeSlots(data?.slots) + if (Array.isArray(data?.options)) this.options = sortOptions(data.options) + if (data?.values) this.values = { ...this.values, ...data.values } + showSnackbar(data?.message || "Favorite slots saved.") + } catch (e) { + showSnackbar(e?.message || "Failed to save favorite slots.", "error") + } finally { + this.saving = false + } + }, + updateSlot(index, patch) { + const slots = this.slots.slice() + slots[index] = { ...slots[index], ...patch } + if (!slots[index].key) { + slots[index].label = "" + } else { + slots[index].label = this.optionByKey.get(slots[index].key)?.label || slots[index].key + } + this.slots = slots + this.saveSlots() + }, + async toggleValue(key, checked) { + const previous = this.values[key] + this.values = { ...this.values, [key]: checked } + try { + const data = await api.updateParam({ key, value: checked }) + if (data?.updated && typeof data.updated === "object") this.values = { ...this.values, ...data.updated } + showSnackbar(data?.message || `Parameter '${key}' updated.`) + } catch (e) { + this.values = { ...this.values, [key]: previous } + showSnackbar(e?.message || "Network error — is the device reachable?", "error") + } + }, + async runAction(key) { + try { + const data = await api.activateFavoriteAction(key) + showSnackbar(data?.message || "Favorite action sent.") + } catch (e) { + showSnackbar(e?.message || "Failed to send favorite action.", "error") + } + }, + }, + async mounted() { await this.load() }, + template: ` +
+
Loading favorite slots...
+ + +
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyEmbed.js b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyEmbed.js new file mode 100644 index 0000000000..b3c13b949c --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyEmbed.js @@ -0,0 +1,82 @@ +// Single source of truth for page-in-page embeds of the classic Galaxy SPA. +// Every embed (ToolEmbed, Home dashboard, Tuning, Navigation maps/keys/speeds, +// SystemTools toggles, Logs troubleshoot) renders through this component so the +// classic page always gets: an `embedded=1` marker, sidebar-hide styles, and +// optional navigation forwarding back to the mobile app. +export const GalaxyEmbed = { + name: "GalaxyEmbed", + props: { + src: { type: String, required: true }, + title: { type: String, default: "Tool" }, + // When true, injects a bridge that forwards the classic page's internal + // navigation to the mobile app (which opens it as a proper ToolEmbed page). + forwardNav: { type: Boolean, default: false }, + }, + data() { + return { + embedStyle: ` + #sidebar, #sidebar_shell, #sidebarUnderlay { display: none !important; } + #menu_button { display: none !important; } + .content { margin-left: 0 !important; } + body { padding-left: 0 !important; } + `, + } + }, + computed: { + frameSrc() { + const base = this.src + return base + (base.includes("?") ? "&" : "?") + "embedded=1" + }, + }, + methods: { + injectEmbedStyles() { + const frame = this.$refs.frame + if (!frame) return + try { + const doc = frame.contentDocument || frame.contentWindow?.document + if (!doc || !doc.head) return + let style = doc.getElementById("gx-embed-hide-sidebar") + if (!style) { + style = doc.createElement("style") + style.id = "gx-embed-hide-sidebar" + doc.head.appendChild(style) + } + style.textContent = this.embedStyle + + if (!this.forwardNav) return + let bridge = doc.getElementById("gx-embed-nav-bridge") + if (!bridge) { + bridge = doc.createElement("script") + bridge.id = "gx-embed-nav-bridge" + bridge.textContent = `(() => { + const post = () => { + if (window.self === window.top) return + const params = new URLSearchParams(window.location.search) + params.delete("embedded") + const qs = params.toString() + window.parent.postMessage({ source: "galaxy-embed", path: window.location.pathname + (qs ? "?" + qs : "") }, "*") + } + const patch = (type) => { + const orig = history[type] + history[type] = function () { const r = orig.apply(this, arguments); post(); return r } + } + patch("pushState") + patch("replaceState") + window.addEventListener("popstate", post) + })()` + doc.head.appendChild(bridge) + } + } catch (e) { + } + }, + }, + mounted() { + this.$refs.frame?.addEventListener("load", () => this.injectEmbedStyles()) + }, + template: ` +
+ +
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyModal.js b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyModal.js new file mode 100644 index 0000000000..368dfce1b6 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyModal.js @@ -0,0 +1,63 @@ +export const GalaxyModal = { + name: "GalaxyModal", + props: { + modelValue: { type: Boolean, default: false }, + title: { type: String, default: "Are you sure?" }, + message: { type: String, default: "" }, + confirmLabel: { type: String, default: "Confirm" }, + cancelLabel: { type: String, default: "Cancel" }, + danger: { type: Boolean, default: false }, + sheet: { type: Boolean, default: true }, + }, + emits: ["update:modelValue", "confirm", "cancel"], + methods: { + close() { this.$emit("update:modelValue", false) }, + cancel() { this.close(); this.$emit("cancel") }, + confirm() { this.$emit("confirm"); this.close() }, + }, + template: ` + +
+ + + +
+
+ `, +} + +export function GalaxyConfirm({ title, message, confirmLabel = "Confirm", danger = false } = {}) { + return new Promise((resolve) => { + const host = document.createElement("div") + document.body.appendChild(host) + const { createApp, h } = window.__galaxyVue + let instance + const app = createApp({ + render() { + return h(GalaxyModal, { + modelValue: true, + title, + message, + confirmLabel, + danger, + "onUpdate:modelValue": (v) => { if (!v) teardown() }, + onConfirm: () => { teardown(); resolve(true) }, + onCancel: () => { teardown(); resolve(false) }, + }) + }, + }) + const teardown = () => { + app.unmount() + host.remove() + resolve(false) + } + instance = app.mount(host) + }) +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxySection.js b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxySection.js new file mode 100644 index 0000000000..815f29450a --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxySection.js @@ -0,0 +1,25 @@ +export const GalaxySection = { + name: "GalaxySection", + props: { + title: { type: String, required: true }, + icon: { type: String, default: "bi-toggles" }, + count: { type: [Number, String], default: "" }, + defaultOpen: { type: Boolean, default: true }, + }, + data() { return { open: this.defaultOpen } }, + template: ` +
+
+ + {{ title }} + {{ count }} + +
+ +
+ +
+
+
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyToggleCard.js b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyToggleCard.js new file mode 100644 index 0000000000..5fd48954f6 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyToggleCard.js @@ -0,0 +1,226 @@ +import { api, showSnackbar } from "../api.js" +import { + coerceValueByType, formatSliderValue, formatReadoutValue, getColorDefault, + normalizeHexColor, numericBounds, numericEpsilon, snapNumericToBoundsAndStep, + stepPrecision, +} from "../params.js" +import { FavoritesEditor } from "./FavoritesEditor.js" + +export const GalaxyToggleCard = { + name: "GalaxyToggleCard", + components: { FavoritesEditor }, + props: { + param: { type: Object, required: true }, + value: { default: undefined }, + locked: { type: Boolean, default: false }, + manageable: { type: Boolean, default: false }, + manageOpen: { type: Boolean, default: false }, + }, + emits: ["change", "manage"], + data() { + return { + updating: false, + endpointOptions: null, + optionsLoaded: false, + endpointLoading: false, + preview: undefined, + interacting: false, + } + }, + computed: { + bounds() { return numericBounds(this.param, {}) }, + precision() { return stepPrecision(this.bounds.step, this.param.precision) }, + epsilon() { return numericEpsilon(this.precision) }, + isSlider() { return this.isNumeric }, + isNumeric() { return this.param.ui_type === "numeric" }, + isReadout() { return this.param.ui_type === "readout" }, + isGroup() { return this.param.ui_type === "group" }, + currentValue() { return this.preview !== undefined ? this.preview : this.value }, + displayValue() { + if (this.isColor) return normalizeHexColor(this.value) ? normalizeHexColor(this.value).toUpperCase() : "Stock" + if (this.isReadout) return formatReadoutValue(this.param, this.value) + return this.value !== undefined && this.value !== null ? formatSliderValue(this.value, String(this.bounds.step), this.param.precision, this.param.key) : ".." + }, + sliderDisplay() { + return this.value !== undefined ? formatSliderValue(this.currentValue, String(this.bounds.step), this.param.precision, this.param.key) : ".." + }, + isColor() { return this.param.ui_type === "color" }, + isAction() { return this.param.ui_type === "action" }, + isFavorites() { return this.param.ui_type === "favorites" }, + isText() { return this.param.ui_type === "text" }, + isSelect() { return this.param.ui_type === "dropdown" }, + isSwitch() { return !this.isNumeric && !this.isColor && !this.isAction && !this.isFavorites && !this.isGroup && !this.isReadout && !this.isSelect && !this.isText }, + selectOptions() { + return this.param.options || this.endpointOptions || [] + }, + optionsLoading() { + return Boolean(this.param.options_endpoint) && this.endpointLoading + }, + }, + methods: { + normalizeHexColor, + getColorDefault, + coerce(v) { return coerceValueByType(v, this.param.data_type) }, + labelOf(el) { return el?.options?.[el.selectedIndex]?.textContent || "" }, + rollback(prev) { this.$emit("change", { key: this.param.key, value: prev }) }, + async commit(nextValue) { + const prev = this.value + const label = this.lastLabel || "" + this.$emit("change", { key: this.param.key, value: nextValue }) + this.updating = true + try { + const data = await api.updateParam({ key: this.param.key, value: nextValue, label }) + const updated = data?.updated && typeof data.updated === "object" ? data.updated : {} + if (Object.prototype.hasOwnProperty.call(updated, this.param.key)) { + this.$emit("change", { key: this.param.key, value: updated[this.param.key], ...updated }) + } + showSnackbar(data?.message || `Parameter '${this.param.key}' updated.`) + } catch (err) { + this.rollback(prev) + showSnackbar(err?.message || "Network error — is the device reachable?", "error") + } finally { + this.updating = false + } + }, + onSwitch(e) { + if (!this.locked) this.commit(!!e.target.checked) + else e.target.checked = !!this.value + }, + onSelect(e) { + if (this.locked) { e.target.value = String(this.value ?? "") ; return } + this.lastLabel = e.target.options?.[e.target.selectedIndex]?.textContent || "" + this.commit(this.coerce(e.target.value)) + }, + onText(e) { + if (!this.locked) this.commit(this.coerce(e.target.value)) + }, + onColor(e) { + if (this.locked) return + this.commit(normalizeHexColor(e.target.value) || getColorDefault(this.param)) + }, + beginInteract() { this.interacting = true }, + flushSlider(rawValue) { + const next = snapNumericToBoundsAndStep(rawValue, this.bounds, this.precision) + this.preview = undefined + if (next === null) return + const current = this.snap(this.value) + if (Math.abs(next - current) <= this.epsilon) return + this.commit(next) + }, + onSliderInput(e) { + this.beginInteract() + this.preview = Number(e.target.value) + }, + onSliderCommit(e) { + this.interacting = false + this.flushSlider(e.target.value) + }, + onSliderBlur(e) { + if (this.interacting) this.onSliderCommit(e) + }, + snap(raw) { + return snapNumericToBoundsAndStep(raw, this.bounds, this.precision) + }, + async resetToDefault() { + const defaults = await api.getDefaults() + const stockKey = `${this.param.key}Stock` + const stock = defaults?.[stockKey] + const raw = stock !== undefined && stock !== null ? stock : defaults?.[this.param.key] + const next = this.snap(raw) + if (next === null) { showSnackbar("No default value available for this setting.", "error"); return } + if (Math.abs(next - (this.snap(this.value) ?? 0)) <= this.epsilon) return + this.commit(next) + }, + resetColor() { + if (normalizeHexColor(this.value) === "") return + this.commit("stock") + }, + runAction() { + if (this.locked || this.updating) return + this.updating = true + api.postAction(String(this.param.action_endpoint || "")) + .then((data) => { + if (!data?.error) { + showSnackbar(data?.message || `${this.param.label || this.param.key} completed.`) + if (data?.updated && typeof data.updated === "object") this.$emit("change", data.updated) + } else { + showSnackbar(data.error, "error") + } + }) + .catch(() => showSnackbar(`${this.param.label || this.param.key} failed.`, "error")) + .finally(() => { this.updating = false }) + }, + loadEndpointOptions() { + if (!this.param.options_endpoint || this.optionsLoaded) return + this.optionsLoaded = true + this.endpointLoading = true + api.getOptions(this.param.options_endpoint) + .then((opts) => { this.endpointOptions = opts }) + .catch(() => { this.endpointOptions = [] }) + .finally(() => { this.endpointLoading = false }) + }, + }, + mounted() { + if (this.param.options_endpoint) this.loadEndpointOptions() + }, + template: ` +
+
+
+ {{ param.label }} + Advanced + + {{ param.description }} +
Locked: This setting can only be changed while parked.
+
+ + + +
+ +
+ +
+ {{ sliderDisplay }} + + +
+ + + + + +
+ {{ displayValue }} + + +
+ + {{ displayValue }} + + + + +
+ +
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/ManeuverCard.js b/starpilot/system/the_galaxy/assets/mobile/js/components/ManeuverCard.js new file mode 100644 index 0000000000..235d47fb7e --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/ManeuverCard.js @@ -0,0 +1,89 @@ +import { usePolling, formatAgeSeconds } from "../composables.js" +import { showSnackbar } from "../api.js" + +function safeNumber(value, fallback = 0) { + const n = Number(value) + return Number.isFinite(n) ? n : fallback +} + +export const ManeuverCard = { + name: "ManeuverCard", + props: { + title: { type: String, required: true }, + icon: { type: String, default: "bi-sign-turn-right" }, + intro: { type: String, default: "" }, + start: { type: Function, required: true }, + stop: { type: Function, required: true }, + status: { type: Function, required: true }, + interval: { type: Number, default: 3000 }, + }, + data() { + return { loading: true, busy: false, data: null } + }, + created() { + this.poll = usePolling(() => this.refreshStatus(), { interval: this.interval }) + this.poll.start() + }, + beforeUnmount() { this.poll?.destroy() }, + methods: { + formatAgeSeconds, + safeNumber, + async refreshStatus() { + try { + const payload = await this.status() + this.data = payload && typeof payload === "object" ? { ...payload, history: Array.isArray(payload.history) ? payload.history : [] } : null + this.loading = false + } catch (e) { + this.loading = false + throw e + } + }, + async run(action) { + if (this.busy) return + this.busy = true + try { + const fn = action === "start" ? this.start : this.stop + const payload = await fn() + this.data = payload && typeof payload === "object" ? { ...payload, history: Array.isArray(payload.history) ? payload.history : [] } : this.data + showSnackbar(payload?.message || "Action complete.") + } catch (e) { + showSnackbar(e?.message || "Action failed.", "error") + } finally { + this.busy = false + } + }, + }, + template: ` +
+
+ + {{ title }} +
+
+

{{ intro }}

+
+ + +
+
Loading status...
+
+
Mode{{ data.modeEnabled ? 'Yes' : 'No' }}
+
State{{ data.state || 'idle' }}
+
Onroad{{ data.isOnroad ? 'Yes' : 'No' }}
+
Engaged{{ data.isEngaged ? 'Yes' : 'No' }}
+
Phase{{ data.phase || 'n/a' }}
+
Step{{ safeNumber(data.stepIndex, 0) }}/{{ safeNumber(data.stepTotal, 0) }}
+
Run{{ safeNumber(data.runIndex, 0) }}/{{ safeNumber(data.runTotal, 0) }}
+
Updated{{ formatAgeSeconds(data.updatedAgeSec) }}
+
Current{{ data.maneuver || 'n/a' }}
+
+
+
Progress Chain
+
    +
  1. {{ line }}
  2. +
+
+
+
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/ParamSections.js b/starpilot/system/the_galaxy/assets/mobile/js/components/ParamSections.js new file mode 100644 index 0000000000..d94b8b2fcd --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/ParamSections.js @@ -0,0 +1,66 @@ +import { api } from "../api.js" +import { isSettingVisible, slugifySectionName, applyParamChange } from "../params.js" +import { SettingTree } from "./SettingTree.js" +import { GalaxySection } from "./GalaxySection.js" + +export const ParamSections = { + name: "ParamSections", + components: { SettingTree, GalaxySection }, + props: { + sectionNames: { type: Array, required: true }, + search: { type: String, default: "" }, + }, + data() { + return { + layout: [], + values: {}, + expanded: {}, + loading: true, + error: "", + } + }, + computed: { + sections() { + return this.layout + .filter((s) => this.sectionNames.includes(s.name)) + .map((s) => ({ + ...s, + params: (s.params || []).filter((p) => isSettingVisible(s, p, this.values) && this.matches(p)), + slug: slugifySectionName(s.name), + })) + .filter((s) => s.params.length > 0) + }, + }, + methods: { + matches(p) { + if (!this.search) return true + const q = this.search.toLowerCase() + return [p.label, p.key, p.description].some((v) => String(v || "").toLowerCase().includes(q)) + }, + async load() { + try { + const [layout, values] = await Promise.all([api.getLayout(), api.getParams()]) + this.layout = layout + this.values = values || {} + } catch (e) { + this.error = e?.message || "Failed to load settings." + } finally { + this.loading = false + } + }, + onParamChange(patch) { this.values = applyParamChange(this.values, patch) }, + toggleManage(key) { this.expanded = { ...this.expanded, [key]: !this.expanded[key] } }, + }, + async mounted() { await this.load() }, + template: ` +
+
Loading configuration...
+
{{ error }}
+ + +
No settings in this section.
+
+
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/SettingTree.js b/starpilot/system/the_galaxy/assets/mobile/js/components/SettingTree.js new file mode 100644 index 0000000000..7d32b8915c --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/SettingTree.js @@ -0,0 +1,46 @@ +import { GalaxyToggleCard } from "./GalaxyToggleCard.js" +import { hasChildParams, isGroupParam, isParamEnabledForChildren } from "../params.js" + +export const SettingTree = { + name: "SettingTree", + components: { GalaxyToggleCard }, + props: { + params: { type: Array, required: true }, + parentKey: { default: null }, + depth: { type: Number, default: 0 }, + values: { type: Object, required: true }, + expanded: { type: Object, default: () => ({}) }, + lockReason: { type: Function, default: () => "" }, + }, + emits: ["change", "manage"], + computed: { + children() { + return this.params.filter((p) => (p.parent_key || null) === this.parentKey) + }, + }, + methods: { + enabledForChildren(p) { return isParamEnabledForChildren(p, this.values) }, + isParent(p) { return hasChildParams(this.params, p.key) }, + isGroup(p) { return isGroupParam(p) }, + isExpanded(p) { return !!this.expanded[p.key] }, + showChildren(p) { return this.isParent(p) && this.enabledForChildren(p) && this.isExpanded(p) }, + manageable(p) { return this.isParent(p) && this.enabledForChildren(p) }, + manageOpen(p) { return this.isParent(p) && this.enabledForChildren(p) && this.isExpanded(p) }, + }, + template: ` + + `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/WheelControls.js b/starpilot/system/the_galaxy/assets/mobile/js/components/WheelControls.js new file mode 100644 index 0000000000..a1a063b603 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/WheelControls.js @@ -0,0 +1,165 @@ +import { api } from "../api.js" +import { usePolling } from "../composables.js" + +const FAVORITE_SLOT_COUNT = 3 + +export const WheelControls = { + name: "WheelControls", + data() { + return { + loading: true, busy: "", available: false, offroad: false, learning: false, + devices: [], mappings: [], slots: [], controllerSlots: [], controllerOptions: [], + joystickDevice: "", learningSlot: null, remainingSeconds: 0, testing: false, + lastTested: null, speedUnit: "mph", speedMinimum: 0, speedMaximum: 0, error: "", + } + }, + created() { this.poll = usePolling(() => this.refresh(), { interval: 750 }); this.poll.start() }, + beforeUnmount() { this.poll?.destroy() }, + methods: { + async refresh() { + try { + const p = await api.getWheelControlsStatus() + this.available = !!p.available + this.offroad = !!p.offroad + this.learning = !!p.learning + this.devices = Array.isArray(p.devices) ? p.devices : [] + this.mappings = Array.isArray(p.mappings) ? p.mappings : [] + this.slots = Array.isArray(p.slots) ? p.slots : [] + this.controllerSlots = Array.isArray(p.controller_slots) ? p.controller_slots : [] + this.controllerOptions = Array.isArray(p.controller_options) ? p.controller_options : [] + this.joystickDevice = typeof p.joystick_device === "string" ? p.joystick_device : "" + this.learningSlot = Number.isInteger(p.learning_slot) ? p.learning_slot : null + this.remainingSeconds = Number.isFinite(Number(p.remaining_seconds)) ? Number(p.remaining_seconds) : 0 + this.testing = !!p.testing + this.lastTested = p.last_tested && typeof p.last_tested === "object" ? p.last_tested : null + this.speedUnit = String(p.speed_unit || "mph") + this.speedMinimum = Number.isFinite(Number(p.speed_minimum)) ? Number(p.speed_minimum) : 0 + this.speedMaximum = Number.isFinite(Number(p.speed_maximum)) ? Number(p.speed_maximum) : 0 + this.error = "" + } catch (e) { + this.available = false + this.error = e?.message || "Wheel controls are unavailable" + } finally { + this.loading = false + } + }, + async request(operation, body = {}) { + if (this.busy) return + this.busy = operation + try { + await api.wheelControlsOp(operation, body) + this.error = "" + await this.refresh() + } catch (e) { + this.error = e?.message || "Wheel control operation failed" + } finally { + this.busy = "" + } + }, + mappingsOf(slot) { return this.mappings.filter((m) => m.slot === slot) }, + actionSlotIndex(i) { return FAVORITE_SLOT_COUNT + i }, + learn(slot) { this.request(this.learningAt(slot) ? "cancel" : "learn", { slot }) }, + learningAt(slot) { return !!this.learning && this.learningSlot === slot }, + disabled() { return !this.offroad || !!this.busy }, + configured(slot) { return !!slot?.enabled && !!slot?.key }, + optionByKey(key) { return this.controllerOptions.find((o) => o.key === key) || null }, + isSpeedSlot(slot) { return this.optionByKey(slot?.key)?.value_type === "speed" }, + onActionSelect(i, e) { + if (this.disabled()) return + const key = String(e.target.value || "") + const option = this.optionByKey(key) + const value = option?.value_type === "speed" + ? Number(this.controllerSlots[i]?.value ?? option.default_value ?? 30) + : null + this.request("action", { slot: i, key, value }) + }, + onSpeedChange(i, e) { + if (this.disabled()) return + const key = String(this.controllerSlots[i]?.key || "") + const value = Number(e.target.value) + if (!Number.isFinite(value)) return + this.request("action", { slot: i, key, value }) + }, + listenLabel(slot) { + if (!this.learningAt(slot)) return "Learn Button" + const seconds = Math.max(0, Math.ceil(this.remainingSeconds)) + return seconds > 0 ? `Listening (${seconds}s)` : "Listening..." + }, + }, + template: ` +
+
+

Mappings can only be changed while offroad. Mapped buttons continue working onroad.

+

{{ error }}

+

The wheel control service is starting.

+
+ + +
+
+ {{ lastTested.mapped ? 'Successful' : 'Not mapped' }} +

{{ lastTested.event_name || ('Button ' + lastTested.event_code) }} on {{ lastTested.device_name || 'External input' }} {{ lastTested.mapped ? 'is mapped to slot ' + lastTested.slot : 'has no mapping' }}.

+
+ +

Connected input devices

+

Favorite buttons are the default, with controller-only actions below. Only the selected gamepad controls Joystick Mode.

+
+
+
+ {{ d.name }} + {{ d.joystick_capable ? 'Buttons and joystick axes' : 'Buttons only' }} +
+ +
+
+

Connect or pair a controller, macropad, or keyboard.

+ +

On-screen Favorites

+
+
+
+ Favorite #{{ i + 1 }} + {{ configured(slot) ? (slot.label || slot.key) : 'Not configured' }} +
+ + {{ mappingsOf(i).map(m => m.event_name).join(', ') }} +
+
+

Choose and enable these slots in Toggles to map buttons to them.

+ +

Controller-only Actions

+

Ten additional actions for physical buttons. These never appear as on-screen Favorites.

+
+
+
+
+ Controller Action #{{ i + 1 }} + {{ slot.enabled ? (slot.label || 'Configured') : 'Not configured' }} +
+ +
+ +
+
+ Set speed ({{ speedUnit }}) +
+ +
+
Press one button on your controller, macropad, or keyboard.
+
+ {{ m.event_name || ('Button ' + m.event_code) }} +
+
+
+
+
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/composables.js b/starpilot/system/the_galaxy/assets/mobile/js/composables.js new file mode 100644 index 0000000000..648d51a09b --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/composables.js @@ -0,0 +1,100 @@ +import { computed, reactive } from "vue" +import { showSnackbar } from "./api.js" +import { navigate, store } from "./store.js" + +export function useTabRouting(basePath, tabs) { + const tab = computed(() => { + const prefix = basePath + "/" + const slug = store.route.startsWith(prefix) + ? store.route.slice(prefix.length).split("/")[0] + : "" + for (const [key, s] of Object.entries(tabs)) { + if (s === slug) return key + } + return Object.keys(tabs)[0] + }) + + function selectTab(key) { + const slug = tabs[key] + if (slug === undefined) return + const href = slug ? `${basePath}/${slug}` : basePath + if (href !== store.route) navigate(href) + } + + return { tab, selectTab } +} + +export function usePolling(fn, { interval = 3000, enabled = () => true } = {}) { + const state = reactive({ running: false, lastError: "", lastErrorAt: 0 }) + let timer = null + let destroyed = false + + const stop = () => { if (timer) { clearTimeout(timer); timer = null } } + const tick = async () => { + if (destroyed || !enabled() || document.visibilityState !== "visible") { + timer = setTimeout(tick, interval) + return + } + try { + await fn() + state.lastError = "" + } catch (e) { + state.lastError = e?.message || String(e) + state.lastErrorAt = Date.now() + } + if (!destroyed) timer = setTimeout(tick, interval) + } + const start = () => { stop(); timer = setTimeout(tick, 0) } + const destroy = () => { destroyed = true; stop() } + return { state, start, stop, destroy } +} + +export function useLogStream({ endpoint, snapshotFn, interval = 2000 } = {}) { + const state = reactive({ log: "", latest: "", paused: false, transport: "idle" }) + let es = null + let timer = null + let destroyed = false + + const apply = (data) => { + state.latest = data || "" + if (!state.paused) state.log = state.latest + } + const snapshotFetch = async () => { + if (!snapshotFn) return + try { apply((await snapshotFn())?.data || "") } catch (e) { } + } + const stopStream = () => { if (es) { es.close(); es = null } } + const stopPolling = () => { if (timer) { clearInterval(timer); timer = null } } + const startPolling = () => { + stopStream() + state.transport = "polling" + snapshotFetch() + timer = setInterval(() => { if (!destroyed) snapshotFetch() }, interval) + } + const startStream = () => { + stopPolling() + if (!endpoint) return startPolling() + state.transport = "streaming" + es = new EventSource(endpoint) + es.onmessage = (e) => apply(e.data) + es.onerror = () => { if (snapshotFn) startPolling() } + } + const start = () => (snapshotFn ? startPolling() : startStream()) + const destroy = () => { destroyed = true; stopStream(); stopPolling() } + const togglePause = () => { + state.paused = !state.paused + if (!state.paused) state.log = state.latest + } + const notify = (message, level) => showSnackbar(message, level) + return { state, start, destroy, togglePause, notify } +} + +export function formatAgeSeconds(value) { + const sec = Number(value) + if (!Number.isFinite(sec) || sec < 0) return "unknown" + if (sec < 1) return "just now" + if (sec < 60) return `${Math.round(sec)}s ago` + const min = sec / 60 + if (min < 60) return `${Math.round(min)}m ago` + return `${Math.round(min / 60)}h ago` +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/params.js b/starpilot/system/the_galaxy/assets/mobile/js/params.js new file mode 100644 index 0000000000..0c76e85909 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/params.js @@ -0,0 +1,269 @@ +export const GALAXY_DEVELOPER_MODE_KEY = "GalaxyDeveloperMode" + +const HIDDEN_SETTING_KEYS = new Set(["HumanAcceleration"]) +const RADAR_REQUIRED_KEYS = new Set(["HumanLaneChanges", "RadarTakeoffs"]) +const VEHICLE_SETTING_MAKES = { + RivianAngleControl: ["Rivian"], + TeslaCoopSteering: ["Tesla"], + NAPRadarEnabled: ["Tesla"], + NAPRadarBehindNosecone: ["Tesla"], + NAPRadarOffset: ["Tesla"], + NAPPedalEnabled: ["Tesla"], + NAPPedalCanBus: ["Tesla"], + NAPAdaptiveAccel: ["Tesla"], + NAPPedalCalibDone: ["Tesla"], + NAPPedalCalibFactor: ["Tesla"], + NAPPedalCalibZero: ["Tesla"], + GMPedalLongitudinal: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"], + GMDashSpoofOffsets: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"], + IgnoreIgnitionLine: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"], + LongPitch: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"], + RemoteStartBootsComma: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"], + HKGRemoteStartBootsComma: ["Genesis", "Hyundai", "Kia"], + VoltSNG: ["Chevrolet", "Holden"], + GMAutoHold: ["Chevrolet", "Holden"], + VoltOnePedalMode: ["Chevrolet", "Holden"], + RemapCancelToDistance: ["Chevrolet", "Holden"], + JeepBrakeHold: ["Jeep"], + SubaruSNG: ["Subaru"], + SubaruSNGManualParkingBrake: ["Subaru"], + SubaruStopStartOff: ["Subaru"], + SubaruAvhOnAtStartup: ["Subaru"], + ClusterOffset: ["Lexus", "Toyota"], + SNGHack: ["Lexus", "Toyota"], + ToyotaAutoHold: ["Lexus", "Toyota"], +} + +export function normalizeVehicleMake(value) { + return String(value ?? "").trim().toLowerCase() +} + +export function isVehicleSettingVisible(section, param, values) { + const allowedMakes = param.vehicle_makes || (section.name === "Vehicle" ? VEHICLE_SETTING_MAKES[param.key] : null) + if (!allowedMakes) return true + const selectedMake = normalizeVehicleMake(values.CarMake) + return allowedMakes.some((make) => normalizeVehicleMake(make) === selectedMake) +} + +function toSelectValue(value) { + return value === null || value === undefined ? "" : String(value) +} + +export function matchesSettingValueCondition(param, values) { + if (!param.visible_when_key) return true + const allowedValues = Array.isArray(param.visible_when_values) ? param.visible_when_values : [] + const currentValue = toSelectValue(values[param.visible_when_key]) + return allowedValues.some((value) => toSelectValue(value) === currentValue) +} + +export function isSettingVisible(section, param, values) { + if (HIDDEN_SETTING_KEYS.has(param.key) || !isVehicleSettingVisible(section, param, values) || !matchesSettingValueCondition(param, values)) return false + if (param.requires_capability && !values[param.requires_capability]) return false + if (RADAR_REQUIRED_KEYS.has(param.key) && !values.HasRadar) return false + if (param.key === "AlphaLongitudinalEnabled" && !values.AlphaLongitudinalAvailable) return false + if (values[GALAXY_DEVELOPER_MODE_KEY]) return true + return section.name === "Favorites" || param.settings_tier === "simple" +} + +export function isAdvancedHiddenByDeveloperMode(section, param, values) { + if (param.settings_tier !== "advanced") return false + if (HIDDEN_SETTING_KEYS.has(param.key)) return false + if (!isVehicleSettingVisible(section, param, values) || !matchesSettingValueCondition(param, values)) return false + if (param.requires_capability && !values[param.requires_capability]) return false + if (RADAR_REQUIRED_KEYS.has(param.key) && !values.HasRadar) return false + if (param.key === "AlphaLongitudinalEnabled" && !values.AlphaLongitudinalAvailable) return false + return true +} + +export function countAdvancedHiddenByDeveloperMode(layout, values) { + if (values[GALAXY_DEVELOPER_MODE_KEY]) return 0 + let count = 0 + for (const section of layout) { + if (section.name === "Favorites") continue + for (const param of section.params || []) { + if (isAdvancedHiddenByDeveloperMode(section, param, values)) count++ + } + } + return count +} + +export function numericBounds(param, values) { + const defaultBounds = { + min: param.min !== undefined ? param.min : (param.data_type === "float" ? 0.0 : 0), + max: param.max !== undefined ? param.max : (param.data_type === "float" ? 100.0 : 100), + step: param.step !== undefined ? param.step : (param.data_type === "float" ? 0.01 : 1), + } + const toFinite = (value) => { + const n = Number(value) + return Number.isFinite(n) ? n : null + } + if (param.key === "ScreenBrightness" || param.key === "ScreenBrightnessOnroad") { + return { min: 1, max: 101, step: 1 } + } + if (/^(Traffic|Aggressive|Standard|Relaxed)Jerk(Acceleration|Deceleration|Danger|SpeedDecrease|Speed)$/.test(String(param.key || ""))) { + return { min: 25, max: 200, step: 1 } + } + if (param.key === "SteerKP") { + const base = toFinite(values?.SteerKPStock) || toFinite(values?.SteerKP) || 0.6 + return { min: +(base * 0.5).toFixed(2), max: +(base * 1.5).toFixed(2), step: 0.01 } + } + if (param.key === "SteerLatAccel") { + const base = toFinite(values?.SteerLatAccelStock) || toFinite(values?.SteerLatAccel) || 2.0 + return { min: +(base * 0.5).toFixed(2), max: +(base * 1.25).toFixed(2), step: 0.01 } + } + if (param.key === "SteerRatio") { + const base = toFinite(values?.SteerRatioStock) || toFinite(values?.SteerRatio) || 15.0 + return { min: +(base * 0.25).toFixed(2), max: +(base * 1.5).toFixed(2), step: 0.01 } + } + return defaultBounds +} + +export function stepPrecision(step, explicitPrecision) { + if (explicitPrecision !== undefined && explicitPrecision !== null && explicitPrecision !== "") { + const parsed = Number.parseInt(explicitPrecision, 10) + if (Number.isFinite(parsed) && parsed >= 0) return parsed + } + const stepStr = String(step ?? "") + if (!stepStr.includes(".")) return 0 + return stepStr.split(".")[1].length +} + +export function numericEpsilon(precision) { + return Math.pow(10, -(precision + 2)) +} + +export function clampNumeric(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +export function snapNumericToBoundsAndStep(rawValue, bounds, precision) { + const min = Number(bounds.min) + const max = Number(bounds.max) + const step = Number(bounds.step) + const value = Number(rawValue) + if (!Number.isFinite(min) || !Number.isFinite(max) || !Number.isFinite(value)) return null + const clamped = clampNumeric(value, min, max) + if (!Number.isFinite(step) || step <= 0) { + return clampNumeric(Number(clamped.toFixed(precision)), min, max) + } + const snapped = min + Math.round((clamped - min) / step) * step + return clampNumeric(Number(snapped.toFixed(precision)), min, max) +} + +export function coerceValueByType(rawValue, dataType) { + if (dataType === "int") { + const n = Number.parseInt(rawValue, 10) + return Number.isFinite(n) ? n : rawValue + } + if (dataType === "float") { + const n = Number.parseFloat(rawValue) + return Number.isFinite(n) ? n : rawValue + } + return rawValue +} + +export function formatSliderValue(val, stepStr, precisionInt, key) { + if (val === null || val === undefined) return "--" + const v = parseFloat(val) + if (Number.isNaN(v)) return val + if (key === "SwitchbackModeCooldown") { + if (v === 0) return "Off" + return v === 1 ? "1 min" : `${v} min` + } + if (key === "DeviceShutdown") { + return v === 1 ? "1 hour" : `${v} hours` + } + const volumeKeys = [ + "BelowSteerSpeedVolume", "DisengageVolume", "EngageVolume", "PromptVolume", + "PromptDistractedVolume", "RefuseVolume", "WarningImmediateVolume", "WarningSoftVolume", + ] + if (key && volumeKeys.includes(key)) { + if (v === 0) return "Muted" + if (v === 101) return "Auto" + return `${v}%` + } + if (precisionInt !== undefined && precisionInt !== null) { + return Number(v.toFixed(precisionInt)).toString() + } + if (!stepStr || !stepStr.includes(".")) return Math.round(v).toString() + const dec = stepStr.split(".")[1].length + return Number(v.toFixed(dec)).toString() +} + +export function formatReadoutValue(p, value) { + const raw = value + const parsed = parseFloat(raw) + if (raw === undefined || raw === null || Number.isNaN(parsed)) return "--" + const precision = p.precision !== undefined && p.precision !== null ? Number(p.precision) : 2 + const formatted = Number(parsed.toFixed(Math.max(0, precision))).toString() + return p.unit ? `${formatted}${p.unit}` : formatted +} + +export function normalizeHexColor(rawValue) { + const value = String(rawValue ?? "").trim() + if (!value || value.toLowerCase() === "stock") return "" + const stripped = value.startsWith("#") ? value.slice(1) : value + if (!/^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(stripped)) return "" + return `#${stripped.slice(0, 6).toLowerCase()}` +} + +export function getColorDefault(param) { + const candidate = normalizeHexColor(param?.default_color) + if (candidate) return candidate + return { LaneLinesColor: "#00ff00", PathEdgesColor: "#00ff00", PathColor: "#30ff9c" }[param?.key] || "#ffffff" +} + +export function slugifySectionName(name) { + return String(name || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") +} + +export function isGroupParam(param) { + return !!param && param.ui_type === "group" +} + +export function applyParamChange(values, patch) { + const next = { ...(values || {}) } + if (!patch || typeof patch !== "object") return next + if ("key" in patch && "value" in patch) { + next[patch.key] = patch.value + for (const [k, v] of Object.entries(patch)) { + if (k !== "key" && k !== "value") next[k] = v + } + } else { + Object.assign(next, patch) + } + return next +} + +export function isParamEnabledForChildren(paramOrKey, values) { + const param = typeof paramOrKey === "string" ? { key: paramOrKey } : paramOrKey + if (isGroupParam(param)) return true + return !!(param && param.key && values[param.key]) +} + +export function hasChildParams(paramsList, key) { + return (paramsList || []).some((param) => (param.parent_key || null) === key) +} + +export function buildRenderTree(paramsList, values, expanded, isVisible) { + const out = [] + const list = paramsList || [] + const visible = isVisible || (() => true) + + function walk(parentKey, depth) { + for (const param of list) { + if ((param.parent_key || null) !== parentKey) continue + if (!visible(param)) continue + out.push({ param, depth }) + if (hasChildParams(list, param.key) && isParamEnabledForChildren(param, values) && expanded[param.key]) { + walk(param.key, depth + 1) + } + } + } + + walk(null, 0) + return out +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/store.js b/starpilot/system/the_galaxy/assets/mobile/js/store.js new file mode 100644 index 0000000000..1925ade368 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/store.js @@ -0,0 +1,122 @@ +import { reactive } from "vue" + +const THEME_KEY = "galaxy-theme" + +function initialTheme() { + return localStorage.getItem(THEME_KEY) || "dark" +} + +export const store = reactive({ + route: "/", + params: {}, + drawerOpen: false, + search: "", + snackbar: null, + online: false, + deviceStatus: "Parked", + history: ["/"], + theme: initialTheme(), +}) + +export function setTheme(theme) { + const next = theme === "light" ? "light" : "dark" + store.theme = next + document.documentElement.setAttribute("data-theme", next) + try { localStorage.setItem(THEME_KEY, next) } catch (e) {} +} + +export function toggleTheme() { + setTheme(store.theme === "dark" ? "light" : "dark") +} + +export function parseHash(hash) { + const raw = hash.replace(/^#/, "") || "/" + const [pathname, queryString] = raw.split("?") + const params = {} + if (queryString) { + for (const pair of queryString.split("&")) { + const [k, v] = pair.split("=") + if (k) params[decodeURIComponent(k)] = decodeURIComponent(v || "") + } + } + return { path: pathname, params } +} + +// Rebuild the canonical hash string for a route, preserving its query params. +export function toHash(route) { + const { path, params } = parseHash(route) + const qs = Object.keys(params).map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`).join("&") + return qs ? `${path}?${qs}` : path +} + +function currentPath() { + return parseHash(window.location.hash).path +} + +function pushIfNew(route) { + const last = store.history[store.history.length - 1] + if (last !== route) store.history.push(route) +} + +function applyRoute(route, { scrollToTop = false } = {}) { + const { path, params } = parseHash(route) + const pathChanged = path !== store.route + store.route = path + store.params = params + store.drawerOpen = false + // Only jump to the top for real view changes. In-place hash updates (a Manage + // panel opening under the same section, an embed switching src) must not yank + // the reader back to the top of the page. + if (scrollToTop || pathChanged) window.scrollTo(0, 0) +} + +export function navigate(target) { + const { path } = parseHash(target) + if (path === currentPath()) { + applyRoute(target) + window.location.hash = toHash(target) + return + } + pushIfNew(path) + applyRoute(target) + window.location.hash = toHash(target) +} + +export function goHome() { + navigate("/") +} + +export function goBack() { + const current = currentPath() + if (store.history[store.history.length - 1] === current && store.history.length > 1) { + store.history.pop() + } + const prev = store.history[store.history.length - 1] || "/" + applyRoute(prev, { scrollToTop: true }) + window.location.hash = prev +} + +const NATIVE_ROOTS = new Set(["/", "/settings", "/tools", "/recordings", "/logs", "/tuning", "/navigation", "/vehicle", "/system", "/embed"]) + +export function toolHref(link) { + const path = link.split("?")[0] + if (NATIVE_ROOTS.has(path) || path.startsWith("/settings/") || path.startsWith("/embed")) return link + return "/embed?src=" + encodeURIComponent(path) +} + +export function initRouter() { + const apply = () => { + const route = (window.location.hash || "").replace(/^#/, "") || "/" + const { path, params } = parseHash(route) + const pathChanged = path !== store.route + store.route = path + store.params = params + store.drawerOpen = false + pushIfNew(route) + if (pathChanged) window.scrollTo(0, 0) + } + window.addEventListener("hashchange", apply) + store.history = ["/"] + apply() + setTheme(store.theme) +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Home.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Home.js new file mode 100644 index 0000000000..126cbe94fe --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Home.js @@ -0,0 +1,11 @@ +import { GalaxyEmbed } from "../components/GalaxyEmbed.js" + +export const Home = { + name: "Home", + components: { GalaxyEmbed }, + template: ` +
+ +
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Logs.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Logs.js new file mode 100644 index 0000000000..b8ba239cba --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Logs.js @@ -0,0 +1,246 @@ +import { api, showSnackbar } from "../api.js" +import { useLogStream } from "../composables.js" +import { GalaxyConfirm } from "../components/GalaxyModal.js" +import { GalaxyEmbed } from "../components/GalaxyEmbed.js" + +function parseLogDate(filename) { + const m = filename.match(/(\d{4})-(\d{2})-(\d{2})[T_]?(\d{2})-?(\d{2})-?(\d{2})?/) + if (!m) return new Date() + const date = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] || 0), Number(m[5] || 0), Number(m[6] || 0)) + return isNaN(date.getTime()) ? new Date() : date +} + +export const Logs = { + name: "Logs", + components: { GalaxyEmbed }, + data() { + return { + tab: "errors", + errorFiles: [], + errorsLoading: true, + selectedLog: "", + logContent: "", + logLoading: false, + tmuxFiles: [], + tmuxLoading: true, + troubleshootData: null, + troubleshootRunning: false, + searchFilter: "", + tmuxAutoScroll: true, + } + }, + created() { + this.stream = useLogStream({ endpoint: "/api/tmux_log/live", snapshotFn: () => api.tmuxSnapshot(), interval: 2000 }) + }, + watch: { + "stream.state.log"() { + this.$nextTick(() => { + if (this.stream.state.paused || !this.tmuxAutoScroll) return + const el = this.$refs.tmuxtail + if (el) el.scrollTop = el.scrollHeight + }) + }, + }, + mounted() { + this.loadErrorLogs() + this.loadTmuxLogs() + this.stream.start() + this.loadTroubleshoot() + }, + beforeUnmount() { this.stream.destroy() }, + computed: { + filteredErrorFiles() { + if (!this.searchFilter) return this.errorFiles + const q = this.searchFilter.toLowerCase() + return this.errorFiles.filter((f) => f.filename.toLowerCase().includes(q)) + }, + }, + methods: { + async loadErrorLogs() { + try { + const files = await api.getErrorLogs() + this.errorFiles = files.map((f) => { + const date = parseLogDate(f) + return { filename: f, date: date.toLocaleString() } + }) + } catch (e) { + showSnackbar("Failed to load error logs.", "error") + } finally { + this.errorsLoading = false + } + }, + async viewLog(file) { + this.selectedLog = file.filename + this.logLoading = true + try { + this.logContent = await api.getErrorLog(file.filename) + } catch (e) { + this.logContent = "Could not load this log." + } finally { + this.logLoading = false + this.$nextTick(() => { + const el = this.$refs.logview + if (el) el.scrollTop = el.scrollHeight + }) + } + }, + async deleteLog(file) { + if (!(await GalaxyConfirm({ title: "Delete log?", message: `Delete ${file.filename}?`, confirmLabel: "Delete", danger: true }))) return + await api.deleteErrorLog(file.filename) + this.errorFiles = this.errorFiles.filter((f) => f.filename !== file.filename) + if (this.selectedLog === file.filename) this.selectedLog = "" + showSnackbar("Log deleted!") + }, + async deleteAllLogs() { + if (!(await GalaxyConfirm({ title: "Delete all error logs?", message: "This cannot be undone.", confirmLabel: "Delete All", danger: true }))) return + try { + const ok = await api.deleteAllErrorLogs() + if (!ok) throw new Error("Delete failed") + this.errorFiles = [] + this.selectedLog = "" + showSnackbar("All error logs deleted!") + } catch (e) { + showSnackbar("Delete all failed.", "error") + } + }, + copyLog() { + const text = this.logContent + if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text) + else { const ta = document.createElement("textarea"); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove() } + showSnackbar("Copied to clipboard!") + }, + async loadTmuxLogs() { + try { + const files = await api.getTmuxLogs() + this.tmuxFiles = files.map((f) => { + const date = new Date(f.timestamp * 1000) + return { filename: f.filename, date: date.toLocaleString() } + }) + } catch (e) { + this.tmuxFiles = [] + } finally { + this.tmuxLoading = false + } + }, + async captureTmux() { + const ok = await api.tmuxCapture() + showSnackbar(ok ? "Current session captured!" : "Capture failed.", ok ? "info" : "error") + this.tmuxLoading = true + await this.loadTmuxLogs() + }, + async deleteTmux(file) { + if (!(await GalaxyConfirm({ title: "Delete log?", message: `Delete ${file.filename}?`, confirmLabel: "Delete", danger: true }))) return + const ok = await api.deleteTmuxLog(file.filename) + showSnackbar(ok ? "Deleted!" : "Delete failed.", ok ? "info" : "error") + this.tmuxFiles = this.tmuxFiles.filter((f) => f.filename !== file.filename) + }, + async deleteAllTmux() { + if (!(await GalaxyConfirm({ title: "Delete all logs?", message: "This cannot be undone.", confirmLabel: "Delete All", danger: true }))) return + const ok = await api.deleteAllTmuxLogs() + showSnackbar(ok ? "All logs deleted!" : "Delete failed.", ok ? "info" : "error") + this.tmuxFiles = [] + }, + async loadTroubleshoot() { + try { + this.troubleshootData = await api.getTroubleshoot() + } catch (e) { this.troubleshootData = null } + }, + async runTroubleshoot() { + this.troubleshootRunning = true + try { + this.troubleshootData = await api.runTroubleshoot() + } catch (e) { + showSnackbar("Troubleshoot failed.", "error") + } finally { + this.troubleshootRunning = false + } + }, + async resetTroubleshoot() { + const ok = await api.resetTroubleshoot() + if (ok) this.troubleshootData = null + showSnackbar(ok ? "Troubleshoot reset!" : "Reset failed.", ok ? "info" : "error") + }, + onTmuxScroll(e) { + const el = e.target + this.tmuxAutoScroll = el.scrollHeight - el.scrollTop - el.clientHeight < 40 + }, + }, + template: ` +
+

Logs & Diagnostics

+ +
+ + + +
+ + + + + + +
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Navigation.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Navigation.js new file mode 100644 index 0000000000..845f769fd5 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Navigation.js @@ -0,0 +1,89 @@ +import { api, showSnackbar } from "../api.js" +import { GalaxySection } from "../components/GalaxySection.js" +import { GalaxyEmbed } from "../components/GalaxyEmbed.js" +import { useTabRouting } from "../composables.js" + +export const Navigation = { + name: "Navigation", + components: { GalaxySection, GalaxyEmbed }, + data() { + return { destination: "", favorites: [], navLoading: true } + }, + setup() { + return useTabRouting("/navigation", { + nav: "", maps: "maps", keys: "keys", speeds: "speeds", + }) + }, + computed: { + embedSrc() { + const sources = { + maps: "/manage_maps", + keys: "/manage_navigation_keys", + speeds: "/download_speed_limits", + } + return sources[this.tab] || "" + }, + embedTitle() { + const titles = { + maps: "Maps", + keys: "App Keys", + speeds: "Speed Limits", + } + return titles[this.tab] || "Navigation" + }, + }, + mounted() { this.loadNavigation() }, + methods: { + async loadNavigation() { + this.navLoading = true + try { + const data = await api.getNavigation() + this.destination = data?.destination || data?.name || "" + this.favorites = Array.isArray(data?.favorites) ? data.favorites : [] + } catch (e) { + this.favorites = [] + } finally { + this.navLoading = false + } + }, + async setDestination() { + if (!this.destination) return + try { + const payload = await api.setNavigation({ destination: this.destination }) + showSnackbar(payload?.message || "Destination set.") + } catch (e) { + showSnackbar(e?.message || "Failed to set destination.", "error") + } + }, + }, + template: ` +
+

Navigation & Maps

+ +
+ + + + +
+ + + + +
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Recordings.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Recordings.js new file mode 100644 index 0000000000..c0d8f8339d --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Recordings.js @@ -0,0 +1,305 @@ +import { api, showSnackbar } from "../api.js" +import { GalaxyConfirm } from "../components/GalaxyModal.js" +import { GalaxySection } from "../components/GalaxySection.js" + +function fmtDuration(seconds) { + seconds = Number(seconds) || 0 + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + return h > 0 ? `${h}h ${m}m` : `${m}m` +} + +function formatBytes(bytes) { + if (!bytes) return "0 MB" + const mb = bytes / 1e6 + return mb >= 1000 ? `${(mb / 1000).toFixed(2)} GB` : `${mb.toFixed(1)} MB` +} + +function normalizeRoute(r) { + const name = String(r?.name || "") + const isCustomName = !!r?.isCustomName + return { + name, + displayName: r?.displayName || name.split("--").pop() || name, + displayDate: r?.displayDate || "", + approxDurationSeconds: Number(r?.approxDurationSeconds || 0), + segmentCount: Number(r?.segmentCount || r?.numSegments || 0), + is_preserved: !!r?.is_preserved, + isCustomName, + png: r?.png || "", + } +} + +export const Recordings = { + name: "Recordings", + components: { GalaxySection }, + data() { + return { + loading: true, + error: "", + routes: [], + progress: 0, + searchQuery: "", + sortOrder: "newest", + showPreservedOnly: false, + playerRoute: null, + playerLoading: false, + playerError: "", + segments: [], + current: 0, + cameras: [], + selectedCamera: "", + logsRoute: null, + logsData: null, + } + }, + computed: { + stats() { + return { + count: this.routes.length, + formattedDuration: fmtDuration(this.routes.reduce((n, r) => n + r.approxDurationSeconds, 0)), + preservedCount: this.routes.filter((r) => r.is_preserved).length, + } + }, + visibleRoutes() { + let list = this.routes.slice() + if (this.showPreservedOnly) list = list.filter((r) => r.is_preserved) + if (this.searchQuery.trim()) { + const q = this.searchQuery.toLowerCase() + list = list.filter((r) => [r.displayName, r.displayDate, r.name].some((v) => String(v || "").toLowerCase().includes(q))) + } + const sorters = { + newest: (a, b) => (b.name > a.name ? 1 : -1), + oldest: (a, b) => (a.name > b.name ? 1 : -1), + longest: (a, b) => b.approxDurationSeconds - a.approxDurationSeconds, + shortest: (a, b) => a.approxDurationSeconds - b.approxDurationSeconds, + } + return list.sort(sorters[this.sortOrder] || sorters.newest) + }, + }, + methods: { + fmtDuration, + formatBytes, + async loadRoutes() { + this.loading = true + this.error = "" + this.routes = [] + this.progress = 0 + const seen = new Set() + try { + this.controller?.abort() + this.controller = new AbortController() + await api.getRoutesStream({ + signal: this.controller.signal, + onProgress: (p) => { this.progress = p }, + onRoutes: (raw) => { + for (const r of raw) { + if (seen.has(r.name)) continue + seen.add(r.name) + this.routes.push(normalizeRoute(r)) + } + }, + }) + } catch (e) { + if (e?.name !== "AbortError") this.error = "Couldn't load routes. Try refreshing." + } finally { + this.loading = false + } + }, + async deleteRoute(route) { + if (!(await GalaxyConfirm({ title: "Delete route?", message: `Delete “${route.displayName}”?`, confirmLabel: "Delete", danger: true }))) return + try { + await api.deleteRoute(route.name) + this.routes = this.routes.filter((r) => r.name !== route.name) + showSnackbar("Route deleted!") + } catch (e) { + showSnackbar("Delete failed.", "error") + } + }, + async togglePreserved(route) { + try { + await api.setRoutePreserved(route.name, !route.is_preserved) + route.is_preserved = !route.is_preserved + } catch (e) { + showSnackbar("Failed to update preserved state.", "error") + } + }, + async renameRoute(route) { + const newName = prompt("Rename route:", route.displayName) + if (!newName || newName === route.displayName) return + try { + const payload = await api.renameRoute(route.name, newName) + Object.assign(route, normalizeRoute({ ...route, name: payload.name || newName, isCustomName: true })) + route.displayName = payload.name || newName + showSnackbar("Route renamed!") + } catch (e) { + showSnackbar("Rename failed.", "error") + } + }, + async deleteAllRoutes(includePreserved) { + const label = includePreserved ? "Delete all routes, including preserved?" : "Delete all non-preserved routes?" + if (!(await GalaxyConfirm({ title: label, message: "This action cannot be undone.", confirmLabel: includePreserved ? "Delete Everything" : "Delete Non-Preserved", danger: true }))) return + try { + const payload = await api.deleteAllRoutes(includePreserved) + this.routes = [] + showSnackbar(payload?.message || "Routes deleted!") + } catch (e) { + showSnackbar("Failed to delete routes.", "error") + } + }, + async openPlayer(route) { + this.playerRoute = route + this.playerLoading = true + this.playerError = "" + try { + const data = await api.getRoute(route.name) + const segments = Array.isArray(data.segment_urls) ? data.segment_urls.filter((u) => typeof u === "string") : [] + const cameras = ["forward", "wide", "driver"].filter((c) => data.available_cameras?.includes(c)) + if (!segments.length) throw new Error("No video segments for this route.") + if (!cameras.length) throw new Error("No camera video for this route.") + this.segments = segments + this.current = 0 + this.cameras = cameras + this.selectedCamera = cameras.includes("forward") ? "forward" : cameras[0] + this.$nextTick(() => this.playSegment()) + } catch (e) { + this.playerError = e?.message || "Could not load route." + } finally { + this.playerLoading = false + } + }, + cameraUrl(url, low) { + if (this.selectedCamera === "forward") return low && !url.includes("?" ) ? `${url}?quality=low` : url + const sep = url.includes("?") ? "&" : "?" + return `${url}${sep}camera=${encodeURIComponent(this.selectedCamera)}${low ? "&quality=low" : ""}` + }, + playSegment() { + const video = this.$refs.player + if (!video || !this.segments[this.current]) return + video.src = this.cameraUrl(this.segments[this.current]) + video.load() + video.play().catch(() => {}) + }, + downloadRoute() { + if (!this.playerRoute) return + const a = document.createElement("a") + a.href = `/video/${this.playerRoute.name}/combined?camera=${encodeURIComponent(this.selectedCamera)}` + a.download = `${this.playerRoute.displayName}-${this.selectedCamera}.mp4` + a.click() + }, + closePlayer() { + if (this.$refs.player) { this.$refs.player.pause(); this.$refs.player.removeAttribute("src") } + this.playerRoute = null + this.playerLoading = false + this.playerError = "" + this.segments = [] + this.cameras = [] + }, + async openLogs(route) { + try { + this.logsData = await api.getRouteLogs(route.name) + this.logsRoute = route + } catch (e) { + showSnackbar("Could not read logs.", "error") + } + }, + }, + async mounted() { await this.loadRoutes() }, + beforeUnmount() { this.controller?.abort() }, + template: ` +
+

Recordings

+ +
+
+ + Dashcam Routes + {{ stats.count }} drives · {{ stats.formattedDuration }} +
+
+ + + + +
+
+ +
Finding local routes... ({{ Math.round(progress) }}%)
+
{{ error }}
+
+
No routes found.
+
+
+ {{ r.displayName }} Preserved + {{ fmtDuration(r.approxDurationSeconds) }} · {{ r.segmentCount }} segments +
+
+ + + + +
+
+
+ +
+
+ + Delete local routes +
+
+ + +
+
+ +
+
+ + {{ logsData.segments?.length || 0 }} segments · {{ formatBytes(logsData.totalBytes) }} + Download all (.tar) +
+
+
+ Segment {{ seg.segmentNum }} + {{ seg.filename }} · {{ formatBytes(seg.bytes) }} +
+ Download +
+
+ + +
+ +
+
+
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Settings.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Settings.js new file mode 100644 index 0000000000..64e6c2ed78 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Settings.js @@ -0,0 +1,166 @@ +import { api, showSnackbar } from "../api.js" +import { navigate, store } from "../store.js" +import { + applyParamChange, countAdvancedHiddenByDeveloperMode, GALAXY_DEVELOPER_MODE_KEY, isSettingVisible, + slugifySectionName, +} from "../params.js" +import { SettingTree } from "../components/SettingTree.js" +import { GalaxyToggleCard } from "../components/GalaxyToggleCard.js" +import { GalaxySection } from "../components/GalaxySection.js" +import { DevModeBanner } from "../components/DevModeBanner.js" + +export const Settings = { + name: "Settings", + components: { SettingTree, GalaxyToggleCard, GalaxySection, DevModeBanner }, + data() { + return { + layout: [], + values: {}, + expanded: {}, + loading: true, + activeSectionSlug: "", + defaultSectionSlug: "lateral-steering", + } + }, + computed: { + devModeOn() { return !!this.values[GALAXY_DEVELOPER_MODE_KEY] }, + route() { return store.route }, + sections() { + return this.layout + .filter((s) => s.name !== "Model & Customization") + .map((s) => ({ + ...s, + params: (s.params || []).filter((p) => isSettingVisible(s, p, this.values)), + slug: slugifySectionName(s.name), + })) + .filter((s) => s.params.length > 0) + }, + activeSection() { + return this.sections.find((s) => s.slug === this.activeSectionSlug) || this.sections[0] + }, + hiddenAdvancedCount() { return countAdvancedHiddenByDeveloperMode(this.layout, this.values) }, + searchActive() { return !!this.searchTerm }, + searchTerm: { + get() { return store.search }, + set(v) { store.search = v }, + }, + searchResults() { + if (!this.searchActive) return [] + return this.sections + .map((s) => ({ ...s, matches: s.params.filter((p) => this.matchesFilter(p)) })) + .filter((s) => s.matches.length > 0) + }, + }, + methods: { + async load() { + try { + const [layout, values, defaults] = await Promise.all([ + api.getLayout(), api.getParams(), api.getDefaults(), + ]) + this.layout = layout + this.values = values || {} + this.defaults = defaults || {} + if (!this.activeSectionSlug && this.sections.length) { + const preferred = this.sections.find((s) => s.slug === this.defaultSectionSlug) + this.activeSectionSlug = (preferred || this.sections[0]).slug + } + } catch (e) { + showSnackbar("Failed to load settings: " + (e?.message || e), "error") + } finally { + this.loading = false + } + }, + onParamChange(patch) { + this.values = applyParamChange(this.values, patch) + }, + toggleManage(key) { + const next = !this.expanded[key] + this.expanded = { ...this.expanded, [key]: next } + const base = "/settings/" + this.activeSectionSlug + window.location.hash = next ? `${base}?open=${encodeURIComponent(key)}` : base + }, + matchesFilter(p) { + if (!this.searchTerm) return true + const q = this.searchTerm.toLowerCase() + return [p.label, p.key, p.description].some((v) => String(v || "").toLowerCase().includes(q)) + }, + selectSection(slug) { + if (slug !== this.activeSectionSlug) navigate("/settings/" + slug) + }, + applyRouteSection() { + const route = store.route + if (!route.startsWith("/settings/")) return + const slug = route.replace(/^\/settings\/?/, "").split("?")[0].split("/")[0] + if (slug && this.sections.some((s) => s.slug === slug)) this.activeSectionSlug = slug + if (store.params.open) this.expanded = { ...this.expanded, [store.params.open]: true } + }, + lockReason(param) { + if (param?.requires_offroad && this.values.IsOnroad) return "This setting can only be changed while parked." + if (param?.requires_parked && !this.values.VehicleParked) return "This setting can only be changed while the vehicle is in Park." + if (param?.disabled_when_key_true && this.values[param.disabled_when_key_true]) return param.disabled_reason || "Disabled by another setting." + if (param?.requires_nonempty_key) { + const val = this.values[param.requires_nonempty_key] + if (!val || val === "{}" || val === "") return param.disabled_reason || "Required configuration missing." + } + return "" + }, + }, + watch: { + route() { this.applyRouteSection() }, + devModeOn() { this.load() }, + }, + async mounted() { + await this.load() + this.applyRouteSection() + }, + template: ` +
+

Toggles

+ + + +
Loading configuration...
+ + + +
No settings available.
+
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js b/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js new file mode 100644 index 0000000000..11d5be3372 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js @@ -0,0 +1,261 @@ +import { api, showSnackbar } from "../api.js" +import { usePolling } from "../composables.js" +import { GalaxyConfirm } from "../components/GalaxyModal.js" +import { GalaxySection } from "../components/GalaxySection.js" +import { GalaxyEmbed } from "../components/GalaxyEmbed.js" + +function shortCommit(commit) { + return String(commit || "").slice(0, 10) || "—" +} + +export const SystemTools = { + name: "SystemTools", + components: { GalaxySection, GalaxyEmbed }, + data() { + return { + branches: [], + currentBranch: "", + branchLoading: true, + fastStatus: null, + checkedForUpdates: false, + busy: "", + } + }, + created() { this.poll = usePolling(() => this.loadFastStatus(), { interval: 3000 }); this.poll.start() }, + mounted() { this.loadBranches() }, + beforeUnmount() { this.poll?.destroy() }, + computed: { + updateAvailable() { return !!this.fastStatus?.updateAvailable && !this.fastStatus?.running }, + }, + methods: { + shortCommit, + async loadBranches() { + try { + const data = await api.getUpdateBranches() + this.branches = Array.isArray(data?.branches) ? data.branches : [] + this.currentBranch = data?.currentBranch || "" + this.isOnroad = !!data?.isOnroad + } catch (e) { + showSnackbar("Failed to load update info.", "error") + } finally { + this.branchLoading = false + } + }, + async loadFastStatus() { + try { this.fastStatus = await api.getUpdateFastStatus() } catch (e) { this.fastStatus = null } + }, + async backupToggles() { + try { + const blob = await api.backupToggles() + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = "toggle-backup.json" + a.click() + setTimeout(() => URL.revokeObjectURL(url), 1000) + showSnackbar("Toggle backup downloaded.") + } catch (e) { + showSnackbar(e?.message || "Backup failed.", "error") + } + }, + onRestoreFile(e) { + const file = e.target.files[0] + e.target.value = "" + if (!file) return + if (file.size > 5_000_000) { showSnackbar("That toggle backup file is too large.", "error"); return } + file.text().then((text) => { + let data + try { data = JSON.parse(text) } catch { showSnackbar("That file is not a valid toggle backup.", "error"); return } + if (!data || typeof data !== "object" || Array.isArray(data)) { showSnackbar("That file is not a valid toggle backup.", "error"); return } + api.restoreToggles(data).then((res) => { + showSnackbar(res?.message || "Toggles restored!") + }).catch((err) => showSnackbar(err?.message || "Failed to restore toggles.", "error")) + }) + }, + async resetDefault() { + if (!(await GalaxyConfirm({ title: "Reset toggles to default?", message: "This resets all toggles to their default values and reboots.", confirmLabel: "Reset", danger: true }))) return + try { + await api.resetTogglesDefault() + showSnackbar("Resetting toggles to default... rebooting.") + } catch (e) { + showSnackbar("Reset failed.", "error") + } + }, + onBranchSelect(e) { + const branch = e.target.value + e.target.value = this.currentBranch || "" + this.switchBranch(branch) + }, + async switchBranch(branch) { + if (!branch || branch === this.currentBranch) return + if (!(await GalaxyConfirm({ title: "Switch branch?", message: `Switch to ${branch} and update?`, confirmLabel: "Switch" }))) return + try { + await api.setUpdateBranch(branch) + showSnackbar(`Switching to ${branch}...`) + } catch (e) { + showSnackbar(e?.message || "Switch failed.", "error") + } + }, + async checkUpdates() { + if (this.busy) return + this.busy = "check" + try { + await this.loadFastStatus() + this.checkedForUpdates = true + const st = this.fastStatus + if (st?.running) showSnackbar("An update is already running.") + else if (st?.updateAvailable) showSnackbar(st?.message || "Update available.") + else showSnackbar(st?.message || "No update available — you're up to date.") + } catch (e) { + showSnackbar("Failed to check for updates.", "error") + } finally { + this.busy = "" + } + }, + async applyFastUpdate() { + if (this.busy || this.isOnroad) return + if (this.fastStatus?.running) { showSnackbar("Fast update is already running."); return } + if (!this.checkedForUpdates || !this.updateAvailable) { + showSnackbar("No update available. Run \"Check for Updates\" first.", "error") + return + } + const st = this.fastStatus + const confirmed = await GalaxyConfirm({ + title: "Update available", + message: `Fast update to the latest commit on ${st?.branch || "this branch"}.\n\nYour device will reboot when the update is done.`, + confirmLabel: "Update & Reboot", + danger: true, + }) + if (!confirmed) return + await this.runUpdate("fast") + }, + async runUpdate(action) { + if (this.busy) return + this.busy = action + try { + if (action === "rollback") { + const st = this.fastStatus + if (st && !st.rollbackAvailable) { + showSnackbar("No previous installed version is available to roll back to.", "error") + return + } + } + if (action !== "fast") { + const actionLabels = { recover: "Recover the interrupted update?", rollback: "Roll back to the previous installed version?" } + if (!(await GalaxyConfirm({ title: actionLabels[action] || "Continue?", message: "Your device will reboot when the operation is done.", confirmLabel: "Continue", danger: true }))) return + } + const fn = action === "fast" ? api.updateFast : action === "recover" ? api.updateRecover : api.updateRollback + const payload = await fn() + showSnackbar(payload?.message || "Update started.") + await this.loadFastStatus() + } catch (e) { + showSnackbar(e?.message || "Update failed.", "error") + } finally { + this.busy = "" + } + }, + async factoryReset() { + if (!(await GalaxyConfirm({ title: "Factory reset?", message: "This wipes params, backups, themes, models, maps, and route data, then reboots. This cannot be undone.", confirmLabel: "Factory Reset", danger: true }))) return + try { + await api.factoryReset() + showSnackbar("Factory resetting...") + } catch (e) { + showSnackbar(e?.message || "Factory reset failed.", "error") + } + }, + async saveMe() { + if (!(await GalaxyConfirm({ title: "SAVE ME", message: "This will factory reset the device by wiping params, backups, themes, models, maps, and route data. The device will reboot when the wipe is complete. This cannot be undone.", confirmLabel: "Factory Reset", danger: true }))) return + try { + await api.factoryReset() + showSnackbar("SAVE ME initiated — factory resetting...") + } catch (e) { + showSnackbar(e?.message || "Factory reset failed.", "error") + } + }, + async deleteAllDrivingRoutes() { + if (!(await GalaxyConfirm({ title: "Delete All Driving Routes", message: "This permanently deletes all local routes from standard, high-resolution, and alternate footage storage. It does not reset settings or reboot the device.", confirmLabel: "Delete Routes", danger: true }))) return + try { + const payload = await api.deleteAllRoutes(true) + showSnackbar(payload?.message || "All local driving routes deleted.") + } catch (e) { + showSnackbar(e?.message || "Failed to delete driving routes.", "error") + } + }, + }, + template: ` +
+

System Tools

+ + +
+ + + + + + +
+ +
+ + +
+
Loading update info...
+ +
+
+ + +
+

Last resort only. This wipes params, backups, themes, models, maps, and route data, then reboots the device.

+ +
+
+
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/ToolEmbed.js b/starpilot/system/the_galaxy/assets/mobile/js/views/ToolEmbed.js new file mode 100644 index 0000000000..a78134ed07 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/ToolEmbed.js @@ -0,0 +1,46 @@ +import { store } from "../store.js" +import { GalaxyEmbed } from "../components/GalaxyEmbed.js" + +export const ToolEmbed = { + name: "ToolEmbed", + components: { GalaxyEmbed }, + computed: { + src() { return store.params.src || "/tools" }, + title() { + const map = { + "/manage_models": "Model Manager", + "/galaxy": "Galaxy", + "/sentry": "Sentry Mode", + "/plots": "Live Plots", + "/download_speed_limits": "Download Speed Limits", + "/testing_ground": "Testing Ground", + "/theme_maker": "Theme Maker", + "/troubleshoot": "Troubleshoot", + "/manage_tmux": "Tmux Log", + "/manage_toggles": "Backup and Restore", + "/manage_updates": "Software", + "/manage_error_logs": "Error Logs", + "/bluetooth": "Bluetooth", + "/wheel-controls": "Controllers", + "/vehicle_features": "Vehicle Features", + "/manage_v_asm": "V-Adj Spot Monitor", + "/manage_pip_sidecam": "PiP Side Camera", + "/manage_doors": "Lock/Unlock Doors", + "/manage_tsk": "Toyota Security Keys", + "/set_navigation_destination": "Navigation Destination", + "/manage_navigation_keys": "App Keys", + "/manage_maps": "Maps", + } + return map[this.src] || "Tool" + }, + }, + template: ` +
+
+ + {{ title }} +
+ +
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Tools.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Tools.js new file mode 100644 index 0000000000..64c9595513 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Tools.js @@ -0,0 +1,39 @@ +import { navigate, toolHref } from "../store.js" + +const TOOLS = [ + { name: "Galaxy", link: "/galaxy", icon: "bi-globe2", desc: "Pairing & remote access" }, + { name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle", desc: "Error logs, tmux, troubleshoot" }, + { name: "Model Manager", link: "/manage_models", icon: "bi-cpu", desc: "Install/swap models" }, + { name: "Navigation & Maps", link: "/navigation", icon: "bi-map", desc: "Offline maps & destinations" }, + { name: "PiP Side Camera", link: "/manage_pip_sidecam", icon: "bi-camera-video", desc: "Adjust PiP side-camera window" }, + { name: "Plots", link: "/plots", icon: "bi-graph-up-arrow", desc: "Live telemetry plots" }, + { name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation", desc: "Sentry alerts & security" }, + { name: "System Tools", link: "/system", icon: "bi-arrow-repeat", desc: "Backup, restore, updates" }, + { name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2", desc: "Experiments & testing" }, + { name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill", desc: "Customize the look" }, + { name: "Tuning & Maneuvers", link: "/tuning", icon: "bi-sign-turn-right", desc: "Steering & speed behaviour" }, + { name: "V-ASM Spot Monitor", link: "/manage_v_asm", icon: "bi-bounding-box", desc: "Adjust spot-monitor window" }, + { name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front", desc: "Controllers, bluetooth, vehicle features" }, +].sort((a, b) => a.name.localeCompare(b.name)) + +export const Tools = { + name: "Tools", + data() { return { TOOLS } }, + methods: { + open(t) { + navigate(toolHref(t.link)) + }, + }, + template: ` +
+

Tools

+
+ +
+
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Tuning.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Tuning.js new file mode 100644 index 0000000000..767cebca41 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Tuning.js @@ -0,0 +1,15 @@ +import { GalaxyEmbed } from "../components/GalaxyEmbed.js" + +export const Tuning = { + name: "Tuning", + components: { GalaxyEmbed }, + template: ` +
+
+ + Tuning & Maneuvers +
+ +
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Vehicle.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Vehicle.js new file mode 100644 index 0000000000..568b06ed96 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Vehicle.js @@ -0,0 +1,79 @@ +import { api, showSnackbar } from "../api.js" +import { navigate, toolHref } from "../store.js" +import { WheelControls } from "../components/WheelControls.js" +import { BluetoothPanel } from "../components/BluetoothPanel.js" +import { GalaxySection } from "../components/GalaxySection.js" + +const FEATURES = [ + { key: "doors", name: "Lock/Unlock Doors", icon: "bi-door-closed", desc: "Send lock or unlock commands remotely to your vehicle.", embed: "/manage_doors" }, + { key: "tsk", name: "Toyota Security Keys", icon: "bi-key-fill", desc: "Manage and apply security keys for secOC protected devices.", embed: "/manage_tsk" }, +] + +export const Vehicle = { + name: "Vehicle", + components: { WheelControls, BluetoothPanel, GalaxySection }, + data() { + return { + features: FEATURES, + featureStatus: {}, + busy: "", + } + }, + methods: { + statusOf(key) { return this.featureStatus[key] || "untested" }, + async openFeature(f) { + if (this.busy) return + if (this.statusOf(f.key) === "denied") { + showSnackbar(`${f.name} is not supported for your current vehicle.`, "error") + return + } + if (this.statusOf(f.key) === "allowed") { + navigate(toolHref(f.embed)) + return + } + this.busy = f.key + try { + const data = await api.carFeaturesCheck(f.key) + const allowed = !!data?.result + this.featureStatus = { ...this.featureStatus, [f.key]: allowed ? "allowed" : "denied" } + if (allowed) navigate(toolHref(f.embed)) + else showSnackbar(`${f.name} is not supported for your current vehicle.`, "error") + } catch (e) { + this.featureStatus = { ...this.featureStatus, [f.key]: "denied" } + showSnackbar("Could not check vehicle compatibility.", "error") + } finally { + this.busy = "" + } + }, + }, + template: ` +
+

Vehicle Controls

+ + + + + + + + + + +
+ +

These features verify vehicle compatibility when launched.

+
+
+
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/manifest.json b/starpilot/system/the_galaxy/assets/mobile/manifest.json new file mode 100644 index 0000000000..3fdbe29c9b --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "Galaxy", + "short_name": "Galaxy", + "description": "Control and configure your openpilot device from anywhere.", + "icons": [ + { "src": "/assets/images/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, + { "src": "/assets/images/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }, + { "src": "/assets/images/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" } + ], + "id": "46bf2df73deba8e1512c35de", + "start_url": "/mobile/", + "scope": "/", + "background_color": "#06060f", + "theme_color": "#8b6cc5", + "display": "standalone", + "display_override": ["standalone", "minimal-ui"], + "orientation": "portrait-primary" +} diff --git a/starpilot/system/the_galaxy/assets/vendor/vue/vue.esm-browser.js b/starpilot/system/the_galaxy/assets/vendor/vue/vue.esm-browser.js new file mode 100644 index 0000000000..c2fd80a85d --- /dev/null +++ b/starpilot/system/the_galaxy/assets/vendor/vue/vue.esm-browser.js @@ -0,0 +1,16774 @@ +/** +* vue v3.4.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/ +/*! #__NO_SIDE_EFFECTS__ */ +// @__NO_SIDE_EFFECTS__ +function makeMap(str, expectsLowerCase) { + const set = new Set(str.split(",")); + return expectsLowerCase ? (val) => set.has(val.toLowerCase()) : (val) => set.has(val); +} + +const EMPTY_OBJ = Object.freeze({}) ; +const EMPTY_ARR = Object.freeze([]) ; +const NOOP = () => { +}; +const NO = () => false; +const isOn = (key) => key.charCodeAt(0) === 111 && key.charCodeAt(1) === 110 && // uppercase letter +(key.charCodeAt(2) > 122 || key.charCodeAt(2) < 97); +const isModelListener = (key) => key.startsWith("onUpdate:"); +const extend = Object.assign; +const remove = (arr, el) => { + const i = arr.indexOf(el); + if (i > -1) { + arr.splice(i, 1); + } +}; +const hasOwnProperty$1 = Object.prototype.hasOwnProperty; +const hasOwn = (val, key) => hasOwnProperty$1.call(val, key); +const isArray = Array.isArray; +const isMap = (val) => toTypeString(val) === "[object Map]"; +const isSet = (val) => toTypeString(val) === "[object Set]"; +const isDate = (val) => toTypeString(val) === "[object Date]"; +const isRegExp = (val) => toTypeString(val) === "[object RegExp]"; +const isFunction = (val) => typeof val === "function"; +const isString = (val) => typeof val === "string"; +const isSymbol = (val) => typeof val === "symbol"; +const isObject = (val) => val !== null && typeof val === "object"; +const isPromise = (val) => { + return (isObject(val) || isFunction(val)) && isFunction(val.then) && isFunction(val.catch); +}; +const objectToString = Object.prototype.toString; +const toTypeString = (value) => objectToString.call(value); +const toRawType = (value) => { + return toTypeString(value).slice(8, -1); +}; +const isPlainObject = (val) => toTypeString(val) === "[object Object]"; +const isIntegerKey = (key) => isString(key) && key !== "NaN" && key[0] !== "-" && "" + parseInt(key, 10) === key; +const isReservedProp = /* @__PURE__ */ makeMap( + // the leading comma is intentional so empty string "" is also included + ",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted" +); +const isBuiltInDirective = /* @__PURE__ */ makeMap( + "bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo" +); +const cacheStringFunction = (fn) => { + const cache = /* @__PURE__ */ Object.create(null); + return (str) => { + const hit = cache[str]; + return hit || (cache[str] = fn(str)); + }; +}; +const camelizeRE = /-(\w)/g; +const camelize = cacheStringFunction((str) => { + return str.replace(camelizeRE, (_, c) => c ? c.toUpperCase() : ""); +}); +const hyphenateRE = /\B([A-Z])/g; +const hyphenate = cacheStringFunction( + (str) => str.replace(hyphenateRE, "-$1").toLowerCase() +); +const capitalize = cacheStringFunction((str) => { + return str.charAt(0).toUpperCase() + str.slice(1); +}); +const toHandlerKey = cacheStringFunction((str) => { + const s = str ? `on${capitalize(str)}` : ``; + return s; +}); +const hasChanged = (value, oldValue) => !Object.is(value, oldValue); +const invokeArrayFns = (fns, ...arg) => { + for (let i = 0; i < fns.length; i++) { + fns[i](...arg); + } +}; +const def = (obj, key, value, writable = false) => { + Object.defineProperty(obj, key, { + configurable: true, + enumerable: false, + writable, + value + }); +}; +const looseToNumber = (val) => { + const n = parseFloat(val); + return isNaN(n) ? val : n; +}; +const toNumber = (val) => { + const n = isString(val) ? Number(val) : NaN; + return isNaN(n) ? val : n; +}; +let _globalThis; +const getGlobalThis = () => { + return _globalThis || (_globalThis = typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : {}); +}; + +const PatchFlagNames = { + [1]: `TEXT`, + [2]: `CLASS`, + [4]: `STYLE`, + [8]: `PROPS`, + [16]: `FULL_PROPS`, + [32]: `NEED_HYDRATION`, + [64]: `STABLE_FRAGMENT`, + [128]: `KEYED_FRAGMENT`, + [256]: `UNKEYED_FRAGMENT`, + [512]: `NEED_PATCH`, + [1024]: `DYNAMIC_SLOTS`, + [2048]: `DEV_ROOT_FRAGMENT`, + [-1]: `HOISTED`, + [-2]: `BAIL` +}; + +const slotFlagsText = { + [1]: "STABLE", + [2]: "DYNAMIC", + [3]: "FORWARDED" +}; + +const GLOBALS_ALLOWED = "Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error"; +const isGloballyAllowed = /* @__PURE__ */ makeMap(GLOBALS_ALLOWED); + +const range = 2; +function generateCodeFrame(source, start = 0, end = source.length) { + start = Math.max(0, Math.min(start, source.length)); + end = Math.max(0, Math.min(end, source.length)); + if (start > end) return ""; + let lines = source.split(/(\r?\n)/); + const newlineSequences = lines.filter((_, idx) => idx % 2 === 1); + lines = lines.filter((_, idx) => idx % 2 === 0); + let count = 0; + const res = []; + for (let i = 0; i < lines.length; i++) { + count += lines[i].length + (newlineSequences[i] && newlineSequences[i].length || 0); + if (count >= start) { + for (let j = i - range; j <= i + range || end > count; j++) { + if (j < 0 || j >= lines.length) continue; + const line = j + 1; + res.push( + `${line}${" ".repeat(Math.max(3 - String(line).length, 0))}| ${lines[j]}` + ); + const lineLength = lines[j].length; + const newLineSeqLength = newlineSequences[j] && newlineSequences[j].length || 0; + if (j === i) { + const pad = start - (count - (lineLength + newLineSeqLength)); + const length = Math.max( + 1, + end > count ? lineLength - pad : end - start + ); + res.push(` | ` + " ".repeat(pad) + "^".repeat(length)); + } else if (j > i) { + if (end > count) { + const length = Math.max(Math.min(end - count, lineLength), 1); + res.push(` | ` + "^".repeat(length)); + } + count += lineLength + newLineSeqLength; + } + } + break; + } + } + return res.join("\n"); +} + +function normalizeStyle(value) { + if (isArray(value)) { + const res = {}; + for (let i = 0; i < value.length; i++) { + const item = value[i]; + const normalized = isString(item) ? parseStringStyle(item) : normalizeStyle(item); + if (normalized) { + for (const key in normalized) { + res[key] = normalized[key]; + } + } + } + return res; + } else if (isString(value) || isObject(value)) { + return value; + } +} +const listDelimiterRE = /;(?![^(]*\))/g; +const propertyDelimiterRE = /:([^]+)/; +const styleCommentRE = /\/\*[^]*?\*\//g; +function parseStringStyle(cssText) { + const ret = {}; + cssText.replace(styleCommentRE, "").split(listDelimiterRE).forEach((item) => { + if (item) { + const tmp = item.split(propertyDelimiterRE); + tmp.length > 1 && (ret[tmp[0].trim()] = tmp[1].trim()); + } + }); + return ret; +} +function stringifyStyle(styles) { + let ret = ""; + if (!styles || isString(styles)) { + return ret; + } + for (const key in styles) { + const value = styles[key]; + if (isString(value) || typeof value === "number") { + const normalizedKey = key.startsWith(`--`) ? key : hyphenate(key); + ret += `${normalizedKey}:${value};`; + } + } + return ret; +} +function normalizeClass(value) { + let res = ""; + if (isString(value)) { + res = value; + } else if (isArray(value)) { + for (let i = 0; i < value.length; i++) { + const normalized = normalizeClass(value[i]); + if (normalized) { + res += normalized + " "; + } + } + } else if (isObject(value)) { + for (const name in value) { + if (value[name]) { + res += name + " "; + } + } + } + return res.trim(); +} +function normalizeProps(props) { + if (!props) return null; + let { class: klass, style } = props; + if (klass && !isString(klass)) { + props.class = normalizeClass(klass); + } + if (style) { + props.style = normalizeStyle(style); + } + return props; +} + +const HTML_TAGS = "html,body,base,head,link,meta,style,title,address,article,aside,footer,header,hgroup,h1,h2,h3,h4,h5,h6,nav,section,div,dd,dl,dt,figcaption,figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,data,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,option,output,progress,select,textarea,details,dialog,menu,summary,template,blockquote,iframe,tfoot"; +const SVG_TAGS = "svg,animate,animateMotion,animateTransform,circle,clipPath,color-profile,defs,desc,discard,ellipse,feBlend,feColorMatrix,feComponentTransfer,feComposite,feConvolveMatrix,feDiffuseLighting,feDisplacementMap,feDistantLight,feDropShadow,feFlood,feFuncA,feFuncB,feFuncG,feFuncR,feGaussianBlur,feImage,feMerge,feMergeNode,feMorphology,feOffset,fePointLight,feSpecularLighting,feSpotLight,feTile,feTurbulence,filter,foreignObject,g,hatch,hatchpath,image,line,linearGradient,marker,mask,mesh,meshgradient,meshpatch,meshrow,metadata,mpath,path,pattern,polygon,polyline,radialGradient,rect,set,solidcolor,stop,switch,symbol,text,textPath,title,tspan,unknown,use,view"; +const MATH_TAGS = "annotation,annotation-xml,maction,maligngroup,malignmark,math,menclose,merror,mfenced,mfrac,mfraction,mglyph,mi,mlabeledtr,mlongdiv,mmultiscripts,mn,mo,mover,mpadded,mphantom,mprescripts,mroot,mrow,ms,mscarries,mscarry,msgroup,msline,mspace,msqrt,msrow,mstack,mstyle,msub,msubsup,msup,mtable,mtd,mtext,mtr,munder,munderover,none,semantics"; +const VOID_TAGS = "area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr"; +const isHTMLTag = /* @__PURE__ */ makeMap(HTML_TAGS); +const isSVGTag = /* @__PURE__ */ makeMap(SVG_TAGS); +const isMathMLTag = /* @__PURE__ */ makeMap(MATH_TAGS); +const isVoidTag = /* @__PURE__ */ makeMap(VOID_TAGS); + +const specialBooleanAttrs = `itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly`; +const isSpecialBooleanAttr = /* @__PURE__ */ makeMap(specialBooleanAttrs); +const isBooleanAttr = /* @__PURE__ */ makeMap( + specialBooleanAttrs + `,async,autofocus,autoplay,controls,default,defer,disabled,hidden,inert,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected` +); +function includeBooleanAttr(value) { + return !!value || value === ""; +} +const isKnownHtmlAttr = /* @__PURE__ */ makeMap( + `accept,accept-charset,accesskey,action,align,allow,alt,async,autocapitalize,autocomplete,autofocus,autoplay,background,bgcolor,border,buffered,capture,challenge,charset,checked,cite,class,code,codebase,color,cols,colspan,content,contenteditable,contextmenu,controls,coords,crossorigin,csp,data,datetime,decoding,default,defer,dir,dirname,disabled,download,draggable,dropzone,enctype,enterkeyhint,for,form,formaction,formenctype,formmethod,formnovalidate,formtarget,headers,height,hidden,high,href,hreflang,http-equiv,icon,id,importance,inert,integrity,ismap,itemprop,keytype,kind,label,lang,language,loading,list,loop,low,manifest,max,maxlength,minlength,media,min,multiple,muted,name,novalidate,open,optimum,pattern,ping,placeholder,poster,preload,radiogroup,readonly,referrerpolicy,rel,required,reversed,rows,rowspan,sandbox,scope,scoped,selected,shape,size,sizes,slot,span,spellcheck,src,srcdoc,srclang,srcset,start,step,style,summary,tabindex,target,title,translate,type,usemap,value,width,wrap` +); +const isKnownSvgAttr = /* @__PURE__ */ makeMap( + `xmlns,accent-height,accumulate,additive,alignment-baseline,alphabetic,amplitude,arabic-form,ascent,attributeName,attributeType,azimuth,baseFrequency,baseline-shift,baseProfile,bbox,begin,bias,by,calcMode,cap-height,class,clip,clipPathUnits,clip-path,clip-rule,color,color-interpolation,color-interpolation-filters,color-profile,color-rendering,contentScriptType,contentStyleType,crossorigin,cursor,cx,cy,d,decelerate,descent,diffuseConstant,direction,display,divisor,dominant-baseline,dur,dx,dy,edgeMode,elevation,enable-background,end,exponent,fill,fill-opacity,fill-rule,filter,filterRes,filterUnits,flood-color,flood-opacity,font-family,font-size,font-size-adjust,font-stretch,font-style,font-variant,font-weight,format,from,fr,fx,fy,g1,g2,glyph-name,glyph-orientation-horizontal,glyph-orientation-vertical,glyphRef,gradientTransform,gradientUnits,hanging,height,href,hreflang,horiz-adv-x,horiz-origin-x,id,ideographic,image-rendering,in,in2,intercept,k,k1,k2,k3,k4,kernelMatrix,kernelUnitLength,kerning,keyPoints,keySplines,keyTimes,lang,lengthAdjust,letter-spacing,lighting-color,limitingConeAngle,local,marker-end,marker-mid,marker-start,markerHeight,markerUnits,markerWidth,mask,maskContentUnits,maskUnits,mathematical,max,media,method,min,mode,name,numOctaves,offset,opacity,operator,order,orient,orientation,origin,overflow,overline-position,overline-thickness,panose-1,paint-order,path,pathLength,patternContentUnits,patternTransform,patternUnits,ping,pointer-events,points,pointsAtX,pointsAtY,pointsAtZ,preserveAlpha,preserveAspectRatio,primitiveUnits,r,radius,referrerPolicy,refX,refY,rel,rendering-intent,repeatCount,repeatDur,requiredExtensions,requiredFeatures,restart,result,rotate,rx,ry,scale,seed,shape-rendering,slope,spacing,specularConstant,specularExponent,speed,spreadMethod,startOffset,stdDeviation,stemh,stemv,stitchTiles,stop-color,stop-opacity,strikethrough-position,strikethrough-thickness,string,stroke,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-miterlimit,stroke-opacity,stroke-width,style,surfaceScale,systemLanguage,tabindex,tableValues,target,targetX,targetY,text-anchor,text-decoration,text-rendering,textLength,to,transform,transform-origin,type,u1,u2,underline-position,underline-thickness,unicode,unicode-bidi,unicode-range,units-per-em,v-alphabetic,v-hanging,v-ideographic,v-mathematical,values,vector-effect,version,vert-adv-y,vert-origin-x,vert-origin-y,viewBox,viewTarget,visibility,width,widths,word-spacing,writing-mode,x,x-height,x1,x2,xChannelSelector,xlink:actuate,xlink:arcrole,xlink:href,xlink:role,xlink:show,xlink:title,xlink:type,xmlns:xlink,xml:base,xml:lang,xml:space,y,y1,y2,yChannelSelector,z,zoomAndPan` +); +function isRenderableAttrValue(value) { + if (value == null) { + return false; + } + const type = typeof value; + return type === "string" || type === "number" || type === "boolean"; +} + +function looseCompareArrays(a, b) { + if (a.length !== b.length) return false; + let equal = true; + for (let i = 0; equal && i < a.length; i++) { + equal = looseEqual(a[i], b[i]); + } + return equal; +} +function looseEqual(a, b) { + if (a === b) return true; + let aValidType = isDate(a); + let bValidType = isDate(b); + if (aValidType || bValidType) { + return aValidType && bValidType ? a.getTime() === b.getTime() : false; + } + aValidType = isSymbol(a); + bValidType = isSymbol(b); + if (aValidType || bValidType) { + return a === b; + } + aValidType = isArray(a); + bValidType = isArray(b); + if (aValidType || bValidType) { + return aValidType && bValidType ? looseCompareArrays(a, b) : false; + } + aValidType = isObject(a); + bValidType = isObject(b); + if (aValidType || bValidType) { + if (!aValidType || !bValidType) { + return false; + } + const aKeysCount = Object.keys(a).length; + const bKeysCount = Object.keys(b).length; + if (aKeysCount !== bKeysCount) { + return false; + } + for (const key in a) { + const aHasKey = a.hasOwnProperty(key); + const bHasKey = b.hasOwnProperty(key); + if (aHasKey && !bHasKey || !aHasKey && bHasKey || !looseEqual(a[key], b[key])) { + return false; + } + } + } + return String(a) === String(b); +} +function looseIndexOf(arr, val) { + return arr.findIndex((item) => looseEqual(item, val)); +} + +const isRef$1 = (val) => { + return !!(val && val.__v_isRef === true); +}; +const toDisplayString = (val) => { + return isString(val) ? val : val == null ? "" : isArray(val) || isObject(val) && (val.toString === objectToString || !isFunction(val.toString)) ? isRef$1(val) ? toDisplayString(val.value) : JSON.stringify(val, replacer, 2) : String(val); +}; +const replacer = (_key, val) => { + if (isRef$1(val)) { + return replacer(_key, val.value); + } else if (isMap(val)) { + return { + [`Map(${val.size})`]: [...val.entries()].reduce( + (entries, [key, val2], i) => { + entries[stringifySymbol(key, i) + " =>"] = val2; + return entries; + }, + {} + ) + }; + } else if (isSet(val)) { + return { + [`Set(${val.size})`]: [...val.values()].map((v) => stringifySymbol(v)) + }; + } else if (isSymbol(val)) { + return stringifySymbol(val); + } else if (isObject(val) && !isArray(val) && !isPlainObject(val)) { + return String(val); + } + return val; +}; +const stringifySymbol = (v, i = "") => { + var _a; + return ( + // Symbol.description in es2019+ so we need to cast here to pass + // the lib: es2016 check + isSymbol(v) ? `Symbol(${(_a = v.description) != null ? _a : i})` : v + ); +}; + +function warn$2(msg, ...args) { + console.warn(`[Vue warn] ${msg}`, ...args); +} + +let activeEffectScope; +class EffectScope { + constructor(detached = false) { + this.detached = detached; + /** + * @internal + */ + this._active = true; + /** + * @internal + */ + this.effects = []; + /** + * @internal + */ + this.cleanups = []; + this.parent = activeEffectScope; + if (!detached && activeEffectScope) { + this.index = (activeEffectScope.scopes || (activeEffectScope.scopes = [])).push( + this + ) - 1; + } + } + get active() { + return this._active; + } + run(fn) { + if (this._active) { + const currentEffectScope = activeEffectScope; + try { + activeEffectScope = this; + return fn(); + } finally { + activeEffectScope = currentEffectScope; + } + } else { + warn$2(`cannot run an inactive effect scope.`); + } + } + /** + * This should only be called on non-detached scopes + * @internal + */ + on() { + activeEffectScope = this; + } + /** + * This should only be called on non-detached scopes + * @internal + */ + off() { + activeEffectScope = this.parent; + } + stop(fromParent) { + if (this._active) { + let i, l; + for (i = 0, l = this.effects.length; i < l; i++) { + this.effects[i].stop(); + } + for (i = 0, l = this.cleanups.length; i < l; i++) { + this.cleanups[i](); + } + if (this.scopes) { + for (i = 0, l = this.scopes.length; i < l; i++) { + this.scopes[i].stop(true); + } + } + if (!this.detached && this.parent && !fromParent) { + const last = this.parent.scopes.pop(); + if (last && last !== this) { + this.parent.scopes[this.index] = last; + last.index = this.index; + } + } + this.parent = void 0; + this._active = false; + } + } +} +function effectScope(detached) { + return new EffectScope(detached); +} +function recordEffectScope(effect, scope = activeEffectScope) { + if (scope && scope.active) { + scope.effects.push(effect); + } +} +function getCurrentScope() { + return activeEffectScope; +} +function onScopeDispose(fn) { + if (activeEffectScope) { + activeEffectScope.cleanups.push(fn); + } else { + warn$2( + `onScopeDispose() is called when there is no active effect scope to be associated with.` + ); + } +} + +let activeEffect; +class ReactiveEffect { + constructor(fn, trigger, scheduler, scope) { + this.fn = fn; + this.trigger = trigger; + this.scheduler = scheduler; + this.active = true; + this.deps = []; + /** + * @internal + */ + this._dirtyLevel = 4; + /** + * @internal + */ + this._trackId = 0; + /** + * @internal + */ + this._runnings = 0; + /** + * @internal + */ + this._shouldSchedule = false; + /** + * @internal + */ + this._depsLength = 0; + recordEffectScope(this, scope); + } + get dirty() { + if (this._dirtyLevel === 2 || this._dirtyLevel === 3) { + this._dirtyLevel = 1; + pauseTracking(); + for (let i = 0; i < this._depsLength; i++) { + const dep = this.deps[i]; + if (dep.computed) { + triggerComputed(dep.computed); + if (this._dirtyLevel >= 4) { + break; + } + } + } + if (this._dirtyLevel === 1) { + this._dirtyLevel = 0; + } + resetTracking(); + } + return this._dirtyLevel >= 4; + } + set dirty(v) { + this._dirtyLevel = v ? 4 : 0; + } + run() { + this._dirtyLevel = 0; + if (!this.active) { + return this.fn(); + } + let lastShouldTrack = shouldTrack; + let lastEffect = activeEffect; + try { + shouldTrack = true; + activeEffect = this; + this._runnings++; + preCleanupEffect(this); + return this.fn(); + } finally { + postCleanupEffect(this); + this._runnings--; + activeEffect = lastEffect; + shouldTrack = lastShouldTrack; + } + } + stop() { + if (this.active) { + preCleanupEffect(this); + postCleanupEffect(this); + this.onStop && this.onStop(); + this.active = false; + } + } +} +function triggerComputed(computed) { + return computed.value; +} +function preCleanupEffect(effect2) { + effect2._trackId++; + effect2._depsLength = 0; +} +function postCleanupEffect(effect2) { + if (effect2.deps.length > effect2._depsLength) { + for (let i = effect2._depsLength; i < effect2.deps.length; i++) { + cleanupDepEffect(effect2.deps[i], effect2); + } + effect2.deps.length = effect2._depsLength; + } +} +function cleanupDepEffect(dep, effect2) { + const trackId = dep.get(effect2); + if (trackId !== void 0 && effect2._trackId !== trackId) { + dep.delete(effect2); + if (dep.size === 0) { + dep.cleanup(); + } + } +} +function effect(fn, options) { + if (fn.effect instanceof ReactiveEffect) { + fn = fn.effect.fn; + } + const _effect = new ReactiveEffect(fn, NOOP, () => { + if (_effect.dirty) { + _effect.run(); + } + }); + if (options) { + extend(_effect, options); + if (options.scope) recordEffectScope(_effect, options.scope); + } + if (!options || !options.lazy) { + _effect.run(); + } + const runner = _effect.run.bind(_effect); + runner.effect = _effect; + return runner; +} +function stop(runner) { + runner.effect.stop(); +} +let shouldTrack = true; +let pauseScheduleStack = 0; +const trackStack = []; +function pauseTracking() { + trackStack.push(shouldTrack); + shouldTrack = false; +} +function resetTracking() { + const last = trackStack.pop(); + shouldTrack = last === void 0 ? true : last; +} +function pauseScheduling() { + pauseScheduleStack++; +} +function resetScheduling() { + pauseScheduleStack--; + while (!pauseScheduleStack && queueEffectSchedulers.length) { + queueEffectSchedulers.shift()(); + } +} +function trackEffect(effect2, dep, debuggerEventExtraInfo) { + var _a; + if (dep.get(effect2) !== effect2._trackId) { + dep.set(effect2, effect2._trackId); + const oldDep = effect2.deps[effect2._depsLength]; + if (oldDep !== dep) { + if (oldDep) { + cleanupDepEffect(oldDep, effect2); + } + effect2.deps[effect2._depsLength++] = dep; + } else { + effect2._depsLength++; + } + { + (_a = effect2.onTrack) == null ? void 0 : _a.call(effect2, extend({ effect: effect2 }, debuggerEventExtraInfo)); + } + } +} +const queueEffectSchedulers = []; +function triggerEffects(dep, dirtyLevel, debuggerEventExtraInfo) { + var _a; + pauseScheduling(); + for (const effect2 of dep.keys()) { + let tracking; + if (effect2._dirtyLevel < dirtyLevel && (tracking != null ? tracking : tracking = dep.get(effect2) === effect2._trackId)) { + effect2._shouldSchedule || (effect2._shouldSchedule = effect2._dirtyLevel === 0); + effect2._dirtyLevel = dirtyLevel; + } + if (effect2._shouldSchedule && (tracking != null ? tracking : tracking = dep.get(effect2) === effect2._trackId)) { + { + (_a = effect2.onTrigger) == null ? void 0 : _a.call(effect2, extend({ effect: effect2 }, debuggerEventExtraInfo)); + } + effect2.trigger(); + if ((!effect2._runnings || effect2.allowRecurse) && effect2._dirtyLevel !== 2) { + effect2._shouldSchedule = false; + if (effect2.scheduler) { + queueEffectSchedulers.push(effect2.scheduler); + } + } + } + } + resetScheduling(); +} + +const createDep = (cleanup, computed) => { + const dep = /* @__PURE__ */ new Map(); + dep.cleanup = cleanup; + dep.computed = computed; + return dep; +}; + +const targetMap = /* @__PURE__ */ new WeakMap(); +const ITERATE_KEY = Symbol("iterate" ); +const MAP_KEY_ITERATE_KEY = Symbol("Map key iterate" ); +function track(target, type, key) { + if (shouldTrack && activeEffect) { + let depsMap = targetMap.get(target); + if (!depsMap) { + targetMap.set(target, depsMap = /* @__PURE__ */ new Map()); + } + let dep = depsMap.get(key); + if (!dep) { + depsMap.set(key, dep = createDep(() => depsMap.delete(key))); + } + trackEffect( + activeEffect, + dep, + { + target, + type, + key + } + ); + } +} +function trigger(target, type, key, newValue, oldValue, oldTarget) { + const depsMap = targetMap.get(target); + if (!depsMap) { + return; + } + let deps = []; + if (type === "clear") { + deps = [...depsMap.values()]; + } else if (key === "length" && isArray(target)) { + const newLength = Number(newValue); + depsMap.forEach((dep, key2) => { + if (key2 === "length" || !isSymbol(key2) && key2 >= newLength) { + deps.push(dep); + } + }); + } else { + if (key !== void 0) { + deps.push(depsMap.get(key)); + } + switch (type) { + case "add": + if (!isArray(target)) { + deps.push(depsMap.get(ITERATE_KEY)); + if (isMap(target)) { + deps.push(depsMap.get(MAP_KEY_ITERATE_KEY)); + } + } else if (isIntegerKey(key)) { + deps.push(depsMap.get("length")); + } + break; + case "delete": + if (!isArray(target)) { + deps.push(depsMap.get(ITERATE_KEY)); + if (isMap(target)) { + deps.push(depsMap.get(MAP_KEY_ITERATE_KEY)); + } + } + break; + case "set": + if (isMap(target)) { + deps.push(depsMap.get(ITERATE_KEY)); + } + break; + } + } + pauseScheduling(); + for (const dep of deps) { + if (dep) { + triggerEffects( + dep, + 4, + { + target, + type, + key, + newValue, + oldValue, + oldTarget + } + ); + } + } + resetScheduling(); +} +function getDepFromReactive(object, key) { + const depsMap = targetMap.get(object); + return depsMap && depsMap.get(key); +} + +const isNonTrackableKeys = /* @__PURE__ */ makeMap(`__proto__,__v_isRef,__isVue`); +const builtInSymbols = new Set( + /* @__PURE__ */ Object.getOwnPropertyNames(Symbol).filter((key) => key !== "arguments" && key !== "caller").map((key) => Symbol[key]).filter(isSymbol) +); +const arrayInstrumentations = /* @__PURE__ */ createArrayInstrumentations(); +function createArrayInstrumentations() { + const instrumentations = {}; + ["includes", "indexOf", "lastIndexOf"].forEach((key) => { + instrumentations[key] = function(...args) { + const arr = toRaw(this); + for (let i = 0, l = this.length; i < l; i++) { + track(arr, "get", i + ""); + } + const res = arr[key](...args); + if (res === -1 || res === false) { + return arr[key](...args.map(toRaw)); + } else { + return res; + } + }; + }); + ["push", "pop", "shift", "unshift", "splice"].forEach((key) => { + instrumentations[key] = function(...args) { + pauseTracking(); + pauseScheduling(); + const res = toRaw(this)[key].apply(this, args); + resetScheduling(); + resetTracking(); + return res; + }; + }); + return instrumentations; +} +function hasOwnProperty(key) { + if (!isSymbol(key)) key = String(key); + const obj = toRaw(this); + track(obj, "has", key); + return obj.hasOwnProperty(key); +} +class BaseReactiveHandler { + constructor(_isReadonly = false, _isShallow = false) { + this._isReadonly = _isReadonly; + this._isShallow = _isShallow; + } + get(target, key, receiver) { + const isReadonly2 = this._isReadonly, isShallow2 = this._isShallow; + if (key === "__v_isReactive") { + return !isReadonly2; + } else if (key === "__v_isReadonly") { + return isReadonly2; + } else if (key === "__v_isShallow") { + return isShallow2; + } else if (key === "__v_raw") { + if (receiver === (isReadonly2 ? isShallow2 ? shallowReadonlyMap : readonlyMap : isShallow2 ? shallowReactiveMap : reactiveMap).get(target) || // receiver is not the reactive proxy, but has the same prototype + // this means the receiver is a user proxy of the reactive proxy + Object.getPrototypeOf(target) === Object.getPrototypeOf(receiver)) { + return target; + } + return; + } + const targetIsArray = isArray(target); + if (!isReadonly2) { + if (targetIsArray && hasOwn(arrayInstrumentations, key)) { + return Reflect.get(arrayInstrumentations, key, receiver); + } + if (key === "hasOwnProperty") { + return hasOwnProperty; + } + } + const res = Reflect.get(target, key, receiver); + if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) { + return res; + } + if (!isReadonly2) { + track(target, "get", key); + } + if (isShallow2) { + return res; + } + if (isRef(res)) { + return targetIsArray && isIntegerKey(key) ? res : res.value; + } + if (isObject(res)) { + return isReadonly2 ? readonly(res) : reactive(res); + } + return res; + } +} +class MutableReactiveHandler extends BaseReactiveHandler { + constructor(isShallow2 = false) { + super(false, isShallow2); + } + set(target, key, value, receiver) { + let oldValue = target[key]; + if (!this._isShallow) { + const isOldValueReadonly = isReadonly(oldValue); + if (!isShallow(value) && !isReadonly(value)) { + oldValue = toRaw(oldValue); + value = toRaw(value); + } + if (!isArray(target) && isRef(oldValue) && !isRef(value)) { + if (isOldValueReadonly) { + return false; + } else { + oldValue.value = value; + return true; + } + } + } + const hadKey = isArray(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwn(target, key); + const result = Reflect.set(target, key, value, receiver); + if (target === toRaw(receiver)) { + if (!hadKey) { + trigger(target, "add", key, value); + } else if (hasChanged(value, oldValue)) { + trigger(target, "set", key, value, oldValue); + } + } + return result; + } + deleteProperty(target, key) { + const hadKey = hasOwn(target, key); + const oldValue = target[key]; + const result = Reflect.deleteProperty(target, key); + if (result && hadKey) { + trigger(target, "delete", key, void 0, oldValue); + } + return result; + } + has(target, key) { + const result = Reflect.has(target, key); + if (!isSymbol(key) || !builtInSymbols.has(key)) { + track(target, "has", key); + } + return result; + } + ownKeys(target) { + track( + target, + "iterate", + isArray(target) ? "length" : ITERATE_KEY + ); + return Reflect.ownKeys(target); + } +} +class ReadonlyReactiveHandler extends BaseReactiveHandler { + constructor(isShallow2 = false) { + super(true, isShallow2); + } + set(target, key) { + { + warn$2( + `Set operation on key "${String(key)}" failed: target is readonly.`, + target + ); + } + return true; + } + deleteProperty(target, key) { + { + warn$2( + `Delete operation on key "${String(key)}" failed: target is readonly.`, + target + ); + } + return true; + } +} +const mutableHandlers = /* @__PURE__ */ new MutableReactiveHandler(); +const readonlyHandlers = /* @__PURE__ */ new ReadonlyReactiveHandler(); +const shallowReactiveHandlers = /* @__PURE__ */ new MutableReactiveHandler( + true +); +const shallowReadonlyHandlers = /* @__PURE__ */ new ReadonlyReactiveHandler(true); + +const toShallow = (value) => value; +const getProto = (v) => Reflect.getPrototypeOf(v); +function get(target, key, isReadonly2 = false, isShallow2 = false) { + target = target["__v_raw"]; + const rawTarget = toRaw(target); + const rawKey = toRaw(key); + if (!isReadonly2) { + if (hasChanged(key, rawKey)) { + track(rawTarget, "get", key); + } + track(rawTarget, "get", rawKey); + } + const { has: has2 } = getProto(rawTarget); + const wrap = isShallow2 ? toShallow : isReadonly2 ? toReadonly : toReactive; + if (has2.call(rawTarget, key)) { + return wrap(target.get(key)); + } else if (has2.call(rawTarget, rawKey)) { + return wrap(target.get(rawKey)); + } else if (target !== rawTarget) { + target.get(key); + } +} +function has(key, isReadonly2 = false) { + const target = this["__v_raw"]; + const rawTarget = toRaw(target); + const rawKey = toRaw(key); + if (!isReadonly2) { + if (hasChanged(key, rawKey)) { + track(rawTarget, "has", key); + } + track(rawTarget, "has", rawKey); + } + return key === rawKey ? target.has(key) : target.has(key) || target.has(rawKey); +} +function size(target, isReadonly2 = false) { + target = target["__v_raw"]; + !isReadonly2 && track(toRaw(target), "iterate", ITERATE_KEY); + return Reflect.get(target, "size", target); +} +function add(value, _isShallow = false) { + if (!_isShallow && !isShallow(value) && !isReadonly(value)) { + value = toRaw(value); + } + const target = toRaw(this); + const proto = getProto(target); + const hadKey = proto.has.call(target, value); + if (!hadKey) { + target.add(value); + trigger(target, "add", value, value); + } + return this; +} +function set(key, value, _isShallow = false) { + if (!_isShallow && !isShallow(value) && !isReadonly(value)) { + value = toRaw(value); + } + const target = toRaw(this); + const { has: has2, get: get2 } = getProto(target); + let hadKey = has2.call(target, key); + if (!hadKey) { + key = toRaw(key); + hadKey = has2.call(target, key); + } else { + checkIdentityKeys(target, has2, key); + } + const oldValue = get2.call(target, key); + target.set(key, value); + if (!hadKey) { + trigger(target, "add", key, value); + } else if (hasChanged(value, oldValue)) { + trigger(target, "set", key, value, oldValue); + } + return this; +} +function deleteEntry(key) { + const target = toRaw(this); + const { has: has2, get: get2 } = getProto(target); + let hadKey = has2.call(target, key); + if (!hadKey) { + key = toRaw(key); + hadKey = has2.call(target, key); + } else { + checkIdentityKeys(target, has2, key); + } + const oldValue = get2 ? get2.call(target, key) : void 0; + const result = target.delete(key); + if (hadKey) { + trigger(target, "delete", key, void 0, oldValue); + } + return result; +} +function clear() { + const target = toRaw(this); + const hadItems = target.size !== 0; + const oldTarget = isMap(target) ? new Map(target) : new Set(target) ; + const result = target.clear(); + if (hadItems) { + trigger(target, "clear", void 0, void 0, oldTarget); + } + return result; +} +function createForEach(isReadonly2, isShallow2) { + return function forEach(callback, thisArg) { + const observed = this; + const target = observed["__v_raw"]; + const rawTarget = toRaw(target); + const wrap = isShallow2 ? toShallow : isReadonly2 ? toReadonly : toReactive; + !isReadonly2 && track(rawTarget, "iterate", ITERATE_KEY); + return target.forEach((value, key) => { + return callback.call(thisArg, wrap(value), wrap(key), observed); + }); + }; +} +function createIterableMethod(method, isReadonly2, isShallow2) { + return function(...args) { + const target = this["__v_raw"]; + const rawTarget = toRaw(target); + const targetIsMap = isMap(rawTarget); + const isPair = method === "entries" || method === Symbol.iterator && targetIsMap; + const isKeyOnly = method === "keys" && targetIsMap; + const innerIterator = target[method](...args); + const wrap = isShallow2 ? toShallow : isReadonly2 ? toReadonly : toReactive; + !isReadonly2 && track( + rawTarget, + "iterate", + isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY + ); + return { + // iterator protocol + next() { + const { value, done } = innerIterator.next(); + return done ? { value, done } : { + value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value), + done + }; + }, + // iterable protocol + [Symbol.iterator]() { + return this; + } + }; + }; +} +function createReadonlyMethod(type) { + return function(...args) { + { + const key = args[0] ? `on key "${args[0]}" ` : ``; + warn$2( + `${capitalize(type)} operation ${key}failed: target is readonly.`, + toRaw(this) + ); + } + return type === "delete" ? false : type === "clear" ? void 0 : this; + }; +} +function createInstrumentations() { + const mutableInstrumentations2 = { + get(key) { + return get(this, key); + }, + get size() { + return size(this); + }, + has, + add, + set, + delete: deleteEntry, + clear, + forEach: createForEach(false, false) + }; + const shallowInstrumentations2 = { + get(key) { + return get(this, key, false, true); + }, + get size() { + return size(this); + }, + has, + add(value) { + return add.call(this, value, true); + }, + set(key, value) { + return set.call(this, key, value, true); + }, + delete: deleteEntry, + clear, + forEach: createForEach(false, true) + }; + const readonlyInstrumentations2 = { + get(key) { + return get(this, key, true); + }, + get size() { + return size(this, true); + }, + has(key) { + return has.call(this, key, true); + }, + add: createReadonlyMethod("add"), + set: createReadonlyMethod("set"), + delete: createReadonlyMethod("delete"), + clear: createReadonlyMethod("clear"), + forEach: createForEach(true, false) + }; + const shallowReadonlyInstrumentations2 = { + get(key) { + return get(this, key, true, true); + }, + get size() { + return size(this, true); + }, + has(key) { + return has.call(this, key, true); + }, + add: createReadonlyMethod("add"), + set: createReadonlyMethod("set"), + delete: createReadonlyMethod("delete"), + clear: createReadonlyMethod("clear"), + forEach: createForEach(true, true) + }; + const iteratorMethods = [ + "keys", + "values", + "entries", + Symbol.iterator + ]; + iteratorMethods.forEach((method) => { + mutableInstrumentations2[method] = createIterableMethod(method, false, false); + readonlyInstrumentations2[method] = createIterableMethod(method, true, false); + shallowInstrumentations2[method] = createIterableMethod(method, false, true); + shallowReadonlyInstrumentations2[method] = createIterableMethod( + method, + true, + true + ); + }); + return [ + mutableInstrumentations2, + readonlyInstrumentations2, + shallowInstrumentations2, + shallowReadonlyInstrumentations2 + ]; +} +const [ + mutableInstrumentations, + readonlyInstrumentations, + shallowInstrumentations, + shallowReadonlyInstrumentations +] = /* @__PURE__ */ createInstrumentations(); +function createInstrumentationGetter(isReadonly2, shallow) { + const instrumentations = shallow ? isReadonly2 ? shallowReadonlyInstrumentations : shallowInstrumentations : isReadonly2 ? readonlyInstrumentations : mutableInstrumentations; + return (target, key, receiver) => { + if (key === "__v_isReactive") { + return !isReadonly2; + } else if (key === "__v_isReadonly") { + return isReadonly2; + } else if (key === "__v_raw") { + return target; + } + return Reflect.get( + hasOwn(instrumentations, key) && key in target ? instrumentations : target, + key, + receiver + ); + }; +} +const mutableCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(false, false) +}; +const shallowCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(false, true) +}; +const readonlyCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(true, false) +}; +const shallowReadonlyCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(true, true) +}; +function checkIdentityKeys(target, has2, key) { + const rawKey = toRaw(key); + if (rawKey !== key && has2.call(target, rawKey)) { + const type = toRawType(target); + warn$2( + `Reactive ${type} contains both the raw and reactive versions of the same object${type === `Map` ? ` as keys` : ``}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.` + ); + } +} + +const reactiveMap = /* @__PURE__ */ new WeakMap(); +const shallowReactiveMap = /* @__PURE__ */ new WeakMap(); +const readonlyMap = /* @__PURE__ */ new WeakMap(); +const shallowReadonlyMap = /* @__PURE__ */ new WeakMap(); +function targetTypeMap(rawType) { + switch (rawType) { + case "Object": + case "Array": + return 1 /* COMMON */; + case "Map": + case "Set": + case "WeakMap": + case "WeakSet": + return 2 /* COLLECTION */; + default: + return 0 /* INVALID */; + } +} +function getTargetType(value) { + return value["__v_skip"] || !Object.isExtensible(value) ? 0 /* INVALID */ : targetTypeMap(toRawType(value)); +} +function reactive(target) { + if (isReadonly(target)) { + return target; + } + return createReactiveObject( + target, + false, + mutableHandlers, + mutableCollectionHandlers, + reactiveMap + ); +} +function shallowReactive(target) { + return createReactiveObject( + target, + false, + shallowReactiveHandlers, + shallowCollectionHandlers, + shallowReactiveMap + ); +} +function readonly(target) { + return createReactiveObject( + target, + true, + readonlyHandlers, + readonlyCollectionHandlers, + readonlyMap + ); +} +function shallowReadonly(target) { + return createReactiveObject( + target, + true, + shallowReadonlyHandlers, + shallowReadonlyCollectionHandlers, + shallowReadonlyMap + ); +} +function createReactiveObject(target, isReadonly2, baseHandlers, collectionHandlers, proxyMap) { + if (!isObject(target)) { + { + warn$2( + `value cannot be made ${isReadonly2 ? "readonly" : "reactive"}: ${String( + target + )}` + ); + } + return target; + } + if (target["__v_raw"] && !(isReadonly2 && target["__v_isReactive"])) { + return target; + } + const existingProxy = proxyMap.get(target); + if (existingProxy) { + return existingProxy; + } + const targetType = getTargetType(target); + if (targetType === 0 /* INVALID */) { + return target; + } + const proxy = new Proxy( + target, + targetType === 2 /* COLLECTION */ ? collectionHandlers : baseHandlers + ); + proxyMap.set(target, proxy); + return proxy; +} +function isReactive(value) { + if (isReadonly(value)) { + return isReactive(value["__v_raw"]); + } + return !!(value && value["__v_isReactive"]); +} +function isReadonly(value) { + return !!(value && value["__v_isReadonly"]); +} +function isShallow(value) { + return !!(value && value["__v_isShallow"]); +} +function isProxy(value) { + return value ? !!value["__v_raw"] : false; +} +function toRaw(observed) { + const raw = observed && observed["__v_raw"]; + return raw ? toRaw(raw) : observed; +} +function markRaw(value) { + if (Object.isExtensible(value)) { + def(value, "__v_skip", true); + } + return value; +} +const toReactive = (value) => isObject(value) ? reactive(value) : value; +const toReadonly = (value) => isObject(value) ? readonly(value) : value; + +const COMPUTED_SIDE_EFFECT_WARN = `Computed is still dirty after getter evaluation, likely because a computed is mutating its own dependency in its getter. State mutations in computed getters should be avoided. Check the docs for more details: https://vuejs.org/guide/essentials/computed.html#getters-should-be-side-effect-free`; +class ComputedRefImpl { + constructor(getter, _setter, isReadonly, isSSR) { + this.getter = getter; + this._setter = _setter; + this.dep = void 0; + this.__v_isRef = true; + this["__v_isReadonly"] = false; + this.effect = new ReactiveEffect( + () => getter(this._value), + () => triggerRefValue( + this, + this.effect._dirtyLevel === 2 ? 2 : 3 + ) + ); + this.effect.computed = this; + this.effect.active = this._cacheable = !isSSR; + this["__v_isReadonly"] = isReadonly; + } + get value() { + const self = toRaw(this); + if ((!self._cacheable || self.effect.dirty) && hasChanged(self._value, self._value = self.effect.run())) { + triggerRefValue(self, 4); + } + trackRefValue(self); + if (self.effect._dirtyLevel >= 2) { + if (this._warnRecursive) { + warn$2(COMPUTED_SIDE_EFFECT_WARN, ` + +getter: `, this.getter); + } + triggerRefValue(self, 2); + } + return self._value; + } + set value(newValue) { + this._setter(newValue); + } + // #region polyfill _dirty for backward compatibility third party code for Vue <= 3.3.x + get _dirty() { + return this.effect.dirty; + } + set _dirty(v) { + this.effect.dirty = v; + } + // #endregion +} +function computed$1(getterOrOptions, debugOptions, isSSR = false) { + let getter; + let setter; + const onlyGetter = isFunction(getterOrOptions); + if (onlyGetter) { + getter = getterOrOptions; + setter = () => { + warn$2("Write operation failed: computed value is readonly"); + } ; + } else { + getter = getterOrOptions.get; + setter = getterOrOptions.set; + } + const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR); + if (debugOptions && !isSSR) { + cRef.effect.onTrack = debugOptions.onTrack; + cRef.effect.onTrigger = debugOptions.onTrigger; + } + return cRef; +} + +function trackRefValue(ref2) { + var _a; + if (shouldTrack && activeEffect) { + ref2 = toRaw(ref2); + trackEffect( + activeEffect, + (_a = ref2.dep) != null ? _a : ref2.dep = createDep( + () => ref2.dep = void 0, + ref2 instanceof ComputedRefImpl ? ref2 : void 0 + ), + { + target: ref2, + type: "get", + key: "value" + } + ); + } +} +function triggerRefValue(ref2, dirtyLevel = 4, newVal, oldVal) { + ref2 = toRaw(ref2); + const dep = ref2.dep; + if (dep) { + triggerEffects( + dep, + dirtyLevel, + { + target: ref2, + type: "set", + key: "value", + newValue: newVal, + oldValue: oldVal + } + ); + } +} +function isRef(r) { + return !!(r && r.__v_isRef === true); +} +function ref(value) { + return createRef(value, false); +} +function shallowRef(value) { + return createRef(value, true); +} +function createRef(rawValue, shallow) { + if (isRef(rawValue)) { + return rawValue; + } + return new RefImpl(rawValue, shallow); +} +class RefImpl { + constructor(value, __v_isShallow) { + this.__v_isShallow = __v_isShallow; + this.dep = void 0; + this.__v_isRef = true; + this._rawValue = __v_isShallow ? value : toRaw(value); + this._value = __v_isShallow ? value : toReactive(value); + } + get value() { + trackRefValue(this); + return this._value; + } + set value(newVal) { + const useDirectValue = this.__v_isShallow || isShallow(newVal) || isReadonly(newVal); + newVal = useDirectValue ? newVal : toRaw(newVal); + if (hasChanged(newVal, this._rawValue)) { + const oldVal = this._rawValue; + this._rawValue = newVal; + this._value = useDirectValue ? newVal : toReactive(newVal); + triggerRefValue(this, 4, newVal, oldVal); + } + } +} +function triggerRef(ref2) { + triggerRefValue(ref2, 4, ref2.value ); +} +function unref(ref2) { + return isRef(ref2) ? ref2.value : ref2; +} +function toValue(source) { + return isFunction(source) ? source() : unref(source); +} +const shallowUnwrapHandlers = { + get: (target, key, receiver) => unref(Reflect.get(target, key, receiver)), + set: (target, key, value, receiver) => { + const oldValue = target[key]; + if (isRef(oldValue) && !isRef(value)) { + oldValue.value = value; + return true; + } else { + return Reflect.set(target, key, value, receiver); + } + } +}; +function proxyRefs(objectWithRefs) { + return isReactive(objectWithRefs) ? objectWithRefs : new Proxy(objectWithRefs, shallowUnwrapHandlers); +} +class CustomRefImpl { + constructor(factory) { + this.dep = void 0; + this.__v_isRef = true; + const { get, set } = factory( + () => trackRefValue(this), + () => triggerRefValue(this) + ); + this._get = get; + this._set = set; + } + get value() { + return this._get(); + } + set value(newVal) { + this._set(newVal); + } +} +function customRef(factory) { + return new CustomRefImpl(factory); +} +function toRefs(object) { + if (!isProxy(object)) { + warn$2(`toRefs() expects a reactive object but received a plain one.`); + } + const ret = isArray(object) ? new Array(object.length) : {}; + for (const key in object) { + ret[key] = propertyToRef(object, key); + } + return ret; +} +class ObjectRefImpl { + constructor(_object, _key, _defaultValue) { + this._object = _object; + this._key = _key; + this._defaultValue = _defaultValue; + this.__v_isRef = true; + } + get value() { + const val = this._object[this._key]; + return val === void 0 ? this._defaultValue : val; + } + set value(newVal) { + this._object[this._key] = newVal; + } + get dep() { + return getDepFromReactive(toRaw(this._object), this._key); + } +} +class GetterRefImpl { + constructor(_getter) { + this._getter = _getter; + this.__v_isRef = true; + this.__v_isReadonly = true; + } + get value() { + return this._getter(); + } +} +function toRef(source, key, defaultValue) { + if (isRef(source)) { + return source; + } else if (isFunction(source)) { + return new GetterRefImpl(source); + } else if (isObject(source) && arguments.length > 1) { + return propertyToRef(source, key, defaultValue); + } else { + return ref(source); + } +} +function propertyToRef(source, key, defaultValue) { + const val = source[key]; + return isRef(val) ? val : new ObjectRefImpl(source, key, defaultValue); +} + +const TrackOpTypes = { + "GET": "get", + "HAS": "has", + "ITERATE": "iterate" +}; +const TriggerOpTypes = { + "SET": "set", + "ADD": "add", + "DELETE": "delete", + "CLEAR": "clear" +}; + +const stack$1 = []; +function pushWarningContext(vnode) { + stack$1.push(vnode); +} +function popWarningContext() { + stack$1.pop(); +} +let isWarning = false; +function warn$1(msg, ...args) { + if (isWarning) return; + isWarning = true; + pauseTracking(); + const instance = stack$1.length ? stack$1[stack$1.length - 1].component : null; + const appWarnHandler = instance && instance.appContext.config.warnHandler; + const trace = getComponentTrace(); + if (appWarnHandler) { + callWithErrorHandling( + appWarnHandler, + instance, + 11, + [ + // eslint-disable-next-line no-restricted-syntax + msg + args.map((a) => { + var _a, _b; + return (_b = (_a = a.toString) == null ? void 0 : _a.call(a)) != null ? _b : JSON.stringify(a); + }).join(""), + instance && instance.proxy, + trace.map( + ({ vnode }) => `at <${formatComponentName(instance, vnode.type)}>` + ).join("\n"), + trace + ] + ); + } else { + const warnArgs = [`[Vue warn]: ${msg}`, ...args]; + if (trace.length && // avoid spamming console during tests + true) { + warnArgs.push(` +`, ...formatTrace(trace)); + } + console.warn(...warnArgs); + } + resetTracking(); + isWarning = false; +} +function getComponentTrace() { + let currentVNode = stack$1[stack$1.length - 1]; + if (!currentVNode) { + return []; + } + const normalizedStack = []; + while (currentVNode) { + const last = normalizedStack[0]; + if (last && last.vnode === currentVNode) { + last.recurseCount++; + } else { + normalizedStack.push({ + vnode: currentVNode, + recurseCount: 0 + }); + } + const parentInstance = currentVNode.component && currentVNode.component.parent; + currentVNode = parentInstance && parentInstance.vnode; + } + return normalizedStack; +} +function formatTrace(trace) { + const logs = []; + trace.forEach((entry, i) => { + logs.push(...i === 0 ? [] : [` +`], ...formatTraceEntry(entry)); + }); + return logs; +} +function formatTraceEntry({ vnode, recurseCount }) { + const postfix = recurseCount > 0 ? `... (${recurseCount} recursive calls)` : ``; + const isRoot = vnode.component ? vnode.component.parent == null : false; + const open = ` at <${formatComponentName( + vnode.component, + vnode.type, + isRoot + )}`; + const close = `>` + postfix; + return vnode.props ? [open, ...formatProps(vnode.props), close] : [open + close]; +} +function formatProps(props) { + const res = []; + const keys = Object.keys(props); + keys.slice(0, 3).forEach((key) => { + res.push(...formatProp(key, props[key])); + }); + if (keys.length > 3) { + res.push(` ...`); + } + return res; +} +function formatProp(key, value, raw) { + if (isString(value)) { + value = JSON.stringify(value); + return raw ? value : [`${key}=${value}`]; + } else if (typeof value === "number" || typeof value === "boolean" || value == null) { + return raw ? value : [`${key}=${value}`]; + } else if (isRef(value)) { + value = formatProp(key, toRaw(value.value), true); + return raw ? value : [`${key}=Ref<`, value, `>`]; + } else if (isFunction(value)) { + return [`${key}=fn${value.name ? `<${value.name}>` : ``}`]; + } else { + value = toRaw(value); + return raw ? value : [`${key}=`, value]; + } +} +function assertNumber(val, type) { + if (val === void 0) { + return; + } else if (typeof val !== "number") { + warn$1(`${type} is not a valid number - got ${JSON.stringify(val)}.`); + } else if (isNaN(val)) { + warn$1(`${type} is NaN - the duration expression might be incorrect.`); + } +} + +const ErrorCodes = { + "SETUP_FUNCTION": 0, + "0": "SETUP_FUNCTION", + "RENDER_FUNCTION": 1, + "1": "RENDER_FUNCTION", + "WATCH_GETTER": 2, + "2": "WATCH_GETTER", + "WATCH_CALLBACK": 3, + "3": "WATCH_CALLBACK", + "WATCH_CLEANUP": 4, + "4": "WATCH_CLEANUP", + "NATIVE_EVENT_HANDLER": 5, + "5": "NATIVE_EVENT_HANDLER", + "COMPONENT_EVENT_HANDLER": 6, + "6": "COMPONENT_EVENT_HANDLER", + "VNODE_HOOK": 7, + "7": "VNODE_HOOK", + "DIRECTIVE_HOOK": 8, + "8": "DIRECTIVE_HOOK", + "TRANSITION_HOOK": 9, + "9": "TRANSITION_HOOK", + "APP_ERROR_HANDLER": 10, + "10": "APP_ERROR_HANDLER", + "APP_WARN_HANDLER": 11, + "11": "APP_WARN_HANDLER", + "FUNCTION_REF": 12, + "12": "FUNCTION_REF", + "ASYNC_COMPONENT_LOADER": 13, + "13": "ASYNC_COMPONENT_LOADER", + "SCHEDULER": 14, + "14": "SCHEDULER", + "COMPONENT_UPDATE": 15, + "15": "COMPONENT_UPDATE" +}; +const ErrorTypeStrings$1 = { + ["sp"]: "serverPrefetch hook", + ["bc"]: "beforeCreate hook", + ["c"]: "created hook", + ["bm"]: "beforeMount hook", + ["m"]: "mounted hook", + ["bu"]: "beforeUpdate hook", + ["u"]: "updated", + ["bum"]: "beforeUnmount hook", + ["um"]: "unmounted hook", + ["a"]: "activated hook", + ["da"]: "deactivated hook", + ["ec"]: "errorCaptured hook", + ["rtc"]: "renderTracked hook", + ["rtg"]: "renderTriggered hook", + [0]: "setup function", + [1]: "render function", + [2]: "watcher getter", + [3]: "watcher callback", + [4]: "watcher cleanup function", + [5]: "native event handler", + [6]: "component event handler", + [7]: "vnode hook", + [8]: "directive hook", + [9]: "transition hook", + [10]: "app errorHandler", + [11]: "app warnHandler", + [12]: "ref function", + [13]: "async component loader", + [14]: "scheduler flush", + [15]: "component update" +}; +function callWithErrorHandling(fn, instance, type, args) { + try { + return args ? fn(...args) : fn(); + } catch (err) { + handleError(err, instance, type); + } +} +function callWithAsyncErrorHandling(fn, instance, type, args) { + if (isFunction(fn)) { + const res = callWithErrorHandling(fn, instance, type, args); + if (res && isPromise(res)) { + res.catch((err) => { + handleError(err, instance, type); + }); + } + return res; + } + if (isArray(fn)) { + const values = []; + for (let i = 0; i < fn.length; i++) { + values.push(callWithAsyncErrorHandling(fn[i], instance, type, args)); + } + return values; + } else { + warn$1( + `Invalid value type passed to callWithAsyncErrorHandling(): ${typeof fn}` + ); + } +} +function handleError(err, instance, type, throwInDev = true) { + const contextVNode = instance ? instance.vnode : null; + if (instance) { + let cur = instance.parent; + const exposedInstance = instance.proxy; + const errorInfo = ErrorTypeStrings$1[type] ; + while (cur) { + const errorCapturedHooks = cur.ec; + if (errorCapturedHooks) { + for (let i = 0; i < errorCapturedHooks.length; i++) { + if (errorCapturedHooks[i](err, exposedInstance, errorInfo) === false) { + return; + } + } + } + cur = cur.parent; + } + const appErrorHandler = instance.appContext.config.errorHandler; + if (appErrorHandler) { + pauseTracking(); + callWithErrorHandling( + appErrorHandler, + null, + 10, + [err, exposedInstance, errorInfo] + ); + resetTracking(); + return; + } + } + logError(err, type, contextVNode, throwInDev); +} +function logError(err, type, contextVNode, throwInDev = true) { + { + const info = ErrorTypeStrings$1[type]; + if (contextVNode) { + pushWarningContext(contextVNode); + } + warn$1(`Unhandled error${info ? ` during execution of ${info}` : ``}`); + if (contextVNode) { + popWarningContext(); + } + if (throwInDev) { + throw err; + } else { + console.error(err); + } + } +} + +let isFlushing = false; +let isFlushPending = false; +const queue = []; +let flushIndex = 0; +const pendingPostFlushCbs = []; +let activePostFlushCbs = null; +let postFlushIndex = 0; +const resolvedPromise = /* @__PURE__ */ Promise.resolve(); +let currentFlushPromise = null; +const RECURSION_LIMIT = 100; +function nextTick(fn) { + const p = currentFlushPromise || resolvedPromise; + return fn ? p.then(this ? fn.bind(this) : fn) : p; +} +function findInsertionIndex(id) { + let start = flushIndex + 1; + let end = queue.length; + while (start < end) { + const middle = start + end >>> 1; + const middleJob = queue[middle]; + const middleJobId = getId(middleJob); + if (middleJobId < id || middleJobId === id && middleJob.pre) { + start = middle + 1; + } else { + end = middle; + } + } + return start; +} +function queueJob(job) { + if (!queue.length || !queue.includes( + job, + isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex + )) { + if (job.id == null) { + queue.push(job); + } else { + queue.splice(findInsertionIndex(job.id), 0, job); + } + queueFlush(); + } +} +function queueFlush() { + if (!isFlushing && !isFlushPending) { + isFlushPending = true; + currentFlushPromise = resolvedPromise.then(flushJobs); + } +} +function invalidateJob(job) { + const i = queue.indexOf(job); + if (i > flushIndex) { + queue.splice(i, 1); + } +} +function queuePostFlushCb(cb) { + if (!isArray(cb)) { + if (!activePostFlushCbs || !activePostFlushCbs.includes( + cb, + cb.allowRecurse ? postFlushIndex + 1 : postFlushIndex + )) { + pendingPostFlushCbs.push(cb); + } + } else { + pendingPostFlushCbs.push(...cb); + } + queueFlush(); +} +function flushPreFlushCbs(instance, seen, i = isFlushing ? flushIndex + 1 : 0) { + { + seen = seen || /* @__PURE__ */ new Map(); + } + for (; i < queue.length; i++) { + const cb = queue[i]; + if (cb && cb.pre) { + if (instance && cb.id !== instance.uid) { + continue; + } + if (checkRecursiveUpdates(seen, cb)) { + continue; + } + queue.splice(i, 1); + i--; + cb(); + } + } +} +function flushPostFlushCbs(seen) { + if (pendingPostFlushCbs.length) { + const deduped = [...new Set(pendingPostFlushCbs)].sort( + (a, b) => getId(a) - getId(b) + ); + pendingPostFlushCbs.length = 0; + if (activePostFlushCbs) { + activePostFlushCbs.push(...deduped); + return; + } + activePostFlushCbs = deduped; + { + seen = seen || /* @__PURE__ */ new Map(); + } + for (postFlushIndex = 0; postFlushIndex < activePostFlushCbs.length; postFlushIndex++) { + const cb = activePostFlushCbs[postFlushIndex]; + if (checkRecursiveUpdates(seen, cb)) { + continue; + } + if (cb.active !== false) cb(); + } + activePostFlushCbs = null; + postFlushIndex = 0; + } +} +const getId = (job) => job.id == null ? Infinity : job.id; +const comparator = (a, b) => { + const diff = getId(a) - getId(b); + if (diff === 0) { + if (a.pre && !b.pre) return -1; + if (b.pre && !a.pre) return 1; + } + return diff; +}; +function flushJobs(seen) { + isFlushPending = false; + isFlushing = true; + { + seen = seen || /* @__PURE__ */ new Map(); + } + queue.sort(comparator); + const check = (job) => checkRecursiveUpdates(seen, job) ; + try { + for (flushIndex = 0; flushIndex < queue.length; flushIndex++) { + const job = queue[flushIndex]; + if (job && job.active !== false) { + if (check(job)) { + continue; + } + callWithErrorHandling( + job, + job.i, + job.i ? 15 : 14 + ); + } + } + } finally { + flushIndex = 0; + queue.length = 0; + flushPostFlushCbs(seen); + isFlushing = false; + currentFlushPromise = null; + if (queue.length || pendingPostFlushCbs.length) { + flushJobs(seen); + } + } +} +function checkRecursiveUpdates(seen, fn) { + if (!seen.has(fn)) { + seen.set(fn, 1); + } else { + const count = seen.get(fn); + if (count > RECURSION_LIMIT) { + const instance = fn.i; + const componentName = instance && getComponentName(instance.type); + handleError( + `Maximum recursive updates exceeded${componentName ? ` in component <${componentName}>` : ``}. This means you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself. Possible sources include component template, render function, updated hook or watcher source function.`, + null, + 10 + ); + return true; + } else { + seen.set(fn, count + 1); + } + } +} + +let isHmrUpdating = false; +const hmrDirtyComponents = /* @__PURE__ */ new Map(); +{ + getGlobalThis().__VUE_HMR_RUNTIME__ = { + createRecord: tryWrap(createRecord), + rerender: tryWrap(rerender), + reload: tryWrap(reload) + }; +} +const map = /* @__PURE__ */ new Map(); +function registerHMR(instance) { + const id = instance.type.__hmrId; + let record = map.get(id); + if (!record) { + createRecord(id, instance.type); + record = map.get(id); + } + record.instances.add(instance); +} +function unregisterHMR(instance) { + map.get(instance.type.__hmrId).instances.delete(instance); +} +function createRecord(id, initialDef) { + if (map.has(id)) { + return false; + } + map.set(id, { + initialDef: normalizeClassComponent(initialDef), + instances: /* @__PURE__ */ new Set() + }); + return true; +} +function normalizeClassComponent(component) { + return isClassComponent(component) ? component.__vccOpts : component; +} +function rerender(id, newRender) { + const record = map.get(id); + if (!record) { + return; + } + record.initialDef.render = newRender; + [...record.instances].forEach((instance) => { + if (newRender) { + instance.render = newRender; + normalizeClassComponent(instance.type).render = newRender; + } + instance.renderCache = []; + isHmrUpdating = true; + instance.effect.dirty = true; + instance.update(); + isHmrUpdating = false; + }); +} +function reload(id, newComp) { + const record = map.get(id); + if (!record) return; + newComp = normalizeClassComponent(newComp); + updateComponentDef(record.initialDef, newComp); + const instances = [...record.instances]; + for (let i = 0; i < instances.length; i++) { + const instance = instances[i]; + const oldComp = normalizeClassComponent(instance.type); + let dirtyInstances = hmrDirtyComponents.get(oldComp); + if (!dirtyInstances) { + if (oldComp !== record.initialDef) { + updateComponentDef(oldComp, newComp); + } + hmrDirtyComponents.set(oldComp, dirtyInstances = /* @__PURE__ */ new Set()); + } + dirtyInstances.add(instance); + instance.appContext.propsCache.delete(instance.type); + instance.appContext.emitsCache.delete(instance.type); + instance.appContext.optionsCache.delete(instance.type); + if (instance.ceReload) { + dirtyInstances.add(instance); + instance.ceReload(newComp.styles); + dirtyInstances.delete(instance); + } else if (instance.parent) { + instance.parent.effect.dirty = true; + queueJob(() => { + instance.parent.update(); + dirtyInstances.delete(instance); + }); + } else if (instance.appContext.reload) { + instance.appContext.reload(); + } else if (typeof window !== "undefined") { + window.location.reload(); + } else { + console.warn( + "[HMR] Root or manually mounted instance modified. Full reload required." + ); + } + } + queuePostFlushCb(() => { + hmrDirtyComponents.clear(); + }); +} +function updateComponentDef(oldComp, newComp) { + extend(oldComp, newComp); + for (const key in oldComp) { + if (key !== "__file" && !(key in newComp)) { + delete oldComp[key]; + } + } +} +function tryWrap(fn) { + return (id, arg) => { + try { + return fn(id, arg); + } catch (e) { + console.error(e); + console.warn( + `[HMR] Something went wrong during Vue component hot-reload. Full reload required.` + ); + } + }; +} + +let devtools$1; +let buffer = []; +let devtoolsNotInstalled = false; +function emit$1(event, ...args) { + if (devtools$1) { + devtools$1.emit(event, ...args); + } else if (!devtoolsNotInstalled) { + buffer.push({ event, args }); + } +} +function setDevtoolsHook$1(hook, target) { + var _a, _b; + devtools$1 = hook; + if (devtools$1) { + devtools$1.enabled = true; + buffer.forEach(({ event, args }) => devtools$1.emit(event, ...args)); + buffer = []; + } else if ( + // handle late devtools injection - only do this if we are in an actual + // browser environment to avoid the timer handle stalling test runner exit + // (#4815) + typeof window !== "undefined" && // some envs mock window but not fully + window.HTMLElement && // also exclude jsdom + // eslint-disable-next-line no-restricted-syntax + !((_b = (_a = window.navigator) == null ? void 0 : _a.userAgent) == null ? void 0 : _b.includes("jsdom")) + ) { + const replay = target.__VUE_DEVTOOLS_HOOK_REPLAY__ = target.__VUE_DEVTOOLS_HOOK_REPLAY__ || []; + replay.push((newHook) => { + setDevtoolsHook$1(newHook, target); + }); + setTimeout(() => { + if (!devtools$1) { + target.__VUE_DEVTOOLS_HOOK_REPLAY__ = null; + devtoolsNotInstalled = true; + buffer = []; + } + }, 3e3); + } else { + devtoolsNotInstalled = true; + buffer = []; + } +} +function devtoolsInitApp(app, version) { + emit$1("app:init" /* APP_INIT */, app, version, { + Fragment, + Text, + Comment, + Static + }); +} +function devtoolsUnmountApp(app) { + emit$1("app:unmount" /* APP_UNMOUNT */, app); +} +const devtoolsComponentAdded = /* @__PURE__ */ createDevtoolsComponentHook( + "component:added" /* COMPONENT_ADDED */ +); +const devtoolsComponentUpdated = /* @__PURE__ */ createDevtoolsComponentHook("component:updated" /* COMPONENT_UPDATED */); +const _devtoolsComponentRemoved = /* @__PURE__ */ createDevtoolsComponentHook( + "component:removed" /* COMPONENT_REMOVED */ +); +const devtoolsComponentRemoved = (component) => { + if (devtools$1 && typeof devtools$1.cleanupBuffer === "function" && // remove the component if it wasn't buffered + !devtools$1.cleanupBuffer(component)) { + _devtoolsComponentRemoved(component); + } +}; +/*! #__NO_SIDE_EFFECTS__ */ +// @__NO_SIDE_EFFECTS__ +function createDevtoolsComponentHook(hook) { + return (component) => { + emit$1( + hook, + component.appContext.app, + component.uid, + component.parent ? component.parent.uid : void 0, + component + ); + }; +} +const devtoolsPerfStart = /* @__PURE__ */ createDevtoolsPerformanceHook( + "perf:start" /* PERFORMANCE_START */ +); +const devtoolsPerfEnd = /* @__PURE__ */ createDevtoolsPerformanceHook( + "perf:end" /* PERFORMANCE_END */ +); +function createDevtoolsPerformanceHook(hook) { + return (component, type, time) => { + emit$1(hook, component.appContext.app, component.uid, component, type, time); + }; +} +function devtoolsComponentEmit(component, event, params) { + emit$1( + "component:emit" /* COMPONENT_EMIT */, + component.appContext.app, + component, + event, + params + ); +} + +let currentRenderingInstance = null; +let currentScopeId = null; +function setCurrentRenderingInstance(instance) { + const prev = currentRenderingInstance; + currentRenderingInstance = instance; + currentScopeId = instance && instance.type.__scopeId || null; + return prev; +} +function pushScopeId(id) { + currentScopeId = id; +} +function popScopeId() { + currentScopeId = null; +} +const withScopeId = (_id) => withCtx; +function withCtx(fn, ctx = currentRenderingInstance, isNonScopedSlot) { + if (!ctx) return fn; + if (fn._n) { + return fn; + } + const renderFnWithContext = (...args) => { + if (renderFnWithContext._d) { + setBlockTracking(-1); + } + const prevInstance = setCurrentRenderingInstance(ctx); + let res; + try { + res = fn(...args); + } finally { + setCurrentRenderingInstance(prevInstance); + if (renderFnWithContext._d) { + setBlockTracking(1); + } + } + { + devtoolsComponentUpdated(ctx); + } + return res; + }; + renderFnWithContext._n = true; + renderFnWithContext._c = true; + renderFnWithContext._d = true; + return renderFnWithContext; +} + +function validateDirectiveName(name) { + if (isBuiltInDirective(name)) { + warn$1("Do not use built-in directive ids as custom directive id: " + name); + } +} +function withDirectives(vnode, directives) { + if (currentRenderingInstance === null) { + warn$1(`withDirectives can only be used inside render functions.`); + return vnode; + } + const instance = getComponentPublicInstance(currentRenderingInstance); + const bindings = vnode.dirs || (vnode.dirs = []); + for (let i = 0; i < directives.length; i++) { + let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]; + if (dir) { + if (isFunction(dir)) { + dir = { + mounted: dir, + updated: dir + }; + } + if (dir.deep) { + traverse(value); + } + bindings.push({ + dir, + instance, + value, + oldValue: void 0, + arg, + modifiers + }); + } + } + return vnode; +} +function invokeDirectiveHook(vnode, prevVNode, instance, name) { + const bindings = vnode.dirs; + const oldBindings = prevVNode && prevVNode.dirs; + for (let i = 0; i < bindings.length; i++) { + const binding = bindings[i]; + if (oldBindings) { + binding.oldValue = oldBindings[i].value; + } + let hook = binding.dir[name]; + if (hook) { + pauseTracking(); + callWithAsyncErrorHandling(hook, instance, 8, [ + vnode.el, + binding, + vnode, + prevVNode + ]); + resetTracking(); + } + } +} + +const leaveCbKey = Symbol("_leaveCb"); +const enterCbKey$1 = Symbol("_enterCb"); +function useTransitionState() { + const state = { + isMounted: false, + isLeaving: false, + isUnmounting: false, + leavingVNodes: /* @__PURE__ */ new Map() + }; + onMounted(() => { + state.isMounted = true; + }); + onBeforeUnmount(() => { + state.isUnmounting = true; + }); + return state; +} +const TransitionHookValidator = [Function, Array]; +const BaseTransitionPropsValidators = { + mode: String, + appear: Boolean, + persisted: Boolean, + // enter + onBeforeEnter: TransitionHookValidator, + onEnter: TransitionHookValidator, + onAfterEnter: TransitionHookValidator, + onEnterCancelled: TransitionHookValidator, + // leave + onBeforeLeave: TransitionHookValidator, + onLeave: TransitionHookValidator, + onAfterLeave: TransitionHookValidator, + onLeaveCancelled: TransitionHookValidator, + // appear + onBeforeAppear: TransitionHookValidator, + onAppear: TransitionHookValidator, + onAfterAppear: TransitionHookValidator, + onAppearCancelled: TransitionHookValidator +}; +const recursiveGetSubtree = (instance) => { + const subTree = instance.subTree; + return subTree.component ? recursiveGetSubtree(subTree.component) : subTree; +}; +const BaseTransitionImpl = { + name: `BaseTransition`, + props: BaseTransitionPropsValidators, + setup(props, { slots }) { + const instance = getCurrentInstance(); + const state = useTransitionState(); + return () => { + const children = slots.default && getTransitionRawChildren(slots.default(), true); + if (!children || !children.length) { + return; + } + let child = children[0]; + if (children.length > 1) { + let hasFound = false; + for (const c of children) { + if (c.type !== Comment) { + if (hasFound) { + warn$1( + " can only be used on a single element or component. Use for lists." + ); + break; + } + child = c; + hasFound = true; + } + } + } + const rawProps = toRaw(props); + const { mode } = rawProps; + if (mode && mode !== "in-out" && mode !== "out-in" && mode !== "default") { + warn$1(`invalid mode: ${mode}`); + } + if (state.isLeaving) { + return emptyPlaceholder(child); + } + const innerChild = getKeepAliveChild(child); + if (!innerChild) { + return emptyPlaceholder(child); + } + let enterHooks = resolveTransitionHooks( + innerChild, + rawProps, + state, + instance, + // #11061, ensure enterHooks is fresh after clone + (hooks) => enterHooks = hooks + ); + setTransitionHooks(innerChild, enterHooks); + const oldChild = instance.subTree; + const oldInnerChild = oldChild && getKeepAliveChild(oldChild); + if (oldInnerChild && oldInnerChild.type !== Comment && !isSameVNodeType(innerChild, oldInnerChild) && recursiveGetSubtree(instance).type !== Comment) { + const leavingHooks = resolveTransitionHooks( + oldInnerChild, + rawProps, + state, + instance + ); + setTransitionHooks(oldInnerChild, leavingHooks); + if (mode === "out-in" && innerChild.type !== Comment) { + state.isLeaving = true; + leavingHooks.afterLeave = () => { + state.isLeaving = false; + if (instance.update.active !== false) { + instance.effect.dirty = true; + instance.update(); + } + }; + return emptyPlaceholder(child); + } else if (mode === "in-out" && innerChild.type !== Comment) { + leavingHooks.delayLeave = (el, earlyRemove, delayedLeave) => { + const leavingVNodesCache = getLeavingNodesForType( + state, + oldInnerChild + ); + leavingVNodesCache[String(oldInnerChild.key)] = oldInnerChild; + el[leaveCbKey] = () => { + earlyRemove(); + el[leaveCbKey] = void 0; + delete enterHooks.delayedLeave; + }; + enterHooks.delayedLeave = delayedLeave; + }; + } + } + return child; + }; + } +}; +const BaseTransition = BaseTransitionImpl; +function getLeavingNodesForType(state, vnode) { + const { leavingVNodes } = state; + let leavingVNodesCache = leavingVNodes.get(vnode.type); + if (!leavingVNodesCache) { + leavingVNodesCache = /* @__PURE__ */ Object.create(null); + leavingVNodes.set(vnode.type, leavingVNodesCache); + } + return leavingVNodesCache; +} +function resolveTransitionHooks(vnode, props, state, instance, postClone) { + const { + appear, + mode, + persisted = false, + onBeforeEnter, + onEnter, + onAfterEnter, + onEnterCancelled, + onBeforeLeave, + onLeave, + onAfterLeave, + onLeaveCancelled, + onBeforeAppear, + onAppear, + onAfterAppear, + onAppearCancelled + } = props; + const key = String(vnode.key); + const leavingVNodesCache = getLeavingNodesForType(state, vnode); + const callHook = (hook, args) => { + hook && callWithAsyncErrorHandling( + hook, + instance, + 9, + args + ); + }; + const callAsyncHook = (hook, args) => { + const done = args[1]; + callHook(hook, args); + if (isArray(hook)) { + if (hook.every((hook2) => hook2.length <= 1)) done(); + } else if (hook.length <= 1) { + done(); + } + }; + const hooks = { + mode, + persisted, + beforeEnter(el) { + let hook = onBeforeEnter; + if (!state.isMounted) { + if (appear) { + hook = onBeforeAppear || onBeforeEnter; + } else { + return; + } + } + if (el[leaveCbKey]) { + el[leaveCbKey]( + true + /* cancelled */ + ); + } + const leavingVNode = leavingVNodesCache[key]; + if (leavingVNode && isSameVNodeType(vnode, leavingVNode) && leavingVNode.el[leaveCbKey]) { + leavingVNode.el[leaveCbKey](); + } + callHook(hook, [el]); + }, + enter(el) { + let hook = onEnter; + let afterHook = onAfterEnter; + let cancelHook = onEnterCancelled; + if (!state.isMounted) { + if (appear) { + hook = onAppear || onEnter; + afterHook = onAfterAppear || onAfterEnter; + cancelHook = onAppearCancelled || onEnterCancelled; + } else { + return; + } + } + let called = false; + const done = el[enterCbKey$1] = (cancelled) => { + if (called) return; + called = true; + if (cancelled) { + callHook(cancelHook, [el]); + } else { + callHook(afterHook, [el]); + } + if (hooks.delayedLeave) { + hooks.delayedLeave(); + } + el[enterCbKey$1] = void 0; + }; + if (hook) { + callAsyncHook(hook, [el, done]); + } else { + done(); + } + }, + leave(el, remove) { + const key2 = String(vnode.key); + if (el[enterCbKey$1]) { + el[enterCbKey$1]( + true + /* cancelled */ + ); + } + if (state.isUnmounting) { + return remove(); + } + callHook(onBeforeLeave, [el]); + let called = false; + const done = el[leaveCbKey] = (cancelled) => { + if (called) return; + called = true; + remove(); + if (cancelled) { + callHook(onLeaveCancelled, [el]); + } else { + callHook(onAfterLeave, [el]); + } + el[leaveCbKey] = void 0; + if (leavingVNodesCache[key2] === vnode) { + delete leavingVNodesCache[key2]; + } + }; + leavingVNodesCache[key2] = vnode; + if (onLeave) { + callAsyncHook(onLeave, [el, done]); + } else { + done(); + } + }, + clone(vnode2) { + const hooks2 = resolveTransitionHooks( + vnode2, + props, + state, + instance, + postClone + ); + if (postClone) postClone(hooks2); + return hooks2; + } + }; + return hooks; +} +function emptyPlaceholder(vnode) { + if (isKeepAlive(vnode)) { + vnode = cloneVNode(vnode); + vnode.children = null; + return vnode; + } +} +function getKeepAliveChild(vnode) { + if (!isKeepAlive(vnode)) { + return vnode; + } + if (vnode.component) { + return vnode.component.subTree; + } + const { shapeFlag, children } = vnode; + if (children) { + if (shapeFlag & 16) { + return children[0]; + } + if (shapeFlag & 32 && isFunction(children.default)) { + return children.default(); + } + } +} +function setTransitionHooks(vnode, hooks) { + if (vnode.shapeFlag & 6 && vnode.component) { + setTransitionHooks(vnode.component.subTree, hooks); + } else if (vnode.shapeFlag & 128) { + vnode.ssContent.transition = hooks.clone(vnode.ssContent); + vnode.ssFallback.transition = hooks.clone(vnode.ssFallback); + } else { + vnode.transition = hooks; + } +} +function getTransitionRawChildren(children, keepComment = false, parentKey) { + let ret = []; + let keyedFragmentCount = 0; + for (let i = 0; i < children.length; i++) { + let child = children[i]; + const key = parentKey == null ? child.key : String(parentKey) + String(child.key != null ? child.key : i); + if (child.type === Fragment) { + if (child.patchFlag & 128) keyedFragmentCount++; + ret = ret.concat( + getTransitionRawChildren(child.children, keepComment, key) + ); + } else if (keepComment || child.type !== Comment) { + ret.push(key != null ? cloneVNode(child, { key }) : child); + } + } + if (keyedFragmentCount > 1) { + for (let i = 0; i < ret.length; i++) { + ret[i].patchFlag = -2; + } + } + return ret; +} + +/*! #__NO_SIDE_EFFECTS__ */ +// @__NO_SIDE_EFFECTS__ +function defineComponent(options, extraOptions) { + return isFunction(options) ? ( + // #8326: extend call and options.name access are considered side-effects + // by Rollup, so we have to wrap it in a pure-annotated IIFE. + /* @__PURE__ */ (() => extend({ name: options.name }, extraOptions, { setup: options }))() + ) : options; +} + +const isAsyncWrapper = (i) => !!i.type.__asyncLoader; +/*! #__NO_SIDE_EFFECTS__ */ +// @__NO_SIDE_EFFECTS__ +function defineAsyncComponent(source) { + if (isFunction(source)) { + source = { loader: source }; + } + const { + loader, + loadingComponent, + errorComponent, + delay = 200, + timeout, + // undefined = never times out + suspensible = true, + onError: userOnError + } = source; + let pendingRequest = null; + let resolvedComp; + let retries = 0; + const retry = () => { + retries++; + pendingRequest = null; + return load(); + }; + const load = () => { + let thisRequest; + return pendingRequest || (thisRequest = pendingRequest = loader().catch((err) => { + err = err instanceof Error ? err : new Error(String(err)); + if (userOnError) { + return new Promise((resolve, reject) => { + const userRetry = () => resolve(retry()); + const userFail = () => reject(err); + userOnError(err, userRetry, userFail, retries + 1); + }); + } else { + throw err; + } + }).then((comp) => { + if (thisRequest !== pendingRequest && pendingRequest) { + return pendingRequest; + } + if (!comp) { + warn$1( + `Async component loader resolved to undefined. If you are using retry(), make sure to return its return value.` + ); + } + if (comp && (comp.__esModule || comp[Symbol.toStringTag] === "Module")) { + comp = comp.default; + } + if (comp && !isObject(comp) && !isFunction(comp)) { + throw new Error(`Invalid async component load result: ${comp}`); + } + resolvedComp = comp; + return comp; + })); + }; + return defineComponent({ + name: "AsyncComponentWrapper", + __asyncLoader: load, + get __asyncResolved() { + return resolvedComp; + }, + setup() { + const instance = currentInstance; + if (resolvedComp) { + return () => createInnerComp(resolvedComp, instance); + } + const onError = (err) => { + pendingRequest = null; + handleError( + err, + instance, + 13, + !errorComponent + ); + }; + if (suspensible && instance.suspense || false) { + return load().then((comp) => { + return () => createInnerComp(comp, instance); + }).catch((err) => { + onError(err); + return () => errorComponent ? createVNode(errorComponent, { + error: err + }) : null; + }); + } + const loaded = ref(false); + const error = ref(); + const delayed = ref(!!delay); + if (delay) { + setTimeout(() => { + delayed.value = false; + }, delay); + } + if (timeout != null) { + setTimeout(() => { + if (!loaded.value && !error.value) { + const err = new Error( + `Async component timed out after ${timeout}ms.` + ); + onError(err); + error.value = err; + } + }, timeout); + } + load().then(() => { + loaded.value = true; + if (instance.parent && isKeepAlive(instance.parent.vnode)) { + instance.parent.effect.dirty = true; + queueJob(instance.parent.update); + } + }).catch((err) => { + onError(err); + error.value = err; + }); + return () => { + if (loaded.value && resolvedComp) { + return createInnerComp(resolvedComp, instance); + } else if (error.value && errorComponent) { + return createVNode(errorComponent, { + error: error.value + }); + } else if (loadingComponent && !delayed.value) { + return createVNode(loadingComponent); + } + }; + } + }); +} +function createInnerComp(comp, parent) { + const { ref: ref2, props, children, ce } = parent.vnode; + const vnode = createVNode(comp, props, children); + vnode.ref = ref2; + vnode.ce = ce; + delete parent.vnode.ce; + return vnode; +} + +const isKeepAlive = (vnode) => vnode.type.__isKeepAlive; +const KeepAliveImpl = { + name: `KeepAlive`, + // Marker for special handling inside the renderer. We are not using a === + // check directly on KeepAlive in the renderer, because importing it directly + // would prevent it from being tree-shaken. + __isKeepAlive: true, + props: { + include: [String, RegExp, Array], + exclude: [String, RegExp, Array], + max: [String, Number] + }, + setup(props, { slots }) { + const instance = getCurrentInstance(); + const sharedContext = instance.ctx; + const cache = /* @__PURE__ */ new Map(); + const keys = /* @__PURE__ */ new Set(); + let current = null; + { + instance.__v_cache = cache; + } + const parentSuspense = instance.suspense; + const { + renderer: { + p: patch, + m: move, + um: _unmount, + o: { createElement } + } + } = sharedContext; + const storageContainer = createElement("div"); + sharedContext.activate = (vnode, container, anchor, namespace, optimized) => { + const instance2 = vnode.component; + move(vnode, container, anchor, 0, parentSuspense); + patch( + instance2.vnode, + vnode, + container, + anchor, + instance2, + parentSuspense, + namespace, + vnode.slotScopeIds, + optimized + ); + queuePostRenderEffect(() => { + instance2.isDeactivated = false; + if (instance2.a) { + invokeArrayFns(instance2.a); + } + const vnodeHook = vnode.props && vnode.props.onVnodeMounted; + if (vnodeHook) { + invokeVNodeHook(vnodeHook, instance2.parent, vnode); + } + }, parentSuspense); + { + devtoolsComponentAdded(instance2); + } + }; + sharedContext.deactivate = (vnode) => { + const instance2 = vnode.component; + invalidateMount(instance2.m); + invalidateMount(instance2.a); + move(vnode, storageContainer, null, 1, parentSuspense); + queuePostRenderEffect(() => { + if (instance2.da) { + invokeArrayFns(instance2.da); + } + const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted; + if (vnodeHook) { + invokeVNodeHook(vnodeHook, instance2.parent, vnode); + } + instance2.isDeactivated = true; + }, parentSuspense); + { + devtoolsComponentAdded(instance2); + } + }; + function unmount(vnode) { + resetShapeFlag(vnode); + _unmount(vnode, instance, parentSuspense, true); + } + function pruneCache(filter) { + cache.forEach((vnode, key) => { + const name = getComponentName(vnode.type); + if (name && (!filter || !filter(name))) { + pruneCacheEntry(key); + } + }); + } + function pruneCacheEntry(key) { + const cached = cache.get(key); + if (cached && (!current || !isSameVNodeType(cached, current))) { + unmount(cached); + } else if (current) { + resetShapeFlag(current); + } + cache.delete(key); + keys.delete(key); + } + watch( + () => [props.include, props.exclude], + ([include, exclude]) => { + include && pruneCache((name) => matches(include, name)); + exclude && pruneCache((name) => !matches(exclude, name)); + }, + // prune post-render after `current` has been updated + { flush: "post", deep: true } + ); + let pendingCacheKey = null; + const cacheSubtree = () => { + if (pendingCacheKey != null) { + if (isSuspense(instance.subTree.type)) { + queuePostRenderEffect(() => { + cache.set(pendingCacheKey, getInnerChild(instance.subTree)); + }, instance.subTree.suspense); + } else { + cache.set(pendingCacheKey, getInnerChild(instance.subTree)); + } + } + }; + onMounted(cacheSubtree); + onUpdated(cacheSubtree); + onBeforeUnmount(() => { + cache.forEach((cached) => { + const { subTree, suspense } = instance; + const vnode = getInnerChild(subTree); + if (cached.type === vnode.type && cached.key === vnode.key) { + resetShapeFlag(vnode); + const da = vnode.component.da; + da && queuePostRenderEffect(da, suspense); + return; + } + unmount(cached); + }); + }); + return () => { + pendingCacheKey = null; + if (!slots.default) { + return null; + } + const children = slots.default(); + const rawVNode = children[0]; + if (children.length > 1) { + { + warn$1(`KeepAlive should contain exactly one component child.`); + } + current = null; + return children; + } else if (!isVNode(rawVNode) || !(rawVNode.shapeFlag & 4) && !(rawVNode.shapeFlag & 128)) { + current = null; + return rawVNode; + } + let vnode = getInnerChild(rawVNode); + if (vnode.type === Comment) { + current = null; + return vnode; + } + const comp = vnode.type; + const name = getComponentName( + isAsyncWrapper(vnode) ? vnode.type.__asyncResolved || {} : comp + ); + const { include, exclude, max } = props; + if (include && (!name || !matches(include, name)) || exclude && name && matches(exclude, name)) { + current = vnode; + return rawVNode; + } + const key = vnode.key == null ? comp : vnode.key; + const cachedVNode = cache.get(key); + if (vnode.el) { + vnode = cloneVNode(vnode); + if (rawVNode.shapeFlag & 128) { + rawVNode.ssContent = vnode; + } + } + pendingCacheKey = key; + if (cachedVNode) { + vnode.el = cachedVNode.el; + vnode.component = cachedVNode.component; + if (vnode.transition) { + setTransitionHooks(vnode, vnode.transition); + } + vnode.shapeFlag |= 512; + keys.delete(key); + keys.add(key); + } else { + keys.add(key); + if (max && keys.size > parseInt(max, 10)) { + pruneCacheEntry(keys.values().next().value); + } + } + vnode.shapeFlag |= 256; + current = vnode; + return isSuspense(rawVNode.type) ? rawVNode : vnode; + }; + } +}; +const KeepAlive = KeepAliveImpl; +function matches(pattern, name) { + if (isArray(pattern)) { + return pattern.some((p) => matches(p, name)); + } else if (isString(pattern)) { + return pattern.split(",").includes(name); + } else if (isRegExp(pattern)) { + return pattern.test(name); + } + return false; +} +function onActivated(hook, target) { + registerKeepAliveHook(hook, "a", target); +} +function onDeactivated(hook, target) { + registerKeepAliveHook(hook, "da", target); +} +function registerKeepAliveHook(hook, type, target = currentInstance) { + const wrappedHook = hook.__wdc || (hook.__wdc = () => { + let current = target; + while (current) { + if (current.isDeactivated) { + return; + } + current = current.parent; + } + return hook(); + }); + injectHook(type, wrappedHook, target); + if (target) { + let current = target.parent; + while (current && current.parent) { + if (isKeepAlive(current.parent.vnode)) { + injectToKeepAliveRoot(wrappedHook, type, target, current); + } + current = current.parent; + } + } +} +function injectToKeepAliveRoot(hook, type, target, keepAliveRoot) { + const injected = injectHook( + type, + hook, + keepAliveRoot, + true + /* prepend */ + ); + onUnmounted(() => { + remove(keepAliveRoot[type], injected); + }, target); +} +function resetShapeFlag(vnode) { + vnode.shapeFlag &= ~256; + vnode.shapeFlag &= ~512; +} +function getInnerChild(vnode) { + return vnode.shapeFlag & 128 ? vnode.ssContent : vnode; +} + +function injectHook(type, hook, target = currentInstance, prepend = false) { + if (target) { + const hooks = target[type] || (target[type] = []); + const wrappedHook = hook.__weh || (hook.__weh = (...args) => { + pauseTracking(); + const reset = setCurrentInstance(target); + const res = callWithAsyncErrorHandling(hook, target, type, args); + reset(); + resetTracking(); + return res; + }); + if (prepend) { + hooks.unshift(wrappedHook); + } else { + hooks.push(wrappedHook); + } + return wrappedHook; + } else { + const apiName = toHandlerKey(ErrorTypeStrings$1[type].replace(/ hook$/, "")); + warn$1( + `${apiName} is called when there is no active component instance to be associated with. Lifecycle injection APIs can only be used during execution of setup().` + (` If you are using async setup(), make sure to register lifecycle hooks before the first await statement.` ) + ); + } +} +const createHook = (lifecycle) => (hook, target = currentInstance) => { + if (!isInSSRComponentSetup || lifecycle === "sp") { + injectHook(lifecycle, (...args) => hook(...args), target); + } +}; +const onBeforeMount = createHook("bm"); +const onMounted = createHook("m"); +const onBeforeUpdate = createHook("bu"); +const onUpdated = createHook("u"); +const onBeforeUnmount = createHook("bum"); +const onUnmounted = createHook("um"); +const onServerPrefetch = createHook("sp"); +const onRenderTriggered = createHook( + "rtg" +); +const onRenderTracked = createHook( + "rtc" +); +function onErrorCaptured(hook, target = currentInstance) { + injectHook("ec", hook, target); +} + +const COMPONENTS = "components"; +const DIRECTIVES = "directives"; +function resolveComponent(name, maybeSelfReference) { + return resolveAsset(COMPONENTS, name, true, maybeSelfReference) || name; +} +const NULL_DYNAMIC_COMPONENT = Symbol.for("v-ndc"); +function resolveDynamicComponent(component) { + if (isString(component)) { + return resolveAsset(COMPONENTS, component, false) || component; + } else { + return component || NULL_DYNAMIC_COMPONENT; + } +} +function resolveDirective(name) { + return resolveAsset(DIRECTIVES, name); +} +function resolveAsset(type, name, warnMissing = true, maybeSelfReference = false) { + const instance = currentRenderingInstance || currentInstance; + if (instance) { + const Component = instance.type; + if (type === COMPONENTS) { + const selfName = getComponentName( + Component, + false + ); + if (selfName && (selfName === name || selfName === camelize(name) || selfName === capitalize(camelize(name)))) { + return Component; + } + } + const res = ( + // local registration + // check instance[type] first which is resolved for options API + resolve(instance[type] || Component[type], name) || // global registration + resolve(instance.appContext[type], name) + ); + if (!res && maybeSelfReference) { + return Component; + } + if (warnMissing && !res) { + const extra = type === COMPONENTS ? ` +If this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement.` : ``; + warn$1(`Failed to resolve ${type.slice(0, -1)}: ${name}${extra}`); + } + return res; + } else { + warn$1( + `resolve${capitalize(type.slice(0, -1))} can only be used in render() or setup().` + ); + } +} +function resolve(registry, name) { + return registry && (registry[name] || registry[camelize(name)] || registry[capitalize(camelize(name))]); +} + +function renderList(source, renderItem, cache, index) { + let ret; + const cached = cache && cache[index]; + if (isArray(source) || isString(source)) { + ret = new Array(source.length); + for (let i = 0, l = source.length; i < l; i++) { + ret[i] = renderItem(source[i], i, void 0, cached && cached[i]); + } + } else if (typeof source === "number") { + if (!Number.isInteger(source)) { + warn$1(`The v-for range expect an integer value but got ${source}.`); + } + ret = new Array(source); + for (let i = 0; i < source; i++) { + ret[i] = renderItem(i + 1, i, void 0, cached && cached[i]); + } + } else if (isObject(source)) { + if (source[Symbol.iterator]) { + ret = Array.from( + source, + (item, i) => renderItem(item, i, void 0, cached && cached[i]) + ); + } else { + const keys = Object.keys(source); + ret = new Array(keys.length); + for (let i = 0, l = keys.length; i < l; i++) { + const key = keys[i]; + ret[i] = renderItem(source[key], key, i, cached && cached[i]); + } + } + } else { + ret = []; + } + if (cache) { + cache[index] = ret; + } + return ret; +} + +function createSlots(slots, dynamicSlots) { + for (let i = 0; i < dynamicSlots.length; i++) { + const slot = dynamicSlots[i]; + if (isArray(slot)) { + for (let j = 0; j < slot.length; j++) { + slots[slot[j].name] = slot[j].fn; + } + } else if (slot) { + slots[slot.name] = slot.key ? (...args) => { + const res = slot.fn(...args); + if (res) res.key = slot.key; + return res; + } : slot.fn; + } + } + return slots; +} + +function renderSlot(slots, name, props = {}, fallback, noSlotted) { + if (currentRenderingInstance.isCE || currentRenderingInstance.parent && isAsyncWrapper(currentRenderingInstance.parent) && currentRenderingInstance.parent.isCE) { + if (name !== "default") props.name = name; + return createVNode("slot", props, fallback && fallback()); + } + let slot = slots[name]; + if (slot && slot.length > 1) { + warn$1( + `SSR-optimized slot function detected in a non-SSR-optimized render function. You need to mark this component with $dynamic-slots in the parent template.` + ); + slot = () => []; + } + if (slot && slot._c) { + slot._d = false; + } + openBlock(); + const validSlotContent = slot && ensureValidVNode(slot(props)); + const rendered = createBlock( + Fragment, + { + key: (props.key || // slot content array of a dynamic conditional slot may have a branch + // key attached in the `createSlots` helper, respect that + validSlotContent && validSlotContent.key || `_${name}`) + // #7256 force differentiate fallback content from actual content + (!validSlotContent && fallback ? "_fb" : "") + }, + validSlotContent || (fallback ? fallback() : []), + validSlotContent && slots._ === 1 ? 64 : -2 + ); + if (!noSlotted && rendered.scopeId) { + rendered.slotScopeIds = [rendered.scopeId + "-s"]; + } + if (slot && slot._c) { + slot._d = true; + } + return rendered; +} +function ensureValidVNode(vnodes) { + return vnodes.some((child) => { + if (!isVNode(child)) return true; + if (child.type === Comment) return false; + if (child.type === Fragment && !ensureValidVNode(child.children)) + return false; + return true; + }) ? vnodes : null; +} + +function toHandlers(obj, preserveCaseIfNecessary) { + const ret = {}; + if (!isObject(obj)) { + warn$1(`v-on with no argument expects an object value.`); + return ret; + } + for (const key in obj) { + ret[preserveCaseIfNecessary && /[A-Z]/.test(key) ? `on:${key}` : toHandlerKey(key)] = obj[key]; + } + return ret; +} + +const getPublicInstance = (i) => { + if (!i) return null; + if (isStatefulComponent(i)) return getComponentPublicInstance(i); + return getPublicInstance(i.parent); +}; +const publicPropertiesMap = ( + // Move PURE marker to new line to workaround compiler discarding it + // due to type annotation + /* @__PURE__ */ extend(/* @__PURE__ */ Object.create(null), { + $: (i) => i, + $el: (i) => i.vnode.el, + $data: (i) => i.data, + $props: (i) => shallowReadonly(i.props) , + $attrs: (i) => shallowReadonly(i.attrs) , + $slots: (i) => shallowReadonly(i.slots) , + $refs: (i) => shallowReadonly(i.refs) , + $parent: (i) => getPublicInstance(i.parent), + $root: (i) => getPublicInstance(i.root), + $emit: (i) => i.emit, + $options: (i) => resolveMergedOptions(i) , + $forceUpdate: (i) => i.f || (i.f = () => { + i.effect.dirty = true; + queueJob(i.update); + }), + $nextTick: (i) => i.n || (i.n = nextTick.bind(i.proxy)), + $watch: (i) => instanceWatch.bind(i) + }) +); +const isReservedPrefix = (key) => key === "_" || key === "$"; +const hasSetupBinding = (state, key) => state !== EMPTY_OBJ && !state.__isScriptSetup && hasOwn(state, key); +const PublicInstanceProxyHandlers = { + get({ _: instance }, key) { + if (key === "__v_skip") { + return true; + } + const { ctx, setupState, data, props, accessCache, type, appContext } = instance; + if (key === "__isVue") { + return true; + } + let normalizedProps; + if (key[0] !== "$") { + const n = accessCache[key]; + if (n !== void 0) { + switch (n) { + case 1 /* SETUP */: + return setupState[key]; + case 2 /* DATA */: + return data[key]; + case 4 /* CONTEXT */: + return ctx[key]; + case 3 /* PROPS */: + return props[key]; + } + } else if (hasSetupBinding(setupState, key)) { + accessCache[key] = 1 /* SETUP */; + return setupState[key]; + } else if (data !== EMPTY_OBJ && hasOwn(data, key)) { + accessCache[key] = 2 /* DATA */; + return data[key]; + } else if ( + // only cache other properties when instance has declared (thus stable) + // props + (normalizedProps = instance.propsOptions[0]) && hasOwn(normalizedProps, key) + ) { + accessCache[key] = 3 /* PROPS */; + return props[key]; + } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) { + accessCache[key] = 4 /* CONTEXT */; + return ctx[key]; + } else if (shouldCacheAccess) { + accessCache[key] = 0 /* OTHER */; + } + } + const publicGetter = publicPropertiesMap[key]; + let cssModule, globalProperties; + if (publicGetter) { + if (key === "$attrs") { + track(instance.attrs, "get", ""); + markAttrsAccessed(); + } else if (key === "$slots") { + track(instance, "get", key); + } + return publicGetter(instance); + } else if ( + // css module (injected by vue-loader) + (cssModule = type.__cssModules) && (cssModule = cssModule[key]) + ) { + return cssModule; + } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) { + accessCache[key] = 4 /* CONTEXT */; + return ctx[key]; + } else if ( + // global properties + globalProperties = appContext.config.globalProperties, hasOwn(globalProperties, key) + ) { + { + return globalProperties[key]; + } + } else if (currentRenderingInstance && (!isString(key) || // #1091 avoid internal isRef/isVNode checks on component instance leading + // to infinite warning loop + key.indexOf("__v") !== 0)) { + if (data !== EMPTY_OBJ && isReservedPrefix(key[0]) && hasOwn(data, key)) { + warn$1( + `Property ${JSON.stringify( + key + )} must be accessed via $data because it starts with a reserved character ("$" or "_") and is not proxied on the render context.` + ); + } else if (instance === currentRenderingInstance) { + warn$1( + `Property ${JSON.stringify(key)} was accessed during render but is not defined on instance.` + ); + } + } + }, + set({ _: instance }, key, value) { + const { data, setupState, ctx } = instance; + if (hasSetupBinding(setupState, key)) { + setupState[key] = value; + return true; + } else if (setupState.__isScriptSetup && hasOwn(setupState, key)) { + warn$1(`Cannot mutate