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:
co-authored by
Claude Opus 5
parent
2a5d21d328
commit
1418a566fd
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user