The cards drag too, held by their headings

Same arrangement the provider panel gives its readings, applied to the four
charging cards: drag one and the column reorders live under the pointer, the
card being dragged goes half-transparent, the one it is over takes a ring, and
the rail's lock holds the lot still.

Two things differ from the readings row, both because these are four different
things rather than four of one. A card is placed with the CSS order property
instead of by moving markup, so each keeps its own template and its own v-if.
And the handle is the card's heading rather than the whole card — a card that
was draggable everywhere would fight the current-limit slider and the address
fields for the pointer.

Saved on the profile as charger_card_order, beside the tab order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-01 21:24:04 +02:00
co-authored by Claude Opus 5
parent 1418a566fd
commit 3c64d6e84c
8 changed files with 191 additions and 20 deletions
+31 -14
View File
@@ -43,7 +43,8 @@ type userRecord struct {
CarOrder json.RawMessage `json:"car_order"`
// The charging page's tab arrangement, stored the same way and for the same
// reason.
ChargerTabOrder json.RawMessage `json:"charger_tab_order"`
ChargerTabOrder json.RawMessage `json:"charger_tab_order"`
ChargerCardOrder json.RawMessage `json:"charger_card_order"`
}
// carOrder decodes the stored garage arrangement, treating anything unexpected
@@ -52,6 +53,8 @@ func (rec userRecord) carOrder() []string { return decodeStringList(rec.CarOrder
func (rec userRecord) chargerTabOrder() []string { return decodeStringList(rec.ChargerTabOrder) }
func (rec userRecord) chargerCardOrder() []string { return decodeStringList(rec.ChargerCardOrder) }
// decodeStringList reads a PocketBase json field that holds a list of strings,
// treating anything unexpected as empty rather than failing the whole read.
func decodeStringList(raw json.RawMessage) []string {
@@ -82,9 +85,10 @@ func (rec userRecord) toModel() models.User {
Role: orDefault(rec.Role, "user"),
Created: rec.Created,
Organization: rec.Organization,
CarOrder: rec.carOrder(),
ChargerTabOrder: rec.chargerTabOrder(),
Organization: rec.Organization,
CarOrder: rec.carOrder(),
ChargerTabOrder: rec.chargerTabOrder(),
ChargerCardOrder: rec.chargerCardOrder(),
}
if t := parsePBDate(rec.DeletionRequestedAt); !t.IsZero() {
u.DeletionRequestedAt = &t
@@ -133,16 +137,17 @@ func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) {
}
type updateMeRequest struct {
Name *string `json:"name"`
Bio *string `json:"bio"`
Theme *string `json:"theme"`
Locale *string `json:"locale"`
DateFormat *string `json:"dateFormat"`
Currency *string `json:"currency"`
FontSize *string `json:"fontSize"`
DragLocked *bool `json:"dragLocked"`
CarOrder *[]string `json:"carOrder"`
ChargerTabOrder *[]string `json:"chargerTabOrder"`
Name *string `json:"name"`
Bio *string `json:"bio"`
Theme *string `json:"theme"`
Locale *string `json:"locale"`
DateFormat *string `json:"dateFormat"`
Currency *string `json:"currency"`
FontSize *string `json:"fontSize"`
DragLocked *bool `json:"dragLocked"`
CarOrder *[]string `json:"carOrder"`
ChargerTabOrder *[]string `json:"chargerTabOrder"`
ChargerCardOrder *[]string `json:"chargerCardOrder"`
}
// maxCarOrder bounds the stored arrangement. listCars fetches at most 200 owned
@@ -168,6 +173,10 @@ func normalizeChargerTabOrder(in []string) ([]string, error) {
return normalizeOrder("chargerTabOrder", in, maxChargerTabOrder)
}
func normalizeChargerCardOrder(in []string) ([]string, error) {
return normalizeOrder("chargerCardOrder", in, maxChargerTabOrder)
}
func normalizeOrder(field string, in []string, max int) ([]string, error) {
if len(in) > max {
return nil, fmt.Errorf("%s is too long (max %d)", field, max)
@@ -284,6 +293,14 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
}
payload["charger_tab_order"] = keys
}
if in.ChargerCardOrder != nil {
keys, err := normalizeChargerCardOrder(*in.ChargerCardOrder)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload["charger_card_order"] = keys
}
var rec userRecord
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, &rec); err != nil {
+2
View File
@@ -255,6 +255,8 @@ var collectionsSchema = map[string][]fieldDef{
// The charging page's tab order. Per user like the garage order, and for
// the same reason: it is this person's arrangement of their own page.
fJSON("charger_tab_order", 2000),
// The charging page's card order, stored the same way as its tab order.
fJSON("charger_card_order", 2000),
},
}
+3 -2
View File
@@ -495,8 +495,9 @@ type User struct {
// CarOrder is the garage arrangement: car ids in the order this user dragged
// them into. The car list is already returned in this order, so a client only
// needs it to send an updated arrangement back.
CarOrder []string `json:"carOrder"`
ChargerTabOrder []string `json:"chargerTabOrder"`
CarOrder []string `json:"carOrder"`
ChargerTabOrder []string `json:"chargerTabOrder"`
ChargerCardOrder []string `json:"chargerCardOrder"`
// Non-empty while an account-deletion request is pending its cooldown.
DeletionRequestedAt *time.Time `json:"deletionRequestedAt,omitempty"`
+3
View File
@@ -108,6 +108,9 @@
"boost": "Boost denne session",
"modbusHint": "Indtast laderens adresse på dette netværk og gem den. Laderen skal være tændt og på samme netværk som DriverVault."
},
"cards": {
"dragHint": "Træk et kort i dets overskrift for at ændre rækkefølgen i kolonnen."
},
"modbus": {
"title": "Laderaflæsninger",
"phases": "Pr. fase",
+3
View File
@@ -94,6 +94,9 @@
"boost": "Boost this session",
"modbusHint": "Enter the charger's address on this network and save it. The charger must be powered on and on the same network as DriverVault."
},
"cards": {
"dragHint": "Drag a card by its heading to rearrange the column."
},
"modbus": {
"title": "Charger readings",
"phases": "Per phase",
+3
View File
@@ -110,6 +110,9 @@
"boost": "Boost w tej sesji",
"modbusHint": "Podaj adres ładowarki w tej sieci i zapisz go. Ładowarka musi być włączona i w tej samej sieci co DriverVault."
},
"cards": {
"dragHint": "Przeciągnij kartę za nagłówek, aby zmienić kolejność w kolumnie."
},
"modbus": {
"title": "Odczyty ładowarki",
"phases": "Na fazę",
+2
View File
@@ -16,6 +16,7 @@ export const prefs = reactive({
// view because it arrives with the profile anyway, and the bar has to render
// in the right order on the first paint.
chargerTabOrder: [],
chargerCardOrder: [],
});
const FONT_SCALE = { small: "93.75%", medium: "100%", large: "112.5%" };
@@ -54,6 +55,7 @@ export function applyProfilePrefs(profile) {
prefs.fontSize = profile.fontSize || "medium";
prefs.dragLocked = !!profile.dragLocked;
prefs.chargerTabOrder = Array.isArray(profile.chargerTabOrder) ? profile.chargerTabOrder : [];
prefs.chargerCardOrder = Array.isArray(profile.chargerCardOrder) ? profile.chargerCardOrder : [];
applyTheme();
applyFontSize();
}
+144 -4
View File
@@ -135,6 +135,88 @@ const sessionMetrics = computed(() =>
// --- Real OCPP control (Anker Solix), gated by the per-user control mode ---
// The demo session card above is presentational; this card drives a real charger
// via the control endpoints when the user has picked Own/Proxy CSMS in Settings.
// --- Arranging the cards ---
//
// The four cards drag into any order, like the provider panel's readings, and
// the arrangement is saved on the profile beside the tab order. The column is a
// flex box, so a card is placed with the CSS order property rather than by
// moving markup: these are four different things, not four of one thing.
//
// The handle is the card's own header. Making the whole card draggable would
// fight the controls inside it — a range slider and two text fields cannot
// share a pointer with a native drag.
const ALL_CHARGER_CARDS = ["control", "connection", "readings", "info"];
const cardKeys = ref([...ALL_CHARGER_CARDS]);
watch(
() => prefs.chargerCardOrder,
(order) => {
const arranged = [];
for (const key of order || []) {
if (ALL_CHARGER_CARDS.includes(key) && !arranged.includes(key)) arranged.push(key);
}
cardKeys.value = [...arranged, ...ALL_CHARGER_CARDS.filter((k) => !arranged.includes(k))];
},
{ immediate: true }
);
const canArrangeCards = computed(() => !prefs.dragLocked);
const dragCard = ref(""); // card being dragged
const dropCard = ref(""); // card it is currently hovering over
const cardOrderError = ref("");
let cardsMoved = false; // the column changed during this drag and isn't saved yet
// Where this card sits in the column.
function cardOrder(key) {
const i = cardKeys.value.indexOf(key);
return i < 0 ? ALL_CHARGER_CARDS.length : i; // unarranged falls to the end, not the top
}
function onCardDragStart(key, e) {
dragCard.value = key;
cardsMoved = false;
e.dataTransfer.effectAllowed = "move";
// Firefox only starts a drag once something is on the transfer.
e.dataTransfer.setData("text/plain", key);
}
// Reorder live as the pointer crosses cards, so the column shows the
// arrangement you are about to get. dragenter fires again for every element
// inside the same card, so only a genuinely new one moves anything.
function onCardDragEnter(key) {
if (!dragCard.value || key === dragCard.value || dropCard.value === key) return;
dropCard.value = key;
const list = cardKeys.value;
const from = list.indexOf(dragCard.value);
const to = list.indexOf(key);
if (from < 0 || to < 0) return;
list.splice(to, 0, ...list.splice(from, 1));
cardsMoved = true;
}
// Save whatever the column now shows. Called from both drop and dragend: a card
// released beside the column never produces a drop.
async function commitCardOrder() {
dragCard.value = "";
dropCard.value = "";
if (!cardsMoved) return;
cardsMoved = false;
cardOrderError.value = "";
const arranged = [...cardKeys.value];
try {
await api.updateMe({ chargerCardOrder: arranged });
prefs.chargerCardOrder = arranged;
} catch (e) {
// The arrangement didn't stick; say so and put the stored one back rather
// than leaving the column showing an order the server does not have.
cardOrderError.value = e.message;
cardKeys.value = [
...prefs.chargerCardOrder.filter((k) => ALL_CHARGER_CARDS.includes(k)),
...ALL_CHARGER_CARDS.filter((k) => !prefs.chargerCardOrder.includes(k)),
];
}
}
// --- Folding the cards ---
//
// The column runs long once the readings are in it, so every card folds away.
@@ -853,15 +935,35 @@ onMounted(async () => {
<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">
<p
v-if="cardOrderError"
class="rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger"
style="order: -1"
>
{{ cardOrderError }}
</p>
<!-- Acting on the charger — first, because it is what the page is
opened for. It appears only once there is a connection to act
over, which the card below is where you set up. -->
<div v-if="ctlActive && ctlConnected" class="dh-card p-4">
<div
v-if="ctlActive && ctlConnected"
class="dh-card p-4 transition-shadow duration-150"
:class="dropCard === 'control' ? 'ring-2 ring-accent' : ''"
:style="{ order: cardOrder('control') }"
@dragenter.prevent="onCardDragEnter('control')"
@dragover.prevent
@drop.prevent="commitCardOrder"
>
<button
type="button"
:draggable="canArrangeCards"
:title="canArrangeCards ? t('charging.cards.dragHint') : ''"
class="flex w-full items-center justify-between gap-3 text-left"
:class="[canArrangeCards ? 'cursor-grab active:cursor-grabbing' : '', dragCard === 'control' ? 'opacity-50' : '']"
:aria-expanded="isOpen('control')"
@click="toggleCard('control')"
@dragstart="onCardDragStart('control', $event)"
@dragend="commitCardOrder"
>
<p class="text-sm font-semibold text-strong">{{ t("charging.control.title") }}</p>
<svg
@@ -957,12 +1059,25 @@ onMounted(async () => {
<!-- Which charger, and how to reach it. Below the controls: it is
touched once and then left alone. -->
<div v-if="ctlActive" class="dh-card p-4">
<div
v-if="ctlActive"
class="dh-card p-4 transition-shadow duration-150"
:class="dropCard === 'connection' ? 'ring-2 ring-accent' : ''"
:style="{ order: cardOrder('connection') }"
@dragenter.prevent="onCardDragEnter('connection')"
@dragover.prevent
@drop.prevent="commitCardOrder"
>
<button
type="button"
:draggable="canArrangeCards"
:title="canArrangeCards ? t('charging.cards.dragHint') : ''"
class="flex w-full items-center justify-between gap-3 text-left"
:class="[canArrangeCards ? 'cursor-grab active:cursor-grabbing' : '', dragCard === 'connection' ? 'opacity-50' : '']"
:aria-expanded="isOpen('connection')"
@click="toggleCard('connection')"
@dragstart="onCardDragStart('connection', $event)"
@dragend="commitCardOrder"
>
<p class="text-sm font-semibold text-strong">{{ t("charging.control.connectionTitle") }}</p>
<!-- Whether the charger is reachable is the one thing worth seeing
@@ -1060,12 +1175,25 @@ onMounted(async () => {
<!-- What the charger reports over Modbus. Its own card rather than a
tail on the control one: control is for acting on the charger, and
this is a long read that pushed the buttons off the screen. -->
<div v-if="ctlIsModbus && ctlConnected" class="dh-card p-4">
<div
v-if="ctlIsModbus && ctlConnected"
class="dh-card p-4 transition-shadow duration-150"
:class="dropCard === 'readings' ? 'ring-2 ring-accent' : ''"
:style="{ order: cardOrder('readings') }"
@dragenter.prevent="onCardDragEnter('readings')"
@dragover.prevent
@drop.prevent="commitCardOrder"
>
<button
type="button"
:draggable="canArrangeCards"
:title="canArrangeCards ? t('charging.cards.dragHint') : ''"
class="flex w-full items-center justify-between gap-3 text-left"
:class="[canArrangeCards ? 'cursor-grab active:cursor-grabbing' : '', dragCard === 'readings' ? 'opacity-50' : '']"
:aria-expanded="isOpen('readings')"
@click="toggleCard('readings')"
@dragstart="onCardDragStart('readings', $event)"
@dragend="commitCardOrder"
>
<p class="text-sm font-semibold text-strong">{{ t("charging.modbus.title") }}</p>
<svg
@@ -1156,13 +1284,25 @@ onMounted(async () => {
charger. It stands on its own control needs Own/Proxy CSMS, but
what the charger *is* is known either way, so with control off this
card is what fills the column instead of a bare hint. -->
<div class="dh-card p-4">
<div
class="dh-card p-4 transition-shadow duration-150"
:class="dropCard === 'info' ? 'ring-2 ring-accent' : ''"
:style="{ order: cardOrder('info') }"
@dragenter.prevent="onCardDragEnter('info')"
@dragover.prevent
@drop.prevent="commitCardOrder"
>
<div class="flex items-center justify-between gap-2">
<button
type="button"
:draggable="canArrangeCards"
:title="canArrangeCards ? t('charging.cards.dragHint') : ''"
class="flex grow items-center justify-between gap-3 text-left"
:class="[canArrangeCards ? 'cursor-grab active:cursor-grabbing' : '', dragCard === 'info' ? 'opacity-50' : '']"
:aria-expanded="isOpen('info')"
@click="toggleCard('info')"
@dragstart="onCardDragStart('info', $event)"
@dragend="commitCardOrder"
>
<p class="text-sm font-semibold text-strong">{{ t("charging.info.title") }}</p>
<svg