mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-07-12 12:52:13 +08:00
@@ -35,12 +35,6 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.softwareGrid p {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.drivingStat {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
|
||||
@@ -1,152 +1,145 @@
|
||||
import { html } from "/assets/vendor/arrow-core.js";
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core";
|
||||
|
||||
const HOME_STATE = {
|
||||
status: "loading", // loading | ready | error
|
||||
data: null,
|
||||
unit: "miles",
|
||||
error: "",
|
||||
initialized: false,
|
||||
};
|
||||
function DiskUsage(disk) {
|
||||
const used = parseFloat(disk.usedPercentage) || 0;
|
||||
const rightRadius = used >= 100 ? "0" : "var(--border-radius-md)";
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
return html`
|
||||
<div class="disk">
|
||||
<p>${disk.used} used of ${disk.size}</p>
|
||||
<div class="progress">
|
||||
<div
|
||||
class="bar"
|
||||
style="
|
||||
border-bottom-right-radius: ${rightRadius};
|
||||
border-top-right-radius: ${rightRadius};
|
||||
width: ${100 - used}%;
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatInt(value) {
|
||||
const n = Number(value || 0);
|
||||
return Number.isFinite(n) ? n.toLocaleString("en-US", { maximumFractionDigits: 0 }) : "0";
|
||||
}
|
||||
function DriveStat(title, stats = {}, defaultUnit) {
|
||||
const format = (n) =>
|
||||
n?.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}) ?? "0";
|
||||
|
||||
function renderDiskUsageSection(state) {
|
||||
const { data } = state;
|
||||
const shell = document.getElementById("home_shell");
|
||||
if (!shell) return;
|
||||
|
||||
if (state.status === "error") {
|
||||
shell.innerHTML = `<p class="error">Failed to load data: ${state.error}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.status !== "ready" || !data) {
|
||||
shell.innerHTML = "<p>Loading...</p>";
|
||||
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 = {}) => `
|
||||
return html`
|
||||
<div class="drivingStat">
|
||||
<h2>${title}</h2>
|
||||
<div><p>${formatInt(stats.drives)}</p><p>drives</p></div>
|
||||
<div><p>${formatInt(stats.distance)}</p><p>${stats.unit || state.unit}</p></div>
|
||||
<div><p>${formatInt(stats.hours)}</p><p>hours</p></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const diskBlock = (disk = {}) => {
|
||||
const usedPct = Number.parseFloat(disk.usedPercentage) || 0;
|
||||
const rightRadius = usedPct >= 100 ? "0" : "var(--border-radius-md)";
|
||||
return `
|
||||
<div class="disk">
|
||||
<p>${disk.used || "0 GB"} used of ${disk.size || "0 GB"}</p>
|
||||
<div class="progress">
|
||||
<div
|
||||
class="bar"
|
||||
style="
|
||||
border-bottom-right-radius: ${rightRadius};
|
||||
border-top-right-radius: ${rightRadius};
|
||||
width: ${Math.max(0, 100 - usedPct)}%;
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
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]) => `<p><strong>${label}:</strong> ${value ?? "Unknown"}</p>`)
|
||||
.join("");
|
||||
|
||||
const diskMarkup = diskError.length
|
||||
? `<p>${diskError.join("<br>")}</p>`
|
||||
: (diskUsage.length ? diskUsage.map(diskBlock).join("") : diskBlock({}));
|
||||
|
||||
shell.innerHTML = `
|
||||
<div>
|
||||
<h1>Galaxy</h1>
|
||||
|
||||
<div class="drivingStats">
|
||||
${statBlock("All Time", driveStats.all)}
|
||||
${statBlock("Past Week", driveStats.week)}
|
||||
${statBlock("FrogPilot", driveStats.frogpilot)}
|
||||
</div>
|
||||
|
||||
<h2>Disk Usage</h2>
|
||||
<div class="diskUsage">
|
||||
${diskMarkup}
|
||||
</div>
|
||||
|
||||
<h2>Software Info</h2>
|
||||
<div class="softwareInfo">
|
||||
<div class="softwareGrid">${softwareMarkup}</div>
|
||||
</div>
|
||||
<div><p>${format(stats.drives)}</p><p>drives</p></div>
|
||||
<div><p>${format(stats.distance)}</p><p>${stats.unit ?? defaultUnit}</p></div>
|
||||
<div><p>${format(stats.hours)}</p><p>hours</p></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function initializeHome() {
|
||||
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`<p><strong>${label}:</strong> ${value ?? "Unknown"}</p>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderDiskUsageSection({ diskError, diskUsage }) {
|
||||
if (diskError) {
|
||||
return html`<p>${diskError.join("<br>")}</p>`;
|
||||
}
|
||||
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() {
|
||||
try {
|
||||
const [statsResponse, unitResponse] = await Promise.all([
|
||||
withTimeout(fetch("/api/stats"), 5000, "stats request"),
|
||||
withTimeout(fetch("/api/params?key=IsMetric"), 5000, "metric request"),
|
||||
fetch("/api/stats"),
|
||||
fetch("/api/params?key=IsMetric"),
|
||||
]);
|
||||
|
||||
if (!statsResponse.ok) throw new Error(`stats API error: ${statsResponse.status}`);
|
||||
if (!unitResponse.ok) throw new Error(`params API error: ${unitResponse.status}`);
|
||||
if (!statsResponse.ok) throw new Error(`API error: ${statsResponse.statusText}`);
|
||||
if (!unitResponse.ok) throw new Error(`API error: ${unitResponse.statusText}`);
|
||||
|
||||
const statsJson = await withTimeout(statsResponse.json(), 5000, "stats JSON parse");
|
||||
const isMetricText = (await withTimeout(unitResponse.text(), 5000, "metric read")).trim();
|
||||
const statsJson = await statsResponse.json();
|
||||
const isMetricText = (await unitResponse.text()).trim();
|
||||
const isMetric = isMetricText === "1";
|
||||
|
||||
HOME_STATE.data = statsJson;
|
||||
HOME_STATE.unit = isMetricText === "1" ? "kilometers" : "miles";
|
||||
HOME_STATE.status = "ready";
|
||||
state.data = statsJson;
|
||||
state.unit = isMetric ? "kilometers" : "miles";
|
||||
localStorage.setItem("isMetric", isMetricText);
|
||||
} catch (err) {
|
||||
HOME_STATE.status = "error";
|
||||
HOME_STATE.error = err?.message || String(err);
|
||||
console.error("Failed to initialize component:", err);
|
||||
state.error = err.message;
|
||||
} finally {
|
||||
state.isLoading = false;
|
||||
}
|
||||
|
||||
renderDiskUsageSection(HOME_STATE);
|
||||
}
|
||||
|
||||
export function Home() {
|
||||
setTimeout(() => {
|
||||
renderDiskUsageSection(HOME_STATE);
|
||||
if (!HOME_STATE.initialized) {
|
||||
HOME_STATE.initialized = true;
|
||||
initializeHome();
|
||||
}
|
||||
}, 0);
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
initialize();
|
||||
}
|
||||
|
||||
return html`<div id="home_shell"><p>Loading...</p></div>`;
|
||||
return html`
|
||||
<div>
|
||||
${() => {
|
||||
if (state.isLoading) {
|
||||
return html`<p>Loading...</p>`;
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return html`<p class="error">Failed to load data: ${state.error}</p>`;
|
||||
}
|
||||
|
||||
if (state.data) {
|
||||
const { driveStats, softwareInfo } = state.data;
|
||||
return html`
|
||||
<h1>Galaxy</h1>
|
||||
|
||||
<div class="drivingStats">
|
||||
${DriveStat("All Time", driveStats?.all, state.unit)}
|
||||
${DriveStat("Past Week", driveStats?.week, state.unit)}
|
||||
${DriveStat("FrogPilot", driveStats?.frogpilot, state.unit)}
|
||||
</div>
|
||||
|
||||
<h2>Disk Usage</h2>
|
||||
<div class="diskUsage">
|
||||
${renderDiskUsageSection(state.data)}
|
||||
</div>
|
||||
|
||||
<h2>Software Info</h2>
|
||||
<div class="softwareInfo">
|
||||
<div class="softwareGrid">${renderSoftwareInfo(softwareInfo)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`<p>No data available.</p>`;
|
||||
}}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html } from "/assets/vendor/arrow-core.js";
|
||||
import { html } from "https://esm.sh/@arrow-js/core";
|
||||
|
||||
export function Modal({
|
||||
title,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js";
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core";
|
||||
import {
|
||||
addRouteToMap,
|
||||
formatMetersToHuman,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { Modal } from "/assets/components/modal.js";
|
||||
|
||||
export function NavKeys() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { isGalaxyTunnel } from "/assets/js/utils.js"
|
||||
import { getOrdinalSuffix } from "/assets/components/navigation/navigation_utilities.js"
|
||||
import { Modal } from "/assets/components/modal.js";
|
||||
@@ -13,18 +13,8 @@ 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())) {
|
||||
@@ -42,73 +32,10 @@ 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)}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const response = await fetch(`/api/routes?timezone=${encodeURIComponent(userTimezone)}`);
|
||||
if (!response.ok) throw new Error();
|
||||
|
||||
const reader = response.body.getReader();
|
||||
@@ -119,24 +46,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(/\r?\n\r?\n/);
|
||||
const lines = buffer.split("\n\n");
|
||||
buffer = lines.pop();
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data:")) {
|
||||
if (line.startsWith("data: ")) {
|
||||
try {
|
||||
const payload = line.substring(5).trim()
|
||||
if (!payload) continue
|
||||
const data = JSON.parse(payload);
|
||||
const data = JSON.parse(line.substring(6));
|
||||
if (data.progress !== undefined && data.total !== undefined) {
|
||||
state.progress = data.progress;
|
||||
state.total = data.total;
|
||||
}
|
||||
if (data.routes) {
|
||||
enqueueRoutes(data.routes)
|
||||
const routes = data.routes.map(route => ({
|
||||
...route,
|
||||
timestamp: formatRouteDate(route.timestamp),
|
||||
}));
|
||||
state.routes.push(...routes);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse JSON:", e);
|
||||
@@ -144,35 +71,21 @@ async function fetchRoutes() {
|
||||
}
|
||||
}
|
||||
}
|
||||
flushPendingRoutes()
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
state.error = "Couldn't load routes. Please try again later..."
|
||||
}
|
||||
} catch (_) {
|
||||
state.error = "Couldn't load routes. Please try again later..."
|
||||
} finally {
|
||||
if (requestToken === routesRequestToken) {
|
||||
flushPendingRoutes()
|
||||
state.loading = false
|
||||
if (routesAbortController === controller) {
|
||||
routesAbortController = null
|
||||
}
|
||||
}
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -478,9 +391,6 @@ export function RouteRecordings() {
|
||||
</div>
|
||||
`;
|
||||
}}
|
||||
${() => state.truncated ? html`
|
||||
<p class="screen-recordings-message">Showing first ${MAX_RENDERED_ROUTES} routes to keep the UI responsive.</p>
|
||||
` : ""}
|
||||
${() => {
|
||||
if (state.routes.length > 0) {
|
||||
return html`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { isGalaxyTunnel } from "/assets/js/utils.js"
|
||||
import { Modal } from "/assets/components/modal.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { createBrowserHistory, createRouter } from "/assets/vendor/remix-router-1.3.1.js"
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { createBrowserHistory, createRouter } from "https://esm.sh/@remix-run/router@1.3.1"
|
||||
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,15 +25,6 @@ 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,
|
||||
@@ -43,20 +34,6 @@ function createRoute(id, path, component) {
|
||||
}
|
||||
}
|
||||
|
||||
function SafeHome() {
|
||||
return html`
|
||||
<div>
|
||||
<h1>Galaxy</h1>
|
||||
<p>Safe mode is active while UI rendering is being repaired.</p>
|
||||
<p>
|
||||
<a href="/galaxy">Galaxy Pairing</a> |
|
||||
<a href="/device_settings">Toggles</a> |
|
||||
<a href="/manage_updates">Software</a>
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function Root() {
|
||||
let routes = [
|
||||
createRoute("device_settings", "/device_settings/:section?", DeviceSettings),
|
||||
@@ -86,11 +63,6 @@ function Root() {
|
||||
history: createBrowserHistory(),
|
||||
}).initialize()
|
||||
|
||||
window.__thePondNavigate = (href) => {
|
||||
router.navigate(toRouterHref(href))
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
|
||||
routerState = reactive({
|
||||
activePath: "/",
|
||||
activePathFull: "/",
|
||||
@@ -98,13 +70,10 @@ function Root() {
|
||||
navigation: { state: "loading" },
|
||||
errors: [],
|
||||
params: {},
|
||||
activeMatch: null,
|
||||
})
|
||||
|
||||
router.subscribe(({ initialized, navigation, matches, errors }) => {
|
||||
const match = Array.isArray(matches) && matches.length > 0
|
||||
? matches[matches.length - 1]
|
||||
: null
|
||||
const [match] = matches || []
|
||||
Object.assign(routerState, {
|
||||
initialized,
|
||||
activePath: match?.route?.path || "",
|
||||
@@ -112,7 +81,6 @@ function Root() {
|
||||
navigation,
|
||||
params: match?.params || {},
|
||||
errors,
|
||||
activeMatch: match,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -128,12 +96,12 @@ function Root() {
|
||||
return html`<h1>Not Found</h1>`
|
||||
}
|
||||
|
||||
const routeElement = routerState.activeMatch?.route?.element
|
||||
if (typeof routeElement !== "function") {
|
||||
console.warn("[router] no route element for path:", routerState.activePathFull)
|
||||
const match = routes.find(r => r.path === routerState.activePath)
|
||||
if (!match) {
|
||||
console.warn("[router] no route match for path:", routerState.activePathFull)
|
||||
return Home({ params: routerState.params })
|
||||
}
|
||||
return routeElement({ params: routerState.params })
|
||||
return match.element({ params: routerState.params })
|
||||
}}
|
||||
</div>
|
||||
`
|
||||
@@ -145,7 +113,7 @@ export function Link(href, children, onClick, classes = "") {
|
||||
href="${() => href}"
|
||||
@click="${(e) => {
|
||||
e.preventDefault()
|
||||
router.navigate(toRouterHref(e.currentTarget.href))
|
||||
router.navigate(e.currentTarget.href)
|
||||
hideSidebar()
|
||||
onClick?.()
|
||||
}}"
|
||||
@@ -153,28 +121,13 @@ export function Link(href, children, onClick, classes = "") {
|
||||
}
|
||||
|
||||
export function Navigate(href) {
|
||||
router.navigate(toRouterHref(href))
|
||||
router.navigate(href)
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
|
||||
function mountRouterWhenReady() {
|
||||
const mountNode = document.getElementById("app")
|
||||
if (!mountNode) {
|
||||
// Some embedded browsers execute module scripts before <body> 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 })
|
||||
if (!window.__thePondRouterMounted) {
|
||||
window.__thePondRouterMounted = true
|
||||
Root()(document.getElementById("app"))
|
||||
} else {
|
||||
mountRouterWhenReady()
|
||||
console.warn("[router] duplicate mount prevented")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html } from "/assets/vendor/arrow-core.js"
|
||||
import { html } from "https://esm.sh/@arrow-js/core"
|
||||
import { upperFirst } from "/assets/js/utils.js"
|
||||
import { Navigate } from "/assets/components/router.js"
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { html } from "/assets/vendor/arrow-core.js";
|
||||
import { hideSidebar, upperFirst } from "/assets/js/utils.js";
|
||||
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";
|
||||
|
||||
const MENU_ITEMS = {
|
||||
const MenuItems = {
|
||||
home: [
|
||||
{ name: "Home", link: "/", icon: "bi-house-fill" },
|
||||
],
|
||||
@@ -31,89 +32,36 @@ const MENU_ITEMS = {
|
||||
],
|
||||
};
|
||||
|
||||
function matchesPath(currentPath, link) {
|
||||
if (link === "/") return currentPath === "/";
|
||||
return currentPath === link || currentPath.startsWith(`${link}/`);
|
||||
}
|
||||
const state = reactive({
|
||||
activeRoute: ""
|
||||
});
|
||||
|
||||
function buildSectionMarkup(section, links, currentPath) {
|
||||
const linksMarkup = links.map((link) => {
|
||||
const active = matchesPath(currentPath, link.link) ? "active" : "";
|
||||
return `
|
||||
<li class="${active}">
|
||||
<a class="menu-item-link" href="${link.link}">
|
||||
<i class="bi ${link.icon}"></i>
|
||||
<span>${upperFirst(link.name)}</span>
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
}).join("");
|
||||
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 ?? "";
|
||||
|
||||
return `
|
||||
<div class="sidebar_widget">
|
||||
<ul class="menu_section">
|
||||
<li>
|
||||
<span class="section-title">${upperFirst(section)}</span>
|
||||
<ul id="${section}">
|
||||
${linksMarkup}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function bindSidebarHandlers() {
|
||||
const menuButton = document.getElementById("menu_button");
|
||||
const underlay = document.getElementById("sidebarUnderlay");
|
||||
function navigate(link) {
|
||||
state.activeRoute = link.name;
|
||||
window.scrollTo(0, 0);
|
||||
hideSidebar();
|
||||
|
||||
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");
|
||||
document.querySelectorAll('.sidebar li').forEach(el => {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
|
||||
const linkElement = document.querySelector(`.sidebar li a[href="${link.link}"]`);
|
||||
if (linkElement) {
|
||||
linkElement.parentElement.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
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 = `
|
||||
return html`
|
||||
<div id="sidebarUnderlay" class="hidden"></div>
|
||||
<div id="sidebar" class="sidebar">
|
||||
<div>
|
||||
@@ -124,15 +72,49 @@ function renderSidebarIntoShell(currentPath) {
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
${sectionsMarkup}
|
||||
${() => Object.entries(MenuItems).map(([section, links]) => html`
|
||||
<div class="sidebar_widget">
|
||||
<ul class="menu_section">
|
||||
<li>
|
||||
<span class="section-title">${upperFirst(section)}</span>
|
||||
<ul id="${section}">
|
||||
${links.map(link => {
|
||||
const isActive = state.activeRoute === link.name;
|
||||
const classList = [isActive && "active"].filter(Boolean).join(" ");
|
||||
|
||||
const content = html`
|
||||
<div class="menu-item-link">
|
||||
<i class="bi ${link.icon}"></i>
|
||||
<span>${upperFirst(link.name)}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<li class="${classList}">
|
||||
${Link(link.link, content, () => navigate(link))}
|
||||
</li>
|
||||
`;
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
bindSidebarHandlers();
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function Sidebar(currentPath) {
|
||||
setTimeout(() => renderSidebarIntoShell(currentPath), 0);
|
||||
return html`<div id="sidebar_shell"></div>`;
|
||||
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);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", setupMenuButton, false);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { Modal } from "/assets/components/modal.js";
|
||||
|
||||
export function TailscaleControl() {
|
||||
|
||||
@@ -367,58 +367,6 @@
|
||||
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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
|
||||
const endpointOptionsCache = {}
|
||||
const endpointOptionsInflight = {}
|
||||
@@ -220,18 +220,6 @@ 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),
|
||||
@@ -424,41 +412,6 @@ 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)
|
||||
@@ -622,7 +575,6 @@ 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`
|
||||
<div class="ds-stepper">
|
||||
<button
|
||||
@@ -631,28 +583,7 @@ function renderSettingRow(p) {
|
||||
@click="${() => stepNumericParam(p, -1)}">-</button>
|
||||
<div class="ds-stepper-meta">
|
||||
<span>${formatSliderValue(bounds.min, String(bounds.step), p.precision, p.key)} to ${formatSliderValue(bounds.max, String(bounds.step), p.precision, p.key)}</span>
|
||||
<span class="ds-step-value">Step: ${stepLabel} per click</span>
|
||||
<span class="ds-default-value">Default: ${defaultLabel}</span>
|
||||
<div class="ds-manual-row">
|
||||
<input
|
||||
type="number"
|
||||
class="ds-manual-input"
|
||||
id="ds-manual-${p.key}"
|
||||
min="${bounds.min}"
|
||||
max="${bounds.max}"
|
||||
step="${bounds.step}"
|
||||
?disabled="${updating}"
|
||||
value="${() => formatNumericForInput(resolveCurrentNumericValue(p, numericBounds(p)), precision)}"
|
||||
@keydown="${(e) => {
|
||||
if (e.key !== "Enter") return
|
||||
e.preventDefault()
|
||||
applyManualNumericParam(p)
|
||||
}}" />
|
||||
<button
|
||||
class="ds-apply-btn"
|
||||
?disabled="${updating}"
|
||||
@click="${() => applyManualNumericParam(p)}">Apply</button>
|
||||
</div>
|
||||
<button
|
||||
class="ds-reset-btn"
|
||||
disabled="${() => !canReset || false}"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html } from "/assets/vendor/arrow-core.js"
|
||||
import { html } from "https://esm.sh/@arrow-js/core"
|
||||
|
||||
export function DoorControl () {
|
||||
async function lockDoors () {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js";
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core";
|
||||
import { formatSecondsToHuman, parseErrorLogToDate } from "/assets/js/utils.js";
|
||||
import { Modal } from "/assets/components/modal.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { isGalaxyTunnel } from "/assets/js/utils.js"
|
||||
import { Modal } from "/assets/components/modal.js"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js";
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core";
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html } from "/assets/vendor/arrow-core.js"
|
||||
import { html } from "https://esm.sh/@arrow-js/core"
|
||||
|
||||
export function SpeedLimits() {
|
||||
function handleDownload() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js";
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core";
|
||||
import { Modal } from "/assets/components/modal.js";
|
||||
|
||||
const defaultColors = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { formatSecondsToHuman, isGalaxyTunnel } from "/assets/js/utils.js"
|
||||
import { Modal } from "/assets/components/modal.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { Modal } from "/assets/components/modal.js"
|
||||
import { TailscaleControl } from "/assets/components/tailscale/tailscale.js"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { Modal } from "/assets/components/modal.js"
|
||||
|
||||
const state = reactive({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { DoorControl } from "/assets/components/tools/doors.js"
|
||||
import { TSKManager } from "/assets/components/tools/tsk_manager.js"
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -41,20 +41,7 @@
|
||||
<link rel="stylesheet" href="/assets/components/tools/longitudinal_maneuvers.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
|
||||
|
||||
<script type="module">
|
||||
import("/assets/components/router.js").catch((err) => {
|
||||
console.error("[the_pond] bootstrap failed", err);
|
||||
const target = document.getElementById("app") || document.body;
|
||||
const pre = document.createElement("pre");
|
||||
pre.style.whiteSpace = "pre-wrap";
|
||||
pre.style.color = "#ff8080";
|
||||
pre.style.background = "#200";
|
||||
pre.style.padding = "12px";
|
||||
pre.style.margin = "12px";
|
||||
pre.textContent = `[the_pond] failed to load router:\n${err?.stack || err}`;
|
||||
target.appendChild(pre);
|
||||
});
|
||||
</script>
|
||||
<script type="module" src="/assets/components/router.js"></script>
|
||||
<script src="/assets/js/snackbar.js"></script>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
|
||||
Reference in New Issue
Block a user