A page opens where you put its tabs

Every tab bar in the app drags into the order you want, and then all three of
them opened on a tab picked in the source anyway: "public" on Charging, "info"
on a car, "personal" in Settings. Dragging Home chargers to the front of the
charging bar rearranged the bar and changed nothing about where the page landed,
which is the opposite of what dragging it there says.

So the front of the bar is now the landing tab, everywhere. An arrangement is
already the statement of what you want to see first; it just wasn't being read
as one. Settings > Appearance overrides it per page for the case where reading
order and landing tab are two different wishes, with "First in the bar" as the
default and the meaning of no override at all.

The rule lives in one place, lib/tabs.js, because it is one rule and three
pages: the saved choice if that tab is actually on the bar, otherwise whatever
leads it. The bar it is given is the one that will really render, hidden tabs
and inapplicable ones already dropped, so a default that no longer has a button
- a tab switched off for that car, Users on a non-admin - falls back to the
front instead of opening nothing. The tab key lists moved there too, since the
picker needs all three and would otherwise have copied them.

Each page starts on no tab and keeps following the profile until the user says
otherwise, rather than guessing and then correcting itself: the arrangement and
the default both arrive with /api/me, which on a hard refresh lands after the
view has mounted. A click ends the following, and so does the start of a drag -
rearranging a bar must not pull the content out from under the pointer. In
Settings ?tab= still wins over both, since that is what /admin redirects to.

Stored as defaultTabs on the profile, one page->tab map validated per page: a
tab that exists but on another page is an error, and an empty value is stored
as an absent key so "no default" has a single representation.

Also adds charger_tab_order and charger_card_order to the PocketBase setup
script. They were never there - the arrangements of the last two commits had no
column to persist into on a freshly set-up server - and default_tabs would have
gone the same way beside them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-02 08:52:20 +02:00
co-authored by Claude Opus 5
parent 641f427db4
commit f3235c403c
13 changed files with 347 additions and 33 deletions
+9 -1
View File
@@ -335,7 +335,15 @@
"fontSize": "Skriftstørrelse",
"fontSmall": "lille",
"fontMedium": "mellem",
"fontLarge": "stor"
"fontLarge": "stor",
"defaultTab": "Standardfane",
"defaultTabFirst": "Første i rækken",
"defaultTabHint": "Hvilken fane hver side åbner på. „Første i rækken“ følger den rækkefølge, du har trukket fanerne i.",
"defaultTabPage": {
"charging": "Opladningssiden",
"car": "Bilsiden",
"settings": "Indstillingssiden"
}
},
+9 -1
View File
@@ -334,7 +334,15 @@
"fontSize": "Font size",
"fontSmall": "small",
"fontMedium": "medium",
"fontLarge": "large"
"fontLarge": "large",
"defaultTab": "Default tab",
"defaultTabFirst": "First in the bar",
"defaultTabHint": "Which tab each page opens on. “First in the bar” follows the order you dragged the tabs into.",
"defaultTabPage": {
"charging": "Charging page",
"car": "Car page",
"settings": "Settings page"
}
},
+9 -1
View File
@@ -339,7 +339,15 @@
"fontSize": "Rozmiar czcionki",
"fontSmall": "mała",
"fontMedium": "średnia",
"fontLarge": "duża"
"fontLarge": "duża",
"defaultTab": "Domyślna zakładka",
"defaultTabFirst": "Pierwsza w pasku",
"defaultTabHint": "Od której zakładki otwiera się każda strona. „Pierwsza w pasku” idzie za kolejnością, w którą przeciągnięto zakładki.",
"defaultTabPage": {
"charging": "Strona ładowania",
"car": "Strona samochodu",
"settings": "Strona ustawień"
}
},
+46
View File
@@ -0,0 +1,46 @@
// The tabbed pages and the rule for which tab each one opens on.
//
// Every bar in the app is arrangeable, and a bar the user dragged says what they
// want to see first — so a page opens on whichever tab now leads it. The Settings
// picker overrides that per page, for the case where the reading order and the
// landing tab are genuinely different wishes.
//
// The key lists live here rather than in the three views because Settings offers
// the picker for all of them and would otherwise duplicate every list.
import { prefs } from "../prefs.js";
export const CHARGING_TABS = ["public", "home"];
// A car's tabs in their default order. Information sits second because the
// connected service, when there is one, is what you came to look at.
export const CAR_TABS = [
"provider", "info", "services", "technical", "maintenance", "fuel",
"charging", "documents", "parts", "reminders",
];
export const SETTINGS_TABS = ["personal", "users", "organization", "integrations"];
// The pages the picker offers, with the i18n key for each tab's label. A car's
// connected-service tab was translated as car.tabs.connected long before it
// became a key, hence the one remapping.
export const TAB_SURFACES = [
{ surface: "charging", keys: CHARGING_TABS, labelKey: (key) => `charging.tabs.${key}` },
{
surface: "car",
keys: CAR_TABS,
labelKey: (key) => `car.tabs.${key === "provider" ? "connected" : key}`,
},
{ surface: "settings", keys: SETTINGS_TABS, labelKey: (key) => `settings.tabs.${key}` },
];
// The tab a page should open on. `available` is the bar as it will actually
// render — already arranged, with hidden tabs and ones that don't apply dropped
// — so a saved default that no longer has a button falls through to the front of
// the bar rather than opening nothing.
export function defaultTabFor(surface, available) {
const keys = available || [];
const chosen = prefs.defaultTabs?.[surface];
if (chosen && keys.includes(chosen)) return chosen;
return keys[0] || "";
}
+8
View File
@@ -17,6 +17,11 @@ export const prefs = reactive({
// in the right order on the first paint.
chargerTabOrder: [],
chargerCardOrder: [],
// Which tab each tabbed page opens on, keyed by page:
// { charging: "home", car: "info", settings: "personal" }. A page missing here
// opens on whichever tab leads its bar — see lib/tabs.js, which owns the rule
// and the key lists.
defaultTabs: {},
});
const FONT_SCALE = { small: "93.75%", medium: "100%", large: "112.5%" };
@@ -56,6 +61,9 @@ export function applyProfilePrefs(profile) {
prefs.dragLocked = !!profile.dragLocked;
prefs.chargerTabOrder = Array.isArray(profile.chargerTabOrder) ? profile.chargerTabOrder : [];
prefs.chargerCardOrder = Array.isArray(profile.chargerCardOrder) ? profile.chargerCardOrder : [];
prefs.defaultTabs = profile.defaultTabs && typeof profile.defaultTabs === "object"
? { ...profile.defaultTabs }
: {};
applyTheme();
applyFontSize();
}
+37 -16
View File
@@ -19,6 +19,7 @@ import {
reminderStatus,
} from "../lib/format.js";
import { SERVICE_PARTS, changedParts, visibleParts } from "../lib/serviceParts.js";
import { CAR_TABS, defaultTabFor } from "../lib/tabs.js";
import { t, tSplit } from "../i18n";
import { askConfirm } from "../lib/confirm.js";
import CarFormModal from "../components/CarFormModal.vue";
@@ -88,7 +89,10 @@ const isOwner = computed(() => car.value?.access === "owner");
const canWrite = computed(() => car.value?.access === "owner" || car.value?.access === "write");
const isReadOnly = computed(() => car.value?.access === "read");
const activeTab = ref("info");
// Which tab the page opens on follows the account's default (Settings
// Appearance) and otherwise the front of this car's bar. Empty until the car —
// and with it the arrangement — has loaded.
const activeTab = ref("");
// Connected-service tab. It leads the bar by default — ahead of Information —
// because for a car imported from a manufacturer account that is the live view
@@ -124,12 +128,10 @@ const dueReminders = computed(
// activeTab, so a hidden tab's content is unreachable rather than unlabelled.
const hiddenTabs = computed(() => car.value?.hiddenTabs || []);
const hiddenFields = computed(() => car.value?.hiddenFields || []);
// The tabs in their default order. Information sits second because the
// connected service, when there is one, is what you came to look at.
const ALL_TAB_KEYS = [
"provider", "info", "services", "technical", "maintenance", "fuel",
"charging", "documents", "parts", "reminders",
];
// The tabs in their default order (lib/tabs.js, shared with the default-tab
// picker in Settings). Information sits second because the connected service,
// when there is one, is what you came to look at.
const ALL_TAB_KEYS = CAR_TABS;
// The arrangement of the bar: the full list of tab keys, hidden ones and the
// connected-service tab included, so a tab switched back on — or a provider
@@ -159,14 +161,30 @@ const TABS = computed(() =>
.map((key) => ({ key, label: tabPickerLabel(key) }))
);
// Switching a tab off while standing on it (or landing on a car whose provider
// tab doesn't apply) would otherwise leave the page on a tab that no longer has
// a button.
watch(TABS, (tabs) => {
if (tabs.length && !tabs.some((tab) => tab.key === activeTab.value)) {
activeTab.value = tabs[0].key;
}
});
// Landing tab, and the guard that keeps the page off a tab with no button —
// one switched off while standing on it, or a provider tab that doesn't apply to
// the car just opened.
//
// Until the user picks a tab the page keeps following the bar: the car (with its
// arrangement) and the profile (with the default) both load after this view
// mounts, so the first render has nothing to go on yet. A click or a drag ends
// that — the page then stays where they put it.
const tabPicked = ref(false);
watch(
[TABS, () => prefs.defaultTabs],
([tabs]) => {
const keys = tabs.map((tab) => tab.key);
if (!keys.length) return;
if (!tabPicked.value) activeTab.value = defaultTabFor("car", keys);
else if (!keys.includes(activeTab.value)) activeTab.value = keys[0];
},
{ immediate: true }
);
function selectTab(key) {
tabPicked.value = true;
activeTab.value = key;
}
// Dragging a tab, on the same native drag events as the Information rows and the
// garage — so pointer-only, as touch browsers don't fire these. Needs write
@@ -183,6 +201,9 @@ let tabsMoved = false; // the bar changed during this drag and isn't saved yet
function onTabDragStart(key, e) {
dragTab.value = key;
// Rearranging the bar must not pull the content out from under the drag, so
// the page stops following the arrangement the moment one starts.
tabPicked.value = true;
tabsMoved = false;
e.dataTransfer.effectAllowed = "move";
// Firefox only starts a drag once something is on the transfer.
@@ -1085,7 +1106,7 @@ onMounted(load);
dragTab === tab.key ? 'opacity-50' : '',
dropTab === tab.key ? 'bg-sunken rounded-t-control' : '',
]"
@click="activeTab = tab.key"
@click="selectTab(tab.key)"
@dragstart="onTabDragStart(tab.key, $event)"
@dragenter.prevent="onTabDragEnter(tab.key)"
@dragover.prevent
+30 -3
View File
@@ -5,6 +5,7 @@ import { prefs } from "../prefs";
import { askConfirm } from "../lib/confirm.js";
import { api } from "../api";
import { formatDateTime } from "../lib/format.js";
import { CHARGING_TABS, defaultTabFor } from "../lib/tabs.js";
import ChargerImportModal from "../components/ChargerImportModal.vue";
// Charging & map screen, mirroring the web-dashboard UI kit. There is no live
@@ -27,7 +28,11 @@ const stations = [
// from the user's own chargers and their real OCPP control. The public half is
// 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"
//
// Which of them the page opens on is not fixed: it follows the account's default
// (Settings Appearance) and otherwise whichever tab was dragged to the front.
// Empty until that is resolved just below.
const chargerTab = ref(""); // "public" | "home"
// --- Arranging the tab bar ---
//
@@ -36,7 +41,7 @@ const chargerTab = ref("public"); // "public" | "home"
// 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 ALL_CHARGER_TABS = CHARGING_TABS;
const tabKeys = ref([...ALL_CHARGER_TABS]);
watch(
@@ -53,6 +58,25 @@ watch(
{ immediate: true }
);
// Landing tab. The arrangement and the saved default both arrive with the
// profile, which on a hard refresh lands after this view has mounted — so the
// bar keeps following them until the user says otherwise, rather than opening on
// whatever it could guess first. A click or a drag is the user saying otherwise:
// from then on the page stays where they put it.
const tabPicked = ref(false);
watch(
[tabKeys, () => prefs.defaultTabs],
() => {
if (!tabPicked.value) chargerTab.value = defaultTabFor("charging", tabKeys.value);
},
{ immediate: true }
);
function selectTab(tab) {
tabPicked.value = true;
chargerTab.value = tab;
}
// 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);
@@ -63,6 +87,9 @@ let tabsMoved = false; // the bar changed during this drag and isn't saved yet
function onTabDragStart(key, e) {
dragTab.value = key;
// Rearranging the bar must not pull the content out from under the drag, so
// the page stops following the arrangement the moment one starts.
tabPicked.value = true;
tabsMoved = false;
e.dataTransfer.effectAllowed = "move";
// Firefox only starts a drag once something is on the transfer.
@@ -800,7 +827,7 @@ onMounted(async () => {
canArrangeTabs ? 'cursor-grab active:cursor-grabbing' : '',
dragTab === tab ? 'opacity-50' : '',
]"
@click="chargerTab = tab"
@click="selectTab(tab)"
@dragstart="onTabDragStart(tab, $event)"
@dragenter="onTabDragEnter(tab)"
@dragover.prevent
+55 -9
View File
@@ -6,6 +6,7 @@ import { state, isAdmin, logout, refreshProfile } from "../auth";
import { prefs, applyProfilePrefs } from "../prefs";
import { formatDate, formatMoney } from "../lib/format.js";
import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
import { TAB_SURFACES, SETTINGS_TABS, defaultTabFor } from "../lib/tabs.js";
import { askConfirm } from "../lib/confirm.js";
import OrgManager from "../components/OrgManager.vue";
import AdminUsers from "../components/AdminUsers.vue";
@@ -22,16 +23,31 @@ const profile = ref(null);
// mounted (v-show) so their loaded state and in-flight edits survive a tab
// switch.
//
// `?tab=` picks the starting tab, which is what /admin redirects to.
const ALL_TABS = ["personal", "users", "organization", "integrations"];
// `?tab=` picks the starting tab, which is what /admin redirects to. Without
// one the page opens on the account's default (below, in Appearance), and
// otherwise on the first tab.
const ALL_TABS = SETTINGS_TABS;
const tabs = computed(() => ALL_TABS.filter((tab) => tab !== "users" || isAdmin.value));
const activeTab = ref(ALL_TABS.includes(route.query.tab) ? route.query.tab : "personal");
const tabPicked = ref(ALL_TABS.includes(route.query.tab)); // `?tab=` is an explicit ask
const activeTab = ref(tabPicked.value ? route.query.tab : "personal");
// The admin gate only settles once the profile is loaded, so a non-admin who
// asked for ?tab=users lands back on the personal tab rather than on nothing.
watch(tabs, (list) => {
if (!list.includes(activeTab.value)) activeTab.value = "personal";
});
// The admin gate only settles once the profile is loaded — which is also when
// the default arrives — so until then the bar is still being decided: a
// non-admin who asked for ?tab=users lands back on the personal tab rather than
// on nothing, and a default of Users does the same.
watch(
[tabs, () => prefs.defaultTabs],
([list]) => {
if (!tabPicked.value) activeTab.value = defaultTabFor("settings", list);
else if (!list.includes(activeTab.value)) activeTab.value = "personal";
},
{ immediate: true }
);
function selectTab(tab) {
tabPicked.value = true;
activeTab.value = tab;
}
// Each integration card folds open/closed, like the plugin rows in the API
// Server panel. Collapsed by default so the Integrations tab reads as a compact
@@ -160,6 +176,18 @@ async function saveAppearance(patch) {
}
}
// Which tab each tabbed page opens on. Empty means "whichever tab leads the
// bar", so somebody who has already dragged their tabs into the order they want
// never has to come here at all. Saved through saveAppearance like everything
// else on this card, as a whole map — the API stores it as one field.
function defaultTabValue(surface) {
return prefs.defaultTabs?.[surface] || "";
}
function saveDefaultTab(surface, key) {
saveAppearance({ defaultTabs: { ...prefs.defaultTabs, [surface]: key } });
}
const dateFormatExample = computed(() => formatDate(new Date().toISOString()));
const currencyExample = computed(() => formatMoney(1234.5));
@@ -986,7 +1014,7 @@ onBeforeUnmount(() => {
:class="activeTab === tab
? 'border-accent text-strong'
: 'border-transparent text-muted hover:text-body'"
@click="activeTab = tab"
@click="selectTab(tab)"
>
{{ t(`settings.tabs.${tab}`) }}
</button>
@@ -1123,6 +1151,24 @@ onBeforeUnmount(() => {
</div>
</div>
<div class="mt-5">
<label class="dh-label">{{ t("settings.appearance.defaultTab") }}</label>
<div class="grid gap-4 sm:grid-cols-3">
<div v-for="page in TAB_SURFACES" :key="page.surface">
<p class="mb-1.5 text-xs text-muted">{{ t(`settings.appearance.defaultTabPage.${page.surface}`) }}</p>
<select
:value="defaultTabValue(page.surface)"
class="dh-input"
@change="saveDefaultTab(page.surface, $event.target.value)"
>
<option value="">{{ t("settings.appearance.defaultTabFirst") }}</option>
<option v-for="key in page.keys" :key="key" :value="key">{{ t(page.labelKey(key)) }}</option>
</select>
</div>
</div>
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.defaultTabHint") }}</p>
</div>
<p v-if="appearanceError" class="mt-3 text-sm text-danger">{{ appearanceError }}</p>
</section>