Two locations means two full stacks, and until now the app could only ever see the one that served it. The login screen's "Server settings" could point it elsewhere, but as a global swap: it replaced the server rather than adding one, and forgot the session you already had. There is now a server picker at the bottom of the app rail, after signing in rather than on the login screen. It names the server you are reading — which matters most when both sites look identical — and switches in a click. Each server is its own PocketBase with its own users, so nothing is federated: you sign into each one once and a token is kept per server. The home server is the one that served the app, reached same-origin through the BFF proxy; any other is added by address and called straight from the browser, which asks nothing new of the network — an API Server the phone app can reach is internet-facing already. Two things the switch turned out to need: Views load on mount, so swapping the session alone left B's user looking at A's cars. The RouterView is keyed on the active server and a switch returns to the garage, since record ids belong to the server that issued them. A request now carries the id of the server it went out to, and a 401 clears that session rather than whatever is active when the answer lands. A page fires half a dozen calls at once: the first rejection used to switch away and the rest then cleared the session of the server it had switched to, turning one stale remote token into a full sign-out. A remote session expiring falls back to the home server with the entry left in place to sign into again. Only Log out clears them all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
134 lines
5.5 KiB
Vue
134 lines
5.5 KiB
Vue
<script setup>
|
|
// The server picker in the app rail. It names the server you are reading right
|
|
// now — which matters most when two sites hold different cars and the pages
|
|
// otherwise look identical — and switches between them in one click once each
|
|
// has been signed into.
|
|
import { ref, computed } from "vue";
|
|
import { servers, activeServer, displayName, isConnected, setActive } from "../servers";
|
|
import { t } from "../i18n";
|
|
import ServerConnectModal from "./ServerConnectModal.vue";
|
|
|
|
const open = ref(false);
|
|
// The server the dialog is working on: an existing entry to edit/connect, or
|
|
// null with `adding` set for a new one.
|
|
const editing = ref(null);
|
|
const adding = ref(false);
|
|
|
|
const current = computed(() => activeServer.value);
|
|
const showModal = computed(() => adding.value || !!editing.value);
|
|
|
|
// Only worth showing the rail entry once there is a choice to make, or a second
|
|
// server has been added and is waiting to be signed into.
|
|
const hasChoice = computed(() => servers.list.length > 1);
|
|
|
|
function choose(server) {
|
|
if (isConnected(server.id)) {
|
|
setActive(server.id);
|
|
open.value = false;
|
|
return;
|
|
}
|
|
// No session for it yet — the same dialog that adds a server signs you in.
|
|
editing.value = server;
|
|
open.value = false;
|
|
}
|
|
|
|
function edit(server) {
|
|
editing.value = server;
|
|
open.value = false;
|
|
}
|
|
|
|
function add() {
|
|
adding.value = true;
|
|
open.value = false;
|
|
}
|
|
|
|
function closeModal() {
|
|
editing.value = null;
|
|
adding.value = false;
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="relative">
|
|
<!-- One rail entry either way: it names the active server once there is a
|
|
choice, and offers to add the second one when there isn't. -->
|
|
<button
|
|
class="flex w-full items-center gap-3 rounded-control px-2.5 py-2 text-left text-[15px] font-medium text-white/60 transition-colors hover:bg-white/5 hover:text-white md:px-3"
|
|
:title="hasChoice ? t('servers.switchHint') : t('servers.add')"
|
|
@click="hasChoice ? (open = !open) : add()"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0">
|
|
<rect x="3" y="4" width="18" height="7" rx="1.5" />
|
|
<rect x="3" y="13" width="18" height="7" rx="1.5" />
|
|
<path stroke-linecap="round" d="M7 7.5h.01M7 16.5h.01" />
|
|
</svg>
|
|
<span class="hidden min-w-0 flex-1 truncate md:inline">
|
|
{{ hasChoice ? displayName(current) : t("servers.add") }}
|
|
</span>
|
|
<svg v-if="hasChoice" viewBox="0 0 20 20" fill="currentColor" class="hidden h-4 w-4 shrink-0 md:block">
|
|
<path fill-rule="evenodd" d="M10 3a.75.75 0 0 1 .55.24l3.25 3.5a.75.75 0 1 1-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 0 1-1.1-1.02l3.25-3.5A.75.75 0 0 1 10 3Zm-3.8 9.24a.75.75 0 0 1 1.06-.04l2.74 2.908 2.7-2.908a.75.75 0 1 1 1.1 1.02l-3.25 3.5a.75.75 0 0 1-1.1 0l-3.25-3.5a.75.75 0 0 1 .04-1.06Z" clip-rule="evenodd" />
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Click-away backdrop, behind the menu but over everything else. -->
|
|
<div v-if="open" class="fixed inset-0 z-20" @click="open = false"></div>
|
|
|
|
<div v-if="open" class="dh-card absolute bottom-full left-0 z-30 mb-2 w-64 overflow-hidden p-1.5 shadow-pop">
|
|
<p class="eyebrow px-2 pb-1 pt-1.5">{{ t("servers.title") }}</p>
|
|
|
|
<div
|
|
v-for="s in servers.list"
|
|
:key="s.id"
|
|
role="button"
|
|
tabindex="0"
|
|
class="flex w-full cursor-pointer items-center gap-2 rounded-control px-2 py-2 text-left transition-colors hover:bg-sunken"
|
|
@click="choose(s)"
|
|
@keydown.enter="choose(s)"
|
|
>
|
|
<!-- Green once this server has a session of its own; grey means picking
|
|
it will ask for credentials rather than switch. -->
|
|
<span
|
|
class="h-2 w-2 flex-none rounded-full"
|
|
:class="isConnected(s.id) ? 'bg-success' : 'bg-muted/40'"
|
|
:title="isConnected(s.id) ? t('servers.connected') : t('servers.notConnected')"
|
|
></span>
|
|
<span class="min-w-0 flex-1">
|
|
<span class="block truncate text-sm font-medium text-strong">{{ displayName(s) }}</span>
|
|
<span class="data block truncate text-[11px] text-muted">{{ s.url || t("servers.sameOrigin") }}</span>
|
|
</span>
|
|
<svg
|
|
v-if="s.id === servers.activeId"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
class="h-4 w-4 flex-none text-brand-600"
|
|
>
|
|
<path fill-rule="evenodd" d="M16.7 5.3a1 1 0 0 1 0 1.4l-7.5 7.5a1 1 0 0 1-1.4 0l-3.5-3.5a1 1 0 1 1 1.4-1.4l2.8 2.8 6.8-6.8a1 1 0 0 1 1.4 0Z" clip-rule="evenodd" />
|
|
</svg>
|
|
<button
|
|
type="button"
|
|
class="flex-none rounded p-1 text-muted transition-colors hover:bg-page hover:text-strong"
|
|
:title="t('common.edit')"
|
|
@click.stop="edit(s)"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-3.5 w-3.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m16.5 3.5 4 4L8 20H4v-4z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
class="mt-1 flex w-full items-center gap-2 border-t border-subtle px-2 pb-1 pt-2.5 text-left text-sm font-medium text-muted transition-colors hover:text-strong"
|
|
@click="add"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4">
|
|
<path stroke-linecap="round" d="M12 5v14M5 12h14" />
|
|
</svg>
|
|
{{ t("servers.add") }}
|
|
</button>
|
|
</div>
|
|
|
|
<ServerConnectModal v-if="showModal" :server="editing" @done="closeModal" @close="closeModal" />
|
|
</div>
|
|
</template>
|