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
+11
View File
@@ -0,0 +1,11 @@
# --- apps/web (Nuxt) runtime config ---
# Public (exposed to the client bundle)
NUXT_PUBLIC_API_BASE=https://api.continuum.local
NUXT_PUBLIC_WS_BASE=wss://api.continuum.local
NUXT_PUBLIC_SUPABASE_URL=https://xxxxxxxx.supabase.co
NUXT_PUBLIC_SUPABASE_ANON_KEY=replace-with-anon-key
NUXT_PUBLIC_GO2RTC_STREAM_BASE=https://edge.continuum.local/streams
# Desktop (Tauri) build-time
TAURI_SIGNING_PRIVATE_KEY=
TAURI_SIGNING_PRIVATE_KEY_PASSWORD=
+18
View File
@@ -0,0 +1,18 @@
node_modules/
.nuxt/
.output/
dist/
.env
.env.local
*.log
.DS_Store
# Tauri
apps/desktop/src-tauri/target/
apps/desktop/src-tauri/gen/
# Rust
**/*.rs.bk
# pnpm
.pnpm-store/
+27
View File
@@ -0,0 +1,27 @@
# continuum-app
Operator console for the Continuum print farm platform. A single Nuxt 3 SPA
(`apps/web`) shared between the browser and a Tauri 2 desktop shell
(`apps/desktop`), plus a shared component library (`packages/ui`).
## Layout
```
apps/
web/ Nuxt 3 SPA (ssr: false) — Supabase auth, Pinia telemetry store
desktop/ Tauri 2 shell wrapping apps/web's static build
packages/
ui/ Shared Vue components: PrinterCard, TemperatureGraph, WebRTCStreamView
```
## Getting started
```bash
pnpm install
cp .env.example apps/web/.env
pnpm dev:web # browser dev server on :3000
pnpm dev:desktop # Tauri dev shell (requires Rust toolchain)
```
`apps/web` builds as a static SPA so the exact same `apps/web/.output/public`
bundle is served in the browser and embedded into the Tauri desktop app.
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@continuum/desktop",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"tauri": "tauri",
"dev": "tauri dev",
"build": "tauri build"
},
"dependencies": {
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-fs": "^2.0.3",
"@tauri-apps/plugin-shell": "^2.0.2"
},
"devDependencies": {
"@tauri-apps/cli": "^2.1.0"
}
}
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "continuum-desktop"
version = "0.1.0"
description = "Continuum operator console desktop shell"
edition = "2021"
rust-version = "1.77"
[build-dependencies]
tauri-build = { version = "2.0", features = [] }
[dependencies]
tauri = { version = "2.1", features = ["tray-icon"] }
tauri-plugin-fs = "2.0"
tauri-plugin-shell = "2.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[lib]
name = "continuum_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "continuum-desktop"
path = "src/main.rs"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,14 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capabilities granted to the main Continuum window",
"windows": ["main"],
"permissions": [
"core:default",
"fs:default",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:scope-appdata",
"shell:allow-open"
]
}
+39
View File
@@ -0,0 +1,39 @@
// Prevents an additional console window on Windows in release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use tauri::{
menu::{Menu, MenuItem},
tray::TrayIconBuilder,
Manager,
};
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_shell::init())
.setup(|app| {
let show = MenuItem::with_id(app, "show", "Show Continuum", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &quit])?;
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip("Continuum — print farm console")
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
"quit" => app.exit(0),
_ => {}
})
.build(app)?;
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running the Continuum desktop shell");
}
+48
View File
@@ -0,0 +1,48 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Continuum",
"version": "0.1.0",
"identifier": "com.continuum3d.desktop",
"build": {
"beforeDevCommand": "pnpm --filter @continuum/web dev",
"beforeBuildCommand": "pnpm --filter @continuum/web generate",
"devUrl": "http://localhost:3000",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "Continuum",
"width": 1280,
"height": 800,
"minWidth": 960,
"minHeight": 600,
"resizable": true
}
],
"security": {
"csp": null
},
"trayIcon": {
"iconPath": "icons/icon.png",
"iconAsTemplate": true
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"fs": {
"scope": ["$APPDATA/continuum/**", "$DOWNLOAD/**"]
}
}
}
+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"
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "continuum-app",
"private": true,
"version": "0.1.0",
"description": "Continuum print farm operator console — Nuxt SPA + Tauri 2 desktop shell",
"packageManager": "pnpm@9.9.0",
"scripts": {
"dev:web": "pnpm --filter @continuum/web dev",
"dev:desktop": "pnpm --filter @continuum/desktop tauri dev",
"build:web": "pnpm --filter @continuum/web build",
"build:desktop": "pnpm --filter @continuum/desktop tauri build",
"lint": "pnpm -r lint",
"typecheck": "pnpm -r typecheck"
},
"devDependencies": {
"typescript": "^5.6.2"
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@continuum/ui",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "vue-tsc --noEmit",
"lint": "eslint ."
},
"peerDependencies": {
"vue": "^3.5.0"
},
"devDependencies": {
"typescript": "^5.6.2",
"vue-tsc": "^2.1.6"
}
}
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from "vue";
import TemperatureGraph from "./TemperatureGraph.vue";
interface Printer {
id: string;
name: string;
model: string;
farmId: string;
}
interface LiveTelemetry {
state: "idle" | "printing" | "paused" | "error" | "offline";
nozzleTempC: number;
bedTempC: number;
progressPct: number;
}
const props = defineProps<{
printer: Printer;
live?: LiveTelemetry;
}>();
const state = computed(() => props.live?.state ?? "offline");
</script>
<template>
<article class="printer-card" :class="`is-${state}`">
<header>
<h3>{{ printer.name }}</h3>
<span class="printer-card__model">{{ printer.model }}</span>
</header>
<p class="printer-card__state">{{ state }}</p>
<TemperatureGraph
v-if="live"
:nozzle-temp-c="live.nozzleTempC"
:bed-temp-c="live.bedTempC"
/>
<progress v-if="live" :value="live.progressPct" max="100" />
</article>
</template>
<style scoped>
.printer-card {
border: 1px solid #1f232c;
border-radius: 8px;
padding: 1rem;
background: #12151c;
}
.printer-card__model {
opacity: 0.6;
font-size: 0.8rem;
}
.printer-card.is-printing .printer-card__state {
color: #35d07f;
}
.printer-card.is-error .printer-card__state {
color: #f2604c;
}
.printer-card.is-offline .printer-card__state {
color: #6b7280;
}
progress {
width: 100%;
}
</style>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
const props = defineProps<{
nozzleTempC: number;
bedTempC: number;
maxSamples?: number;
}>();
const MAX_SAMPLES = props.maxSamples ?? 60;
const nozzleHistory = ref<number[]>([]);
const bedHistory = ref<number[]>([]);
watch(
() => [props.nozzleTempC, props.bedTempC] as const,
([nozzle, bed]) => {
nozzleHistory.value = [...nozzleHistory.value, nozzle].slice(-MAX_SAMPLES);
bedHistory.value = [...bedHistory.value, bed].slice(-MAX_SAMPLES);
},
{ immediate: true },
);
function toPoints(history: number[], height: number) {
if (history.length === 0) return "";
const max = Math.max(...history, 1);
const step = 100 / Math.max(history.length - 1, 1);
return history
.map((value, index) => `${index * step},${height - (value / max) * height}`)
.join(" ");
}
const nozzlePoints = computed(() => toPoints(nozzleHistory.value, 40));
const bedPoints = computed(() => toPoints(bedHistory.value, 40));
onBeforeUnmount(() => {
nozzleHistory.value = [];
bedHistory.value = [];
});
</script>
<template>
<div class="temp-graph">
<svg viewBox="0 0 100 40" preserveAspectRatio="none">
<polyline class="temp-graph__nozzle" :points="nozzlePoints" fill="none" />
<polyline class="temp-graph__bed" :points="bedPoints" fill="none" />
</svg>
<div class="temp-graph__labels">
<span>Nozzle {{ nozzleTempC.toFixed(0) }}°C</span>
<span>Bed {{ bedTempC.toFixed(0) }}°C</span>
</div>
</div>
</template>
<style scoped>
.temp-graph svg {
width: 100%;
height: 48px;
}
.temp-graph__nozzle {
stroke: #f2a33c;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
.temp-graph__bed {
stroke: #4c9df2;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
.temp-graph__labels {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
opacity: 0.8;
}
</style>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from "vue";
const props = defineProps<{
streamBaseUrl: string;
cameraId: string;
}>();
const videoEl = ref<HTMLVideoElement | null>(null);
const pc = ref<RTCPeerConnection | null>(null);
const status = ref<"connecting" | "connected" | "failed">("connecting");
async function connect() {
status.value = "connecting";
pc.value?.close();
const connection = new RTCPeerConnection();
pc.value = connection;
connection.addTransceiver("video", { direction: "recvonly" });
connection.ontrack = (event) => {
if (videoEl.value) videoEl.value.srcObject = event.streams[0];
};
connection.onconnectionstatechange = () => {
if (connection.connectionState === "connected") status.value = "connected";
if (connection.connectionState === "failed") status.value = "failed";
};
const offer = await connection.createOffer();
await connection.setLocalDescription(offer);
// go2rtc's WebRTC WHEP-style signaling endpoint.
const response = await fetch(`${props.streamBaseUrl}/api/webrtc?src=${props.cameraId}`, {
method: "POST",
headers: { "Content-Type": "application/sdp" },
body: offer.sdp,
});
const answerSdp = await response.text();
await connection.setRemoteDescription({ type: "answer", sdp: answerSdp });
}
watch(() => [props.streamBaseUrl, props.cameraId], connect, { immediate: true });
onBeforeUnmount(() => {
pc.value?.close();
pc.value = null;
});
</script>
<template>
<div class="webrtc-view">
<video ref="videoEl" autoplay playsinline muted />
<span class="webrtc-view__status" :class="`is-${status}`">{{ status }}</span>
</div>
</template>
<style scoped>
.webrtc-view {
position: relative;
background: #000;
border-radius: 6px;
overflow: hidden;
}
.webrtc-view video {
width: 100%;
display: block;
}
.webrtc-view__status {
position: absolute;
bottom: 6px;
right: 8px;
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.6);
}
.webrtc-view__status.is-connected {
color: #35d07f;
}
.webrtc-view__status.is-failed {
color: #f2604c;
}
</style>
+3
View File
@@ -0,0 +1,3 @@
export { default as PrinterCard } from "./components/PrinterCard.vue";
export { default as TemperatureGraph } from "./components/TemperatureGraph.vue";
export { default as WebRTCStreamView } from "./components/WebRTCStreamView.vue";
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"declaration": true,
"skipLibCheck": true
},
"include": ["src"]
}
+3
View File
@@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"