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>
@@ -5,6 +5,7 @@
|
||||
"name": "web",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"cwd": "web",
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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?
|
||||
@@ -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.
|
||||
@@ -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/
|
||||
```
|
||||
|
||||
@@ -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}"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
# Build artifacts (generated)
|
||||
dist/
|
||||
*.exe
|
||||
.env
|
||||
*.log
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,3 @@
|
||||
module drivervault/webapp
|
||||
|
||||
go 1.26
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 550 B After Width: | Height: | Size: 550 B |
|
Before Width: | Height: | Size: 381 B After Width: | Height: | Size: 381 B |
|
Before Width: | Height: | Size: 381 B After Width: | Height: | Size: 381 B |
|
Before Width: | Height: | Size: 381 B After Width: | Height: | Size: 381 B |
|
Before Width: | Height: | Size: 715 B After Width: | Height: | Size: 715 B |
|
Before Width: | Height: | Size: 660 B After Width: | Height: | Size: 660 B |
|
Before Width: | Height: | Size: 405 B After Width: | Height: | Size: 405 B |
|
Before Width: | Height: | Size: 905 B After Width: | Height: | Size: 905 B |
|
Before Width: | Height: | Size: 550 B After Width: | Height: | Size: 550 B |
@@ -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
|
||||