Initial commit

This commit is contained in:
tajniak81
2026-07-06 08:50:52 +02:00
commit ba3f227361
153 changed files with 18116 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "web",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}
+15
View File
@@ -0,0 +1,15 @@
# 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
@@ -0,0 +1,24 @@
# 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
@@ -0,0 +1,33 @@
# 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.
+75
View File
@@ -0,0 +1,75 @@
# 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.
## Run
```powershell
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
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`.
## Features
- **Dashboard** — one card per car: last service, odometer, next-due date/km, and
a status badge (OK / due soon ≤30d / overdue) from the Excel formulas. Add a
car; shared cars are labelled and gated by your access level.
- **Car detail** — full service history (date, km, computed next date/km, and the
changed-parts flags) plus the per-car parts catalog and all car spec fields
(engine / transmission / differential oil, brake fluid, coolant, VIN, …).
Add/edit/delete service records, parts, and the car; **share** the car with
other users (read/write, owner only). Edit/delete controls are hidden for
read-only shares.
- **Settings** — account (name / email verification / password), appearance
(theme light/dark/system, locale, date format, font size), profile (avatar,
bio), data **export/import**, active sessions with remote logout, and the
account-deletion state machine.
- **Admin** — `/admin` user management (list / create / role / reset password /
delete), gated by the admin role via a router guard + nav link.
- **Theming** — light/dark/system app-wide (Tailwind v4 class strategy); `prefs.js`
toggles `.dark` on `<html>` and applies the saved theme/locale/date/font.
## Auth & access
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
@@ -0,0 +1,18 @@
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}"
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DriverVault</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
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;
}
}
+1501
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "drivervault-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"@vitejs/plugin-vue": "^6.0.7",
"tailwindcss": "^4.3.2",
"vite": "^8.1.1"
},
"dependencies": {
"vue": "^3.5.39",
"vue-router": "^4.6.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<rect width="256" height="256" rx="56" fill="#1E40AF"></rect>
<svg x="53" y="53" width="150" height="150" viewBox="0 0 48 48">
<g transform="translate(7 0) skewX(-13)">
<rect x="9" y="16" width="6" height="16" rx="3" fill="#ffffff" opacity="0.55"></rect>
<rect x="19" y="12" width="6" height="24" rx="3" fill="#ffffff" opacity="0.8"></rect>
<rect x="29" y="8" width="6" height="32" rx="3" fill="#ffffff"></rect>
</g>
</svg>
</svg>

After

Width:  |  Height:  |  Size: 550 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 48 48" fill="none">
<g transform="translate(7 0) skewX(-13)">
<rect x="9" y="16" width="6" height="16" rx="3" fill="#0F1E3D"></rect>
<rect x="19" y="12" width="6" height="24" rx="3" fill="#0F1E3D"></rect>
<rect x="29" y="8" width="6" height="32" rx="3" fill="#0F1E3D"></rect>
</g>
</svg>

After

Width:  |  Height:  |  Size: 381 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 48 48" fill="none">
<g transform="translate(7 0) skewX(-13)">
<rect x="9" y="16" width="6" height="16" rx="3" fill="#ffffff"></rect>
<rect x="19" y="12" width="6" height="24" rx="3" fill="#ffffff"></rect>
<rect x="29" y="8" width="6" height="32" rx="3" fill="#ffffff"></rect>
</g>
</svg>

After

Width:  |  Height:  |  Size: 381 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 48 48" fill="none">
<g transform="translate(7 0) skewX(-13)">
<rect x="9" y="16" width="6" height="16" rx="3" fill="#1E40AF"></rect>
<rect x="19" y="12" width="6" height="24" rx="3" fill="#3B82F6"></rect>
<rect x="29" y="8" width="6" height="32" rx="3" fill="#60A5FA"></rect>
</g>
</svg>

After

Width:  |  Height:  |  Size: 381 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="660" height="128" viewBox="0 0 330 64">
<rect width="330" height="64" fill="#0B1730"></rect>
<svg x="4" y="8" width="48" height="48" viewBox="0 0 48 48">
<g transform="translate(7 0) skewX(-13)">
<rect x="9" y="16" width="6" height="16" rx="3" fill="#60A5FA"></rect>
<rect x="19" y="12" width="6" height="24" rx="3" fill="#93C5FD"></rect>
<rect x="29" y="8" width="6" height="32" rx="3" fill="#ffffff"></rect>
</g>
</svg>
<text x="62" y="44" font-family="Archivo, sans-serif" font-style="italic" font-weight="800" font-size="36" letter-spacing="-1"><tspan fill="#ffffff">Driver</tspan><tspan fill="#60A5FA">Vault</tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 715 B

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="660" height="128" viewBox="0 0 330 64">
<svg x="4" y="8" width="48" height="48" viewBox="0 0 48 48">
<g transform="translate(7 0) skewX(-13)">
<rect x="9" y="16" width="6" height="16" rx="3" fill="#1E40AF"></rect>
<rect x="19" y="12" width="6" height="24" rx="3" fill="#3B82F6"></rect>
<rect x="29" y="8" width="6" height="32" rx="3" fill="#60A5FA"></rect>
</g>
</svg>
<text x="62" y="44" font-family="Archivo, sans-serif" font-style="italic" font-weight="800" font-size="36" letter-spacing="-1"><tspan fill="#0F1E3D">Driver</tspan><tspan fill="#1E40AF">Vault</tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 905 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<rect width="256" height="256" rx="56" fill="#1E40AF"></rect>
<svg x="53" y="53" width="150" height="150" viewBox="0 0 48 48">
<g transform="translate(7 0) skewX(-13)">
<rect x="9" y="16" width="6" height="16" rx="3" fill="#ffffff" opacity="0.55"></rect>
<rect x="19" y="12" width="6" height="24" rx="3" fill="#ffffff" opacity="0.8"></rect>
<rect x="29" y="8" width="6" height="32" rx="3" fill="#ffffff"></rect>
</g>
</svg>
</svg>

After

Width:  |  Height:  |  Size: 550 B

+126
View File
@@ -0,0 +1,126 @@
<script setup>
import { onMounted, onBeforeUnmount, computed, ref } from "vue";
import { RouterView, RouterLink, useRouter, useRoute } from "vue-router";
import { state, isAuthenticated, isAdmin, logout, refreshProfile } from "./auth";
import { prefs, applyProfilePrefs } from "./prefs";
import { api } from "./api";
import Logo from "./components/Logo.vue";
const router = useRouter();
const route = useRoute();
function onLogout() {
logout();
router.replace({ name: "login" });
}
// Effective dark state, kept reactive by observing the <html> class (prefs.js
// toggles `.dark` there, incl. on system-scheme changes).
const isDark = ref(document.documentElement.classList.contains("dark"));
let themeObserver = null;
// Global theme toggle (light ⇄ dark). Applies instantly and persists to the
// profile, mirroring what Settings Appearance does.
function toggleTheme() {
const next = isDark.value ? "light" : "dark";
applyProfilePrefs({ ...prefs, theme: next });
if (isAuthenticated.value) api.updateMe({ theme: next }).catch(() => {});
}
const userInitial = computed(() =>
(state.user?.name || state.user?.email || "?").charAt(0).toUpperCase()
);
// Sidebar nav. Admin item is filtered out for non-admins.
const nav = computed(() =>
[
{ to: "/", label: "Garage", icon: "grid", exact: true },
{ to: "/settings", label: "Settings", icon: "gear" },
isAdmin.value ? { to: "/admin", label: "Users", icon: "users" } : null,
].filter(Boolean)
);
function active(item) {
return item.exact ? route.path === item.to : route.path.startsWith(item.to);
}
// Covers a page reload: the token survives in localStorage, but the richer
// profile (theme, etc.) is only ever kept in memory, so re-fetch it.
onMounted(() => {
if (isAuthenticated.value && !state.profile) refreshProfile().catch(() => {});
themeObserver = new MutationObserver(() => {
isDark.value = document.documentElement.classList.contains("dark");
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
});
onBeforeUnmount(() => themeObserver?.disconnect());
</script>
<template>
<!-- Login page is full-screen and chromeless. -->
<RouterView v-if="route.name === 'login'" />
<!-- Outer page: a step off the app surface so the centered shell reads as a panel. -->
<div v-else class="min-h-screen bg-sunken text-body">
<div class="mx-auto flex min-h-screen max-w-[1368px] bg-page shadow-pop">
<!-- Fixed dark app-shell rail -->
<aside class="sticky top-0 flex h-screen w-16 flex-none flex-col gap-1 bg-brand-900 px-2 py-5 text-white md:w-60 md:px-4">
<RouterLink to="/" class="mb-5 flex h-8 items-center px-1 md:px-2">
<Logo on-dark icon-only class="h-7 md:hidden" />
<Logo on-dark class="hidden h-7 md:flex" />
</RouterLink>
<nav class="flex flex-col gap-1">
<RouterLink
v-for="item in nav"
:key="item.to"
:to="item.to"
class="flex items-center gap-3 rounded-control px-2.5 py-2.5 text-[15px] font-medium transition-colors md:px-3"
:class="active(item)
? 'bg-brand-400/15 text-white'
: 'text-white/60 hover:bg-white/5 hover:text-white'"
>
<!-- garage -->
<svg v-if="item.icon === 'grid'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0" :style="active(item) ? 'color:#60A5FA' : ''"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
<!-- settings -->
<svg v-else-if="item.icon === 'gear'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0" :style="active(item) ? 'color:#60A5FA' : ''"><path stroke-linecap="round" stroke-linejoin="round" d="M9.6 3.6 9 6a7.5 7.5 0 0 0-1.7 1L5 6.3l-2 3.4 2 1.5a7.6 7.6 0 0 0 0 2l-2 1.5 2 3.4 2.3-.7c.5.4 1.1.8 1.7 1l.6 2.4h4l.6-2.4c.6-.2 1.2-.6 1.7-1l2.3.7 2-3.4-2-1.5a7.6 7.6 0 0 0 0-2l2-1.5-2-3.4-2.3.7A7.5 7.5 0 0 0 15 6l-.6-2.4z"/><circle cx="12" cy="12" r="2.6"/></svg>
<!-- users -->
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0" :style="active(item) ? 'color:#60A5FA' : ''"><path stroke-linecap="round" stroke-linejoin="round" d="M16 19v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 9a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm13 10v-2a4 4 0 0 0-3-3.9M16 2.1A4 4 0 0 1 16 9.9"/></svg>
<span class="hidden md:inline">{{ item.label }}</span>
</RouterLink>
</nav>
<!-- Footer: theme toggle + user + logout -->
<div class="mt-auto flex flex-col gap-2 border-t border-white/10 pt-3">
<button
class="flex items-center gap-3 rounded-control px-2.5 py-2 text-[15px] font-medium text-white/60 transition-colors hover:bg-white/5 hover:text-white md:px-3"
@click="toggleTheme"
>
<svg v-if="isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0"><circle cx="12" cy="12" r="4"/><path stroke-linecap="round" d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0"><path stroke-linecap="round" stroke-linejoin="round" d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"/></svg>
<span class="hidden md:inline">{{ isDark ? "Light mode" : "Dark mode" }}</span>
</button>
<div class="flex items-center gap-3 px-1 md:px-2">
<div class="grid h-9 w-9 flex-none place-items-center rounded-full bg-brand-600 font-mono text-sm text-white">{{ userInitial }}</div>
<div class="hidden min-w-0 flex-1 md:block">
<p class="truncate text-sm text-white">{{ state.user?.name || state.user?.email }}</p>
<p class="truncate font-mono text-[11px] text-white/50">Signed in</p>
</div>
<button class="hidden text-white/50 transition-colors hover:text-white md:block" title="Log out" @click="onLogout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3"/></svg>
</button>
</div>
</div>
</aside>
<!-- Main content -->
<main class="min-w-0 flex-1 px-5 py-6 md:px-8 lg:px-10">
<div class="mx-auto max-w-5xl">
<RouterView />
</div>
</main>
</div>
</div>
</template>
+149
View File
@@ -0,0 +1,149 @@
// Single client for the Car Control API Server. The web app never talks to
// PocketBase directly — only to these endpoints (proxied to the API Server in
// dev via vite.config.js).
// Default API base: the Vite env override, else the same-origin "/api" (proxied
// to the API Server in dev). A user can override this at runtime via the login
// screen's "Server settings" — stored in localStorage and used for every call.
export const DEFAULT_API_BASE = import.meta.env.VITE_API_BASE || "/api";
const SERVER_KEY = "cc_server_url";
// Resolved fresh on each request so changing it takes effect without a reload.
function apiBase() {
return localStorage.getItem(SERVER_KEY) || DEFAULT_API_BASE;
}
export function getServerUrl() {
return localStorage.getItem(SERVER_KEY) || "";
}
// Persist a custom API base URL (trailing slashes trimmed). Empty/blank clears
// the override, falling back to the default.
export function setServerUrl(url) {
const trimmed = (url || "").trim().replace(/\/+$/, "");
if (trimmed) localStorage.setItem(SERVER_KEY, trimmed);
else localStorage.removeItem(SERVER_KEY);
}
export const TOKEN_KEY = "cc_token";
export const USER_KEY = "cc_user";
function authHeader() {
const t = localStorage.getItem(TOKEN_KEY);
return t ? { Authorization: "Bearer " + t } : {};
}
async function handleResponse(res, path) {
// An expired/invalid token on any non-login call ends the session.
if (res.status === 401 && path !== "/auth/login") {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
if (location.pathname !== "/login") location.href = "/login";
throw new Error("Session expired — please log in again.");
}
if (res.status === 204) return null;
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) {
const msg = (data && (data.error || data.message)) || res.statusText;
throw new Error(msg);
}
return data;
}
async function request(path, options = {}) {
const res = await fetch(apiBase() + path, {
headers: { "Content-Type": "application/json", ...authHeader(), ...(options.headers || {}) },
...options,
});
return handleResponse(res, path);
}
// Like request(), but for multipart/form-data bodies (file uploads) — the
// browser sets its own Content-Type (with boundary), so we must not.
async function requestForm(path, options = {}) {
const res = await fetch(apiBase() + path, { headers: { ...authHeader() }, ...options });
return handleResponse(res, path);
}
// Fetches a binary response (image, export file) as a Blob, since it needs the
// Authorization header — a plain <img src> or <a href> can't attach one.
async function requestBlob(path) {
const res = await fetch(apiBase() + path, { headers: authHeader() });
if (res.status === 401) return handleResponse(res, path);
if (!res.ok) throw new Error("Request failed: " + res.statusText);
const filename = (res.headers.get("Content-Disposition") || "").match(/filename="([^"]+)"/)?.[1];
return { blob: await res.blob(), filename };
}
export const api = {
// Auth
login: (email, password) =>
request("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }),
me: () => request("/auth/me"),
// Cars
listCars: () => request("/cars"),
getCar: (id) => request(`/cars/${id}`),
createCar: (body) => request("/cars", { method: "POST", body: JSON.stringify(body) }),
updateCar: (id, body) => request(`/cars/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteCar: (id) => request(`/cars/${id}`, { method: "DELETE" }),
// Sharing (owner-only). A share grants another user read or write access.
listCarShares: (carId) => request(`/cars/${carId}/shares`),
addCarShare: (carId, email, permission) =>
request(`/cars/${carId}/shares`, { method: "POST", body: JSON.stringify({ email, permission }) }),
removeCarShare: (carId, userId) =>
request(`/cars/${carId}/shares/${userId}`, { method: "DELETE" }),
// Service records
listCarServices: (carId) => request(`/cars/${carId}/service-records`),
createService: (body) =>
request("/service-records", { method: "POST", body: JSON.stringify(body) }),
updateService: (id, body) =>
request(`/service-records/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteService: (id) => request(`/service-records/${id}`, { method: "DELETE" }),
// Parts
listCarParts: (carId) => request(`/cars/${carId}/parts`),
createPart: (body) => request("/parts", { method: "POST", body: JSON.stringify(body) }),
updatePart: (id, body) =>
request(`/parts/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deletePart: (id) => request(`/parts/${id}`, { method: "DELETE" }),
// Admin — user management (admin role only)
listUsers: () => request("/admin/users"),
createUser: (body) => request("/admin/users", { method: "POST", body: JSON.stringify(body) }),
updateUser: (id, body) => request(`/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
setUserPassword: (id, newPassword) =>
request(`/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ newPassword }) }),
deleteUser: (id) => request(`/admin/users/${id}`, { method: "DELETE" }),
// Settings — account/profile/appearance
getMe: () => request("/me"),
updateMe: (body) => request("/me", { method: "PATCH", body: JSON.stringify(body) }),
changePassword: (oldPassword, newPassword) =>
request("/me/password", { method: "POST", body: JSON.stringify({ oldPassword, newPassword }) }),
requestVerification: () => request("/me/verify/request", { method: "POST" }),
uploadAvatar: (file) => {
const form = new FormData();
form.append("avatar", file);
return requestForm("/me/avatar", { method: "POST", body: form });
},
deleteAvatar: () => request("/me/avatar", { method: "DELETE" }),
getAvatarBlob: () => requestBlob("/me/avatar"),
// Settings — privacy & security (active sessions)
listSessions: () => request("/sessions"),
revokeSession: (id) => request(`/sessions/${id}`, { method: "DELETE" }),
revokeOtherSessions: () => request("/sessions", { method: "DELETE" }),
// Settings — advanced / danger zone
exportData: () => requestBlob("/me/export"),
importData: (payload) => request("/me/import", { method: "POST", body: JSON.stringify(payload) }),
requestAccountDeletion: (confirmEmail) =>
request("/me/delete", { method: "POST", body: JSON.stringify({ confirmEmail }) }),
cancelAccountDeletion: () => request("/me/delete/cancel", { method: "POST" }),
finalizeAccountDeletion: () => request("/me", { method: "DELETE" }),
};
+51
View File
@@ -0,0 +1,51 @@
// Reactive auth state shared across the app. Token + user are persisted to
// localStorage so a refresh keeps the session. The actual network calls live in
// api.js (which reads the token from localStorage on each request).
import { reactive, computed } from "vue";
import { api, TOKEN_KEY, USER_KEY } from "./api";
import { applyProfilePrefs } from "./prefs";
export const state = reactive({
token: localStorage.getItem(TOKEN_KEY) || "",
user: JSON.parse(localStorage.getItem(USER_KEY) || "null"),
// Full settings-panel profile (bio, theme, locale, ...), fetched separately
// from /api/me since the login response only carries id/email/name.
profile: null,
});
export const isAuthenticated = computed(() => !!state.token);
// Admin gate for the UI. Driven by the full profile (fetched from /api/me),
// which always reflects the current role from the DB — so a promotion/demotion
// takes effect on the next profile refresh without needing a re-login.
export const isAdmin = computed(
() => state.profile?.role === "admin" || state.user?.role === "admin"
);
export async function login(email, password) {
const res = await api.login(email, password);
state.token = res.token;
state.user = res.user;
localStorage.setItem(TOKEN_KEY, res.token);
localStorage.setItem(USER_KEY, JSON.stringify(res.user));
await refreshProfile();
return res;
}
export function logout() {
state.token = "";
state.user = null;
state.profile = null;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
// Fetches the full profile (used for Settings + to apply appearance prefs).
// Safe to call on app boot when a token already exists from a previous visit.
export async function refreshProfile() {
if (!state.token) return null;
const profile = await api.getMe();
state.profile = profile;
applyProfilePrefs(profile);
return profile;
}
+173
View File
@@ -0,0 +1,173 @@
<script setup>
import { ref } from "vue";
import { api } from "../api";
import Modal from "./Modal.vue";
const props = defineProps({ car: { type: Object, default: null } });
const emit = defineEmits(["saved", "close"]);
const isEdit = !!props.car;
const saving = ref(false);
const error = ref("");
const form = ref({
name: props.car?.name ?? "",
make: props.car?.make ?? "",
model: props.car?.model ?? "",
year: props.car?.year || "",
registration: props.car?.registration ?? "",
registrationCountry: props.car?.registrationCountry ?? "",
vin: props.car?.vin ?? "",
fuelType: props.car?.fuelType ?? "",
buildDate: props.car?.buildDate ?? "",
firstRegistrationDate: props.car?.firstRegistrationDate ?? "",
oilSpec: props.car?.oilSpec ?? "",
transmissionOilSpec: props.car?.transmissionOilSpec ?? "",
differentialOilSpec: props.car?.differentialOilSpec ?? "",
brakeFluidSpec: props.car?.brakeFluidSpec ?? "",
coolantSpec: props.car?.coolantSpec ?? "",
serviceIntervalDays: props.car?.serviceIntervalDays || 365,
serviceIntervalKm: props.car?.serviceIntervalKm || 15000,
currentKm: props.car?.currentKm || "",
});
async function submit() {
saving.value = true;
error.value = "";
try {
const payload = {
name: form.value.name.trim(),
make: form.value.make.trim(),
model: form.value.model.trim(),
year: form.value.year ? Number(form.value.year) : 0,
registration: form.value.registration.trim(),
registrationCountry: form.value.registrationCountry.trim(),
vin: form.value.vin.trim(),
fuelType: form.value.fuelType,
buildDate: form.value.buildDate,
firstRegistrationDate: form.value.firstRegistrationDate,
oilSpec: form.value.oilSpec.trim(),
transmissionOilSpec: form.value.transmissionOilSpec.trim(),
differentialOilSpec: form.value.differentialOilSpec.trim(),
brakeFluidSpec: form.value.brakeFluidSpec.trim(),
coolantSpec: form.value.coolantSpec.trim(),
serviceIntervalDays: Number(form.value.serviceIntervalDays) || 365,
serviceIntervalKm: Number(form.value.serviceIntervalKm) || 15000,
currentKm: form.value.currentKm ? Number(form.value.currentKm) : 0,
};
const saved = isEdit
? await api.updateCar(props.car.id, payload)
: await api.createCar(payload);
emit("saved", saved);
} catch (e) {
error.value = e.message;
} finally {
saving.value = false;
}
}
</script>
<template>
<Modal :title="isEdit ? 'Edit car' : 'Add a car'" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div>
<label class="dh-label">Name *</label>
<input v-model="form.name" required placeholder="Toyota Yaris" class="dh-input" />
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="dh-label">Make</label>
<input v-model="form.make" placeholder="Toyota" class="dh-input" />
</div>
<div>
<label class="dh-label">Model</label>
<input v-model="form.model" placeholder="Yaris" class="dh-input" />
</div>
<div>
<label class="dh-label">Year</label>
<input v-model="form.year" type="number" placeholder="2015" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="dh-label">Registration</label>
<input v-model="form.registration" placeholder="ABC 1234" class="dh-input" />
</div>
<div>
<label class="dh-label">Registration country</label>
<input v-model="form.registrationCountry" placeholder="Poland" class="dh-input" />
</div>
<div>
<label class="dh-label">VIN</label>
<input v-model="form.vin" placeholder="Vehicle Identification Number" maxlength="17" class="dh-input data uppercase" />
</div>
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="dh-label">Fuel type</label>
<select v-model="form.fuelType" class="dh-input">
<option value=""></option>
<option value="petrol">Petrol (gasoline)</option>
<option value="diesel">Diesel</option>
<option value="hybrid">Hybrid</option>
<option value="electric">Electric</option>
</select>
</div>
<div>
<label class="dh-label">Build date</label>
<input v-model="form.buildDate" type="date" class="dh-input data" />
</div>
<div>
<label class="dh-label">First registration</label>
<input v-model="form.firstRegistrationDate" type="date" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Engine oil spec</label>
<input v-model="form.oilSpec" placeholder="0W20" class="dh-input" />
</div>
<div>
<label class="dh-label">Current odometer (km)</label>
<input v-model="form.currentKm" type="number" placeholder="270185" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Transmission oil spec</label>
<input v-model="form.transmissionOilSpec" placeholder="Toyota WS" class="dh-input" />
</div>
<div>
<label class="dh-label">Differential oil spec</label>
<input v-model="form.differentialOilSpec" placeholder="SAE 75W-90 GL-5" class="dh-input" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Brake fluid spec</label>
<input v-model="form.brakeFluidSpec" placeholder="DOT 4" class="dh-input" />
</div>
<div>
<label class="dh-label">Coolant spec</label>
<input v-model="form.coolantSpec" placeholder="Toyota Super Long Life Coolant" class="dh-input" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Service interval (days)</label>
<input v-model="form.serviceIntervalDays" type="number" class="dh-input data" />
</div>
<div>
<label class="dh-label">Service interval (km)</label>
<input v-model="form.serviceIntervalKm" type="number" class="dh-input data" />
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving…" : isEdit ? "Save changes" : "Add car" }}
</button>
</div>
</form>
</Modal>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup>
// DriverVault lockup: the tri-bar "Fast Forward" mark + Archivo italic-800 wordmark.
// Colours come from the design tokens so it reads on both light and dark shells.
// `onDark` forces the light-on-dark variant (white bars/"Driver", brand-400 "Vault")
// for the always-dark app-shell rail.
defineProps({
iconOnly: { type: Boolean, default: false },
onDark: { type: Boolean, default: false },
});
</script>
<template>
<span class="inline-flex items-center gap-2.5 select-none">
<svg class="h-full w-auto shrink-0" viewBox="0 0 48 48" fill="none" aria-hidden="true">
<g transform="translate(7 0) skewX(-13)">
<rect x="9" y="16" width="6" height="16" rx="3" :fill="onDark ? '#60A5FA' : 'var(--brand-700)'" />
<rect x="19" y="12" width="6" height="24" rx="3" :fill="onDark ? '#93C5FD' : 'var(--brand-500)'" />
<rect x="29" y="8" width="6" height="32" rx="3" :fill="onDark ? '#ffffff' : 'var(--brand-400)'" />
</g>
</svg>
<span
v-if="!iconOnly"
class="text-[1.35em] font-extrabold italic leading-none tracking-[-0.03em]"
>
<span :class="onDark ? 'text-white' : 'text-strong'">Driver</span><span :class="onDark ? 'text-brand-400' : 'text-brandtext'">Vault</span>
</span>
<span class="sr-only">DriverVault</span>
</span>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup>
defineProps({ title: { type: String, default: "" } });
const emit = defineEmits(["close"]);
</script>
<template>
<div class="fixed inset-0 z-30 grid place-items-center bg-brand-900/40 p-4 backdrop-blur-sm" @click.self="emit('close')">
<div class="dh-card w-full max-w-md p-6 shadow-pop">
<h2 v-if="title" class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ title }}</h2>
<slot />
</div>
</div>
</template>
+63
View File
@@ -0,0 +1,63 @@
<script setup>
import { ref } from "vue";
import { api } from "../api";
import Modal from "./Modal.vue";
const props = defineProps({
carId: { type: String, required: true },
part: { type: Object, default: null },
});
const emit = defineEmits(["saved", "close"]);
const isEdit = !!props.part;
const saving = ref(false);
const error = ref("");
const form = ref({
name: props.part?.name ?? "",
partNumber: props.part?.partNumber ?? "",
category: props.part?.category ?? "",
});
async function submit() {
saving.value = true;
error.value = "";
try {
const payload = {
car: props.carId,
name: form.value.name.trim(),
partNumber: form.value.partNumber.trim(),
category: form.value.category.trim(),
};
const saved = isEdit
? await api.updatePart(props.part.id, payload)
: await api.createPart(payload);
emit("saved", saved);
} catch (e) {
error.value = e.message;
} finally {
saving.value = false;
}
}
</script>
<template>
<Modal :title="isEdit ? 'Edit part' : 'Add part'" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div>
<label class="dh-label">Part name *</label>
<input v-model="form.name" required placeholder="Oil Filter" class="dh-input" />
</div>
<div>
<label class="dh-label">Part number</label>
<input v-model="form.partNumber" placeholder="04152-YZZA7" class="dh-input data" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving…" : isEdit ? "Save changes" : "Add part" }}
</button>
</div>
</form>
</Modal>
</template>
@@ -0,0 +1,91 @@
<script setup>
import { ref } from "vue";
import { api } from "../api";
import { formatKm } from "../lib/format.js";
import Modal from "./Modal.vue";
const props = defineProps({
carId: { type: String, required: true },
car: { type: Object, default: null },
service: { type: Object, default: null },
});
const emit = defineEmits(["saved", "close"]);
const isEdit = !!props.service;
const saving = ref(false);
const error = ref("");
const form = ref({
date: props.service ? toDateInput(props.service.date) : new Date().toISOString().slice(0, 10),
km: props.service?.km ?? "",
changedOil: props.service ? props.service.changedOil : true,
changedEngineAirFilter: props.service ? props.service.changedEngineAirFilter : false,
changedCabinAirFilter: props.service ? props.service.changedCabinAirFilter : false,
notes: props.service?.notes ?? "",
});
function toDateInput(value) {
const d = new Date(value);
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
}
async function submit() {
saving.value = true;
error.value = "";
try {
const payload = {
car: props.carId,
date: new Date(form.value.date).toISOString(),
km: form.value.km ? Number(form.value.km) : 0,
changedOil: form.value.changedOil,
changedEngineAirFilter: form.value.changedEngineAirFilter,
changedCabinAirFilter: form.value.changedCabinAirFilter,
notes: form.value.notes.trim(),
};
const saved = isEdit
? await api.updateService(props.service.id, payload)
: await api.createService(payload);
emit("saved", saved);
} catch (e) {
error.value = e.message;
} finally {
saving.value = false;
}
}
</script>
<template>
<Modal :title="isEdit ? 'Edit service record' : 'Add service record'" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Date *</label>
<input v-model="form.date" type="date" required class="dh-input data" />
</div>
<div>
<label class="dh-label">Odometer (km)</label>
<input v-model="form.km" type="number" placeholder="16138" class="dh-input data" />
</div>
</div>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">Changed parts</legend>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedOil" class="accent-[var(--accent)]" /> Oil &amp; oil filter</label>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedEngineAirFilter" class="accent-[var(--accent)]" /> Engine air filter</label>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedCabinAirFilter" class="accent-[var(--accent)]" /> Cabin air filter</label>
</fieldset>
<div>
<label class="dh-label">Notes</label>
<input v-model="form.notes" class="dh-input" />
</div>
<p v-if="car" class="text-xs text-muted">
Next service date (+{{ car.serviceIntervalDays }}d) and km (+{{ formatKm(car.serviceIntervalKm) }}) are computed automatically.
</p>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving…" : isEdit ? "Save changes" : "Add service" }}
</button>
</div>
</form>
</Modal>
</template>
+118
View File
@@ -0,0 +1,118 @@
<script setup>
import { ref, onMounted } from "vue";
import { api } from "../api";
import Modal from "./Modal.vue";
const props = defineProps({ car: { type: Object, required: true } });
const emit = defineEmits(["close"]);
const shares = ref([]);
const loading = ref(true);
const error = ref("");
const email = ref("");
const permission = ref("read");
const submitting = ref(false);
async function load() {
loading.value = true;
error.value = "";
try {
shares.value = await api.listCarShares(props.car.id);
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
async function addShare() {
const addr = email.value.trim();
if (!addr) return;
submitting.value = true;
error.value = "";
try {
await api.addCarShare(props.car.id, addr, permission.value);
email.value = "";
permission.value = "read";
await load();
} catch (e) {
error.value = e.message;
} finally {
submitting.value = false;
}
}
// Change an existing grant's permission (upsert by the same email).
async function setPermission(share, value) {
error.value = "";
try {
await api.addCarShare(props.car.id, share.user.email, value);
await load();
} catch (e) {
error.value = e.message;
}
}
async function removeShare(share) {
error.value = "";
try {
await api.removeCarShare(props.car.id, share.user.id);
await load();
} catch (e) {
error.value = e.message;
}
}
onMounted(load);
</script>
<template>
<Modal :title="`Share ${car.name}`" @close="emit('close')">
<p class="mb-4 text-sm text-muted">
Give another user access to this car. Read-only lets them view; read &amp; write also lets
them edit the car and its service records and parts.
</p>
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<!-- Add form -->
<form class="mb-4 flex items-end gap-2" @submit.prevent="addShare">
<div class="flex-1">
<label class="dh-label">User email</label>
<input v-model="email" type="email" required placeholder="person@example.com" class="dh-input" />
</div>
<select v-model="permission" class="dh-input w-auto">
<option value="read">Read-only</option>
<option value="write">Read &amp; write</option>
</select>
<button type="submit" :disabled="submitting" class="dh-btn dh-btn-primary">Share</button>
</form>
<!-- Current shares -->
<div>
<h3 class="mb-2 text-sm font-semibold text-strong">People with access</h3>
<p v-if="loading" class="text-sm text-muted">Loading</p>
<p v-else-if="shares.length === 0" class="text-sm text-muted">Not shared with anyone yet.</p>
<ul v-else class="divide-y divide-subtle">
<li v-for="s in shares" :key="s.user.id" class="flex items-center justify-between gap-2 py-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-strong">{{ s.user.name || s.user.email }}</p>
<p v-if="s.user.name" class="truncate text-xs text-muted">{{ s.user.email }}</p>
</div>
<div class="flex items-center gap-2">
<select :value="s.permission" @change="setPermission(s, $event.target.value)" class="dh-input w-auto !py-1 !text-xs">
<option value="read">Read-only</option>
<option value="write">Read &amp; write</option>
</select>
<button class="text-xs font-medium text-danger hover:underline" @click="removeShare(s)">Remove</button>
</div>
</li>
</ul>
</div>
<div class="mt-6 flex justify-end">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Done</button>
</div>
</Modal>
</template>
+94
View File
@@ -0,0 +1,94 @@
// Formatting + maintenance-status helpers. The status logic follows the
// spreadsheet idea: a service is "due" when its computed next-service date
// (service date + interval) approaches/passes today, OR when the car's current
// odometer approaches/passes the computed next-service km.
import { prefs } from "../prefs.js";
export function formatDate(value) {
if (!value) return "—";
const d = new Date(value);
if (isNaN(d)) return "—";
const day = String(d.getDate()).padStart(2, "0");
const month = String(d.getMonth() + 1).padStart(2, "0");
const monthName = d.toLocaleDateString(prefs.locale || undefined, { month: "short" });
const year = d.getFullYear();
switch (prefs.dateFormat) {
case "DMY_NUM":
return `${day}-${month}-${year}`;
case "DMY":
return `${day} ${monthName} ${year}`;
case "MDY":
return `${monthName} ${day}, ${year}`;
case "YMD":
default:
return `${year}-${month}-${day}`;
}
}
export function formatKm(value) {
if (value == null || value === "" || value === 0) return "—";
return Number(value).toLocaleString() + " km";
}
const DAY = 24 * 60 * 60 * 1000;
const KM_SOON = 1000; // within 1000 km of due => "soon"
// daysUntil returns whole days from today to the given date (negative = past).
export function daysUntil(value) {
if (!value) return null;
const target = new Date(value);
if (isNaN(target)) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
target.setHours(0, 0, 0, 0);
return Math.round((target - today) / DAY);
}
// Severity ranking so we can pick the worst of the date/km signals.
const RANK = { unknown: 0, ok: 1, soon: 2, overdue: 3 };
// DriverVault status language: On track (green) / Due soon (amber) / Action
// needed (red). Uses the shared badge recipes from style.css so tints flip in
// dark mode automatically.
const STYLE = {
unknown: "dh-badge dh-badge-neutral",
ok: "dh-badge dh-badge-success",
soon: "dh-badge dh-badge-warning",
overdue: "dh-badge dh-badge-danger",
};
// dateSignal classifies the next-due date relative to today.
function dateSignal(nextServiceDate) {
const days = daysUntil(nextServiceDate);
if (days == null) return { key: "unknown", label: "No data" };
if (days < 0) return { key: "overdue", label: `Service Overdue ${Math.abs(days)}d` };
if (days <= 30) return { key: "soon", label: `Due in ${days}d` };
return { key: "ok", label: `OK · ${days}d` };
}
// kmSignal classifies the current odometer against the next-due km.
function kmSignal(currentKm, nextServiceKm) {
if (!currentKm || !nextServiceKm) return { key: "unknown", label: "No km" };
const remaining = nextServiceKm - currentKm;
if (remaining < 0) return { key: "overdue", label: `Service Overdue ${Math.abs(remaining).toLocaleString()} km` };
if (remaining <= KM_SOON) return { key: "soon", label: `In ${remaining.toLocaleString()} km` };
return { key: "ok", label: `${remaining.toLocaleString()} km left` };
}
// serviceStatus combines the date- and km-based signals, returning the worse of
// the two for the badge. `latest` is the most recent service record (with
// nextServiceDate/nextServiceKm); `car` carries the current odometer.
export function serviceStatus(latest, car = null) {
const date = dateSignal(latest?.nextServiceDate);
const km = kmSignal(car?.currentKm, latest?.nextServiceKm);
const worse = RANK[km.key] > RANK[date.key] ? km : date;
// If only one signal has data, use that one's label.
let label = worse.label;
if (date.key === "unknown" && km.key !== "unknown") label = km.label;
else if (km.key === "unknown" && date.key !== "unknown") label = date.label;
return { key: worse.key, label, classes: STYLE[worse.key], date, km };
}
+8
View File
@@ -0,0 +1,8 @@
import { createApp } from "vue";
import "./style.css";
import App from "./App.vue";
import router from "./router";
import { initPrefs } from "./prefs";
initPrefs();
createApp(App).use(router).mount("#app");
+47
View File
@@ -0,0 +1,47 @@
// Appearance preferences (theme / locale / date format / font size), applied
// to the document so every view — not just the Settings page — reflects them.
import { reactive } from "vue";
export const prefs = reactive({
theme: "system", // light | dark | system
locale: "en-US",
dateFormat: "YMD", // YMD | DMY | MDY
fontSize: "medium", // small | medium | large
});
const FONT_SCALE = { small: "93.75%", medium: "100%", large: "112.5%" };
let media = null;
function applyTheme() {
const dark = prefs.theme === "dark" || (prefs.theme === "system" && media?.matches);
document.documentElement.classList.toggle("dark", !!dark);
}
function applyFontSize() {
document.documentElement.style.fontSize = FONT_SCALE[prefs.fontSize] || FONT_SCALE.medium;
}
// Called once at startup so the login screen (pre-auth) already respects the
// system color scheme, before any profile has been loaded.
export function initPrefs() {
if (!media) {
media = window.matchMedia("(prefers-color-scheme: dark)");
media.addEventListener("change", () => {
if (prefs.theme === "system") applyTheme();
});
}
applyTheme();
applyFontSize();
}
// Called after fetching /api/me (login, app boot, and Settings saves) to sync
// the document to the signed-in user's saved preferences.
export function applyProfilePrefs(profile) {
prefs.theme = profile.theme || "system";
prefs.locale = profile.locale || "en-US";
prefs.dateFormat = profile.dateFormat || "YMD";
prefs.fontSize = profile.fontSize || "medium";
applyTheme();
applyFontSize();
}
+37
View File
@@ -0,0 +1,37 @@
import { createRouter, createWebHistory } from "vue-router";
import Dashboard from "./views/Dashboard.vue";
import CarDetail from "./views/CarDetail.vue";
import Login from "./views/Login.vue";
import Settings from "./views/Settings.vue";
import AdminUsers from "./views/AdminUsers.vue";
import { isAuthenticated, isAdmin } from "./auth";
const routes = [
{ path: "/login", name: "login", component: Login, meta: { public: true } },
{ path: "/", name: "dashboard", component: Dashboard },
{ path: "/cars/:id", name: "car", component: CarDetail, props: true },
{ path: "/settings", name: "settings", component: Settings },
{ path: "/admin", name: "admin", component: AdminUsers, meta: { admin: true } },
];
const router = createRouter({
history: createWebHistory(),
routes,
});
// Guard: protected routes require a token; visiting /login while authed bounces home.
router.beforeEach((to) => {
if (!to.meta.public && !isAuthenticated.value) {
return { name: "login", query: { redirect: to.fullPath } };
}
if (to.name === "login" && isAuthenticated.value) {
return { name: "dashboard" };
}
// Admin-only routes: bounce non-admins to the dashboard. (Server enforces the
// real gate; this just avoids showing a page that would 403 on every call.)
if (to.meta.admin && !isAdmin.value) {
return { name: "dashboard" };
}
});
export default router;
+360
View File
@@ -0,0 +1,360 @@
@import "tailwindcss";
/* DriverVault webfonts — Archivo (UI + display, italic 800 is the brand voice) + DM Mono (data/labels). */
@import url("https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,400;0,500;0,600;0,700;0,800;1,600;1,700;1,800&family=DM+Mono:ital,wght@0,400;0,500;1,400&display=swap");
/* Tailwind v4 defaults dark: to a prefers-color-scheme media query. We need
theme to be user-selectable (light/dark/system), so switch it to a class
strategy — prefs.js toggles .dark on <html> based on the saved preference. */
@custom-variant dark (&:where(.dark, .dark *));
/* ============================================================================
DriverVault design tokens (from the design system). Raw ramps + semantic
aliases live here as CSS custom properties; .dark re-points the aliases so
any component styled with the aliases themes for free.
========================================================================== */
:root {
color-scheme: light;
/* ---- Brand blue ramp (from the DriverVault mark) ---- */
--brand-900: #0b1730;
--brand-800: #0f1e3d;
--brand-700: #1e40af;
--brand-600: #2563eb;
--brand-500: #3b82f6;
--brand-400: #60a5fa;
--brand-300: #93c5fd;
--brand-200: #c7dbfb;
--brand-100: #e8f0fd;
/* ---- Cool neutrals ---- */
--ink-900: #0f1e3d;
--ink-700: #28374f;
--ink-600: #3e4e68;
--ink-500: #5c6b85;
--ink-400: #7a8aa6;
--ink-300: #b4bece;
--ink-200: #d6deea;
--ink-100: #e4e9f2;
--ink-50: #eef2f8;
--ink-25: #f7f9fc;
--white: #ffffff;
/* ---- Semantic (car status: OK / due / fault) ---- */
--success-600: #1f8a5b;
--success-100: #e1f3ea;
--warning-600: #d9822b;
--warning-100: #fbeddd;
--danger-600: #dc2a45;
--danger-100: #fbe3e7;
--info-600: #2563eb;
--info-100: #e8f0fd;
/* ---- Semantic aliases ---- */
--surface-page: var(--ink-25);
--surface-card: var(--white);
--surface-sunken: var(--ink-50);
--surface-inverse: var(--brand-900);
--border-subtle: var(--ink-100);
--border-default: var(--ink-200);
--border-strong: var(--ink-300);
--text-strong: var(--ink-900);
--text-body: var(--ink-600);
--text-muted: var(--ink-400);
--text-inverse: var(--white);
--text-brand: var(--brand-700);
--accent: var(--brand-600);
--accent-hover: var(--brand-700);
--accent-press: var(--brand-800);
--focus-ring: var(--brand-400);
/* ---- Elevation (soft, cool, low-spread) ---- */
--shadow-xs: 0 1px 2px rgba(15, 30, 61, 0.06);
--shadow-sm: 0 1px 2px rgba(15, 30, 61, 0.04), 0 2px 6px rgba(15, 30, 61, 0.06);
--shadow-md: 0 1px 2px rgba(15, 30, 61, 0.04), 0 12px 30px -12px rgba(15, 30, 61, 0.14);
--shadow-lg: 0 1px 2px rgba(15, 30, 61, 0.04), 0 24px 60px -28px rgba(15, 30, 61, 0.22);
--shadow-focus: 0 0 0 3px rgba(96, 165, 250, 0.45);
/* ---- Type ---- */
--font-display: "Archivo", system-ui, sans-serif;
--font-sans: "Archivo", system-ui, sans-serif;
--font-mono: "DM Mono", ui-monospace, "SF Mono", monospace;
}
/* Dark theme — prefs.js toggles .dark on <html>. Re-points semantic aliases +
the raw ramp steps a few surfaces read directly (from the DS dark.css). */
html.dark {
color-scheme: dark;
/* Raw ramp steps used directly by some surfaces (tracks, tints, washes) */
--ink-25: #0b1730;
--ink-50: #132441;
--ink-100: #1b2c49;
--ink-200: #26395a;
--ink-300: #3c5173;
--brand-100: #17294a;
/* Semantic status tints, re-cut for dark surfaces */
--success-100: #12352a;
--warning-100: #3a2a16;
--danger-100: #3a1620;
--info-100: #122a4d;
--surface-page: #0b1730;
--surface-card: #13233f;
--surface-sunken: #0f1e38;
--surface-inverse: #f2f6fc;
--border-subtle: #21324f;
--border-default: #2c3f5e;
--border-strong: #3c5173;
--text-strong: #f2f6fc;
--text-body: #b7c4d9;
--text-muted: #7c8ca8;
--text-inverse: #0b1730;
--text-brand: #93c5fd;
--accent: #3b82f6;
--accent-hover: #60a5fa;
--accent-press: #2563eb;
--focus-ring: #60a5fa;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.4);
--shadow-md: 0 1px 2px rgba(0, 0, 0, 0.35), 0 12px 30px -12px rgba(0, 0, 0, 0.55);
--shadow-lg: 0 1px 2px rgba(0, 0, 0, 0.4), 0 24px 60px -28px rgba(0, 0, 0, 0.65);
--shadow-focus: 0 0 0 3px rgba(96, 165, 250, 0.5);
}
/* ============================================================================
Map DriverVault tokens into Tailwind's theme so semantic utilities exist:
bg-card, bg-page, text-strong, text-muted, border-subtle, bg-brand-600,
text-success, bg-warning-soft, shadow-card, rounded-card, font-mono, etc.
`inline` keeps the var() reference in generated utilities so they flip in
dark mode automatically (no dark: variant needed for semantic colors).
========================================================================== */
@theme inline {
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
/* Brand ramp */
--color-brand-100: var(--brand-100);
--color-brand-200: var(--brand-200);
--color-brand-300: var(--brand-300);
--color-brand-400: var(--brand-400);
--color-brand-500: var(--brand-500);
--color-brand-600: var(--brand-600);
--color-brand-700: var(--brand-700);
--color-brand-800: var(--brand-800);
--color-brand-900: var(--brand-900);
/* Surfaces */
--color-page: var(--surface-page);
--color-card: var(--surface-card);
--color-sunken: var(--surface-sunken);
--color-inverse: var(--surface-inverse);
/* Text */
--color-strong: var(--text-strong);
--color-body: var(--text-body);
--color-muted: var(--text-muted);
--color-brandtext: var(--text-brand);
--color-oninverse: var(--text-inverse);
/* Borders (color tokens — used as border-subtle/default/strong) */
--color-subtle: var(--border-subtle);
--color-default: var(--border-default);
--color-strongline: var(--border-strong);
/* Accent + focus */
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-press: var(--accent-press);
--color-focus: var(--focus-ring);
/* Status — solid (600) + soft tint (100) */
--color-success: var(--success-600);
--color-success-soft: var(--success-100);
--color-warning: var(--warning-600);
--color-warning-soft: var(--warning-100);
--color-danger: var(--danger-600);
--color-danger-soft: var(--danger-100);
--color-info: var(--info-600);
--color-info-soft: var(--info-100);
/* Radius */
--radius-control: 12px;
--radius-card: 22px;
--radius-pill: 999px;
/* Elevation */
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow-card: var(--shadow-md);
--shadow-pop: var(--shadow-lg);
}
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
font-family: var(--font-sans);
background: var(--surface-page);
color: var(--text-body);
-webkit-font-smoothing: antialiased;
}
/* Small mono eyebrow labels / data units (RANGE, TIRE PSI, LAST SERVICE). */
.eyebrow {
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 11px;
color: var(--text-muted);
}
/* Monospaced data (numbers, units, VINs) so columns align. */
.data {
font-family: var(--font-mono);
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
}
/* ============================================================================
DriverVault component primitives — shared class recipes so every view stays on
brand without repeating long utility strings. Styled via semantic tokens so
they flip in dark mode automatically.
========================================================================== */
@layer components {
/* Cards: border + soft shadow together, generous radius. */
.dh-card {
background: var(--surface-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-card);
box-shadow: var(--shadow-md);
}
/* Buttons — shared shape/motion. */
.dh-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: var(--radius-control);
padding: 0.5rem 1rem;
font-weight: 600;
font-size: 0.875rem;
line-height: 1.25rem;
transition: background-color 0.15s cubic-bezier(0.2, 0.7, 0.2, 1),
color 0.15s, border-color 0.15s, transform 0.15s, box-shadow 0.15s;
cursor: pointer;
white-space: nowrap;
}
.dh-btn:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
}
.dh-btn:active {
transform: scale(0.98);
}
.dh-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.dh-btn-primary {
background: var(--accent);
color: #fff;
}
.dh-btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.dh-btn-primary:active:not(:disabled) {
background: var(--accent-press);
}
.dh-btn-ghost {
background: transparent;
color: var(--text-body);
border: 1px solid var(--border-subtle);
}
.dh-btn-ghost:hover:not(:disabled) {
background: var(--surface-sunken);
color: var(--text-strong);
}
.dh-btn-danger {
background: var(--danger-600);
color: #fff;
}
.dh-btn-danger:hover:not(:disabled) {
filter: brightness(0.94);
}
/* Text/number fields + selects. */
.dh-input {
width: 100%;
background: var(--surface-card);
color: var(--text-strong);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
.dh-input::placeholder {
color: var(--text-muted);
}
.dh-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--shadow-focus);
}
.dh-label {
display: block;
margin-bottom: 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-body);
}
/* Status pills — soft tint + solid text. */
.dh-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
border-radius: var(--radius-pill);
padding: 0.125rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.25rem;
}
.dh-badge-success {
background: var(--success-100);
color: var(--success-600);
}
.dh-badge-warning {
background: var(--warning-100);
color: var(--warning-600);
}
.dh-badge-danger {
background: var(--danger-100);
color: var(--danger-600);
}
.dh-badge-neutral {
background: var(--surface-sunken);
color: var(--text-muted);
}
}
+231
View File
@@ -0,0 +1,231 @@
<script setup>
import { ref, onMounted } from "vue";
import { api } from "../api";
import { state } from "../auth";
import { formatDate } from "../lib/format.js";
import Modal from "../components/Modal.vue";
const users = ref([]);
const loading = ref(true);
const error = ref("");
// Create-user modal.
const showCreate = ref(false);
const createForm = ref({ email: "", name: "", password: "", role: "user" });
const creating = ref(false);
const createError = ref("");
// Reset-password modal.
const pwUser = ref(null);
const newPassword = ref("");
const savingPw = ref(false);
const pwError = ref("");
const myId = state.user?.id;
const adminCount = () => users.value.filter((u) => u.role === "admin").length;
// Whether the destructive/demote controls should be disabled for a row, with a
// reason (mirrors the server guards so the UI doesn't offer a doomed action).
function deleteBlockedReason(u) {
if (u.id === myId) return "You can't delete your own account.";
if (u.role === "admin" && adminCount() <= 1) return "Can't delete the last admin.";
return "";
}
function roleLockReason(u) {
if (u.role === "admin" && adminCount() <= 1) return "Can't demote the last admin.";
return "";
}
async function load() {
loading.value = true;
error.value = "";
try {
users.value = await api.listUsers();
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
async function changeRole(u, role) {
if (role === u.role) return;
error.value = "";
try {
const updated = await api.updateUser(u.id, { role });
Object.assign(u, updated);
} catch (e) {
error.value = e.message;
await load(); // resync the select back to the true value
}
}
async function submitCreate() {
creating.value = true;
createError.value = "";
try {
await api.createUser({
email: createForm.value.email.trim(),
name: createForm.value.name.trim(),
password: createForm.value.password,
role: createForm.value.role,
});
showCreate.value = false;
createForm.value = { email: "", name: "", password: "", role: "user" };
await load();
} catch (e) {
createError.value = e.message;
} finally {
creating.value = false;
}
}
function openResetPassword(u) {
pwUser.value = u;
newPassword.value = "";
pwError.value = "";
}
async function submitResetPassword() {
savingPw.value = true;
pwError.value = "";
try {
await api.setUserPassword(pwUser.value.id, newPassword.value);
pwUser.value = null;
} catch (e) {
pwError.value = e.message;
} finally {
savingPw.value = false;
}
}
async function removeUser(u) {
if (!confirm(`Delete ${u.name || u.email}? This cannot be undone.`)) return;
error.value = "";
try {
await api.deleteUser(u.id);
await load();
} catch (e) {
error.value = e.message;
}
}
onMounted(load);
</script>
<template>
<div>
<div class="mb-6 flex items-end justify-between">
<div>
<p class="eyebrow">Admin</p>
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">Users</h1>
<p class="mt-1 text-sm text-muted">Manage accounts and roles.</p>
</div>
<button class="dh-btn dh-btn-primary" @click="showCreate = 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 user
</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 class="dh-card overflow-x-auto p-0">
<table class="min-w-full text-sm">
<thead class="bg-sunken text-left">
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
<th>Email</th>
<th>Name</th>
<th>Role</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody class="divide-y divide-subtle">
<tr v-for="u in users" :key="u.id" class="transition-colors hover:bg-sunken">
<td class="px-4 py-3 font-medium text-strong">
{{ u.email }}
<span v-if="u.id === myId" class="ml-1 text-xs text-muted">(you)</span>
</td>
<td class="px-4 py-3 text-body">{{ u.name || '—' }}</td>
<td class="px-4 py-3">
<select
:value="u.role"
:disabled="!!roleLockReason(u)"
:title="roleLockReason(u)"
class="dh-input w-auto !py-1 !text-xs disabled:opacity-60"
@change="changeRole(u, $event.target.value)"
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</td>
<td class="px-4 py-3 data text-muted">{{ formatDate(u.created) }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openResetPassword(u)">Reset password</button>
<button
class="ml-3 text-xs font-medium text-danger hover:underline disabled:cursor-not-allowed disabled:text-muted disabled:no-underline"
:disabled="!!deleteBlockedReason(u)"
:title="deleteBlockedReason(u)"
@click="removeUser(u)"
>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create user -->
<Modal v-if="showCreate" title="Add a user" @close="showCreate = false">
<p v-if="createError" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ createError }}</p>
<form class="space-y-3" @submit.prevent="submitCreate">
<div>
<label class="dh-label">Email *</label>
<input v-model="createForm.email" type="email" required autocomplete="off" class="dh-input" />
</div>
<div>
<label class="dh-label">Name</label>
<input v-model="createForm.name" autocomplete="off" class="dh-input" />
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Password * <span class="text-muted">(min 8)</span></label>
<input v-model="createForm.password" type="text" required minlength="8" autocomplete="new-password" class="dh-input data" />
</div>
<div>
<label class="dh-label">Role</label>
<select v-model="createForm.role" class="dh-input">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="showCreate = false">Cancel</button>
<button type="submit" :disabled="creating" class="dh-btn dh-btn-primary">
{{ creating ? "Creating…" : "Create user" }}
</button>
</div>
</form>
</Modal>
<!-- Reset password -->
<Modal v-if="pwUser" :title="`Reset password — ${pwUser.email}`" @close="pwUser = null">
<p v-if="pwError" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ pwError }}</p>
<form class="space-y-3" @submit.prevent="submitResetPassword">
<div>
<label class="dh-label">New password <span class="text-muted">(min 8)</span></label>
<input v-model="newPassword" type="text" required minlength="8" autocomplete="new-password" class="dh-input data" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="pwUser = null">Cancel</button>
<button type="submit" :disabled="savingPw" class="dh-btn dh-btn-primary">
{{ savingPw ? "Saving…" : "Set password" }}
</button>
</div>
</form>
</Modal>
</div>
</template>
+364
View File
@@ -0,0 +1,364 @@
<script setup>
import { ref, onMounted, computed } 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";
import ServiceFormModal from "../components/ServiceFormModal.vue";
import PartFormModal from "../components/PartFormModal.vue";
import ShareModal from "../components/ShareModal.vue";
const props = defineProps({ id: { type: String, required: true } });
const router = useRouter();
const car = ref(null);
const services = ref([]);
const parts = ref([]);
const loading = ref(true);
const error = ref("");
// Modal state. `editing*` null => create mode; an object => edit that record.
const showCarEdit = ref(false);
const showService = ref(false);
const editingService = ref(null);
const showPart = ref(false);
const editingPart = ref(null);
// Delete-car confirmation (guarded: user must type the car name).
const showDeleteCar = ref(false);
const deleteConfirmText = ref("");
const deletingCar = ref(false);
const showShare = ref(false);
const latest = computed(() => services.value[0] || null);
const status = computed(() => serviceStatus(latest.value, car.value));
// Access gating. Owner can do everything (incl. delete + sharing); a "write"
// sharee can edit the car and its records/parts but not delete/share; "read"
// is view-only. Default to owner while loading so nothing flickers as editable
// for a read-only user (car is null until loaded, so buttons are hidden anyway).
const isOwner = computed(() => car.value?.access === "owner");
const canWrite = computed(() => car.value?.access === "owner" || car.value?.access === "write");
const isReadOnly = computed(() => car.value?.access === "read");
const activeTab = ref("info");
async function load() {
loading.value = true;
error.value = "";
try {
[car.value, services.value, parts.value] = await Promise.all([
api.getCar(props.id),
api.listCarServices(props.id),
api.listCarParts(props.id),
]);
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
function openAddService() {
editingService.value = null;
showService.value = true;
}
function openEditService(s) {
editingService.value = s;
showService.value = true;
}
async function onServiceSaved() {
showService.value = false;
editingService.value = null;
await load();
}
async function deleteService(id) {
if (!confirm("Delete this service record?")) return;
try {
await api.deleteService(id);
await load();
} catch (e) {
error.value = e.message;
}
}
function openAddPart() {
editingPart.value = null;
showPart.value = true;
}
function openEditPart(p) {
editingPart.value = p;
showPart.value = true;
}
async function onPartSaved() {
showPart.value = false;
editingPart.value = null;
parts.value = await api.listCarParts(props.id);
}
async function deletePart(id) {
if (!confirm("Delete this part?")) return;
try {
await api.deletePart(id);
parts.value = await api.listCarParts(props.id);
} catch (e) {
error.value = e.message;
}
}
async function onCarSaved(updated) {
showCarEdit.value = false;
car.value = updated;
}
function openDeleteCar() {
deleteConfirmText.value = "";
showDeleteCar.value = true;
}
// Enabled only once the typed name matches — guards this cascade delete.
const canDeleteCar = computed(
() => car.value && deleteConfirmText.value.trim() === car.value.name
);
async function confirmDeleteCar() {
if (!canDeleteCar.value) return;
deletingCar.value = true;
error.value = "";
try {
await api.deleteCar(props.id);
router.push({ name: "dashboard" });
} catch (e) {
error.value = e.message;
deletingCar.value = false;
}
}
function yn(v) {
return v ? "Yes" : "No";
}
const FUEL_LABELS = {
petrol: "Petrol (gasoline)",
diesel: "Diesel",
hybrid: "Hybrid",
electric: "Electric",
};
function fuelLabel(v) {
return FUEL_LABELS[v] || "—";
}
onMounted(load);
</script>
<template>
<div>
<RouterLink to="/" class="mb-4 inline-flex items-center gap-1 text-sm font-medium text-brandtext hover:underline">
All cars
</RouterLink>
<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>
<template v-else-if="car">
<!-- Header -->
<div class="dh-card mb-6 p-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="text-2xl font-bold tracking-[-0.03em] text-strong">{{ car.name }}</h1>
<p class="text-sm text-muted">
{{ [car.make, car.model, car.year || ''].filter(Boolean).join(' ') }}
<span v-if="car.registration"> · {{ car.registration }}</span>
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<span v-if="!isOwner" class="dh-badge dh-badge-neutral">
Shared{{ isReadOnly ? ' · read-only' : '' }}
</span>
<span :class="status.classes">{{ status.label }}</span>
<button v-if="isOwner" class="dh-btn dh-btn-ghost !px-3 !py-1.5" @click="showShare = true">Share</button>
<button v-if="canWrite" class="dh-btn dh-btn-ghost !px-3 !py-1.5" @click="showCarEdit = true">Edit</button>
<button v-if="isOwner" class="dh-btn !px-3 !py-1.5 border border-danger/30 text-danger hover:bg-danger-soft" @click="openDeleteCar">Delete</button>
</div>
</div>
</div>
<!-- Tabs -->
<div class="mb-6 flex gap-1 border-b border-subtle">
<button
class="-mb-px border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors"
:class="activeTab === 'info'
? 'border-accent text-brandtext'
: 'border-transparent text-muted hover:text-strong'"
@click="activeTab = 'info'">
Information
</button>
<button
class="-mb-px border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors"
:class="activeTab === 'services'
? 'border-accent text-brandtext'
: 'border-transparent text-muted hover:text-strong'"
@click="activeTab = 'services'">
Service history
</button>
<button
class="-mb-px border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors"
:class="activeTab === 'parts'
? 'border-accent text-brandtext'
: 'border-transparent text-muted hover:text-strong'"
@click="activeTab = 'parts'">
Parts catalog
</button>
</div>
<!-- Information -->
<section v-if="activeTab === 'info'">
<div class="dh-card p-6">
<dl class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div><dt class="eyebrow">Engine oil spec</dt><dd class="mt-0.5 font-medium text-strong">{{ car.oilSpec || '—' }}</dd></div>
<div><dt class="eyebrow">Transmission oil</dt><dd class="mt-0.5 font-medium text-strong">{{ car.transmissionOilSpec || '—' }}</dd></div>
<div><dt class="eyebrow">Differential oil</dt><dd class="mt-0.5 font-medium text-strong">{{ car.differentialOilSpec || '—' }}</dd></div>
<div><dt class="eyebrow">Brake fluid</dt><dd class="mt-0.5 font-medium text-strong">{{ car.brakeFluidSpec || '—' }}</dd></div>
<div><dt class="eyebrow">Coolant</dt><dd class="mt-0.5 font-medium text-strong">{{ car.coolantSpec || '—' }}</dd></div>
<div><dt class="eyebrow">Odometer</dt><dd class="mt-0.5 data font-medium text-strong">{{ formatKm(car.currentKm) }}</dd></div>
<div><dt class="eyebrow">Service interval</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.serviceIntervalDays }}d · {{ formatKm(car.serviceIntervalKm) }}</dd></div>
<div><dt class="eyebrow">Next due</dt><dd class="mt-0.5 data font-medium text-strong">{{ formatDate(latest?.nextServiceDate) }} · {{ formatKm(latest?.nextServiceKm) }}</dd></div>
<div><dt class="eyebrow">Registration plate</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.registration || '—' }}</dd></div>
<div><dt class="eyebrow">Registration country</dt><dd class="mt-0.5 font-medium text-strong">{{ car.registrationCountry || '—' }}</dd></div>
<div><dt class="eyebrow">VIN</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.vin || '—' }}</dd></div>
<div><dt class="eyebrow">Fuel type</dt><dd class="mt-0.5 font-medium text-strong">{{ fuelLabel(car.fuelType) }}</dd></div>
<div><dt class="eyebrow">Build date</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.buildDate ? formatDate(car.buildDate) : '—' }}</dd></div>
<div><dt class="eyebrow">First registration</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.firstRegistrationDate ? formatDate(car.firstRegistrationDate) : '—' }}</dd></div>
</dl>
</div>
</section>
<!-- Service history -->
<section v-else-if="activeTab === 'services'">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Service history</h2>
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddService">
<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 service
</button>
</div>
<div v-if="services.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
No service records yet.
</div>
<div v-else class="dh-card overflow-x-auto p-0">
<table class="min-w-full text-sm">
<thead class="bg-sunken text-left">
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
<th>Date</th>
<th>Km</th>
<th>Next date</th>
<th>Next km</th>
<th class="!text-center">Oil &amp; filter</th>
<th class="!text-center">Engine air</th>
<th class="!text-center">Cabin air</th>
<th>Notes</th>
<th v-if="canWrite"></th>
</tr>
</thead>
<tbody class="divide-y divide-subtle">
<tr v-for="s in services" :key="s.id" class="transition-colors hover:bg-sunken">
<td class="whitespace-nowrap px-4 py-3 data font-medium text-strong">{{ formatDate(s.date) }}</td>
<td class="whitespace-nowrap px-4 py-3 data text-body">{{ formatKm(s.km) }}</td>
<td class="whitespace-nowrap px-4 py-3 data text-muted">{{ formatDate(s.nextServiceDate) }}</td>
<td class="whitespace-nowrap px-4 py-3 data text-muted">{{ formatKm(s.nextServiceKm) }}</td>
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedOil ? 'text-success' : 'text-muted'">{{ yn(s.changedOil) }}</td>
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedEngineAirFilter ? 'text-success' : 'text-muted'">{{ yn(s.changedEngineAirFilter) }}</td>
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedCabinAirFilter ? 'text-success' : 'text-muted'">{{ yn(s.changedCabinAirFilter) }}</td>
<td class="px-4 py-3 text-body">{{ s.notes || '—' }}</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditService(s)">Edit</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteService(s.id)">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Parts catalog -->
<section v-else>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Parts catalog</h2>
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddPart">
<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 part
</button>
</div>
<div v-if="parts.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
No parts yet.
</div>
<div v-else class="dh-card overflow-hidden p-0">
<table class="min-w-full text-sm">
<thead class="bg-sunken text-left">
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
<th>Part</th>
<th>Part number</th>
<th v-if="canWrite"></th>
</tr>
</thead>
<tbody class="divide-y divide-subtle">
<tr v-for="p in parts" :key="p.id" class="transition-colors hover:bg-sunken">
<td class="px-4 py-3 font-medium text-strong">{{ p.name }}</td>
<td class="px-4 py-3 data text-body">{{ p.partNumber || '—' }}</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditPart(p)">Edit</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deletePart(p.id)">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<!-- Modals -->
<CarFormModal v-if="showCarEdit" :car="car" @saved="onCarSaved" @close="showCarEdit = false" />
<ServiceFormModal
v-if="showService"
:car-id="id"
:car="car"
:service="editingService"
@saved="onServiceSaved"
@close="showService = false"
/>
<PartFormModal
v-if="showPart"
:car-id="id"
:part="editingPart"
@saved="onPartSaved"
@close="showPart = false"
/>
<ShareModal v-if="showShare && car" :car="car" @close="showShare = false" />
<!-- Delete-car confirmation (type-to-confirm; cascade removes all data) -->
<div v-if="showDeleteCar && car" class="fixed inset-0 z-30 grid place-items-center bg-brand-900/40 p-4 backdrop-blur-sm" @click.self="showDeleteCar = false">
<div class="dh-card w-full max-w-md p-6 shadow-pop">
<h2 class="mb-2 text-lg font-bold tracking-[-0.02em] text-danger">Delete this car?</h2>
<p class="mb-4 text-sm text-body">
This permanently deletes <strong class="text-strong">{{ car.name }}</strong> and all of its
<strong class="text-strong">{{ services.length }}</strong> service record{{ services.length === 1 ? '' : 's' }}
and <strong class="text-strong">{{ parts.length }}</strong> part{{ parts.length === 1 ? '' : 's' }}. This cannot be undone.
</p>
<label class="dh-label">
Type <span class="data text-strong">{{ car.name }}</span> to confirm
</label>
<input v-model="deleteConfirmText" :placeholder="car.name" class="dh-input mb-4" />
<div class="flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="showDeleteCar = false">Cancel</button>
<button type="button" :disabled="!canDeleteCar || deletingCar" class="dh-btn dh-btn-danger" @click="confirmDeleteCar">
{{ deletingCar ? 'Deleting' : 'Delete permanently' }}
</button>
</div>
</div>
</div>
</div>
</template>
+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>
+114
View File
@@ -0,0 +1,114 @@
<script setup>
import { ref } from "vue";
import { useRouter, useRoute } from "vue-router";
import { login } from "../auth";
import { getServerUrl, setServerUrl, DEFAULT_API_BASE } from "../api";
import Logo from "../components/Logo.vue";
const router = useRouter();
const route = useRoute();
const email = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
const showPassword = ref(false);
// Server settings: an optional override of the API Server base URL, persisted
// locally. Empty means "use the default" (DEFAULT_API_BASE).
const showServer = ref(false);
const serverUrl = ref(getServerUrl());
const serverSaved = ref(false);
function saveServer() {
setServerUrl(serverUrl.value);
serverUrl.value = getServerUrl();
serverSaved.value = true;
setTimeout(() => (serverSaved.value = false), 2000);
}
function resetServer() {
setServerUrl("");
serverUrl.value = "";
serverSaved.value = true;
setTimeout(() => (serverSaved.value = false), 2000);
}
async function submit() {
loading.value = true;
error.value = "";
try {
await login(email.value.trim(), password.value);
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/";
router.replace(redirect);
} catch (e) {
error.value = e.message || "Login failed";
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="grid min-h-screen place-items-center bg-page px-4">
<div class="w-full max-w-sm">
<div class="mb-7 text-center">
<Logo class="mx-auto mb-4 h-10 w-auto" />
<h1 class="text-2xl font-bold tracking-[-0.02em] text-strong">Sign in</h1>
<p class="mt-1 text-sm text-muted">Your car, on track.</p>
</div>
<form class="dh-card space-y-4 p-6" @submit.prevent="submit">
<p v-if="error" class="rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<div>
<label class="dh-label">Email</label>
<input v-model="email" type="email" required autocomplete="username" class="dh-input" />
</div>
<div>
<label class="dh-label">Password</label>
<div class="relative">
<input v-model="password" :type="showPassword ? 'text' : 'password'" required autocomplete="current-password"
class="dh-input pr-10" />
<button type="button" @click="showPassword = !showPassword"
:aria-label="showPassword ? 'Hide password' : 'Show password'"
class="absolute inset-y-0 right-0 flex items-center px-3 text-muted transition-colors hover:text-strong">
<svg v-if="showPassword" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</button>
</div>
</div>
<button type="submit" :disabled="loading" class="dh-btn dh-btn-primary w-full">
{{ loading ? "Signing in…" : "Sign in" }}
</button>
<!-- Server settings: optional override of the API server address -->
<div class="border-t border-subtle pt-3">
<button type="button" @click="showServer = !showServer"
class="flex w-full items-center justify-between text-xs font-semibold text-muted transition-colors hover:text-strong">
<span>Server settings</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
class="h-4 w-4 transition-transform" :class="showServer ? 'rotate-180' : ''">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd" />
</svg>
</button>
<div v-if="showServer" class="mt-3 space-y-2">
<label class="eyebrow block">API server URL</label>
<input v-model="serverUrl" type="text" :placeholder="DEFAULT_API_BASE" autocomplete="off" class="dh-input data" />
<p class="text-xs text-muted">
Leave blank to use the default (<span class="data">{{ DEFAULT_API_BASE }}</span>).
</p>
<div class="flex items-center gap-2">
<button type="button" @click="saveServer" class="dh-btn dh-btn-ghost !px-3 !py-1.5 !text-xs">Save</button>
<button type="button" @click="resetServer" class="dh-btn !px-3 !py-1.5 !text-xs text-muted hover:bg-sunken">Reset to default</button>
<span v-if="serverSaved" class="text-xs font-medium text-success">Saved </span>
</div>
</div>
</div>
</form>
</div>
</div>
</template>
+682
View File
@@ -0,0 +1,682 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import { useRouter } from "vue-router";
import { api } from "../api";
import { state, logout, refreshProfile } from "../auth";
import { prefs, applyProfilePrefs } from "../prefs";
import { formatDate } from "../lib/format.js";
const router = useRouter();
const loading = ref(true);
const loadError = ref("");
const profile = ref(null);
async function load() {
loading.value = true;
loadError.value = "";
try {
profile.value = await refreshProfile();
await loadSessions();
} catch (e) {
loadError.value = e.message;
} finally {
loading.value = false;
}
}
// --- Account: name ---
const nameDraft = ref("");
const nameSaving = ref(false);
const nameSaved = ref(false);
const nameError = ref("");
function initDrafts() {
nameDraft.value = profile.value.name || "";
bioDraft.value = profile.value.bio || "";
}
async function saveName() {
nameSaving.value = true;
nameError.value = "";
nameSaved.value = false;
try {
profile.value = await api.updateMe({ name: nameDraft.value.trim() });
// Keep the header's displayed name in sync.
state.user = { ...state.user, name: profile.value.name };
localStorage.setItem("cc_user", JSON.stringify(state.user));
nameSaved.value = true;
setTimeout(() => (nameSaved.value = false), 2000);
} catch (e) {
nameError.value = e.message;
} finally {
nameSaving.value = false;
}
}
// --- Account: email verification ---
const verifySending = ref(false);
const verifySent = ref(false);
const verifyError = ref("");
async function sendVerification() {
verifySending.value = true;
verifyError.value = "";
try {
await api.requestVerification();
verifySent.value = true;
} catch (e) {
verifyError.value = e.message;
} finally {
verifySending.value = false;
}
}
// --- Account: password change ---
const oldPassword = ref("");
const newPassword = ref("");
const confirmPassword = ref("");
const passwordSaving = ref(false);
const passwordSaved = ref(false);
const passwordError = ref("");
const passwordMismatch = computed(
() => confirmPassword.value.length > 0 && newPassword.value !== confirmPassword.value
);
async function savePassword() {
passwordError.value = "";
if (newPassword.value.length < 8) {
passwordError.value = "New password must be at least 8 characters.";
return;
}
if (passwordMismatch.value) {
passwordError.value = "New password and confirmation don't match.";
return;
}
passwordSaving.value = true;
try {
await api.changePassword(oldPassword.value, newPassword.value);
oldPassword.value = "";
newPassword.value = "";
confirmPassword.value = "";
passwordSaved.value = true;
setTimeout(() => (passwordSaved.value = false), 2500);
} catch (e) {
passwordError.value = e.message;
} finally {
passwordSaving.value = false;
}
}
// --- Appearance (auto-saves on change) ---
const appearanceError = ref("");
const savingAppearance = ref(false);
async function saveAppearance(patch) {
appearanceError.value = "";
savingAppearance.value = true;
// Apply immediately for a responsive feel; roll back on failure.
const previous = { ...prefs };
applyProfilePrefs({ ...prefs, ...patch });
try {
profile.value = await api.updateMe(patch);
} catch (e) {
applyProfilePrefs(previous);
appearanceError.value = e.message;
} finally {
savingAppearance.value = false;
}
}
const dateFormatExample = computed(() => formatDate(new Date().toISOString()));
// --- Profile: avatar + bio ---
const avatarUrl = ref("");
const avatarUploading = ref(false);
const avatarError = ref("");
const fileInput = ref(null);
async function loadAvatar() {
if (!profile.value?.hasAvatar) {
avatarUrl.value = "";
return;
}
try {
const { blob } = await api.getAvatarBlob();
avatarUrl.value = URL.createObjectURL(blob);
} catch {
avatarUrl.value = "";
}
}
function pickAvatar() {
fileInput.value?.click();
}
async function onAvatarChosen(e) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
avatarUploading.value = true;
avatarError.value = "";
try {
profile.value = await api.uploadAvatar(file);
await loadAvatar();
} catch (err) {
avatarError.value = err.message;
} finally {
avatarUploading.value = false;
}
}
async function removeAvatar() {
avatarUploading.value = true;
avatarError.value = "";
try {
await api.deleteAvatar();
profile.value = { ...profile.value, hasAvatar: false };
avatarUrl.value = "";
} catch (err) {
avatarError.value = err.message;
} finally {
avatarUploading.value = false;
}
}
const bioDraft = ref("");
const bioSaving = ref(false);
const bioSaved = ref(false);
const bioError = ref("");
async function saveBio() {
bioSaving.value = true;
bioError.value = "";
try {
profile.value = await api.updateMe({ bio: bioDraft.value });
bioSaved.value = true;
setTimeout(() => (bioSaved.value = false), 2000);
} catch (e) {
bioError.value = e.message;
} finally {
bioSaving.value = false;
}
}
// --- Privacy & security: sessions ---
const sessions = ref([]);
const sessionsError = ref("");
const revokingId = ref("");
const revokingOthers = ref(false);
async function loadSessions() {
sessions.value = await api.listSessions();
}
async function revokeSession(session) {
if (!confirm(`Log out "${session.deviceLabel}"?`)) return;
revokingId.value = session.id;
sessionsError.value = "";
try {
await api.revokeSession(session.id);
if (session.current) {
onLogout();
return;
}
await loadSessions();
} catch (e) {
sessionsError.value = e.message;
} finally {
revokingId.value = "";
}
}
async function revokeOthers() {
if (!confirm("Log out every other device? This device stays signed in.")) return;
revokingOthers.value = true;
sessionsError.value = "";
try {
await api.revokeOtherSessions();
await loadSessions();
} catch (e) {
sessionsError.value = e.message;
} finally {
revokingOthers.value = false;
}
}
function onLogout() {
logout();
router.replace({ name: "login" });
}
// --- Advanced: export ---
const exporting = ref(false);
const exportError = ref("");
async function exportData() {
exporting.value = true;
exportError.value = "";
try {
const { blob, filename } = await api.exportData();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename || "drivervault-export.json";
a.click();
URL.revokeObjectURL(url);
} catch (e) {
exportError.value = e.message;
} finally {
exporting.value = false;
}
}
// --- Advanced: import ---
const importing = ref(false);
const importError = ref("");
const importResult = ref(null);
const importFileInput = ref(null);
function pickImportFile() {
importFileInput.value?.click();
}
async function onImportFileChosen(e) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
importError.value = "";
importResult.value = null;
let payload;
try {
payload = JSON.parse(await file.text());
} catch {
importError.value = "That file isn't valid JSON.";
return;
}
if (!Array.isArray(payload?.cars) || payload.cars.length === 0) {
importError.value = "That file doesn't look like a DriverVault export (missing a \"cars\" list).";
return;
}
if (
!confirm(
`Import ${payload.cars.length} car(s) from this file? This adds new records — it does not merge with or overwrite existing cars.`
)
) {
return;
}
importing.value = true;
try {
importResult.value = await api.importData(payload);
} catch (err) {
importError.value = err.message;
} finally {
importing.value = false;
}
}
// --- Danger zone: delete account (typed confirmation + cooldown) ---
const showDeleteConfirm = ref(false);
const deleteConfirmEmail = ref("");
const deleteRequesting = ref(false);
const deleteError = ref("");
const eligibleAt = ref(null); // set once a deletion request succeeds this session
const deletionPending = computed(() => !!profile.value?.deletionRequestedAt);
const cooldownElapsed = computed(() => {
if (!deletionPending.value) return false;
const eligible = eligibleAt.value || new Date(new Date(profile.value.deletionRequestedAt).getTime() + 3 * 24 * 60 * 60 * 1000);
return new Date() >= eligible;
});
const canRequestDelete = computed(
() => deleteConfirmEmail.value.trim().toLowerCase() === (profile.value?.email || "").toLowerCase()
);
async function requestDeletion() {
if (!canRequestDelete.value) return;
deleteRequesting.value = true;
deleteError.value = "";
try {
const res = await api.requestAccountDeletion(deleteConfirmEmail.value.trim());
eligibleAt.value = new Date(res.eligibleAt);
profile.value = { ...profile.value, deletionRequestedAt: new Date().toISOString() };
showDeleteConfirm.value = false;
deleteConfirmEmail.value = "";
} catch (e) {
deleteError.value = e.message;
} finally {
deleteRequesting.value = false;
}
}
async function cancelDeletion() {
deleteError.value = "";
try {
await api.cancelAccountDeletion();
profile.value = { ...profile.value, deletionRequestedAt: null };
eligibleAt.value = null;
} catch (e) {
deleteError.value = e.message;
}
}
async function finalizeDeletion() {
if (!confirm("This permanently deletes your account. This cannot be undone. Continue?")) return;
deleteError.value = "";
try {
await api.finalizeAccountDeletion();
onLogout();
} catch (e) {
deleteError.value = e.message;
}
}
onMounted(async () => {
await load();
initDrafts();
await loadAvatar();
});
onBeforeUnmount(() => {
if (avatarUrl.value) URL.revokeObjectURL(avatarUrl.value);
});
</script>
<template>
<div class="mx-auto max-w-3xl">
<div class="mb-6">
<p class="eyebrow">Account</p>
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">Settings</h1>
<p class="mt-1 text-sm text-muted">Manage your account, appearance, and data.</p>
</div>
<p v-if="loadError" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ loadError }}</p>
<p v-if="loading" class="text-muted">Loading</p>
<div v-else-if="profile" class="space-y-6">
<!-- Account -->
<section class="dh-card p-6">
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">Account</h2>
<div class="mb-5">
<label class="dh-label">Name</label>
<div class="flex gap-2">
<input v-model="nameDraft" class="dh-input max-w-sm" />
<button class="dh-btn dh-btn-primary" :disabled="nameSaving || !nameDraft.trim()" @click="saveName">
{{ nameSaving ? "Saving" : nameSaved ? "Saved " : "Save" }}
</button>
</div>
<p v-if="nameError" class="mt-1 text-sm text-danger">{{ nameError }}</p>
</div>
<div class="mb-5">
<label class="dh-label">Email</label>
<div class="flex flex-wrap items-center gap-2">
<span class="data rounded-control border border-subtle bg-sunken px-3 py-2 text-sm text-body">
{{ profile.email }}
</span>
<span class="dh-badge" :class="profile.verified ? 'dh-badge-success' : 'dh-badge-warning'">
{{ profile.verified ? "Verified" : "Not verified" }}
</span>
<button
v-if="!profile.verified && !verifySent"
class="text-sm font-medium text-brandtext hover:underline disabled:opacity-50"
:disabled="verifySending"
@click="sendVerification"
>
{{ verifySending ? "Sending…" : "Resend verification email" }}
</button>
<span v-if="verifySent" class="text-sm text-muted">Verification email requested.</span>
</div>
<p v-if="verifyError" class="mt-1 text-sm text-danger">{{ verifyError }}</p>
</div>
<div>
<h3 class="mb-2 text-sm font-semibold text-strong">Change password</h3>
<div class="grid max-w-sm gap-2">
<input v-model="oldPassword" type="password" placeholder="Current password" autocomplete="current-password" class="dh-input" />
<input v-model="newPassword" type="password" placeholder="New password" autocomplete="new-password" class="dh-input" />
<input v-model="confirmPassword" type="password" placeholder="Confirm new password" autocomplete="new-password" class="dh-input" />
</div>
<p v-if="passwordMismatch" class="mt-1 text-sm text-warning">Passwords don't match yet.</p>
<p v-if="passwordError" class="mt-1 text-sm text-danger">{{ passwordError }}</p>
<button class="dh-btn dh-btn-ghost mt-2" :disabled="passwordSaving || !oldPassword || !newPassword" @click="savePassword">
{{ passwordSaving ? "Updating" : passwordSaved ? "Password updated " : "Update password" }}
</button>
</div>
</section>
<!-- Appearance -->
<section class="dh-card p-6">
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">Appearance</h2>
<div class="mb-5">
<label class="dh-label">Theme</label>
<div class="flex gap-2">
<button
v-for="t in ['light', 'dark', 'system']"
:key="t"
class="rounded-control border px-3 py-1.5 text-sm font-medium capitalize transition-colors"
:class="prefs.theme === t
? 'border-accent bg-accent text-white'
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
@click="saveAppearance({ theme: t })"
>
{{ t }}
</button>
</div>
</div>
<div class="mb-5 grid gap-4 sm:grid-cols-2">
<div>
<label class="dh-label">Language &amp; region</label>
<select :value="prefs.locale" class="dh-input" @change="saveAppearance({ locale: $event.target.value })">
<option value="en-US">English (US)</option>
<option value="en-GB">English (UK)</option>
<option value="pl-PL">Polski</option>
<option value="de-DE">Deutsch</option>
<option value="fr-FR">Français</option>
<option value="es-ES">Español</option>
</select>
</div>
<div>
<label class="dh-label">Date format</label>
<select :value="prefs.dateFormat" class="dh-input" @change="saveAppearance({ dateFormat: $event.target.value })">
<option value="YMD">YYYY-MM-DD</option>
<option value="DMY_NUM">DD-MM-YYYY</option>
<option value="DMY">DD Mon YYYY</option>
<option value="MDY">Mon DD, YYYY</option>
</select>
<p class="mt-1 text-xs text-muted">Example: <span class="data">{{ dateFormatExample }}</span></p>
</div>
</div>
<div>
<label class="dh-label">Font size</label>
<div class="flex gap-2">
<button
v-for="f in ['small', 'medium', 'large']"
:key="f"
class="rounded-control border px-3 py-1.5 text-sm font-medium capitalize transition-colors"
:class="prefs.fontSize === f
? 'border-accent bg-accent text-white'
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
@click="saveAppearance({ fontSize: f })"
>
{{ f }}
</button>
</div>
</div>
<p v-if="appearanceError" class="mt-3 text-sm text-danger">{{ appearanceError }}</p>
</section>
<!-- Profile -->
<section class="dh-card p-6">
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">Profile</h2>
<div class="mb-5 flex items-center gap-4">
<img v-if="avatarUrl" :src="avatarUrl" alt="Avatar" class="h-16 w-16 rounded-full object-cover ring-1 ring-subtle" />
<div v-else class="grid h-16 w-16 place-items-center rounded-full bg-brand-100 text-xl font-bold text-brandtext">
{{ (profile.name || profile.email || "?").charAt(0).toUpperCase() }}
</div>
<div class="flex gap-2">
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5" :disabled="avatarUploading" @click="pickAvatar">
{{ avatarUploading ? "Uploading" : "Upload photo" }}
</button>
<button v-if="profile.hasAvatar" class="dh-btn dh-btn-ghost !px-3 !py-1.5" :disabled="avatarUploading" @click="removeAvatar">
Remove
</button>
</div>
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" class="hidden" @change="onAvatarChosen" />
</div>
<p v-if="avatarError" class="mb-4 text-sm text-danger">{{ avatarError }}</p>
<div>
<label class="dh-label">Bio</label>
<textarea
v-model="bioDraft"
rows="3"
placeholder="A short note visible to other people in your household."
class="dh-input"
/>
<p v-if="bioError" class="mt-1 text-sm text-danger">{{ bioError }}</p>
<button class="dh-btn dh-btn-ghost mt-2" :disabled="bioSaving" @click="saveBio">
{{ bioSaving ? "Saving" : bioSaved ? "Saved " : "Save bio" }}
</button>
</div>
</section>
<!-- Privacy & Security -->
<section class="dh-card p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Privacy &amp; security</h2>
<button
v-if="sessions.length > 1"
class="text-sm font-medium text-brandtext hover:underline disabled:opacity-50"
:disabled="revokingOthers"
@click="revokeOthers"
>
{{ revokingOthers ? "Logging out…" : "Log out all other devices" }}
</button>
</div>
<p class="mb-3 text-sm text-muted">
Two-factor authentication isn't available yet. Active sessions below reflect every device currently signed in.
</p>
<p v-if="sessionsError" class="mb-3 text-sm text-danger">{{ sessionsError }}</p>
<ul class="divide-y divide-subtle">
<li v-for="sess in sessions" :key="sess.id" class="flex items-center justify-between gap-3 py-3">
<div>
<p class="text-sm font-medium text-strong">
{{ sess.deviceLabel }}
<span v-if="sess.current" class="dh-badge dh-badge-neutral ml-2">This device</span>
</p>
<p class="text-xs text-muted">
<span class="data">{{ sess.ip }}</span> · signed in {{ formatDate(sess.created) }}
</p>
</div>
<button
class="shrink-0 text-sm font-medium text-danger hover:underline disabled:opacity-50"
:disabled="revokingId === sess.id"
@click="revokeSession(sess)"
>
{{ revokingId === sess.id ? "Logging out…" : "Log out" }}
</button>
</li>
</ul>
</section>
<!-- Advanced / Danger Zone -->
<section class="dh-card p-6">
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">Advanced</h2>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-strong">Export your data</p>
<p class="text-xs text-muted">Download your profile and all cars, service records, and parts as JSON.</p>
</div>
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" :disabled="exporting" @click="exportData">
{{ exporting ? "Preparing…" : "Export data" }}
</button>
</div>
<p v-if="exportError" class="mt-2 text-sm text-danger">{{ exportError }}</p>
<div class="mt-4 flex items-center justify-between gap-3 border-t border-subtle pt-4">
<div>
<p class="text-sm font-medium text-strong">Import your data</p>
<p class="text-xs text-muted">
Add cars from a previously exported JSON file. This creates new records — it doesn't merge with or overwrite anything existing.
</p>
</div>
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" :disabled="importing" @click="pickImportFile">
{{ importing ? "Importing" : "Import data" }}
</button>
<input ref="importFileInput" type="file" accept="application/json,.json" class="hidden" @change="onImportFileChosen" />
</div>
<p v-if="importResult" class="mt-2 text-sm font-medium text-success">
Imported {{ importResult.carsImported }} car(s), {{ importResult.servicesImported }} service record(s), {{ importResult.partsImported }} part(s).
</p>
<p v-if="importError" class="mt-2 text-sm text-danger">{{ importError }}</p>
</section>
<section class="rounded-card border border-danger/30 bg-danger-soft p-6">
<h2 class="mb-2 text-lg font-bold tracking-[-0.02em] text-danger">Danger zone</h2>
<template v-if="!deletionPending">
<p class="mb-3 text-sm text-danger/90">
Deleting your account removes your login and profile. It does not delete your household's shared cars or
service history. There's a 3-day cooldown before the deletion is final, and you can cancel any time before then.
</p>
<button class="dh-btn !border !border-danger/40 !bg-transparent !text-danger hover:!bg-danger/10" @click="showDeleteConfirm = true">
Delete my account
</button>
<div v-if="showDeleteConfirm" class="mt-4 rounded-control border border-danger/30 bg-card p-4">
<label class="dh-label">
Type <span class="data text-strong">{{ profile.email }}</span> to confirm
</label>
<input v-model="deleteConfirmEmail" :placeholder="profile.email" class="dh-input mb-3 max-w-sm" />
<div class="flex gap-2">
<button class="dh-btn dh-btn-ghost" @click="showDeleteConfirm = false; deleteConfirmEmail = ''">Cancel</button>
<button :disabled="!canRequestDelete || deleteRequesting" class="dh-btn dh-btn-danger" @click="requestDeletion">
{{ deleteRequesting ? "Requesting" : "Request deletion" }}
</button>
</div>
</div>
</template>
<template v-else>
<p class="mb-3 text-sm text-danger/90">
Account deletion requested on {{ formatDate(profile.deletionRequestedAt) }}.
<template v-if="!cooldownElapsed">You can still cancel it becomes permanent after the 3-day cooldown.</template>
<template v-else>The cooldown has passed. You can now finalize the deletion.</template>
</p>
<div class="flex gap-2">
<button class="dh-btn dh-btn-ghost !bg-card" @click="cancelDeletion">Cancel deletion request</button>
<button v-if="cooldownElapsed" class="dh-btn dh-btn-danger" @click="finalizeDeletion">
Permanently delete my account
</button>
</div>
</template>
<p v-if="deleteError" class="mt-3 text-sm font-medium text-danger">{{ deleteError }}</p>
</section>
</div>
</div>
</template>
+24
View File
@@ -0,0 +1,24 @@
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.
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
port: 5173,
// Listen on all interfaces so the dev server is reachable on the LAN
// (e.g. http://10.2.1.101:5173 from another device).
host: true,
// Allow access via the LAN hostname/IP (Vite's host check otherwise blocks
// non-localhost Host headers). true = accept any host (fine for LAN dev).
allowedHosts: true,
proxy: {
"/api": {
target: process.env.VITE_API_TARGET || "http://localhost:8080",
changeOrigin: true,
},
},
},
});