Restructure Web App into server/ + web/ (GsmNode parity)

Reorganize the Web App to match the GsmNode project layout: a Go
backend-for-frontend in server/ that embeds the built SPA and reverse-proxies
/api/* to the API Server, with the Vue 3 + Vite frontend moved into web/.

- Move all frontend files into web/ (history preserved via renames)
- Point vite build output at ../server/dist for Go embedding
- Add server/ Go BFF (main.go, go.mod, .env.example, Run-WebApp.ps1)
- Drop Docker/nginx deploy (Dockerfile, docker-compose.yml, nginx.conf.template,
  .dockerignore) in favor of the BFF, matching GsmNode
- Update .claude/launch.json to run the dev server from web/
- Rewrite README.md for the new layout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 4.8 committed 2026-07-13 11:34:02 +02:00
1 parent ba3f227361
commit 75a2ccc226
47 files changed
+230 -161

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
<script setup>
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { api } from "../api";
import { formatDate, formatKm, serviceStatus } from "../lib/format.js";
import CarFormModal from "../components/CarFormModal.vue";
const router = useRouter();
const cars = ref([]);
const loading = ref(true);
const error = ref("");
const showAdd = ref(false);
async function load() {
loading.value = true;
error.value = "";
try {
const list = await api.listCars();
// For each car, fetch its latest service record to derive next-due status.
cars.value = await Promise.all(
list.map(async (car) => {
const services = await api.listCarServices(car.id);
const latest = services[0] || null; // API sorts newest-first
return { ...car, latest, count: services.length };
})
);
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
function onSaved(car) {
showAdd.value = false;
router.push({ name: "car", params: { id: car.id } });
}
// Service-life progress: how far the car is through its km service interval.
// Returns a { pct, tone } or null when there isn't enough data to compute it.
const TONE_COLOR = {
ok: "var(--success-600)",
soon: "var(--warning-600)",
overdue: "var(--danger-600)",
unknown: "var(--ink-300)",
};
function serviceLife(car) {
const interval = Number(car.serviceIntervalKm);
const nextKm = Number(car.latest?.nextServiceKm);
const currentKm = Number(car.currentKm);
if (!interval || !nextKm || !currentKm) return null;
const remaining = nextKm - currentKm;
const pct = Math.max(0, Math.min(100, Math.round((1 - remaining / interval) * 100)));
return { pct, tone: TONE_COLOR[serviceStatus(car.latest, car).key] || TONE_COLOR.unknown };
}
onMounted(load);
</script>
<template>
<div>
<div class="mb-6 flex items-end justify-between gap-4">
<div>
<p class="eyebrow">Garage</p>
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">Your cars</h1>
<p class="mt-1 text-sm text-muted">Maintenance overview and service history.</p>
</div>
<button class="dh-btn dh-btn-primary" @click="showAdd = true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
Add car
</button>
</div>
<p v-if="error" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ error }}</p>
<p v-if="loading" class="text-muted">Loading…</p>
<div v-else-if="cars.length === 0" class="rounded-card border border-dashed border-default p-12 text-center text-muted">
No cars yet. Click <strong class="text-strong">Add car</strong> to get started.
</div>
<div v-else class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
<RouterLink
v-for="car in cars"
:key="car.id"
:to="{ name: 'car', params: { id: car.id } }"
class="dh-card group block p-5 transition-shadow duration-150 hover:shadow-pop"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h2 class="truncate text-lg font-bold tracking-[-0.02em] text-strong">{{ car.name }}</h2>
<p class="truncate text-xs text-muted">{{ [car.make, car.model, car.year || ''].filter(Boolean).join(' ') }}</p>
</div>
<span :class="serviceStatus(car.latest, car).classes">
{{ serviceStatus(car.latest, car).label }}
</span>
</div>
<span
v-if="car.access && car.access !== 'owner'"
class="dh-badge dh-badge-neutral mt-2"
>
Shared{{ car.access === 'read' ? ' · read-only' : '' }}
</span>
<!-- Service-life bar: fraction of the km interval used up. -->
<div v-if="serviceLife(car)" class="mt-4">
<div class="mb-1.5 flex items-center justify-between">
<span class="eyebrow">Service life</span>
<span class="data text-xs font-medium text-strong">{{ serviceLife(car).pct }}%</span>
</div>
<div class="h-2 overflow-hidden rounded-pill bg-sunken">
<div class="h-full rounded-pill" :style="{ width: serviceLife(car).pct + '%', background: serviceLife(car).tone }" />
</div>
</div>
<dl class="mt-4 space-y-2 text-sm">
<div class="flex items-center justify-between gap-2">
<dt class="eyebrow">Last service</dt>
<dd class="data font-medium text-strong">{{ formatDate(car.latest?.date) }}</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt class="eyebrow">Odometer</dt>
<dd class="data font-medium text-strong">{{ formatKm(car.currentKm) }}</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt class="eyebrow">Next due</dt>
<dd class="data font-medium text-strong">{{ formatDate(car.latest?.nextServiceDate) }}</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt class="eyebrow">Next due km</dt>
<dd class="data font-medium text-strong">{{ formatKm(car.latest?.nextServiceKm) }}</dd>
</div>
</dl>
<p class="mt-4 border-t border-subtle pt-3 text-xs text-muted">
{{ car.count }} service record{{ car.count === 1 ? '' : 's' }}
</p>
</RouterLink>
</div>
<CarFormModal v-if="showAdd" @saved="onSaved" @close="showAdd = false" />
</div>
</template>