diff --git a/frogpilot/system/the_pond/assets/components/home/home.css b/frogpilot/system/the_pond/assets/components/home/home.css index 2b707acfd..9ab6684fb 100644 --- a/frogpilot/system/the_pond/assets/components/home/home.css +++ b/frogpilot/system/the_pond/assets/components/home/home.css @@ -35,6 +35,12 @@ margin: 0; } +.softwareGrid p { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + .drivingStat { display: grid; grid-template-columns: 1fr 1fr 1fr; diff --git a/frogpilot/system/the_pond/assets/components/home/home.js b/frogpilot/system/the_pond/assets/components/home/home.js index adc3c27c7..f59432f62 100644 --- a/frogpilot/system/the_pond/assets/components/home/home.js +++ b/frogpilot/system/the_pond/assets/components/home/home.js @@ -1,145 +1,152 @@ -import { html, reactive } from "https://esm.sh/@arrow-js/core"; +import { html } from "/assets/vendor/arrow-core.js"; -function DiskUsage(disk) { - const used = parseFloat(disk.usedPercentage) || 0; - const rightRadius = used >= 100 ? "0" : "var(--border-radius-md)"; +const HOME_STATE = { + status: "loading", // loading | ready | error + data: null, + unit: "miles", + error: "", + initialized: false, +}; - return html` -
-

${disk.used} used of ${disk.size}

-
-
+function withTimeout(promise, timeoutMs, label) { + return new Promise((resolve, reject) => { + const timerId = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs); + promise.then((value) => { + clearTimeout(timerId); + resolve(value); + }).catch((err) => { + clearTimeout(timerId); + reject(err); + }); + }); +} + +function formatInt(value) { + const n = Number(value || 0); + return Number.isFinite(n) ? n.toLocaleString("en-US", { maximumFractionDigits: 0 }) : "0"; +} + +function renderDiskUsageSection(state) { + const { data } = state; + const shell = document.getElementById("home_shell"); + if (!shell) return; + + if (state.status === "error") { + shell.innerHTML = `

Failed to load data: ${state.error}

`; + return; + } + + if (state.status !== "ready" || !data) { + shell.innerHTML = "

Loading...

"; + return; + } + + const driveStats = data.driveStats || {}; + const softwareInfo = data.softwareInfo || {}; + const diskUsage = Array.isArray(data.diskUsage) ? data.diskUsage : []; + const diskError = Array.isArray(data.diskError) ? data.diskError : []; + + const statBlock = (title, stats = {}) => ` +
+

${title}

+

${formatInt(stats.drives)}

drives

+

${formatInt(stats.distance)}

${stats.unit || state.unit}

+

${formatInt(stats.hours)}

hours

+
+ `; + + const diskBlock = (disk = {}) => { + const usedPct = Number.parseFloat(disk.usedPercentage) || 0; + const rightRadius = usedPct >= 100 ? "0" : "var(--border-radius-md)"; + return ` +
+

${disk.used || "0 GB"} used of ${disk.size || "0 GB"}

+
+
+
+
+ `; + }; + + const softwareFields = [ + ["Branch Name", softwareInfo.branchName], + ["Build", softwareInfo.buildEnvironment], + ["Commit Hash", softwareInfo.commitHash], + ["Fork Maintainer", softwareInfo.forkMaintainer], + ["Update Available", softwareInfo.updateAvailable], + ["Version Date", softwareInfo.versionDate], + ]; + + const softwareMarkup = softwareFields + .map(([label, value]) => `

${label}: ${value ?? "Unknown"}

`) + .join(""); + + const diskMarkup = diskError.length + ? `

${diskError.join("
")}

` + : (diskUsage.length ? diskUsage.map(diskBlock).join("") : diskBlock({})); + + shell.innerHTML = ` +
+

Galaxy

+ +
+ ${statBlock("All Time", driveStats.all)} + ${statBlock("Past Week", driveStats.week)} + ${statBlock("FrogPilot", driveStats.frogpilot)} +
+ +

Disk Usage

+
+ ${diskMarkup} +
+ +

Software Info

+
+
${softwareMarkup}
`; } -function DriveStat(title, stats = {}, defaultUnit) { - const format = (n) => - n?.toLocaleString("en-US", { - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }) ?? "0"; - - return html` -
-

${title}

-

${format(stats.drives)}

drives

-

${format(stats.distance)}

${stats.unit ?? defaultUnit}

-

${format(stats.hours)}

hours

-
- `; -} - -function renderSoftwareInfo(info = {}) { - const fields = [ - ["Branch Name", info.branchName], - ["Build", info.buildEnvironment], - ["Commit Hash", info.commitHash], - ["Fork Maintainer", info.forkMaintainer], - ["Update Available", info.updateAvailable], - ["Version Date", info.versionDate], - ]; - - return fields.map( - ([label, value]) => - html`

${label}: ${value ?? "Unknown"}

` - ); -} - -function renderDiskUsageSection({ diskError, diskUsage }) { - if (diskError) { - return html`

${diskError.join("
")}

`; - } - if (diskUsage?.length) { - return diskUsage.map(DiskUsage); - } - return DiskUsage({ size: "0 GB", used: "0 GB", usedPercentage: "0" }); -} - -const state = reactive({ - data: null, - unit: "miles", - isLoading: true, - error: null, -}); - -let initialized = false; - -async function initialize() { +async function initializeHome() { try { const [statsResponse, unitResponse] = await Promise.all([ - fetch("/api/stats"), - fetch("/api/params?key=IsMetric"), + withTimeout(fetch("/api/stats"), 5000, "stats request"), + withTimeout(fetch("/api/params?key=IsMetric"), 5000, "metric request"), ]); - if (!statsResponse.ok) throw new Error(`API error: ${statsResponse.statusText}`); - if (!unitResponse.ok) throw new Error(`API error: ${unitResponse.statusText}`); + if (!statsResponse.ok) throw new Error(`stats API error: ${statsResponse.status}`); + if (!unitResponse.ok) throw new Error(`params API error: ${unitResponse.status}`); - const statsJson = await statsResponse.json(); - const isMetricText = (await unitResponse.text()).trim(); - const isMetric = isMetricText === "1"; + const statsJson = await withTimeout(statsResponse.json(), 5000, "stats JSON parse"); + const isMetricText = (await withTimeout(unitResponse.text(), 5000, "metric read")).trim(); - state.data = statsJson; - state.unit = isMetric ? "kilometers" : "miles"; - localStorage.setItem("isMetric", isMetricText); + HOME_STATE.data = statsJson; + HOME_STATE.unit = isMetricText === "1" ? "kilometers" : "miles"; + HOME_STATE.status = "ready"; } catch (err) { - console.error("Failed to initialize component:", err); - state.error = err.message; - } finally { - state.isLoading = false; + HOME_STATE.status = "error"; + HOME_STATE.error = err?.message || String(err); } + + renderDiskUsageSection(HOME_STATE); } export function Home() { - if (!initialized) { - initialized = true; - initialize(); - } + setTimeout(() => { + renderDiskUsageSection(HOME_STATE); + if (!HOME_STATE.initialized) { + HOME_STATE.initialized = true; + initializeHome(); + } + }, 0); - return html` -
- ${() => { - if (state.isLoading) { - return html`

Loading...

`; - } - - if (state.error) { - return html`

Failed to load data: ${state.error}

`; - } - - if (state.data) { - const { driveStats, softwareInfo } = state.data; - return html` -

Galaxy

- -
- ${DriveStat("All Time", driveStats?.all, state.unit)} - ${DriveStat("Past Week", driveStats?.week, state.unit)} - ${DriveStat("FrogPilot", driveStats?.frogpilot, state.unit)} -
- -

Disk Usage

-
- ${renderDiskUsageSection(state.data)} -
- -

Software Info

-
-
${renderSoftwareInfo(softwareInfo)}
-
- `; - } - - return html`

No data available.

`; - }} -
- `; + return html`

Loading...

`; } diff --git a/frogpilot/system/the_pond/assets/components/modal.js b/frogpilot/system/the_pond/assets/components/modal.js index cd3af9e46..bb2bdb03d 100644 --- a/frogpilot/system/the_pond/assets/components/modal.js +++ b/frogpilot/system/the_pond/assets/components/modal.js @@ -1,4 +1,4 @@ -import { html } from "https://esm.sh/@arrow-js/core"; +import { html } from "/assets/vendor/arrow-core.js"; export function Modal({ title, diff --git a/frogpilot/system/the_pond/assets/components/navigation/navigation_destination.js b/frogpilot/system/the_pond/assets/components/navigation/navigation_destination.js index 6be8d6406..755308fc9 100644 --- a/frogpilot/system/the_pond/assets/components/navigation/navigation_destination.js +++ b/frogpilot/system/the_pond/assets/components/navigation/navigation_destination.js @@ -1,4 +1,4 @@ -import { html, reactive } from "https://esm.sh/@arrow-js/core"; +import { html, reactive } from "/assets/vendor/arrow-core.js"; import { addRouteToMap, formatMetersToHuman, diff --git a/frogpilot/system/the_pond/assets/components/navigation/navigation_keys.js b/frogpilot/system/the_pond/assets/components/navigation/navigation_keys.js index 3fd881811..9ce319038 100644 --- a/frogpilot/system/the_pond/assets/components/navigation/navigation_keys.js +++ b/frogpilot/system/the_pond/assets/components/navigation/navigation_keys.js @@ -1,4 +1,4 @@ -import { html, reactive } from "https://esm.sh/@arrow-js/core" +import { html, reactive } from "/assets/vendor/arrow-core.js" import { Modal } from "/assets/components/modal.js"; export function NavKeys() { diff --git a/frogpilot/system/the_pond/assets/components/recordings/dashcam_routes.js b/frogpilot/system/the_pond/assets/components/recordings/dashcam_routes.js index 7f82fdb91..0eec5f34e 100644 --- a/frogpilot/system/the_pond/assets/components/recordings/dashcam_routes.js +++ b/frogpilot/system/the_pond/assets/components/recordings/dashcam_routes.js @@ -1,4 +1,4 @@ -import { html, reactive } from "https://esm.sh/@arrow-js/core" +import { html, reactive } from "/assets/vendor/arrow-core.js" import { isGalaxyTunnel } from "/assets/js/utils.js" import { getOrdinalSuffix } from "/assets/components/navigation/navigation_utilities.js" import { Modal } from "/assets/components/modal.js"; @@ -13,8 +13,18 @@ const state = reactive({ total: 0, showDeleteAllModal: false, isDeletingAll: false, + truncated: false, }) +const MAX_RENDERED_ROUTES = 250 +const ROUTE_FLUSH_INTERVAL_MS = 120 + +let routesAbortController = null +let routesRequestToken = 0 +let pendingRoutes = [] +let flushTimerId = null +let seenRouteNames = new Set() + function formatRouteDate(dateString) { const date = new Date(dateString) if (isNaN(date.getTime())) { @@ -32,10 +42,73 @@ function formatRouteDate(dateString) { return `${month} ${day}${getOrdinalSuffix(day)}, ${year} - ${hour}:${minuteStr}${ampm}` } +function resetRouteStreamState() { + pendingRoutes = [] + if (flushTimerId !== null) { + clearTimeout(flushTimerId) + flushTimerId = null + } + seenRouteNames = new Set() +} + +function flushPendingRoutes() { + if (pendingRoutes.length === 0) return + + const availableSlots = Math.max(MAX_RENDERED_ROUTES - state.routes.length, 0) + if (availableSlots <= 0) { + pendingRoutes = [] + state.truncated = true + return + } + + const toAppend = pendingRoutes.slice(0, availableSlots) + pendingRoutes = [] + if (toAppend.length > 0) { + state.routes = [...state.routes, ...toAppend] + } + if (state.routes.length >= MAX_RENDERED_ROUTES) { + state.truncated = true + } +} + +function enqueueRoutes(rawRoutes) { + if (!Array.isArray(rawRoutes) || rawRoutes.length === 0) return + + const nextRoutes = [] + for (const route of rawRoutes) { + const name = String(route?.name || "") + if (!name || seenRouteNames.has(name)) continue + seenRouteNames.add(name) + nextRoutes.push({ + ...route, + timestamp: formatRouteDate(route.timestamp), + }) + } + + if (nextRoutes.length === 0) return + pendingRoutes.push(...nextRoutes) + + if (flushTimerId === null) { + flushTimerId = setTimeout(() => { + flushTimerId = null + flushPendingRoutes() + }, ROUTE_FLUSH_INTERVAL_MS) + } +} + async function fetchRoutes() { + const requestToken = ++routesRequestToken + if (routesAbortController) { + routesAbortController.abort() + } + const controller = new AbortController() + routesAbortController = controller + try { const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const response = await fetch(`/api/routes?timezone=${encodeURIComponent(userTimezone)}`); + const response = await fetch(`/api/routes?timezone=${encodeURIComponent(userTimezone)}`, { + signal: controller.signal, + }); if (!response.ok) throw new Error(); const reader = response.body.getReader(); @@ -46,24 +119,24 @@ async function fetchRoutes() { const { value, done } = await reader.read(); if (done) break; + if (requestToken !== routesRequestToken) return + buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n\n"); + const lines = buffer.split(/\r?\n\r?\n/); buffer = lines.pop(); for (const line of lines) { - if (line.startsWith("data: ")) { + if (line.startsWith("data:")) { try { - const data = JSON.parse(line.substring(6)); + const payload = line.substring(5).trim() + if (!payload) continue + const data = JSON.parse(payload); if (data.progress !== undefined && data.total !== undefined) { state.progress = data.progress; state.total = data.total; } if (data.routes) { - const routes = data.routes.map(route => ({ - ...route, - timestamp: formatRouteDate(route.timestamp), - })); - state.routes.push(...routes); + enqueueRoutes(data.routes) } } catch (e) { console.error("Failed to parse JSON:", e); @@ -71,21 +144,35 @@ async function fetchRoutes() { } } } - } catch (_) { - state.error = "Couldn't load routes. Please try again later..." + flushPendingRoutes() + } catch (error) { + if (error?.name !== "AbortError") { + state.error = "Couldn't load routes. Please try again later..." + } } finally { - state.loading = false + if (requestToken === routesRequestToken) { + flushPendingRoutes() + state.loading = false + if (routesAbortController === controller) { + routesAbortController = null + } + } } } -fetchRoutes() - function refresh() { state.loading = true + state.error = null state.routes = [] + state.progress = 0 + state.total = 0 + state.truncated = false + resetRouteStreamState() fetchRoutes() } +refresh() + let overlay = null function openDialog(htmlStr) { @@ -391,6 +478,9 @@ export function RouteRecordings() {
`; }} + ${() => state.truncated ? html` +

Showing first ${MAX_RENDERED_ROUTES} routes to keep the UI responsive.

+ ` : ""} ${() => { if (state.routes.length > 0) { return html` diff --git a/frogpilot/system/the_pond/assets/components/recordings/screen_recordings.js b/frogpilot/system/the_pond/assets/components/recordings/screen_recordings.js index 9ed184988..09958e47c 100644 --- a/frogpilot/system/the_pond/assets/components/recordings/screen_recordings.js +++ b/frogpilot/system/the_pond/assets/components/recordings/screen_recordings.js @@ -1,4 +1,4 @@ -import { html, reactive } from "https://esm.sh/@arrow-js/core" +import { html, reactive } from "/assets/vendor/arrow-core.js" import { isGalaxyTunnel } from "/assets/js/utils.js" import { Modal } from "/assets/components/modal.js"; diff --git a/frogpilot/system/the_pond/assets/components/router.js b/frogpilot/system/the_pond/assets/components/router.js index a0ff96bc6..68d6659f6 100644 --- a/frogpilot/system/the_pond/assets/components/router.js +++ b/frogpilot/system/the_pond/assets/components/router.js @@ -1,5 +1,5 @@ -import { html, reactive } from "https://esm.sh/@arrow-js/core" -import { createBrowserHistory, createRouter } from "https://esm.sh/@remix-run/router@1.3.1" +import { html, reactive } from "/assets/vendor/arrow-core.js" +import { createBrowserHistory, createRouter } from "/assets/vendor/remix-router-1.3.1.js" import { hideSidebar } from "/assets/js/utils.js" import { DeviceSettings } from "/assets/components/tools/device_settings.js" import { ErrorLogs } from "/assets/components/tools/error_logs.js" @@ -25,6 +25,15 @@ import { UpdateManager } from "/assets/components/tools/update_manager.js" let router, routerState +function toRouterHref(href) { + try { + const url = new URL(href, window.location.origin) + return `${url.pathname}${url.search}${url.hash}` + } catch { + return href + } +} + function createRoute(id, path, component) { return { id, @@ -34,6 +43,20 @@ function createRoute(id, path, component) { } } +function SafeHome() { + return html` +
+

Galaxy

+

Safe mode is active while UI rendering is being repaired.

+

+ Galaxy Pairing | + Toggles | + Software +

+
+ ` +} + function Root() { let routes = [ createRoute("device_settings", "/device_settings/:section?", DeviceSettings), @@ -63,6 +86,11 @@ function Root() { history: createBrowserHistory(), }).initialize() + window.__thePondNavigate = (href) => { + router.navigate(toRouterHref(href)) + window.scrollTo(0, 0) + } + routerState = reactive({ activePath: "/", activePathFull: "/", @@ -70,10 +98,13 @@ function Root() { navigation: { state: "loading" }, errors: [], params: {}, + activeMatch: null, }) router.subscribe(({ initialized, navigation, matches, errors }) => { - const [match] = matches || [] + const match = Array.isArray(matches) && matches.length > 0 + ? matches[matches.length - 1] + : null Object.assign(routerState, { initialized, activePath: match?.route?.path || "", @@ -81,6 +112,7 @@ function Root() { navigation, params: match?.params || {}, errors, + activeMatch: match, }) }) @@ -96,12 +128,12 @@ function Root() { return html`

Not Found

` } - const match = routes.find(r => r.path === routerState.activePath) - if (!match) { - console.warn("[router] no route match for path:", routerState.activePathFull) + const routeElement = routerState.activeMatch?.route?.element + if (typeof routeElement !== "function") { + console.warn("[router] no route element for path:", routerState.activePathFull) return Home({ params: routerState.params }) } - return match.element({ params: routerState.params }) + return routeElement({ params: routerState.params }) }}
` @@ -113,7 +145,7 @@ export function Link(href, children, onClick, classes = "") { href="${() => href}" @click="${(e) => { e.preventDefault() - router.navigate(e.currentTarget.href) + router.navigate(toRouterHref(e.currentTarget.href)) hideSidebar() onClick?.() }}" @@ -121,13 +153,28 @@ export function Link(href, children, onClick, classes = "") { } export function Navigate(href) { - router.navigate(href) + router.navigate(toRouterHref(href)) window.scrollTo(0, 0) } -if (!window.__thePondRouterMounted) { - window.__thePondRouterMounted = true - Root()(document.getElementById("app")) -} else { - console.warn("[router] duplicate mount prevented") +function mountRouterWhenReady() { + const mountNode = document.getElementById("app") + if (!mountNode) { + // Some embedded browsers execute module scripts before is fully available. + setTimeout(mountRouterWhenReady, 0) + return + } + + if (!window.__thePondRouterMounted) { + window.__thePondRouterMounted = true + Root()(mountNode) + } else { + console.warn("[router] duplicate mount prevented") + } +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mountRouterWhenReady, { once: true }) +} else { + mountRouterWhenReady() } diff --git a/frogpilot/system/the_pond/assets/components/settings.js b/frogpilot/system/the_pond/assets/components/settings.js index ba666ecce..c8e85f592 100644 --- a/frogpilot/system/the_pond/assets/components/settings.js +++ b/frogpilot/system/the_pond/assets/components/settings.js @@ -1,4 +1,4 @@ -import { html } from "https://esm.sh/@arrow-js/core" +import { html } from "/assets/vendor/arrow-core.js" import { upperFirst } from "/assets/js/utils.js" import { Navigate } from "/assets/components/router.js" diff --git a/frogpilot/system/the_pond/assets/components/sidebar.js b/frogpilot/system/the_pond/assets/components/sidebar.js index 13bf1cc70..d838fa175 100644 --- a/frogpilot/system/the_pond/assets/components/sidebar.js +++ b/frogpilot/system/the_pond/assets/components/sidebar.js @@ -1,8 +1,7 @@ -import { html, reactive } from "https://esm.sh/@arrow-js/core"; -import { Link } from "/assets/components/router.js"; -import { upperFirst, hideSidebar } from "/assets/js/utils.js"; +import { html } from "/assets/vendor/arrow-core.js"; +import { hideSidebar, upperFirst } from "/assets/js/utils.js"; -const MenuItems = { +const MENU_ITEMS = { home: [ { name: "Home", link: "/", icon: "bi-house-fill" }, ], @@ -32,36 +31,89 @@ const MenuItems = { ], }; -const state = reactive({ - activeRoute: "" -}); +function matchesPath(currentPath, link) { + if (link === "/") return currentPath === "/"; + return currentPath === link || currentPath.startsWith(`${link}/`); +} -export function Sidebar() { - const currentPath = window.location.pathname; - const matchesPath = (link) => { - if (link === "/") return currentPath === "/"; - return currentPath === link || currentPath.startsWith(`${link}/`); - }; - const activeItem = Object.values(MenuItems).flat().find(item => matchesPath(item.link)); - state.activeRoute = activeItem?.name ?? ""; +function buildSectionMarkup(section, links, currentPath) { + const linksMarkup = links.map((link) => { + const active = matchesPath(currentPath, link.link) ? "active" : ""; + return ` +
  • + + + ${upperFirst(link.name)} + +
  • + `; + }).join(""); + return ` + + `; +} - function navigate(link) { - state.activeRoute = link.name; - window.scrollTo(0, 0); - hideSidebar(); +function bindSidebarHandlers() { + const menuButton = document.getElementById("menu_button"); + const underlay = document.getElementById("sidebarUnderlay"); - document.querySelectorAll('.sidebar li').forEach(el => { - el.classList.remove('active'); + if (!menuButton || !underlay) return; + + if (!window.__thePondSidebarMenuBound) { + window.__thePondSidebarMenuBound = true; + menuButton.addEventListener("click", () => { + const sidebar = document.getElementById("sidebar"); + const currentUnderlay = document.getElementById("sidebarUnderlay"); + if (!sidebar || !currentUnderlay) return; + sidebar.classList.toggle("visible"); + currentUnderlay.classList.toggle("hidden"); }); - - const linkElement = document.querySelector(`.sidebar li a[href="${link.link}"]`); - if (linkElement) { - linkElement.parentElement.classList.add('active'); - } } - return html` + underlay.onclick = hideSidebar; + + document.querySelectorAll("#sidebar a.menu-item-link").forEach((anchor) => { + if (anchor.dataset.boundClick === "1") return; + anchor.dataset.boundClick = "1"; + anchor.addEventListener("click", (event) => { + if (event.defaultPrevented) return; + if (event.button !== 0) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + + event.preventDefault(); + const href = anchor.getAttribute("href") || "/"; + const navigate = window.__thePondNavigate; + if (typeof navigate === "function") { + navigate(href); + } else { + window.location.assign(href); + } + hideSidebar(); + window.scrollTo(0, 0); + }); + }); +} + +function renderSidebarIntoShell(currentPath) { + const shell = document.getElementById("sidebar_shell"); + if (!shell) return; + + const activePath = currentPath || window.location.pathname; + const sectionsMarkup = Object.entries(MENU_ITEMS) + .map(([section, links]) => buildSectionMarkup(section, links, activePath)) + .join(""); + + shell.innerHTML = `
    - ${() => Object.entries(MenuItems).map(([section, links]) => html` - - `)} + ${sectionsMarkup} - `; + + `; + + bindSidebarHandlers(); } -function setupMenuButton() { - const button = document.getElementById("menu_button"); - const sidebar = document.getElementById("sidebar"); - const underlay = document.getElementById("sidebarUnderlay"); - - button.addEventListener("click", () => { - sidebar.classList.toggle("visible"); - underlay.classList.toggle("hidden"); - }); - - underlay.addEventListener("click", hideSidebar); +export function Sidebar(currentPath) { + setTimeout(() => renderSidebarIntoShell(currentPath), 0); + return html``; } - -document.addEventListener("DOMContentLoaded", setupMenuButton, false); diff --git a/frogpilot/system/the_pond/assets/components/tailscale/tailscale.js b/frogpilot/system/the_pond/assets/components/tailscale/tailscale.js index a5b5b97e8..1fa55b693 100644 --- a/frogpilot/system/the_pond/assets/components/tailscale/tailscale.js +++ b/frogpilot/system/the_pond/assets/components/tailscale/tailscale.js @@ -1,4 +1,4 @@ -import { html, reactive } from "https://esm.sh/@arrow-js/core" +import { html, reactive } from "/assets/vendor/arrow-core.js" import { Modal } from "/assets/components/modal.js"; export function TailscaleControl() { diff --git a/frogpilot/system/the_pond/assets/components/tools/device_settings.css b/frogpilot/system/the_pond/assets/components/tools/device_settings.css index 10660b7ad..df689f370 100644 --- a/frogpilot/system/the_pond/assets/components/tools/device_settings.css +++ b/frogpilot/system/the_pond/assets/components/tools/device_settings.css @@ -367,6 +367,58 @@ font-weight: var(--font-weight-semibold); } +.ds-step-value { + color: var(--text-muted); +} + +.ds-manual-row { + align-items: center; + display: flex; + gap: 0.35rem; + margin-top: 0.15rem; + width: 100%; +} + +.ds-manual-input { + background: var(--sidebar-bg); + border: var(--border-style-main); + border-radius: var(--border-radius-sm); + color: var(--text-color); + flex: 1; + font-family: var(--font-body); + font-size: var(--font-size-xs); + min-width: 0; + padding: 0.22rem 0.35rem; +} + +.ds-manual-input:focus { + border-color: var(--main-fg); + outline: none; +} + +.ds-apply-btn { + background: transparent; + border: var(--border-style-main); + border-radius: var(--border-radius-sm); + color: var(--text-color); + cursor: pointer; + font-size: var(--font-size-xs); + padding: 0.2rem 0.45rem; + transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); + white-space: nowrap; +} + +.ds-apply-btn:hover:not(:disabled) { + background: var(--main-fg); + border-color: var(--main-fg); + color: var(--color-black); +} + +.ds-apply-btn:disabled { + cursor: not-allowed; + opacity: var(--disabled-opacity); +} + .ds-reset-btn { background: transparent; border: var(--border-style-main); diff --git a/frogpilot/system/the_pond/assets/components/tools/device_settings.js b/frogpilot/system/the_pond/assets/components/tools/device_settings.js index 24162c8a0..a326802fa 100644 --- a/frogpilot/system/the_pond/assets/components/tools/device_settings.js +++ b/frogpilot/system/the_pond/assets/components/tools/device_settings.js @@ -1,4 +1,4 @@ -import { html, reactive } from "https://esm.sh/@arrow-js/core" +import { html, reactive } from "/assets/vendor/arrow-core.js" const endpointOptionsCache = {} const endpointOptionsInflight = {} @@ -220,6 +220,18 @@ function formatSliderValue(val, stepStr, precisionInt, key) { return Number(v.toFixed(dec)).toString() } +function formatNumericForInput(value, precision) { + const n = Number(value) + if (!Number.isFinite(n)) return "" + return Number(n.toFixed(precision)).toString() +} + +function formatStepValue(step, precision) { + const n = Number(step) + if (!Number.isFinite(n)) return "1" + return Number(n.toFixed(Math.max(0, precision))).toString() +} + function numericBounds(param) { const defaultBounds = { min: param.min !== undefined ? param.min : (param.data_type === "float" ? 0.0 : 0), @@ -412,6 +424,41 @@ function stepNumericParam(param, direction) { updateNumericParam(param, next) } +function applyManualNumericParam(param) { + if (isNumericUpdating(param.key)) return + + const inputEl = document.getElementById(`ds-manual-${param.key}`) + if (!inputEl) return + + const raw = String(inputEl.value ?? "").trim() + if (!raw) { + showParamSnackbar("Enter a value first.", "error") + return + } + + const parsed = Number.parseFloat(raw) + if (!Number.isFinite(parsed)) { + showParamSnackbar("Enter a valid number.", "error") + return + } + + const bounds = numericBounds(param) + const precision = stepPrecision(bounds.step, param.precision) + const snapped = snapNumericToBoundsAndStep(parsed, bounds, precision) + if (snapped === null) { + showParamSnackbar("Value is out of range.", "error") + return + } + + inputEl.value = formatNumericForInput(snapped, precision) + + const current = resolveCurrentNumericValue(param, bounds) + const epsilon = Math.pow(10, -(precision + 2)) + if (Math.abs(snapped - current) <= epsilon) return + + updateNumericParam(param, snapped) +} + async function resetNumericParam(param) { const bounds = numericBounds(param) let defaultValue = resolveDefaultNumericValue(param, bounds) @@ -575,6 +622,7 @@ function renderSettingRow(p) { ? formatSliderValue(defaultNumeric, String(bounds.step), p.precision, p.key) : "N/A" const canReset = !updating && defaultNumeric !== null && Math.abs(defaultNumeric - currentNumeric) > epsilon + const stepLabel = formatStepValue(bounds.step, precision) return html`
    ${formatSliderValue(bounds.min, String(bounds.step), p.precision, p.key)} to ${formatSliderValue(bounds.max, String(bounds.step), p.precision, p.key)} + Step: ${stepLabel} per click Default: ${defaultLabel} +
    + + +