diff --git a/API Server/internal/api/defaulttabs_test.go b/API Server/internal/api/defaulttabs_test.go new file mode 100644 index 0000000..c41ce37 --- /dev/null +++ b/API Server/internal/api/defaulttabs_test.go @@ -0,0 +1,53 @@ +package api + +import "testing" + +// Which tab a page opens on is stored per user as a page->tab map, and every +// tabbed page in the web app is in it. The map is small, but it is the one +// preference whose keys have to line up with three different bars, so the +// validation is worth pinning down: an unknown page or an unknown tab is a stale +// or wrong client, and an empty value is how a client says "no default, open on +// whichever tab leads the bar". + +func TestNormalizeDefaultTabs(t *testing.T) { + got, err := normalizeDefaultTabs(map[string]string{ + "charging": " home ", + "car": "reminders", + "settings": "", + }) + if err != nil { + t.Fatalf("normalizeDefaultTabs: %v", err) + } + if got["charging"] != "home" { + t.Errorf("charging = %q, want the trimmed \"home\"", got["charging"]) + } + if got["car"] != "reminders" { + t.Errorf("car = %q, want \"reminders\"", got["car"]) + } + // Cleared rather than stored empty, so "no default" has one representation. + if _, ok := got["settings"]; ok { + t.Errorf("settings = %q, want the key dropped", got["settings"]) + } + + if empty, err := normalizeDefaultTabs(nil); err != nil || len(empty) != 0 { + t.Errorf("normalizeDefaultTabs(nil) = %v, %v; want empty and no error", empty, err) + } + + if _, err := normalizeDefaultTabs(map[string]string{"garage": "info"}); err == nil { + t.Error("normalizeDefaultTabs accepted a page that has no tabs, want an error") + } + // The tab exists, but on another page: the sets are validated per page. + if _, err := normalizeDefaultTabs(map[string]string{"charging": "reminders"}); err == nil { + t.Error("normalizeDefaultTabs accepted a tab from another page, want an error") + } + + // The tab sets are the contract the web app's src/lib/tabs.js mirrors. + for page, want := range map[string]int{"charging": 2, "car": 10, "settings": 4} { + if len(validDefaultTabs[page]) != want { + t.Errorf("%s has %d tabs, want %d", page, len(validDefaultTabs[page]), want) + } + } + if len(validDefaultTabs) != 3 { + t.Errorf("validDefaultTabs has %d pages, want the 3 tabbed pages", len(validDefaultTabs)) + } +} diff --git a/API Server/internal/api/me.go b/API Server/internal/api/me.go index 6be5291..d313aed 100644 --- a/API Server/internal/api/me.go +++ b/API Server/internal/api/me.go @@ -45,6 +45,9 @@ type userRecord struct { // reason. ChargerTabOrder json.RawMessage `json:"charger_tab_order"` ChargerCardOrder json.RawMessage `json:"charger_card_order"` + // Which tab each tabbed page opens on. Raw for the same reason as the + // arrangements: never-set records come back as null or "". + DefaultTabs json.RawMessage `json:"default_tabs"` } // carOrder decodes the stored garage arrangement, treating anything unexpected @@ -55,6 +58,8 @@ func (rec userRecord) chargerTabOrder() []string { return decodeStringList(rec.C func (rec userRecord) chargerCardOrder() []string { return decodeStringList(rec.ChargerCardOrder) } +func (rec userRecord) defaultTabs() map[string]string { return decodeStringMap(rec.DefaultTabs) } + // 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 { @@ -68,6 +73,19 @@ func decodeStringList(raw json.RawMessage) []string { return out } +// decodeStringMap is decodeStringList for a json field holding a flat +// string->string map, and is just as forgiving about what it finds. +func decodeStringMap(raw json.RawMessage) map[string]string { + if len(raw) == 0 { + return nil + } + var out map[string]string + if err := json.Unmarshal(raw, &out); err != nil { + return nil + } + return out +} + func (rec userRecord) toModel() models.User { u := models.User{ ID: rec.ID, @@ -89,6 +107,7 @@ func (rec userRecord) toModel() models.User { CarOrder: rec.carOrder(), ChargerTabOrder: rec.chargerTabOrder(), ChargerCardOrder: rec.chargerCardOrder(), + DefaultTabs: rec.defaultTabs(), } if t := parsePBDate(rec.DeletionRequestedAt); !t.IsZero() { u.DeletionRequestedAt = &t @@ -148,6 +167,10 @@ type updateMeRequest struct { CarOrder *[]string `json:"carOrder"` ChargerTabOrder *[]string `json:"chargerTabOrder"` ChargerCardOrder *[]string `json:"chargerCardOrder"` + + // The whole map every time — the clients send it back with one page changed, + // so a partial merge here would only make "no default" impossible to express. + DefaultTabs *map[string]string `json:"defaultTabs"` } // maxCarOrder bounds the stored arrangement. listCars fetches at most 200 owned @@ -177,6 +200,43 @@ func normalizeChargerCardOrder(in []string) ([]string, error) { return normalizeOrder("chargerCardOrder", in, maxChargerTabOrder) } +// The tabbed pages a default tab can be set for, and the tabs each one has. +// Kept in step with the web app's src/lib/tabs.js. A key the page does not have +// would simply never match a button and the page would open on the front of its +// bar anyway — this is here to keep junk off the record, not to protect the +// clients from themselves. +var validDefaultTabs = map[string]map[string]bool{ + "charging": {"public": true, "home": true}, + "car": { + "provider": true, "info": true, "services": true, "technical": true, + "maintenance": true, "fuel": true, "charging": true, "documents": true, + "parts": true, "reminders": true, + }, + "settings": {"personal": true, "users": true, "organization": true, "integrations": true}, +} + +// normalizeDefaultTabs cleans a client-supplied default-tab map. An empty value +// is how a client says "no default, open on the front of the bar", and is stored +// as an absent key rather than an empty string so the two cannot drift apart. +func normalizeDefaultTabs(in map[string]string) (map[string]string, error) { + out := make(map[string]string, len(in)) + for page, tab := range in { + tabs, ok := validDefaultTabs[page] + if !ok { + return nil, fmt.Errorf("defaultTabs: %q is not a tabbed page", page) + } + tab = strings.TrimSpace(tab) + if tab == "" { + continue + } + if !tabs[tab] { + return nil, fmt.Errorf("defaultTabs: %q is not a tab on the %s page", tab, page) + } + out[page] = tab + } + return out, nil +} + 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) @@ -301,6 +361,14 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) { } payload["charger_card_order"] = keys } + if in.DefaultTabs != nil { + tabs, err := normalizeDefaultTabs(*in.DefaultTabs) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + payload["default_tabs"] = tabs + } var rec userRecord if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, &rec); err != nil { diff --git a/API Server/internal/models/models.go b/API Server/internal/models/models.go index f8a09b8..4d34468 100644 --- a/API Server/internal/models/models.go +++ b/API Server/internal/models/models.go @@ -499,6 +499,12 @@ type User struct { ChargerTabOrder []string `json:"chargerTabOrder"` ChargerCardOrder []string `json:"chargerCardOrder"` + // DefaultTabs is which tab each tabbed page opens on, keyed by page: + // {"charging": "home", "car": "info", "settings": "personal"}. A page absent + // from the map opens on whichever tab leads its bar — the arrangement above — + // so this only carries the cases where landing tab and reading order differ. + DefaultTabs map[string]string `json:"defaultTabs"` + // Non-empty while an account-deletion request is pending its cooldown. DeletionRequestedAt *time.Time `json:"deletionRequestedAt,omitempty"` diff --git a/API Server/scripts/setup-pocketbase.mjs b/API Server/scripts/setup-pocketbase.mjs index 3f396ea..3018f29 100644 --- a/API Server/scripts/setup-pocketbase.mjs +++ b/API Server/scripts/setup-pocketbase.mjs @@ -519,6 +519,12 @@ const DESIRED = { // Per user rather than per car, so it also covers cars shared with them and // never reorders somebody else's garage. See internal/api/cars.go. F.json("car_order", 20000), + // The charging page's tab and card arrangements, and which tab each tabbed + // page opens on ({"charging":"home",…}). Small lists and a three-entry map, + // so none of them need car_order's room. + F.json("charger_tab_order", 2000), + F.json("charger_card_order", 2000), + F.json("default_tabs", 2000), ], }; diff --git a/Web App/README.md b/Web App/README.md index 57e8810..b09389d 100644 --- a/Web App/README.md +++ b/Web App/README.md @@ -119,8 +119,17 @@ Config (`server/.env`, copy from `.env.example`): drop. A property of the car like the choice of which tabs show, so everyone it is shared with sees the same bar, and it needs write access. It covers the hidden tabs too, so switching one back on returns it to where it was, and - Information is arrangeable even though it can't be switched off. Where the - page opens is unchanged: Information, wherever it now sits in the bar. + Information is arrangeable even though it can't be switched off. The page then + opens on whichever tab now leads the bar — see below. +- **Which tab a page opens on** — a bar the user dragged says what they want to + see first, so every tabbed page (Charging, a car, Settings) opens on the tab + that now leads it rather than on a fixed one. Settings › Appearance overrides + that per page for the case where the reading order and the landing tab are two + different wishes; "First in the bar" is the default and means no override. + Stored as `defaultTabs` on the profile ({"charging": "home", …}), so it + follows the user to the next device, and a saved tab that no longer has a + button — one switched off for that car, a Users tab on a non-admin — falls + back to the front of the bar. `/settings?tab=` still wins over both. - **Arranging the Information rows** — the rows on a car's Information tab drag into any order, saved on drop. Also a property of the car, and it covers the hidden rows too, so switching one back on returns it to where it was. Same diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 3182cd3..21ad5cc 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -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" + } }, diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index ee96fe7..9a618ff 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -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" + } }, diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 2b34a4d..70f3b9d 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -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ń" + } }, diff --git a/Web App/web/src/lib/tabs.js b/Web App/web/src/lib/tabs.js new file mode 100644 index 0000000..4e5db93 --- /dev/null +++ b/Web App/web/src/lib/tabs.js @@ -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] || ""; +} diff --git a/Web App/web/src/prefs.js b/Web App/web/src/prefs.js index 5b388f8..22bf4a3 100644 --- a/Web App/web/src/prefs.js +++ b/Web App/web/src/prefs.js @@ -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(); } diff --git a/Web App/web/src/views/CarDetail.vue b/Web App/web/src/views/CarDetail.vue index a40916f..1d74b8e 100644 --- a/Web App/web/src/views/CarDetail.vue +++ b/Web App/web/src/views/CarDetail.vue @@ -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 diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index 1c44861..56f3170 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -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 diff --git a/Web App/web/src/views/Settings.vue b/Web App/web/src/views/Settings.vue index 0f40308..9d37793 100644 --- a/Web App/web/src/views/Settings.vue +++ b/Web App/web/src/views/Settings.vue @@ -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}`) }} @@ -1123,6 +1151,24 @@ onBeforeUnmount(() => { +
{{ t(`settings.appearance.defaultTabPage.${page.surface}`) }}
+ +{{ t("settings.appearance.defaultTabHint") }}
+{{ appearanceError }}