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:
co-authored by
Claude Opus 5
parent
1418a566fd
commit
3c64d6e84c
@@ -44,6 +44,7 @@ type userRecord struct {
|
||||
// The charging page's tab arrangement, stored the same way and for the same
|
||||
// reason.
|
||||
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 {
|
||||
@@ -85,6 +88,7 @@ func (rec userRecord) toModel() models.User {
|
||||
Organization: rec.Organization,
|
||||
CarOrder: rec.carOrder(),
|
||||
ChargerTabOrder: rec.chargerTabOrder(),
|
||||
ChargerCardOrder: rec.chargerCardOrder(),
|
||||
}
|
||||
if t := parsePBDate(rec.DeletionRequestedAt); !t.IsZero() {
|
||||
u.DeletionRequestedAt = &t
|
||||
@@ -143,6 +147,7 @@ type updateMeRequest struct {
|
||||
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 {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -497,6 +497,7 @@ type User struct {
|
||||
// needs it to send an updated arrangement back.
|
||||
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"`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ę",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user