From 3c64d6e84c095e9a043694b009a36fce948ed9af Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:24:04 +0200 Subject: [PATCH] The cards drag too, held by their headings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- API Server/internal/api/me.go | 45 ++++--- API Server/internal/bootstrap/schema.go | 2 + API Server/internal/models/models.go | 5 +- Web App/web/src/i18n/da.json | 3 + Web App/web/src/i18n/en.json | 3 + Web App/web/src/i18n/pl.json | 3 + Web App/web/src/prefs.js | 2 + Web App/web/src/views/Charging.vue | 148 +++++++++++++++++++++++- 8 files changed, 191 insertions(+), 20 deletions(-) diff --git a/API Server/internal/api/me.go b/API Server/internal/api/me.go index d2eb6d2..6be5291 100644 --- a/API Server/internal/api/me.go +++ b/API Server/internal/api/me.go @@ -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 { diff --git a/API Server/internal/bootstrap/schema.go b/API Server/internal/bootstrap/schema.go index 8112b0d..ef1b08b 100644 --- a/API Server/internal/bootstrap/schema.go +++ b/API Server/internal/bootstrap/schema.go @@ -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), }, } diff --git a/API Server/internal/models/models.go b/API Server/internal/models/models.go index de36eb9..f8a09b8 100644 --- a/API Server/internal/models/models.go +++ b/API Server/internal/models/models.go @@ -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"` diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 39fc332..3182cd3 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -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", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 0d1e796..ee96fe7 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -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", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 4bea26a..2b34a4d 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -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ę", diff --git a/Web App/web/src/prefs.js b/Web App/web/src/prefs.js index 2c4934d..5b388f8 100644 --- a/Web App/web/src/prefs.js +++ b/Web App/web/src/prefs.js @@ -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(); } diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index 73e47fd..1c44861 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -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 () => {
+

+ {{ cardOrderError }} +

-
+