Standalone binary embeds the frontend, avoiding the dev-server dependency that made the window fail to load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
144 lines
3.5 KiB
Svelte
144 lines
3.5 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { app } from './lib/app.svelte';
|
|
import { api, inTauri } from './lib/bridge';
|
|
import ConnectionScreen from './components/ConnectionScreen.svelte';
|
|
import HelpOverlay from './components/HelpOverlay.svelte';
|
|
import ProfileDrawer from './components/ProfileDrawer.svelte';
|
|
import RideScreen from './components/RideScreen.svelte';
|
|
import Toasts from './components/Toasts.svelte';
|
|
|
|
let ready = $state(false);
|
|
let fatal = $state<string | null>(null);
|
|
|
|
onMount(async () => {
|
|
if (!inTauri) {
|
|
fatal = 'Not running inside the Tauri shell — start the app with `cargo tauri dev`.';
|
|
return;
|
|
}
|
|
try {
|
|
await app.init();
|
|
ready = true;
|
|
} catch (e) {
|
|
fatal = String(e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Keyboard control (FR-3.19). Handled once, here, and dispatched straight to
|
|
* Rust — the frontend never applies a step itself, it only asks.
|
|
*/
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLInputElement) return;
|
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
|
|
const step = e.shiftKey ? 2 : 0.5;
|
|
const run = (fn: () => Promise<unknown>) => {
|
|
e.preventDefault();
|
|
app.run(fn);
|
|
};
|
|
|
|
switch (e.key) {
|
|
case 'ArrowUp':
|
|
return run(() => api.nudgeGradient(step));
|
|
case 'ArrowDown':
|
|
return run(() => api.nudgeGradient(-step));
|
|
case '0':
|
|
return run(() => api.resetGradient());
|
|
case ' ':
|
|
return run(() => api.togglePause());
|
|
case 'm':
|
|
case 'M':
|
|
return run(() => api.cycleMode());
|
|
case 'l':
|
|
case 'L':
|
|
return run(() => api.markLap());
|
|
case ']':
|
|
return run(() => bumpTarget(1));
|
|
case '[':
|
|
return run(() => bumpTarget(-1));
|
|
case 'p':
|
|
case 'P':
|
|
e.preventDefault();
|
|
app.showProfiles = !app.showProfiles;
|
|
return;
|
|
case 'd':
|
|
case 'D':
|
|
app.screen = 'connect';
|
|
return;
|
|
case 'r':
|
|
case 'R':
|
|
app.screen = 'ride';
|
|
return;
|
|
case '?':
|
|
app.showHelp = !app.showHelp;
|
|
return;
|
|
case 'Escape':
|
|
app.showHelp = false;
|
|
app.showProfiles = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
/** `[` / `]` adjust whichever target the active mode actually uses. */
|
|
async function bumpTarget(dir: number): Promise<unknown> {
|
|
const ride = app.ride;
|
|
if (!ride) return;
|
|
if (ride.mode === 'Erg') return api.setPower(ride.powerTargetW + dir * 10);
|
|
if (ride.mode === 'Resistance') return api.setResistance(ride.resistanceLevel + dir * 2);
|
|
return api.nudgeGradient(dir * 0.5);
|
|
}
|
|
</script>
|
|
|
|
<svelte:window on:keydown={onKey} />
|
|
|
|
<main>
|
|
{#if fatal}
|
|
<div class="fatal">
|
|
<h1>BikeControl could not start</h1>
|
|
<p>{fatal}</p>
|
|
</div>
|
|
{:else if !ready}
|
|
<div class="boot"><span class="label">Starting…</span></div>
|
|
{:else if app.screen === 'ride'}
|
|
<RideScreen />
|
|
{:else}
|
|
<ConnectionScreen />
|
|
{/if}
|
|
|
|
{#if app.showProfiles}<ProfileDrawer />{/if}
|
|
{#if app.showHelp}<HelpOverlay />{/if}
|
|
<Toasts />
|
|
</main>
|
|
|
|
<style>
|
|
main {
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
.boot,
|
|
.fatal {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.6rem;
|
|
height: 100%;
|
|
text-align: center;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.fatal h1 {
|
|
margin: 0;
|
|
font-size: 1.4rem;
|
|
font-weight: 300;
|
|
}
|
|
|
|
.fatal p {
|
|
margin: 0;
|
|
color: var(--ink-dim);
|
|
max-width: 40rem;
|
|
}
|
|
</style>
|