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
+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"]
}