Web App: fold integration cards; split Charging into two tabs
Settings → Integrations: the Toyota and Anker Solix cards are now collapsible, like the plugin rows in the API Server panel. Each header is a toggle with a rotating chevron; the Connected badge stays visible when collapsed. Bodies use v-show so loaded state and in-flight edits survive folding. Collapsed by default. Charging: split into "Public chargers" and "Home chargers" tabs. Public keeps the discovery map (public pins only) + demo session + nearby public stations. Home holds the real OCPP control card (moved here) and the user's home charger list, with a hint pointing to Settings when no control mode is active. Adds charging.tabs.*, stations.homeHeading and stations.noControlHint (en/pl/da). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8656aaaee3
commit
f9b1bcc520
@@ -35,6 +35,10 @@
|
||||
"charging": {
|
||||
"eyebrow": "Opladning og kort",
|
||||
"title": "Ladere i nærheden",
|
||||
"tabs": {
|
||||
"public": "Offentlige ladere",
|
||||
"home": "Hjemmeladere"
|
||||
},
|
||||
"liveMap": "Live-kort",
|
||||
"youAreHere": "Du er her",
|
||||
"session": {
|
||||
@@ -49,6 +53,8 @@
|
||||
},
|
||||
"stations": {
|
||||
"heading": "I nærheden",
|
||||
"homeHeading": "Dine ladere",
|
||||
"noControlHint": "Aktivér Own eller Proxy CSMS under Indstillinger → Integrationer for at styre din hjemmelader her.",
|
||||
"count": {
|
||||
"one": "{n} station",
|
||||
"other": "{n} stationer"
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
"charging": {
|
||||
"eyebrow": "Charging & map",
|
||||
"title": "Nearby chargers",
|
||||
"tabs": {
|
||||
"public": "Public chargers",
|
||||
"home": "Home chargers"
|
||||
},
|
||||
"liveMap": "Live map",
|
||||
"youAreHere": "You are here",
|
||||
"session": {
|
||||
@@ -67,6 +71,8 @@
|
||||
},
|
||||
"stations": {
|
||||
"heading": "Nearby",
|
||||
"homeHeading": "Your chargers",
|
||||
"noControlHint": "Turn on Own or Proxy CSMS in Settings → Integrations to control your home charger here.",
|
||||
"count": {
|
||||
"one": "{n} station",
|
||||
"other": "{n} stations"
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
"charging": {
|
||||
"eyebrow": "Ładowanie i mapa",
|
||||
"title": "Ładowarki w pobliżu",
|
||||
"tabs": {
|
||||
"public": "Ładowarki publiczne",
|
||||
"home": "Ładowarki domowe"
|
||||
},
|
||||
"liveMap": "Mapa na żywo",
|
||||
"youAreHere": "Tu jesteś",
|
||||
"session": {
|
||||
@@ -49,6 +53,8 @@
|
||||
},
|
||||
"stations": {
|
||||
"heading": "W pobliżu",
|
||||
"homeHeading": "Twoje ładowarki",
|
||||
"noControlHint": "Włącz tryb Own lub Proxy CSMS w Ustawienia → Integracje, aby sterować domową ładowarką.",
|
||||
"count": {
|
||||
"one": "{n} stacja",
|
||||
"few": "{n} stacje",
|
||||
|
||||
@@ -17,9 +17,15 @@ 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%" },
|
||||
{ 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%", home: true },
|
||||
];
|
||||
|
||||
// Two tabs split the public charging network (discovery map + nearby stations)
|
||||
// from the user's own home charger(s) and their real OCPP control.
|
||||
const chargerTab = ref("public"); // "public" | "home"
|
||||
const publicStations = stations.filter((s) => !s.home);
|
||||
const homeStations = stations.filter((s) => s.home);
|
||||
|
||||
const selected = ref("sc");
|
||||
const charging = ref(true);
|
||||
|
||||
@@ -136,7 +142,23 @@ onMounted(async () => {
|
||||
<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">
|
||||
<!-- Tabs: public network vs. the user's own home charger(s) -->
|
||||
<div class="mb-6 flex gap-2 border-b border-subtle">
|
||||
<button
|
||||
v-for="tab in ['public', 'home']"
|
||||
:key="tab"
|
||||
class="-mb-px border-b-2 px-1 pb-3 text-sm font-semibold transition-colors"
|
||||
:class="chargerTab === tab
|
||||
? 'border-accent text-strong'
|
||||
: 'border-transparent text-muted hover:text-body'"
|
||||
@click="chargerTab = tab"
|
||||
>
|
||||
{{ t(`charging.tabs.${tab}`) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Public chargers: discovery map + nearby public stations -->
|
||||
<div v-show="chargerTab === 'public'" 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"
|
||||
@@ -166,7 +188,7 @@ onMounted(async () => {
|
||||
|
||||
<!-- charger pins -->
|
||||
<button
|
||||
v-for="s in stations"
|
||||
v-for="s in publicStations"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="absolute flex -translate-x-1/2 -translate-y-full flex-col items-center"
|
||||
@@ -187,9 +209,78 @@ onMounted(async () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Right column -->
|
||||
<!-- Right column: demo session + public station list -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Active session (presentational demo) -->
|
||||
<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 (public network) -->
|
||||
<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: publicStations.length }) }}
|
||||
</div>
|
||||
<button
|
||||
v-for="s in publicStations"
|
||||
: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>
|
||||
|
||||
<!-- Home chargers: the user's own charger(s) + real OCPP control -->
|
||||
<div v-show="chargerTab === 'home'" class="grid gap-6 lg:grid-cols-[1fr_360px] lg:items-start">
|
||||
<!-- Real OCPP control (Own/Proxy CSMS) — the real home-charger control -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Real OCPP control — only when a control mode (Own/Proxy CSMS) is active -->
|
||||
<div v-if="ctlActive" class="dh-card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-semibold text-strong">{{ t("charging.control.title") }}</p>
|
||||
@@ -265,47 +356,21 @@ onMounted(async () => {
|
||||
<p v-if="ctlError" class="mt-2 text-sm text-danger">{{ ctlError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Active session (presentational demo) -->
|
||||
<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>
|
||||
<!-- No control mode active: point the user at Settings to enable it. -->
|
||||
<div v-else class="dh-card p-4">
|
||||
<p class="text-sm font-semibold text-strong">{{ t("charging.control.title") }}</p>
|
||||
<p class="mt-2 text-xs text-muted">{{ t("charging.stations.noControlHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Station list -->
|
||||
<!-- Home station list -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<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 }) }}
|
||||
{{ t("charging.stations.homeHeading") }} · {{ t("charging.stations.count", { n: homeStations.length }) }}
|
||||
</div>
|
||||
<button
|
||||
v-for="s in stations"
|
||||
v-for="s in homeStations"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded-control p-3 text-left transition-colors"
|
||||
|
||||
@@ -18,6 +18,12 @@ const profile = ref(null);
|
||||
// in-flight edits survive a tab switch.
|
||||
const activeTab = ref("personal"); // "personal" | "integrations"
|
||||
|
||||
// Each integration card folds open/closed, like the plugin rows in the API
|
||||
// Server panel. Collapsed by default so the Integrations tab reads as a compact
|
||||
// list of connectors you expand to configure.
|
||||
const toyotaOpen = ref(false);
|
||||
const ankerOpen = ref(false);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
@@ -922,20 +928,32 @@ onBeforeUnmount(() => {
|
||||
<p class="mb-4 mt-1 text-sm text-muted">{{ t("settings.integrations.subtitle") }}</p>
|
||||
|
||||
<div v-if="toyota" class="rounded-control border border-subtle p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start justify-between gap-3 text-left"
|
||||
:aria-expanded="toyotaOpen"
|
||||
@click="toyotaOpen = !toyotaOpen"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.toyota") }}</p>
|
||||
<p class="mt-0.5 text-xs text-muted">{{ t("settings.integrations.toyotaDesc") }}</p>
|
||||
</div>
|
||||
<span
|
||||
v-if="!toyotaEditingOrg"
|
||||
class="dh-badge shrink-0"
|
||||
:class="toyota.enabled ? 'dh-badge-success' : 'dh-badge-warning'"
|
||||
>
|
||||
{{ toyota.enabled ? t("settings.integrations.connected") : t("settings.integrations.notConnected") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
v-if="!toyotaEditingOrg"
|
||||
class="dh-badge"
|
||||
:class="toyota.enabled ? 'dh-badge-success' : 'dh-badge-warning'"
|
||||
>
|
||||
{{ toyota.enabled ? t("settings.integrations.connected") : t("settings.integrations.notConnected") }}
|
||||
</span>
|
||||
<svg
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
class="h-4 w-4 text-muted transition-transform" :class="toyotaOpen ? 'rotate-180' : ''"
|
||||
><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-show="toyotaOpen">
|
||||
<!-- Master / org gates -->
|
||||
<p v-if="!toyota.available" class="mt-3 text-sm text-warning">{{ t("settings.integrations.unavailable") }}</p>
|
||||
<p
|
||||
@@ -1035,24 +1053,37 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<p v-if="toyotaError" class="mt-2 text-sm text-danger">{{ toyotaError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Anker Solix (V1 Smart EV Charger) -->
|
||||
<div v-if="anker" class="mt-4 rounded-control border border-subtle p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start justify-between gap-3 text-left"
|
||||
:aria-expanded="ankerOpen"
|
||||
@click="ankerOpen = !ankerOpen"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.ankerSolix") }}</p>
|
||||
<p class="mt-0.5 text-xs text-muted">{{ t("settings.integrations.ankerSolixDesc") }}</p>
|
||||
</div>
|
||||
<span
|
||||
v-if="!ankerEditingOrg"
|
||||
class="dh-badge shrink-0"
|
||||
:class="anker.enabled ? 'dh-badge-success' : 'dh-badge-warning'"
|
||||
>
|
||||
{{ anker.enabled ? t("settings.integrations.connected") : t("settings.integrations.notConnected") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
v-if="!ankerEditingOrg"
|
||||
class="dh-badge"
|
||||
:class="anker.enabled ? 'dh-badge-success' : 'dh-badge-warning'"
|
||||
>
|
||||
{{ anker.enabled ? t("settings.integrations.connected") : t("settings.integrations.notConnected") }}
|
||||
</span>
|
||||
<svg
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
class="h-4 w-4 text-muted transition-transform" :class="ankerOpen ? 'rotate-180' : ''"
|
||||
><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-show="ankerOpen">
|
||||
<!-- Master / org gates -->
|
||||
<p v-if="!anker.available" class="mt-3 text-sm text-warning">{{ t("settings.integrations.unavailable") }}</p>
|
||||
<p
|
||||
@@ -1220,6 +1251,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<p v-if="ankerError" class="mt-2 text-sm text-danger">{{ ankerError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user