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:
1 parent
ba3f227361
commit
75a2ccc226
47 files changed
+230
-161
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user