Add a Charging tab to the web app sidebar
Mirror the web-dashboard UI kit's charging screen: a map panel with charger pins, an active-session card, and a nearby-stations list. Adds the sidebar nav item, the /charging route, and the Charging view, plus en/pl/da translations. Session and station data are presentational placeholders until the server exposes charging telemetry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
52d9614bad
commit
2eeff15925
@@ -36,6 +36,7 @@ const userInitial = computed(() =>
|
||||
const nav = computed(() =>
|
||||
[
|
||||
{ to: "/", label: t("nav.garage"), icon: "grid", exact: true },
|
||||
{ to: "/charging", label: t("nav.charging"), icon: "bolt" },
|
||||
{ to: "/settings", label: t("nav.settings"), icon: "gear" },
|
||||
isAdmin.value ? { to: "/admin", label: t("nav.users"), icon: "users" } : null,
|
||||
].filter(Boolean)
|
||||
@@ -84,6 +85,8 @@ onBeforeUnmount(() => themeObserver?.disconnect());
|
||||
>
|
||||
<!-- 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>
|
||||
<!-- charging -->
|
||||
<svg v-else-if="item.icon === 'bolt'" 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="M13 2 4.5 13.5H11l-1 8.5 8.5-11.5H12z"/></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 -->
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
"nav": {
|
||||
"garage": "Garage",
|
||||
"charging": "Opladning",
|
||||
"settings": "Indstillinger",
|
||||
"users": "Brugere",
|
||||
"lightMode": "Lys tilstand",
|
||||
@@ -31,6 +32,34 @@
|
||||
"logOut": "Log ud"
|
||||
},
|
||||
|
||||
"charging": {
|
||||
"eyebrow": "Opladning og kort",
|
||||
"title": "Ladere i nærheden",
|
||||
"liveMap": "Live-kort",
|
||||
"youAreHere": "Du er her",
|
||||
"session": {
|
||||
"chargingNow": "Oplader nu",
|
||||
"rate": "Effekt",
|
||||
"added": "Tilføjet",
|
||||
"cost": "Pris",
|
||||
"done": "Færdig",
|
||||
"stop": "Stop opladning",
|
||||
"idle": "Oplader ikke",
|
||||
"idleHint": "Tilslut ved en station for at starte en session."
|
||||
},
|
||||
"stations": {
|
||||
"heading": "I nærheden",
|
||||
"count": {
|
||||
"one": "{n} station",
|
||||
"other": "{n} stationer"
|
||||
},
|
||||
"free": "{avail} af {total} ledige",
|
||||
"full": "Optaget",
|
||||
"offPeak": "uden for spidsbelastning",
|
||||
"homeCharger": "Hjemmelader"
|
||||
}
|
||||
},
|
||||
|
||||
"login": {
|
||||
"title": "Log ind",
|
||||
"tagline": "Styr på din bil.",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
"nav": {
|
||||
"garage": "Garage",
|
||||
"charging": "Charging",
|
||||
"settings": "Settings",
|
||||
"users": "Users",
|
||||
"lightMode": "Light mode",
|
||||
@@ -31,6 +32,34 @@
|
||||
"logOut": "Log out"
|
||||
},
|
||||
|
||||
"charging": {
|
||||
"eyebrow": "Charging & map",
|
||||
"title": "Nearby chargers",
|
||||
"liveMap": "Live map",
|
||||
"youAreHere": "You are here",
|
||||
"session": {
|
||||
"chargingNow": "Charging now",
|
||||
"rate": "Rate",
|
||||
"added": "Added",
|
||||
"cost": "Cost",
|
||||
"done": "Done",
|
||||
"stop": "Stop charging",
|
||||
"idle": "Not charging",
|
||||
"idleHint": "Plug in at a station to start a session."
|
||||
},
|
||||
"stations": {
|
||||
"heading": "Nearby",
|
||||
"count": {
|
||||
"one": "{n} station",
|
||||
"other": "{n} stations"
|
||||
},
|
||||
"free": "{avail} of {total} free",
|
||||
"full": "Full",
|
||||
"offPeak": "off-peak",
|
||||
"homeCharger": "Home charger"
|
||||
}
|
||||
},
|
||||
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"tagline": "Your car, on track.",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
"nav": {
|
||||
"garage": "Garaż",
|
||||
"charging": "Ładowanie",
|
||||
"settings": "Ustawienia",
|
||||
"users": "Użytkownicy",
|
||||
"lightMode": "Tryb jasny",
|
||||
@@ -31,6 +32,36 @@
|
||||
"logOut": "Wyloguj się"
|
||||
},
|
||||
|
||||
"charging": {
|
||||
"eyebrow": "Ładowanie i mapa",
|
||||
"title": "Ładowarki w pobliżu",
|
||||
"liveMap": "Mapa na żywo",
|
||||
"youAreHere": "Tu jesteś",
|
||||
"session": {
|
||||
"chargingNow": "Ładowanie w toku",
|
||||
"rate": "Moc",
|
||||
"added": "Dodano",
|
||||
"cost": "Koszt",
|
||||
"done": "Koniec",
|
||||
"stop": "Zatrzymaj ładowanie",
|
||||
"idle": "Brak ładowania",
|
||||
"idleHint": "Podłącz na stacji, aby rozpocząć sesję."
|
||||
},
|
||||
"stations": {
|
||||
"heading": "W pobliżu",
|
||||
"count": {
|
||||
"one": "{n} stacja",
|
||||
"few": "{n} stacje",
|
||||
"many": "{n} stacji",
|
||||
"other": "{n} stacji"
|
||||
},
|
||||
"free": "{avail} z {total} wolnych",
|
||||
"full": "Zajęte",
|
||||
"offPeak": "poza szczytem",
|
||||
"homeCharger": "Ładowarka domowa"
|
||||
}
|
||||
},
|
||||
|
||||
"login": {
|
||||
"title": "Zaloguj się",
|
||||
"tagline": "Twój samochód pod kontrolą.",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import Dashboard from "./views/Dashboard.vue";
|
||||
import CarDetail from "./views/CarDetail.vue";
|
||||
import Charging from "./views/Charging.vue";
|
||||
import Login from "./views/Login.vue";
|
||||
import Settings from "./views/Settings.vue";
|
||||
import AdminUsers from "./views/AdminUsers.vue";
|
||||
@@ -9,6 +10,7 @@ import { isAuthenticated, isAdmin } from "./auth";
|
||||
const routes = [
|
||||
{ path: "/login", name: "login", component: Login, meta: { public: true } },
|
||||
{ path: "/", name: "dashboard", component: Dashboard },
|
||||
{ path: "/charging", name: "charging", component: Charging },
|
||||
{ path: "/cars/:id", name: "car", component: CarDetail, props: true },
|
||||
{ path: "/settings", name: "settings", component: Settings },
|
||||
{ path: "/admin", name: "admin", component: AdminUsers, meta: { admin: true } },
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { t } from "../i18n";
|
||||
|
||||
// Charging & map screen, mirroring the web-dashboard UI kit. There is no live
|
||||
// charging API yet (only the Anker Solix credential cascade in Settings), so the
|
||||
// session + stations below are presentational placeholders — swap them for real
|
||||
// endpoints once the server exposes charging telemetry.
|
||||
const TONE = {
|
||||
good: { bg: "var(--success-100)", fg: "var(--success-600)" },
|
||||
due: { bg: "var(--warning-100)", fg: "var(--warning-600)" },
|
||||
fault: { bg: "var(--danger-100)", fg: "var(--danger-600)" },
|
||||
};
|
||||
|
||||
const stations = [
|
||||
{ id: "sc", name: "DriverVault Supercharge", dist: "0.4 km", kw: 250, conn: "CCS · NACS", avail: 6, total: 8, price: "0,34 €", tone: "good", x: "47%", y: "34%" },
|
||||
{ id: "evgo", name: "EVgo · Market St", dist: "1.2 km", kw: 150, conn: "CCS", avail: 2, total: 6, price: "0,41 €", tone: "due", x: "26%", y: "60%" },
|
||||
{ id: "cp", name: "ChargePoint Garage", dist: "2.1 km", kw: 62, conn: "J1772", avail: 0, total: 4, price: "0,29 €", tone: "fault", x: "70%", y: "64%" },
|
||||
{ id: "home", name: t("charging.stations.homeCharger"), dist: "—", kw: 11, conn: "NACS", avail: 1, total: 1, price: t("charging.stations.offPeak"), tone: "good", x: "60%", y: "19%" },
|
||||
];
|
||||
|
||||
const selected = ref("sc");
|
||||
const charging = ref(true);
|
||||
|
||||
function stationStatus(s) {
|
||||
return s.avail === 0
|
||||
? t("charging.stations.full")
|
||||
: t("charging.stations.free", { avail: s.avail, total: s.total });
|
||||
}
|
||||
|
||||
const session = {
|
||||
car: "Model Y",
|
||||
from: 62,
|
||||
to: 80,
|
||||
metrics: [
|
||||
["rate", "142 kW"],
|
||||
["added", "+29 km"],
|
||||
["cost", "5,80 €"],
|
||||
["done", "~18 min"],
|
||||
],
|
||||
};
|
||||
|
||||
const sessionMetrics = computed(() =>
|
||||
session.metrics.map(([key, value]) => ({ label: t(`charging.session.${key}`), value }))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<p class="eyebrow">{{ t("charging.eyebrow") }}</p>
|
||||
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">{{ t("charging.title") }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[1fr_360px] lg:items-start">
|
||||
<!-- Map panel -->
|
||||
<div
|
||||
class="relative h-[520px] overflow-hidden rounded-card border border-subtle shadow-card"
|
||||
style="background-color: var(--ink-25);
|
||||
background-image:
|
||||
radial-gradient(circle at 32% 28%, rgba(37,99,235,.06), transparent 42%),
|
||||
repeating-linear-gradient(0deg, transparent 0 44px, rgba(15,30,61,.045) 44px 45px),
|
||||
repeating-linear-gradient(90deg, transparent 0 44px, rgba(15,30,61,.045) 44px 45px);"
|
||||
>
|
||||
<!-- roads -->
|
||||
<div class="absolute left-0 right-0 top-[52%] h-3.5 bg-brand-100"></div>
|
||||
<div class="absolute bottom-0 top-0 left-[58%] w-3.5 bg-brand-100"></div>
|
||||
<div class="absolute left-[18%] top-[-10%] h-[130%] w-2.5 origin-top rotate-[24deg]" style="background: rgba(37,99,235,.10)"></div>
|
||||
|
||||
<!-- legend -->
|
||||
<div class="eyebrow absolute left-4 top-4 flex items-center gap-2 rounded-pill border border-subtle bg-card/90 px-3 py-1.5 backdrop-blur">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-3.5 w-3.5" style="color: var(--brand-600)"><path stroke-linecap="round" stroke-linejoin="round" d="M9 6 3 4v14l6 2 6-2 6 2V6l-6-2-6 2Zm0 0v14m6-16v14"/></svg>
|
||||
{{ t("charging.liveMap") }}
|
||||
</div>
|
||||
|
||||
<!-- you are here -->
|
||||
<div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" :title="t('charging.youAreHere')">
|
||||
<div class="grid h-10 w-10 place-items-center rounded-full" style="background: rgba(37,99,235,.16)">
|
||||
<div class="h-3.5 w-3.5 rounded-full border-2 border-white bg-brand-600 shadow-card"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- charger pins -->
|
||||
<button
|
||||
v-for="s in stations"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="absolute flex -translate-x-1/2 -translate-y-full flex-col items-center"
|
||||
:style="{ left: s.x, top: s.y, zIndex: selected === s.id ? 4 : 2 }"
|
||||
@click="selected = s.id"
|
||||
>
|
||||
<span
|
||||
v-if="selected === s.id"
|
||||
class="mb-1.5 whitespace-nowrap rounded-control border border-subtle bg-card px-2.5 py-1.5 text-xs font-semibold text-strong shadow-card"
|
||||
>{{ s.avail }}/{{ s.total }} · {{ s.kw }} kW</span>
|
||||
<span
|
||||
class="grid place-items-center rounded-[50%_50%_50%_2px] border-2 border-white"
|
||||
:class="selected === s.id ? 'h-9 w-9' : 'h-7 w-7'"
|
||||
:style="{ background: TONE[s.tone].fg, transform: 'rotate(45deg)', boxShadow: 'var(--shadow-sm)' }"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" class="-rotate-45" :class="selected === s.id ? 'h-[18px] w-[18px]' : 'h-3.5 w-3.5'"><path stroke-linecap="round" stroke-linejoin="round" d="M13 2 4.5 13.5H11l-1 8.5 8.5-11.5H12z"/></svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Right column -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Active session -->
|
||||
<div class="relative overflow-hidden rounded-card bg-brand-900 p-5 text-white">
|
||||
<template v-if="charging">
|
||||
<div class="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em]" style="color: var(--brand-300)">
|
||||
<span class="h-1.5 w-1.5 rounded-full" style="background: var(--success-600)"></span>
|
||||
{{ t("charging.session.chargingNow") }} · {{ session.car }}
|
||||
</div>
|
||||
<div class="mt-3 font-mono text-4xl font-medium tracking-[-0.03em]">
|
||||
{{ session.from }}<span class="text-base font-medium text-white/70"> % → {{ session.to }}%</span>
|
||||
</div>
|
||||
<div class="mt-3.5 h-2 overflow-hidden rounded-pill bg-white/15">
|
||||
<div class="h-full rounded-pill bg-brand-400" :style="{ width: session.from + '%' }"></div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-between">
|
||||
<div v-for="m in sessionMetrics" :key="m.label">
|
||||
<div class="font-mono text-[9px] uppercase tracking-[0.12em]" style="color: var(--brand-300)">{{ m.label }}</div>
|
||||
<div class="mt-0.5 font-mono text-[15px] font-medium">{{ m.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 w-full rounded-control border border-white/20 bg-white/10 px-3 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-white/15"
|
||||
@click="charging = false"
|
||||
>{{ t("charging.session.stop") }}</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em]" style="color: var(--brand-300)">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-white/40"></span>
|
||||
{{ t("charging.session.idle") }}
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-white/70">{{ t("charging.session.idleHint") }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Station list -->
|
||||
<div class="dh-card p-2">
|
||||
<div class="eyebrow px-3 pb-1.5 pt-2.5">
|
||||
{{ t("charging.stations.heading") }} · {{ t("charging.stations.count", { n: stations.length }) }}
|
||||
</div>
|
||||
<button
|
||||
v-for="s in stations"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded-control p-3 text-left transition-colors"
|
||||
:class="selected === s.id ? 'bg-brand-100' : 'hover:bg-sunken'"
|
||||
@click="selected = s.id"
|
||||
>
|
||||
<div
|
||||
class="grid h-9 w-9 flex-none place-items-center rounded-control"
|
||||
:class="selected === s.id ? 'bg-card' : 'bg-sunken'"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" :stroke="TONE[s.tone].fg" stroke-width="2" class="h-4.5 w-4.5"><path stroke-linecap="round" stroke-linejoin="round" d="M13 2 4.5 13.5H11l-1 8.5 8.5-11.5H12z"/></svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-semibold text-strong">{{ s.name }}</div>
|
||||
<div class="data text-[11px] text-muted">{{ s.dist }} · {{ s.kw }} kW · {{ s.conn }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="data text-[11px] font-medium" :style="{ color: TONE[s.tone].fg }">{{ stationStatus(s) }}</div>
|
||||
<div class="data mt-0.5 text-[11px] text-muted">{{ s.price }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user