Initial boilerplate scaffold for continuum-app

This commit is contained in:
2026-08-28 16:21:23 +00:00
commit 4cb2e82a92
25 changed files with 810 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { useTelemetryStore } from "~/stores/telemetry";
const telemetry = useTelemetryStore();
const user = useSupabaseUser();
onMounted(() => {
telemetry.connect();
});
onBeforeUnmount(() => {
telemetry.disconnect();
});
</script>
<template>
<div class="app-shell">
<header class="app-shell__header">
<span class="app-shell__brand">Continuum</span>
<span class="app-shell__status" :class="telemetry.connected ? 'is-online' : 'is-offline'">
{{ telemetry.connected ? "Live" : "Reconnecting…" }}
</span>
<span v-if="user" class="app-shell__user">{{ user.email }}</span>
</header>
<main class="app-shell__main">
<NuxtPage />
</main>
</div>
</template>
<style>
:root {
color-scheme: dark;
font-family: system-ui, -apple-system, sans-serif;
}
body {
margin: 0;
background: #0b0d12;
color: #e6e8ec;
}
.app-shell__header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid #1f232c;
}
.app-shell__brand {
font-weight: 700;
letter-spacing: 0.02em;
}
.app-shell__status.is-online {
color: #35d07f;
}
.app-shell__status.is-offline {
color: #f2a33c;
}
.app-shell__user {
margin-left: auto;
opacity: 0.7;
font-size: 0.875rem;
}
.app-shell__main {
padding: 1.25rem;
}
</style>
+47
View File
@@ -0,0 +1,47 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2026-01-01",
// Disabled so the exact same static bundle can be served from the browser
// and embedded verbatim into the Tauri desktop shell.
ssr: false,
devtools: { enabled: true },
modules: ["@nuxtjs/supabase", "@pinia/nuxt"],
supabase: {
redirect: true,
redirectOptions: {
login: "/login",
callback: "/confirm",
exclude: ["/login"],
},
},
runtimeConfig: {
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE || "http://localhost:3001",
wsBase: process.env.NUXT_PUBLIC_WS_BASE || "ws://localhost:3001",
go2rtcStreamBase: process.env.NUXT_PUBLIC_GO2RTC_STREAM_BASE || "http://localhost:1984",
},
},
// apps/desktop's src-tauri config points at this static output directory.
nitro: {
output: {
publicDir: "../desktop/dist",
},
},
vite: {
// Tauri needs a fixed, predictable dev server port.
server: {
strictPort: true,
},
},
typescript: {
strict: true,
},
});
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@continuum/web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "nuxt preview",
"typecheck": "nuxt typecheck",
"lint": "eslint ."
},
"dependencies": {
"@continuum/ui": "workspace:*",
"@nuxtjs/supabase": "^1.4.0",
"@pinia/nuxt": "^0.5.5",
"nuxt": "^3.13.0",
"pinia": "^2.2.2",
"vue": "^3.5.0",
"vue-router": "^4.4.3"
},
"devDependencies": {
"typescript": "^5.6.2"
}
}
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { PrinterCard } from "@continuum/ui";
import { useTelemetryStore } from "~/stores/telemetry";
const telemetry = useTelemetryStore();
const config = useRuntimeConfig();
const { data: printers } = await useFetch(`${config.public.apiBase}/printers`, {
headers: useRequestHeaders(["cookie"]),
});
</script>
<template>
<div class="fleet">
<h1>Fleet overview</h1>
<div class="fleet__grid">
<PrinterCard
v-for="printer in printers ?? []"
:key="printer.id"
:printer="printer"
:live="telemetry.printers[printer.id]"
/>
</div>
<p v-if="!printers?.length">No printers registered yet.</p>
</div>
</template>
<style scoped>
.fleet__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
const email = ref("");
const sent = ref(false);
const error = ref<string | null>(null);
const client = useSupabaseClient();
async function sendMagicLink() {
error.value = null;
const { error: authError } = await client.auth.signInWithOtp({ email: email.value });
if (authError) {
error.value = authError.message;
return;
}
sent.value = true;
}
</script>
<template>
<div class="login">
<h1>Sign in to Continuum</h1>
<form v-if="!sent" @submit.prevent="sendMagicLink">
<input v-model="email" type="email" placeholder="you@farm.com" required />
<button type="submit">Send magic link</button>
</form>
<p v-else>Check your inbox for a sign-in link.</p>
<p v-if="error" class="login__error">{{ error }}</p>
</div>
</template>
<style scoped>
.login {
max-width: 320px;
margin: 4rem auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.login__error {
color: #f2604c;
}
</style>
+86
View File
@@ -0,0 +1,86 @@
import { defineStore } from "pinia";
export interface PrinterTelemetry {
printerId: string;
farmId: string;
state: "idle" | "printing" | "paused" | "error" | "offline";
nozzleTempC: number;
bedTempC: number;
progressPct: number;
updatedAt: string;
}
interface TelemetryState {
socket: WebSocket | null;
connected: boolean;
printers: Record<string, PrinterTelemetry>;
reconnectAttempt: number;
}
export const useTelemetryStore = defineStore("telemetry", {
state: (): TelemetryState => ({
socket: null,
connected: false,
printers: {},
reconnectAttempt: 0,
}),
getters: {
printerList: (state) => Object.values(state.printers),
},
actions: {
connect() {
if (this.socket) return;
const config = useRuntimeConfig();
const client = useSupabaseClient();
client.auth.getSession().then(({ data }) => {
const token = data.session?.access_token ?? "";
const url = `${config.public.wsBase}/ws/telemetry?token=${encodeURIComponent(token)}`;
this.open(url);
});
},
open(url: string) {
const socket = new WebSocket(url);
socket.onopen = () => {
this.connected = true;
this.reconnectAttempt = 0;
};
socket.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as { type: string; data: PrinterTelemetry };
if (payload.type === "printer.telemetry") {
this.printers[payload.data.printerId] = payload.data;
}
} catch {
// Ignore malformed frames rather than crashing the UI.
}
};
socket.onclose = () => {
this.connected = false;
this.socket = null;
const delay = Math.min(30_000, 1_000 * 2 ** this.reconnectAttempt);
this.reconnectAttempt += 1;
setTimeout(() => this.connect(), delay);
};
socket.onerror = () => {
socket.close();
};
this.socket = socket;
},
disconnect() {
this.socket?.close();
this.socket = null;
this.connected = false;
},
},
});
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}