The charging tabs drag, like a car's do

Reordering the bar meant editing the template, which is a poor way to ask for
Home chargers first. The tabs now drag into either order on the same native
drag events as a car's tabs and the garage, down to the live reorder as the
pointer crosses a tab, the grab cursor, and the rail's lock holding the bar
still for anyone who would rather not nudge it on the way to a tab.

The arrangement is saved on the profile as charger_tab_order, beside the garage
order and for the same reason: it is a layout choice that should follow the
account rather than the browser, unlike which cards are folded. The field is
reconciled onto the users collection at boot, so no migration step. Its
normalizer is the garage's, which now takes the field name and cap as
arguments instead of being copied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-01 21:10:52 +02:00
co-authored by Claude Opus 5
parent 2a5d21d328
commit 1418a566fd
8 changed files with 150 additions and 19 deletions
+41 -13
View File
@@ -41,12 +41,17 @@ type userRecord struct {
// holds — null on a record that has never been arranged, and "" on one
// PocketBase stored as an empty value — neither of which is a []string.
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"`
}
// carOrder decodes the stored garage arrangement, treating anything unexpected
// as "not arranged yet" rather than failing the whole profile read.
func (rec userRecord) carOrder() []string { return decodeStringList(rec.CarOrder) }
func (rec userRecord) chargerTabOrder() []string { return decodeStringList(rec.ChargerTabOrder) }
// 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 {
@@ -77,8 +82,9 @@ func (rec userRecord) toModel() models.User {
Role: orDefault(rec.Role, "user"),
Created: rec.Created,
Organization: rec.Organization,
CarOrder: rec.carOrder(),
Organization: rec.Organization,
CarOrder: rec.carOrder(),
ChargerTabOrder: rec.chargerTabOrder(),
}
if t := parsePBDate(rec.DeletionRequestedAt); !t.IsZero() {
u.DeletionRequestedAt = &t
@@ -127,15 +133,16 @@ 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"`
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"`
}
// maxCarOrder bounds the stored arrangement. listCars fetches at most 200 owned
@@ -149,8 +156,21 @@ const maxCarOrder = 500
// a car the user no longer has is harmless: listCars ignores what it can't
// match, and the next drag rewrites the list anyway.
func normalizeCarOrder(in []string) ([]string, error) {
if len(in) > maxCarOrder {
return nil, fmt.Errorf("carOrder is too long (max %d)", maxCarOrder)
return normalizeOrder("carOrder", in, maxCarOrder)
}
// maxChargerTabOrder bounds the charging page's tab arrangement. The page has
// two tabs; the room is for tabs a later release adds, not for a client to park
// a blob on the record.
const maxChargerTabOrder = 20
func normalizeChargerTabOrder(in []string) ([]string, error) {
return normalizeOrder("chargerTabOrder", 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)
}
out := make([]string, 0, len(in))
seen := make(map[string]bool, len(in))
@@ -256,6 +276,14 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
}
payload["car_order"] = ids
}
if in.ChargerTabOrder != nil {
keys, err := normalizeChargerTabOrder(*in.ChargerTabOrder)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload["charger_tab_order"] = keys
}
var rec userRecord
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, &rec); err != nil {
+3
View File
@@ -252,6 +252,9 @@ var collectionsSchema = map[string][]fieldDef{
// user rather than per car, so it also covers cars shared with them and
// never reorders somebody else's garage.
fJSON("car_order", 20000),
// 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),
},
}
+2 -1
View File
@@ -495,7 +495,8 @@ 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"`
CarOrder []string `json:"carOrder"`
ChargerTabOrder []string `json:"chargerTabOrder"`
// Non-empty while an account-deletion request is pending its cooldown.
DeletionRequestedAt *time.Time `json:"deletionRequestedAt,omitempty"`
+1
View File
@@ -48,6 +48,7 @@
"eyebrow": "Opladning og kort",
"title": "Ladere i nærheden",
"tabs": {
"dragHint": "Træk en fane for at ændre rækkefølgen af opladningsfanerne.",
"public": "Offentlige ladere",
"home": "Hjemmeladere"
},
+1
View File
@@ -48,6 +48,7 @@
"eyebrow": "Charging & map",
"title": "Nearby chargers",
"tabs": {
"dragHint": "Drag a tab to rearrange the charging tabs.",
"public": "Public chargers",
"home": "Home chargers"
},
+1
View File
@@ -48,6 +48,7 @@
"eyebrow": "Ładowanie i mapa",
"title": "Ładowarki w pobliżu",
"tabs": {
"dragHint": "Przeciągnij kartę, aby zmienić kolejność kart ładowania.",
"public": "Ładowarki publiczne",
"home": "Ładowarki domowe"
},
+5
View File
@@ -12,6 +12,10 @@ export const prefs = reactive({
// rows, the provider's readings. Nothing to apply to the document — the views
// read it to decide whether their elements are draggable at all.
dragLocked: false,
// The charging page's tab arrangement. Kept here rather than fetched by the
// view because it arrives with the profile anyway, and the bar has to render
// in the right order on the first paint.
chargerTabOrder: [],
});
const FONT_SCALE = { small: "93.75%", medium: "100%", large: "112.5%" };
@@ -49,6 +53,7 @@ export function applyProfilePrefs(profile) {
prefs.currency = profile.currency || "USD";
prefs.fontSize = profile.fontSize || "medium";
prefs.dragLocked = !!profile.dragLocked;
prefs.chargerTabOrder = Array.isArray(profile.chargerTabOrder) ? profile.chargerTabOrder : [];
applyTheme();
applyFontSize();
}
+96 -5
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, computed, onMounted, watch } from "vue";
import { t } from "../i18n";
import { prefs } from "../prefs";
import { askConfirm } from "../lib/confirm.js";
import { api } from "../api";
import { formatDateTime } from "../lib/format.js";
@@ -27,6 +28,83 @@ const stations = [
// still placeholder data; the home half is not — those are records the user
// imported from a service they connected.
const chargerTab = ref("public"); // "public" | "home"
// --- Arranging the tab bar ---
//
// The bar drags into either order, on the same native drag events as the
// garage and a car's tabs — so pointer-only, as touch browsers don't fire
// these. The arrangement is saved on the profile rather than in this browser:
// it is a layout choice that should follow the account, the way the garage
// order does, and unlike which cards are folded.
const ALL_CHARGER_TABS = ["public", "home"];
const tabKeys = ref([...ALL_CHARGER_TABS]);
watch(
() => prefs.chargerTabOrder,
(order) => {
const arranged = [];
for (const key of order || []) {
if (ALL_CHARGER_TABS.includes(key) && !arranged.includes(key)) arranged.push(key);
}
// A tab the stored arrangement doesn't mention — one added in a later
// release — follows the arranged ones, the same rule the car's tabs use.
tabKeys.value = [...arranged, ...ALL_CHARGER_TABS.filter((k) => !arranged.includes(k))];
},
{ immediate: true }
);
// The rail's lock holds the bar still for someone who would rather not nudge it
// on the way to a tab.
const canArrangeTabs = computed(() => !prefs.dragLocked && tabKeys.value.length > 1);
const dragTab = ref(""); // tab being dragged
const dropTab = ref(""); // tab it is currently hovering over
const tabOrderError = ref("");
let tabsMoved = false; // the bar changed during this drag and isn't saved yet
function onTabDragStart(key, e) {
dragTab.value = key;
tabsMoved = 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 tabs, so the bar shows the arrangement
// you are about to get. dragenter fires again for every child element inside
// the same button, so the tab being hovered is remembered and only a genuinely
// new one moves anything.
function onTabDragEnter(key) {
if (!dragTab.value || key === dragTab.value || dropTab.value === key) return;
dropTab.value = key;
const list = tabKeys.value;
const from = list.indexOf(dragTab.value);
const to = list.indexOf(key);
if (from < 0 || to < 0) return;
list.splice(to, 0, ...list.splice(from, 1));
tabsMoved = true;
}
// Save whatever the bar now shows. Called from both drop and dragend: a tab
// released beside the bar never produces a drop, and leaving that arrangement
// unsaved would quietly undo itself on the next load.
async function commitTabOrder() {
dragTab.value = "";
dropTab.value = "";
if (!tabsMoved) return;
tabsMoved = false;
tabOrderError.value = "";
const arranged = [...tabKeys.value];
try {
await api.updateMe({ chargerTabOrder: arranged });
prefs.chargerTabOrder = arranged;
} catch (e) {
// The arrangement didn't stick; say so and put the stored one back rather
// than leaving the bar showing an order the server does not have.
tabOrderError.value = e.message;
tabKeys.value = [...prefs.chargerTabOrder.filter((k) => ALL_CHARGER_TABS.includes(k)),
...ALL_CHARGER_TABS.filter((k) => !prefs.chargerTabOrder.includes(k))];
}
}
const publicStations = stations;
const selected = ref("sc");
@@ -623,16 +701,29 @@ onMounted(async () => {
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">{{ t("charging.title") }}</h1>
</div>
<!-- Tabs: public network vs. the user's own home charger(s) -->
<!-- Tabs: public network vs. the user's own home charger(s). They drag into
either order, like a car's tabs and the garage. -->
<p v-if="tabOrderError" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ tabOrderError }}</p>
<div class="mb-6 flex gap-2 border-b border-subtle">
<button
v-for="tab in ['public', 'home']"
v-for="tab in tabKeys"
:key="tab"
:draggable="canArrangeTabs"
:title="canArrangeTabs ? t('charging.tabs.dragHint') : ''"
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'"
:class="[
chargerTab === tab
? 'border-accent text-strong'
: 'border-transparent text-muted hover:text-body',
canArrangeTabs ? 'cursor-grab active:cursor-grabbing' : '',
dragTab === tab ? 'opacity-50' : '',
]"
@click="chargerTab = tab"
@dragstart="onTabDragStart(tab, $event)"
@dragenter="onTabDragEnter(tab)"
@dragover.prevent
@drop.prevent="commitTabOrder"
@dragend="commitTabOrder"
>
{{ t(`charging.tabs.${tab}`) }}
</button>