feat: integrate Tailwind CSS for improved styling and layout

- Added Tailwind CSS and its Vite plugin to the frontend project.
- Updated App.svelte to enhance UI with Tailwind classes, including a new layout for the serial monitor and command tester.
- Improved logging functionality by increasing log history and adding more detailed log messages.
- Refactored SendBox.svelte for consistent styling with Tailwind.
- Enhanced Pico firmware to support structured event logging and command parsing.
- Updated README_PICO_SERIAL.md to provide comprehensive documentation on serial communication and backend integration.
- Added .dockerignore to optimize Docker builds by excluding unnecessary files.
This commit is contained in:
2025-08-30 14:18:57 +02:00
parent d607308b1d
commit 46899ef7be
13 changed files with 1258 additions and 184 deletions

View File

@@ -1,20 +1,48 @@
# Multi-stage build for SvelteKit (static client + node server fallback)
FROM node:20-bookworm-slim AS builder
WORKDIR /app
######## Combined Frontend + Backend build (single Node runtime) ########
# This Dockerfile now builds BOTH the Svelte frontend and the NestJS backend
# and serves the compiled frontend through the backend (Express static).
# Install deps (use package-lock if present)
COPY package*.json ./
########################
# 1) Frontend build #
########################
FROM node:20-bookworm-slim AS frontend-build
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm install --no-audit --no-fund
# Copy sources and build
COPY . .
COPY frontend/ .
RUN npm run build
# Use a lightweight nginx to serve the client build output
FROM nginx:alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
########################
# 2) Backend build #
########################
FROM node:20-bookworm-slim AS backend-build
WORKDIR /app
COPY backend/package*.json ./
RUN npm install --no-audit --no-fund
COPY backend/ .
# Copy compiled frontend into backend/public (served statically by Nest)
COPY --from=frontend-build /frontend/dist ./public
RUN npm run build
# Default nginx port
EXPOSE 80
########################
# 3) Production image #
########################
FROM node:20-bookworm-slim AS runner
ENV NODE_ENV=production
WORKDIR /app
CMD ["nginx", "-g", "daemon off;"]
# Install only production deps (reuse original package.json)
COPY backend/package*.json ./
RUN npm install --omit=dev --no-audit --no-fund
# Copy backend dist + public assets
COPY --from=backend-build /app/dist ./dist
COPY --from=backend-build /app/public ./public
# Environment (override at runtime as needed)
ENV PORT=3000 \
SERIAL_BAUD=115200
EXPOSE 3000
CMD ["node", "dist/main.js"]

View File

@@ -1,8 +1,19 @@
services:
frontend:
build: .
app:
build:
context: ..
dockerfile: frontend/Dockerfile
container_name: schafkop-app
restart: unless-stopped
environment:
- NODE_ENV=production
- PORT=3000
# Optionally set SERIAL_PORT or PICO_SERIAL_PORT for auto-open
# - SERIAL_PORT=/dev/serial0
# - SERIAL_BAUD=115200
ports:
- "80:80"
- "80:3000"
# Uncomment and adjust one of the following for hardware access on a Pi:
# devices:
# - /dev/serial0:/dev/serial0
# - /dev/ttyACM0:/dev/ttyACM0

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,11 @@
"@tsconfig/svelte": "^5.0.4",
"svelte": "^5.38.1",
"svelte-check": "^4.3.1",
"tailwindcss": "^4.1.12",
"typescript": "~5.8.3",
"vite": "^7.1.2"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.12"
}
}

View File

@@ -10,7 +10,8 @@
let es: EventSource | null = null;
function addLog(line: string) {
log.update((l) => [new Date().toISOString() + ' ' + line, ...l].slice(0, 200));
// keep a fairly large history and prefix with an ISO timestamp
log.update((l) => [new Date().toISOString() + ' ' + line, ...l].slice(0, 2000));
}
async function list() {
@@ -26,14 +27,18 @@
const res = await fetch('/serial/open', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }) });
const j = await res.json().catch(() => null);
console.log('[serial] openPort result', j);
// refresh status
// log result and refresh status
if (j?.ok) addLog('PORT: open requested ' + path);
else addLog('PORT: open request failed - ' + (j?.error ?? 'unknown'));
setTimeout(getStatus, 300);
}
async function closePort() {
console.log('[serial] closePort()');
const res = await fetch('/serial/close', { method: 'POST' });
console.log('[serial] closePort result', await res.json().catch(() => null));
const j = await res.json().catch(() => null);
console.log('[serial] closePort result', j);
addLog('PORT: close requested');
}
async function sendLine(txt: string) {
@@ -61,13 +66,17 @@
if (es) return;
console.log('[serial] startStream()');
es = new EventSource('/serial/stream');
es.addEventListener('open', () => { console.log('[serial][SSE] open'); connected.set(true); });
es.addEventListener('close', () => { console.log('[serial][SSE] close'); connected.set(false); });
es.addEventListener('error', (e: any) => { console.warn('[serial][SSE] error', e); addLog('SSE error'); });
// connection lifecycle events - also log them so the serial box shows open/close/errors
es.addEventListener('open', () => { console.log('[serial][SSE] open'); connected.set(true); addLog('[SSE] connected'); });
es.addEventListener('close', () => { console.log('[serial][SSE] close'); connected.set(false); addLog('[SSE] disconnected'); });
es.addEventListener('error', (e: any) => { console.warn('[serial][SSE] error', e); addLog('[SSE] error: ' + (e?.message ?? JSON.stringify(e))); });
// data events contain the actual lines emitted by the Pico; parse JSON payloads but fall back to raw
es.addEventListener('data', (ev: any) => {
console.log('[serial][SSE] data event', ev.data);
try {
const d = JSON.parse(ev.data);
// always show the raw line emitted by the firmware
addLog('RX: ' + d.line);
} catch (e) {
addLog('RX (raw): ' + ev.data);
@@ -83,6 +92,10 @@
}
let statusInterval: any;
// quick test command defaults
let stepVal: string = '4096';
let dirVal: string = '1';
let speedVal: string = '3000';
onMount(() => {
list();
startStream();
@@ -93,43 +106,109 @@
stopStream();
clearInterval(statusInterval);
});
function sendStepCmd() {
const steps = Number(stepVal);
const dir = Number(dirVal) === 1 ? 1 : 0;
if (!Number.isFinite(steps) || steps <= 0) {
addLog('TX error: invalid STEP value: ' + stepVal);
return;
}
const line = `STEP ${steps} ${dir}`;
sendLine(line);
addLog('TX: ' + line);
}
function sendSpeedCmd() {
const d = Number(speedVal);
if (!Number.isFinite(d) || d <= 0) {
addLog('TX error: invalid SPEED value: ' + speedVal);
return;
}
const line = `SPEED ${d}`;
sendLine(line);
addLog('TX: ' + line);
}
</script>
<main>
<h1>Raspberry Pico — Serial Monitor</h1>
<main class="min-h-screen bg-gradient-to-b from-bg to-surface text-gray-100 p-8">
<h1 class="text-3xl font-bold underline">
Hello world!
</h1>
<div class="container mx-auto">
<header class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold">Raspberry Pico</h1>
<p class="text-sm text-gray-400">Serial monitor & command tester</p>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-gray-300">SSE: {$connected ? 'connected' : 'disconnected'}</span>
<button class="px-3 py-1 bg-primary text-white rounded-md text-sm" on:click={startStream}>Start</button>
<button class="px-3 py-1 bg-gray-700 text-white rounded-md text-sm" on:click={stopStream}>Stop</button>
</div>
</header>
<section>
<h3>Available ports</h3>
<button on:click={list}>Refresh</button>
<ul>
{#each $ports as p}
<li>
<code>{p.path}</code>
<button on:click={() => openPort(p.path)}>Open</button>
</li>
{/each}
</ul>
<button on:click={closePort}>Close port</button>
</section>
<div class="grid grid-cols-12 gap-6">
<aside class="col-span-4 bg-gray-900/60 p-4 rounded-lg border border-gray-800">
<section class="mb-4">
<h3 class="text-sm text-gray-300 font-medium">Available ports</h3>
<div class="flex items-center gap-2 mt-2">
<button class="px-2 py-1 text-sm bg-gray-700 rounded" on:click={list}>Refresh</button>
<button class="px-2 py-1 text-sm bg-red-600 rounded" on:click={closePort}>Close port</button>
</div>
<ul class="mt-3 space-y-2">
{#each $ports as p}
<li class="flex items-center justify-between bg-gray-800/40 p-2 rounded">
<code class="text-xs text-gray-200">{p.path}</code>
<button class="px-2 py-1 text-sm bg-primary rounded" on:click={() => openPort(p.path)}>Open</button>
</li>
{/each}
</ul>
</section>
<section>
<h3>Send</h3>
<div style="display:flex; align-items:center; gap:1rem;">
<div>Port: {$portOpen ? 'open' : 'closed'}</div>
<SendBox disabled={!$portOpen} on:send={(e) => { sendLine(e.detail); addLog('TX: ' + e.detail); }} />
<section class="mb-4">
<h3 class="text-sm text-gray-300 font-medium">Send</h3>
<div class="mt-2 flex items-center gap-2">
<div class="text-xs text-gray-300">Port: <span class="font-medium">{$portOpen ? 'open' : 'closed'}</span></div>
</div>
<div class="mt-2">
<SendBox disabled={!$portOpen} on:send={(e) => { sendLine(e.detail); addLog('TX: ' + e.detail); }} />
</div>
</section>
<section>
<h3 class="text-sm text-gray-300 font-medium">Quick commands</h3>
<div class="mt-2 space-y-2">
<div class="flex gap-2 items-center">
<input class="w-28 p-2 rounded bg-gray-800 text-sm" type="number" bind:value={stepVal} min="1" />
<select class="p-2 rounded bg-gray-800 text-sm" bind:value={dirVal}>
<option value="1">1 (forward)</option>
<option value="0">0 (reverse)</option>
</select>
<button class="px-3 py-1 bg-primary text-white rounded" on:click={sendStepCmd} disabled={!$portOpen}>Send STEP</button>
</div>
<div class="flex gap-2 items-center">
<input class="w-28 p-2 rounded bg-gray-800 text-sm" type="number" bind:value={speedVal} min="1" />
<button class="px-3 py-1 bg-primary text-white rounded" on:click={sendSpeedCmd} disabled={!$portOpen}>Send SPEED</button>
</div>
</div>
</section>
</aside>
<section class="col-span-8 bg-gray-900/60 p-4 rounded-lg border border-gray-800 flex flex-col">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm text-gray-300 font-medium">Live log</h3>
<div class="text-xs text-gray-400">Showing last {2000} lines</div>
</div>
<div class="flex-1 overflow-auto bg-[#0b0b0b] p-3 rounded text-sm font-mono text-gray-100">
{#each $log as l}
<div class="py-0.5"><code class="text-xs">{l}</code></div>
{/each}
</div>
</section>
</div>
</section>
<section>
<h3>Live log ({$connected ? 'connected' : 'disconnected'})</h3>
<button on:click={startStream}>Start stream</button>
<button on:click={stopStream}>Stop stream</button>
<div style="height:320px; overflow:auto; background:#111; color:#dfe6ef; padding:8px; border-radius:6px;">
{#each $log as l}
<div><code>{l}</code></div>
{/each}
</div>
</section>
</div>
</main>
<style>

View File

@@ -12,7 +12,7 @@
}
</script>
<div style="margin-top:0.5rem; display:flex; gap:0.5rem; align-items:center;">
<input bind:value={txt} placeholder={placeholder} on:keydown={(e) => e.key === 'Enter' && doSend()} style="flex:1; padding:0.4rem;" disabled={disabled} />
<button on:click={doSend} disabled={disabled}>Send</button>
</div>
<div class="mt-2 flex gap-2 items-center">
<input class="flex-1 p-2 rounded bg-gray-800 text-sm text-gray-100" bind:value={txt} placeholder={placeholder} on:keydown={(e) => e.key === 'Enter' && doSend()} disabled={disabled} />
<button class="px-3 py-1 bg-primary text-white rounded text-sm" on:click={doSend} disabled={disabled}>Send</button>
</div>

View File

@@ -30,3 +30,15 @@ a:hover { opacity: 0.9; }
@media (prefers-color-scheme: light) {
:root { --bg: #fafafa; --surface:#fff; --text:#111827; --muted:#556; }
}
/* small helpers that complement tailwind tokens */
.container { max-width: 1100px; margin: 0 auto; padding: 1.25rem; }
:focus { outline: 3px solid rgba(124,108,255,0.18); outline-offset: 2px; }
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";

View File

@@ -1,9 +1,13 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte()],
plugins: [
svelte(),
tailwindcss(),
],
server: {
proxy: {
// forward serial API calls (including SSE) to backend running on :3000