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
@@ -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.
|
||||
+60
-34
@@ -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
|
||||
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.
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
|
||||
Reference in new issue
Block a user