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

+1
View File
@@ -5,6 +5,7 @@
"name": "web",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"cwd": "web",
"port": 5173
}
]
-15
View File
@@ -1,15 +0,0 @@
# Keep the build context small; node_modules and dist are rebuilt in the image.
node_modules/
dist/
dist-ssr/
# Logs and local files
*.log
*.local
# VCS / editor noise
.git/
.gitignore
.vscode/
.idea/
.DS_Store
-24
View File
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-33
View File
@@ -1,33 +0,0 @@
# syntax=docker/dockerfile:1
# --- Build stage -------------------------------------------------------------
# Build the Vue 3 + Vite SPA into static assets.
FROM node:22-alpine AS build
WORKDIR /app
# Install dependencies against the lockfile for reproducible builds.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# VITE_API_BASE is baked into the bundle at build time. Leave empty to use the
# same-origin "/api" (nginx proxies it to the API Server — see nginx.conf).
ARG VITE_API_BASE
RUN npm run build
# --- Runtime stage -----------------------------------------------------------
# Serve the built assets with nginx (Alpine-based) and proxy /api upstream.
FROM nginx:alpine
# nginx substitutes ${API_TARGET} into this template at container start
# (only env-defined vars are replaced, so $uri/$host are left intact).
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
COPY --from=build /app/dist /usr/share/nginx/html
# Upstream API Server; override at runtime (compose/`docker run -e`).
ENV API_TARGET=http://api-server:8080
EXPOSE 80
# The default nginx entrypoint renders templates then launches nginx.
+60 -34
View File
@@ -1,27 +1,78 @@
# DriverVault — Web App
Vue 3 + Vite + Tailwind CSS v4 maintenance tracker. Talks **only** to the API
Server (never to PocketBase directly). Built from `Car Service.xlsx`. Plain JS
(not TS). Vue calls the central API Server directly — there is no separate web
backend.
Maintenance tracker for your cars: a **Vue 3 + Vite + Tailwind CSS v4** SPA served
by a small **Go backend-for-frontend (BFF)**. The BFF serves the built SPA and
reverse-proxies `/api/*` to the API Server, so the browser is always same-origin
and all data access still flows through the API Server (never PocketBase directly).
## Run
```
Browser ─► Web App BFF (:8090) ──/api/*──► API Server (:8080) ─► PocketBase
└── serves embedded Vue SPA
```
## Layout
```
server/ Go BFF: embeds web/dist, proxies /api -> API_BASE
main.go
.env.example
dist/ built SPA (generated; embedded at compile time)
web/ Vue 3 + Vite + Tailwind v4 source
src/
main.js app bootstrap
router.js /login, / (dashboard), /cars/:id, /settings, /admin
api.js the only place that calls the API Server (base URL resolution)
auth.js session/profile state, isAdmin
prefs.js theme/locale/date/font preferences -> <html>
lib/format.js date/km formatting + next-service status badges
style.css Tailwind v4 entry (+ dark custom-variant)
App.vue layout shell + nav (Admin link when admin)
components/ Modal, CarFormModal, ServiceFormModal, PartFormModal, ShareModal, Logo
views/ Login, Dashboard, CarDetail, Settings, AdminUsers
```
## Requirements
- Node 18+ and Go 1.26+
- A running **API Server** (see `../API Server`)
## Develop
Two terminals:
```powershell
npm install
npm run dev # http://localhost:5173
# terminal 1 — API Server (see ../API Server/README.md)
cd "../API Server"; ./api-server.exe
# terminal 2 — Vite dev server with hot reload (proxies /api -> :8080)
cd web; npm install; npm run dev # http://localhost:5173
```
The dev server proxies `/api/*` to the API Server (default `http://localhost:8080`,
override with `VITE_API_TARGET`), so the client uses same-origin relative URLs and
avoids CORS. The **API Server must be running** — see `../API Server/README.md`.
The dev server also listens on all interfaces (`host: true`) so it's reachable on
avoids CORS. It also listens on all interfaces (`host: true`) so it's reachable on
the LAN (e.g. `http://10.2.1.101:5173`).
At runtime, users can override the API base URL from the login screen's **Server
settings** (persisted in `localStorage` as `cc_server_url`); resolution order is
that override → `VITE_API_BASE` → `/api`.
## Build & run (production-style)
```powershell
./server/Run-WebApp.ps1 # builds frontend, then serves on :8090
# or manually:
cd web; npm run build # outputs to ../server/dist
cd ../server; go run . # http://localhost:8090
```
Config (`server/.env`, copy from `.env.example`):
| Variable | Purpose | Default |
|---|---|---|
| `WEB_ADDR` | Listen address | `:8090` |
| `API_BASE` | API Server base URL | `http://localhost:8080` |
## Features
- **Dashboard** — one card per car: last service, odometer, next-due date/km, and
@@ -48,28 +99,3 @@ Login gets a JWT from the API Server (stored client-side) and creates a server
session. `auth.js` exposes `isAdmin` and the current profile; the router guards
`public` / `admin` routes. Cars are per-user (owned + shared), and the UI mirrors
the server's read / write / owner access levels.
## Structure
```
src/
├── main.js # app bootstrap
├── router.js # /login, / (dashboard), /cars/:id, /settings, /admin
├── api.js # the only place that calls the API Server (base URL resolution)
├── auth.js # session/profile state, isAdmin
├── prefs.js # theme/locale/date/font preferences → <html>
├── lib/format.js # date/km formatting + next-service status badges
├── style.css # Tailwind v4 entry (+ dark custom-variant)
├── App.vue # layout shell + nav (Admin link when admin)
├── components/
│ ├── Modal.vue CarFormModal.vue ServiceFormModal.vue PartFormModal.vue ShareModal.vue
└── views/
├── Login.vue Dashboard.vue CarDetail.vue
└── Settings.vue AdminUsers.vue
```
## Build
```powershell
npm run build # -> dist/
```
-18
View File
@@ -1,18 +0,0 @@
services:
web-app:
build:
context: .
dockerfile: Dockerfile
args:
# Baked into the bundle at build time. Leave empty to use same-origin
# "/api", which nginx proxies to API_TARGET below.
VITE_API_BASE: "${VITE_API_BASE:-}"
image: drivervault-web
container_name: drivervault-web
restart: unless-stopped
ports:
- "${WEB_PORT:-8081}:80"
environment:
# Upstream API Server that nginx proxies /api/ to. Point this at your
# external API Server (host:port), e.g. http://10.2.1.10:8080.
API_TARGET: "${API_TARGET:-http://api-server:8080}"
-35
View File
@@ -1,35 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Compress text assets.
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# Proxy API calls to the API Server. The /api prefix is preserved because
# ${API_TARGET} has no path, so /api/me -> $API_TARGET/api/me.
location /api/ {
proxy_pass ${API_TARGET};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Cache fingerprinted build assets aggressively.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# SPA fallback: unknown routes return index.html so the Vue router handles them.
location / {
try_files $uri $uri/ /index.html;
}
}
+8
View File
@@ -0,0 +1,8 @@
# Web App (BFF) configuration
# Address the Web App listens on
WEB_ADDR=:8090
# Base URL of the API Server. The BFF reverse-proxies /api/* to this host, so
# the browser only ever talks same-origin to the Web App.
API_BASE=http://localhost:8080
+5
View File
@@ -0,0 +1,5 @@
# Build artifacts (generated)
dist/
*.exe
.env
*.log
+29
View File
@@ -0,0 +1,29 @@
# Builds the Vue frontend (if needed) and runs the Web App BFF.
#
# ./Run-WebApp.ps1 # build frontend then serve
# ./Run-WebApp.ps1 -SkipBuild # serve existing dist only
param([switch]$SkipBuild)
$ErrorActionPreference = "Stop"
$serverDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$root = Split-Path -Parent $serverDir # the "Web App" folder
$webDir = Join-Path $root "web"
$goBin = "C:\Program Files\Go\bin"
if (Test-Path $goBin) { $env:Path = "$goBin;$env:Path" }
if (-not $SkipBuild) {
Write-Host "Building frontend..." -ForegroundColor Cyan
Push-Location $webDir
try {
if (-not (Test-Path "node_modules")) { npm install }
npm run build
} finally { Pop-Location }
}
Push-Location $serverDir
try {
Write-Host "Starting Web App on :8090 (Ctrl+C to stop)..." -ForegroundColor Cyan
go run .
} finally { Pop-Location }
+3
View File
@@ -0,0 +1,3 @@
module drivervault/webapp
go 1.26
+113
View File
@@ -0,0 +1,113 @@
// Command webapp is the Web App backend-for-frontend. It serves the embedded
// Vue single-page app and reverse-proxies /api/* to the API Server, so the
// browser only ever talks to this server (same-origin) and all data access
// still flows through the API Server.
package main
import (
"embed"
"io/fs"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
"time"
)
//go:embed all:dist
var distFS embed.FS
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func main() {
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("[web] ")
loadDotEnv(".env")
addr := getenv("WEB_ADDR", ":8090")
apiBase := strings.TrimRight(getenv("API_BASE", "http://localhost:8080"), "/")
apiURL, err := url.Parse(apiBase)
if err != nil {
log.Fatalf("invalid API_BASE %q: %v", apiBase, err)
}
// Reverse proxy: /api/* -> API Server (path preserved).
proxy := httputil.NewSingleHostReverseProxy(apiURL)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
log.Printf("proxy error for %s: %v", r.URL.Path, e)
http.Error(w, `{"error":"api server unavailable"}`, http.StatusBadGateway)
}
// Embedded SPA file server.
sub, err := fs.Sub(distFS, "dist")
if err != nil {
log.Fatalf("embed dist: %v", err)
}
spa := http.FileServer(http.FS(sub))
mux := http.NewServeMux()
mux.Handle("/api/", proxy)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Serve static assets when they exist; otherwise fall back to index.html
// so client-side routing works on deep links.
if r.URL.Path != "/" {
if f, err := sub.Open(strings.TrimPrefix(r.URL.Path, "/")); err == nil {
f.Close()
spa.ServeHTTP(w, r)
return
}
}
r2 := r.Clone(r.Context())
r2.URL.Path = "/"
spa.ServeHTTP(w, r2)
})
srv := &http.Server{
Addr: addr,
Handler: logRequests(mux),
ReadHeaderTimeout: 10 * time.Second,
}
log.Printf("listening on %s (proxying /api -> %s)", addr, apiBase)
if err := srv.ListenAndServe(); err != nil {
log.Fatalf("server error: %v", err)
}
}
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
})
}
// loadDotEnv loads KEY=VALUE pairs from a .env file if present.
func loadDotEnv(path string) {
data, err := os.ReadFile(path)
if err != nil {
return
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k = strings.TrimSpace(k)
v = strings.Trim(strings.TrimSpace(v), `"'`)
if _, exists := os.LookupEnv(k); !exists {
_ = os.Setenv(k, v)
}
}
}
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -2,10 +2,15 @@ import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
// The web app talks ONLY to the API Server. In dev, /api is proxied to it so we
// avoid CORS and can use same-origin relative URLs in the client.
// The web app talks ONLY to the API Server. The production build is written into
// ../server/dist so the Go BFF can embed it. In dev, /api is proxied to the API
// Server so we avoid CORS and can use same-origin relative URLs in the client.
export default defineConfig({
plugins: [vue(), tailwindcss()],
build: {
outDir: "../server/dist",
emptyOutDir: true,
},
server: {
port: 5173,
// Listen on all interfaces so the dev server is reachable on the LAN