Initial commit
This commit is contained in:
@@ -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>
|
||||
@@ -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" }),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
@@ -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 & 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 & 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 & 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>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & 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 & 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>
|
||||
Reference in New Issue
Block a user