mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-14 09:25:42 +08:00
968 lines
30 KiB
Vue
968 lines
30 KiB
Vue
<template>
|
|
<div class="bridge-connect-page">
|
|
<div class="bridge-connect-background" aria-hidden="true">
|
|
<div class="bridge-connect-background__aurora"/>
|
|
<div class="bridge-connect-background__photo"/>
|
|
<div class="bridge-connect-background__noise"/>
|
|
</div>
|
|
|
|
<main class="relative mx-auto flex min-h-screen w-full max-w-lg flex-col items-center justify-center px-5 py-10">
|
|
<div class="w-full">
|
|
|
|
|
|
<div class="bridge-card mt-5 p-6 sm:p-8" :class="{ 'bridge-card--success': alreadyLinked }">
|
|
<!-- STATE: linked / done -->
|
|
<div v-if="alreadyLinked" class="flex flex-col items-center text-center">
|
|
<div class="bridge-check">
|
|
<span class="bridge-check__ring"/>
|
|
<span class="bridge-check__ring bridge-check__ring--delayed"/>
|
|
<span class="bridge-check__mark">✓</span>
|
|
</div>
|
|
<span class="bridge-success-pill mt-5">Connected</span>
|
|
<h1 class="mt-3 text-2xl font-semibold text-emerald-50">Bridge connected</h1>
|
|
<p class="mt-2 text-sm text-emerald-100/70">
|
|
Linked to <span class="font-semibold text-white">{{ authDisplayName }}</span>.
|
|
</p>
|
|
|
|
<!-- PTT pad -->
|
|
<div
|
|
class="bridge-ptt-pad mt-6 w-full select-none"
|
|
:class="{ 'bridge-ptt-pad--active': pttActive }"
|
|
@mousedown.prevent="startPtt"
|
|
@mouseup.prevent="stopPtt"
|
|
@mouseleave="stopPtt"
|
|
@touchstart.prevent="startPtt"
|
|
@touchend.prevent="stopPtt"
|
|
@touchcancel.prevent="stopPtt"
|
|
>
|
|
<v-icon :icon="pttActive ? 'mdi-access-point' : 'mdi-radio-handheld'" size="32" />
|
|
<p class="mt-1 text-xs uppercase tracking-[0.35em]">
|
|
{{ pttActive ? 'Transmitting …' : 'Hold to transmit' }}
|
|
</p>
|
|
<p class="mt-1 text-[10px] text-white/40">or hold <kbd class="bridge-kbd">Space</kbd></p>
|
|
</div>
|
|
<p v-if="pttError" class="mt-2 text-xs text-red-300">{{ pttError }}</p>
|
|
|
|
<button
|
|
type="button"
|
|
class="bridge-btn-primary mt-5"
|
|
@click="closeWindow"
|
|
>
|
|
Close this page
|
|
</button>
|
|
|
|
<p v-if="autoCloseFailed" class="mt-3 text-xs text-white/[0.45]">
|
|
Your browser blocked auto-close — just close the tab manually.
|
|
</p>
|
|
|
|
<button
|
|
type="button"
|
|
class="mt-5 text-xs text-white/45 transition hover:text-white/75 disabled:opacity-50"
|
|
:disabled="disconnectLoading"
|
|
@click="disconnectBridge"
|
|
>
|
|
{{ disconnectLoading ? 'Unlinking …' : 'Click here to unlink and use another code' }}
|
|
</button>
|
|
<p v-if="disconnectError" class="mt-3 text-sm text-red-300">{{ disconnectError }}</p>
|
|
</div>
|
|
|
|
<!-- STATE: no token → ask for it -->
|
|
<div v-else-if="!hasToken" class="text-center">
|
|
<h1 class="text-2xl font-semibold">Enter your pairing code</h1>
|
|
<p class="mt-2 text-sm text-white/[0.66]">
|
|
Type the 6-character code shown in your desktop Bridge app.
|
|
</p>
|
|
|
|
<form class="mt-6 space-y-4" @submit.prevent="applyPairingCode">
|
|
<input
|
|
v-model="manualTokenInput"
|
|
type="text"
|
|
inputmode="text"
|
|
autocapitalize="characters"
|
|
autocomplete="one-time-code"
|
|
spellcheck="false"
|
|
maxlength="6"
|
|
placeholder="A1B2C3"
|
|
class="w-full rounded-2xl border border-[#16BBD7]/[0.5] bg-[#050c1f] px-4 py-4 text-center font-mono text-3xl font-semibold uppercase tracking-[0.4em] text-[#9be6f2] placeholder:text-[#9be6f2]/[0.3] focus:border-[#72d9ea] focus:outline-none"
|
|
@input="onManualTokenInput"
|
|
>
|
|
<button type="submit" class="bridge-btn-primary w-full" :disabled="!manualTokenReady">
|
|
Continue
|
|
</button>
|
|
<p v-if="manualTokenError" class="text-sm text-amber-200">{{ manualTokenError }}</p>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- STATE: token present, not signed in → login -->
|
|
<div v-else-if="!hasAuthenticatedUser" class="text-center">
|
|
|
|
<h1 class="text-2xl font-semibold">Sign in to link</h1>
|
|
|
|
<p class="mt-2 text-sm text-white/[0.66]">
|
|
Please sign in to your OpenSquawk account to link the Bridge.
|
|
</p>
|
|
|
|
|
|
<NuxtLink :to="loginTarget" class="bridge-btn-primary mt-6 w-full gap-2">
|
|
Go to login
|
|
<span aria-hidden="true">→</span>
|
|
</NuxtLink>
|
|
<button
|
|
type="button"
|
|
class="mt-4 text-sm text-white/55 transition hover:text-white/85"
|
|
@click="clearPairingCode"
|
|
>
|
|
Use another code
|
|
</button>
|
|
<p v-if="tokenLinkedToOtherUser" class="mt-4 text-sm text-amber-100/90">
|
|
This code is already linked to another account.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- STATE: token + signed in, linking in progress -->
|
|
<div v-else class="flex flex-col items-center text-center">
|
|
<template v-if="tokenLinkedToOtherUser">
|
|
<h1 class="text-2xl font-semibold">Code in use</h1>
|
|
<p class="mt-2 text-sm text-white/[0.66]">
|
|
This pairing code is linked to a different account.
|
|
</p>
|
|
<button type="button" class="bridge-btn-primary mt-6" @click="clearPairingCode">
|
|
Use another code
|
|
</button>
|
|
</template>
|
|
<template v-else-if="connectError">
|
|
<h1 class="text-2xl font-semibold">Couldn't link</h1>
|
|
<p class="mt-2 text-sm text-red-300">{{ connectError }}</p>
|
|
<button type="button" class="bridge-btn-primary mt-6" :disabled="connectLoading" @click="connectBridge">
|
|
{{ connectLoading ? 'Linking …' : 'Try again' }}
|
|
</button>
|
|
</template>
|
|
<template v-else>
|
|
<span class="bridge-spinner"/>
|
|
<h1 class="mt-5 text-xl font-semibold">Linking Bridge …</h1>
|
|
<p class="mt-2 text-sm text-white/[0.6]">Connecting code <span class="font-mono text-[#9be6f2]">{{ token }}</span> to {{ authDisplayName }}.</p>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="statusError && !alreadyLinked" class="mt-3 text-center text-sm text-red-300">{{ statusError }}</p>
|
|
|
|
<!-- Collapsible server log (only once linked) -->
|
|
<section v-if="alreadyLinked" class="mt-5">
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center justify-between rounded-t-2xl border border-white/[0.12] bg-[#0a1229]/90 px-4 py-2.5 text-left transition hover:bg-[#0d1633]"
|
|
:class="{ 'rounded-b-2xl': !logPanelOpen }"
|
|
@click="logPanelOpen = !logPanelOpen"
|
|
>
|
|
<span class="flex items-center gap-2.5">
|
|
<span class="font-mono text-xs text-white/45">{{ logPanelOpen ? '▼' : '▶' }}</span>
|
|
<span class="text-sm font-medium text-white/75">Server Log</span>
|
|
<span v-if="logEntries.length" class="rounded-full bg-white/10 px-2 py-0.5 text-[10px] tabular-nums text-white/50">
|
|
{{ logEntries.length }}
|
|
</span>
|
|
</span>
|
|
<span v-if="logPolling" class="h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.6)]"/>
|
|
</button>
|
|
|
|
<div v-if="logPanelOpen" class="rounded-b-2xl border border-t-0 border-white/[0.12] bg-[#060c1e]/95">
|
|
<div v-if="latestTelemetry" class="border-b border-white/[0.08] px-4 py-3">
|
|
<p class="mb-2 text-[10px] uppercase tracking-[0.24em] text-white/40">Latest Telemetry</p>
|
|
<div class="flex flex-wrap gap-x-4 gap-y-1 font-mono text-xs">
|
|
<span class="text-white/70">IAS <span class="text-[#d946ef]">{{ latestTelemetry.AIRSPEED_INDICATED?.toFixed(0) ?? '—' }}</span>kt</span>
|
|
<span class="text-white/70">ALT <span class="text-[#d946ef]">{{ latestTelemetry.PLANE_ALTITUDE?.toFixed(0) ?? '—' }}</span>ft</span>
|
|
<span class="text-white/70">VS <span class="text-[#d946ef]">{{ latestTelemetry.VERTICAL_SPEED?.toFixed(0) ?? '—' }}</span>fpm</span>
|
|
<span class="text-white/70">GS <span class="text-[#d946ef]">{{ latestTelemetry.GROUND_VELOCITY?.toFixed(0) ?? '—' }}</span>kt</span>
|
|
<span class="text-white/70">N1 <span class="text-[#d946ef]">{{ latestTelemetry.TURB_ENG_N1_1?.toFixed(1) ?? '—' }}</span>%</span>
|
|
<span class="text-white/70">XPDR <span class="text-[#d946ef]">{{ formatSquawk(latestTelemetry.TRANSPONDER_CODE) }}</span></span>
|
|
<span class="text-white/70">GND <span :class="latestTelemetry.SIM_ON_GROUND ? 'text-emerald-400' : 'text-amber-400'">{{ latestTelemetry.SIM_ON_GROUND ? 'YES' : 'NO' }}</span></span>
|
|
<span class="text-white/70">GEAR <span :class="latestTelemetry.GEAR_HANDLE_POSITION ? 'text-emerald-400' : 'text-amber-400'">{{ latestTelemetry.GEAR_HANDLE_POSITION ? 'DN' : 'UP' }}</span></span>
|
|
<span class="text-white/70">BRK <span :class="latestTelemetry.BRAKE_PARKING_POSITION ? 'text-rose-400' : 'text-emerald-400'">{{ latestTelemetry.BRAKE_PARKING_POSITION ? 'SET' : 'OFF' }}</span></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div ref="logScrollContainer" class="max-h-[360px] overflow-y-auto">
|
|
<div v-if="!logEntries.length" class="px-5 py-8 text-center text-xs text-white/30">
|
|
No log entries yet. Waiting for bridge activity …
|
|
</div>
|
|
|
|
<div
|
|
v-for="entry in logEntries"
|
|
:key="entry.id"
|
|
class="bridge-log-entry border-b border-white/[0.05] last:border-b-0"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center gap-3 px-4 py-2.5 text-left transition hover:bg-white/[0.03]"
|
|
:class="{ 'bg-white/[0.02]': expandedLogIds.has(entry.id) }"
|
|
@click="toggleLogEntry(entry.id)"
|
|
>
|
|
<span class="h-2 w-2 shrink-0 rounded-full" :style="{ backgroundColor: entry.color }"/>
|
|
<span class="shrink-0 font-mono text-[10px] tabular-nums text-white/35">{{ formatLogTime(entry.timestamp) }}</span>
|
|
<span class="font-mono text-xs font-medium" :style="{ color: entry.color }">{{ entry.method }}</span>
|
|
<span class="truncate font-mono text-xs text-white/50">{{ entry.endpoint }}</span>
|
|
<span class="ml-auto shrink-0 text-xs text-white/40">{{ entry.summary }}</span>
|
|
<span v-if="entry.data" class="shrink-0 font-mono text-[10px] text-white/25">{{ expandedLogIds.has(entry.id) ? '▼' : '▶' }}</span>
|
|
</button>
|
|
|
|
<div v-if="entry.data && expandedLogIds.has(entry.id)" class="border-t border-white/[0.04] bg-[#040812] px-4 py-3">
|
|
<pre class="max-h-[240px] overflow-auto font-mono text-[11px] leading-relaxed text-white/55">{{ JSON.stringify(entry.data, null, 2) }}</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { storeToRefs } from 'pinia'
|
|
import { useHead, useRoute, useRouter } from '#imports'
|
|
import { useAuthStore } from '~/stores/auth'
|
|
|
|
interface BridgeStatusPayload {
|
|
token: string
|
|
connected: boolean
|
|
user: {
|
|
id: string
|
|
email: string
|
|
name?: string | null
|
|
} | null
|
|
simConnected: boolean
|
|
flightActive: boolean
|
|
connectedAt: string | null
|
|
lastStatusAt: string | null
|
|
}
|
|
|
|
useHead({ title: 'Link Bridge · OpenSquawk' })
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const auth = useAuthStore()
|
|
const { user, accessToken, isAuthenticated, initialized } = storeToRefs(auth)
|
|
|
|
const connectLoading = ref(false)
|
|
const connectError = ref('')
|
|
const connectSuccess = ref(false)
|
|
const disconnectLoading = ref(false)
|
|
const disconnectError = ref('')
|
|
|
|
const connectionStatus = ref<BridgeStatusPayload | null>(null)
|
|
const statusInitialized = ref(false)
|
|
const statusError = ref('')
|
|
const statusRequestActive = ref(false)
|
|
|
|
const manualTokenInput = ref('')
|
|
const manualTokenError = ref('')
|
|
const autoConnectAttemptKey = ref('')
|
|
const autoCloseFailed = ref(false)
|
|
|
|
// Log panel state
|
|
interface LogEntry {
|
|
id: number
|
|
timestamp: number
|
|
endpoint: string
|
|
method: string
|
|
statusCode: number
|
|
color: string
|
|
summary: string
|
|
data?: Record<string, unknown>
|
|
}
|
|
|
|
const logPanelOpen = ref(false)
|
|
const logEntries = ref<LogEntry[]>([])
|
|
const logPolling = ref(false)
|
|
const expandedLogIds = ref(new Set<number>())
|
|
const logScrollContainer = ref<HTMLElement | null>(null)
|
|
interface TelemetrySummary {
|
|
AIRSPEED_INDICATED: number
|
|
AIRSPEED_TRUE: number
|
|
GROUND_VELOCITY: number
|
|
VERTICAL_SPEED: number
|
|
PLANE_ALTITUDE: number
|
|
PLANE_PITCH_DEGREES: number
|
|
TURB_ENG_N1_1: number
|
|
TURB_ENG_N1_2: number
|
|
ENG_COMBUSTION: boolean
|
|
SIM_ON_GROUND: boolean
|
|
GEAR_HANDLE_POSITION: boolean
|
|
FLAPS_HANDLE_INDEX: number
|
|
BRAKE_PARKING_POSITION: boolean
|
|
AUTOPILOT_MASTER: boolean
|
|
TRANSPONDER_CODE: number
|
|
ADF_ACTIVE_FREQUENCY: number
|
|
ADF_STANDBY_FREQUENCY: number
|
|
}
|
|
|
|
const latestTelemetry = ref<TelemetrySummary | null>(null)
|
|
|
|
const pttActive = ref(false)
|
|
const pttError = ref('')
|
|
|
|
async function sendPtt(state: 'down' | 'up') {
|
|
if (!token.value) return
|
|
try {
|
|
await $fetch('/api/bridge/ptt', {
|
|
method: 'POST',
|
|
headers: { 'x-bridge-token': token.value },
|
|
body: { state },
|
|
})
|
|
pttError.value = ''
|
|
} catch (err: any) {
|
|
pttError.value = err?.data?.statusMessage || 'PTT failed'
|
|
}
|
|
}
|
|
|
|
function startPtt() {
|
|
if (pttActive.value) return
|
|
pttActive.value = true
|
|
void sendPtt('down')
|
|
}
|
|
|
|
function stopPtt() {
|
|
if (!pttActive.value) return
|
|
pttActive.value = false
|
|
void sendPtt('up')
|
|
}
|
|
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if (!alreadyLinked.value || pttActive.value) return
|
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as HTMLElement)?.tagName ?? '')) return
|
|
if (e.code === 'Space') {
|
|
e.preventDefault()
|
|
startPtt()
|
|
}
|
|
}
|
|
|
|
function onKeyUp(e: KeyboardEvent) {
|
|
if (!alreadyLinked.value) return
|
|
if (e.code === 'Space') {
|
|
e.preventDefault()
|
|
stopPtt()
|
|
}
|
|
}
|
|
|
|
const token = computed(() => {
|
|
const value = route.query.token
|
|
const rawToken = Array.isArray(value)
|
|
? value[0] || ''
|
|
: typeof value === 'string'
|
|
? value
|
|
: ''
|
|
return rawToken.trim()
|
|
})
|
|
|
|
const hasToken = computed(() => token.value.length >= 6)
|
|
const manualTokenReady = computed(() => manualTokenInput.value.length === 6)
|
|
const loginTarget = computed(() => `/login?redirect=${encodeURIComponent(route.fullPath || '/bridge')}`)
|
|
|
|
const authDisplayName = computed(() => {
|
|
if (!user.value) return ''
|
|
return user.value.name || user.value.email
|
|
})
|
|
|
|
const hasAuthenticatedUser = computed(() => Boolean(isAuthenticated.value && user.value))
|
|
const isLinkedToAuthenticatedUser = computed(() => {
|
|
if (!hasAuthenticatedUser.value || !connectionStatus.value?.connected) {
|
|
return false
|
|
}
|
|
return connectionStatus.value.user?.id === user.value?.id
|
|
})
|
|
const tokenLinkedToOtherUser = computed(() => {
|
|
if (!connectionStatus.value?.connected || !hasAuthenticatedUser.value) {
|
|
return false
|
|
}
|
|
return connectionStatus.value.user?.id !== user.value?.id
|
|
})
|
|
|
|
const alreadyLinked = computed(() => isLinkedToAuthenticatedUser.value)
|
|
|
|
function sanitizePairingCode(input: string) {
|
|
return input.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6)
|
|
}
|
|
|
|
function onManualTokenInput() {
|
|
manualTokenInput.value = sanitizePairingCode(manualTokenInput.value)
|
|
if (manualTokenError.value && manualTokenReady.value) {
|
|
manualTokenError.value = ''
|
|
}
|
|
}
|
|
|
|
async function applyPairingCode() {
|
|
manualTokenInput.value = sanitizePairingCode(manualTokenInput.value)
|
|
if (!manualTokenReady.value) {
|
|
manualTokenError.value = 'Please enter a valid 6-character pairing code.'
|
|
return
|
|
}
|
|
|
|
manualTokenError.value = ''
|
|
connectError.value = ''
|
|
await router.replace({
|
|
query: {
|
|
...route.query,
|
|
token: manualTokenInput.value,
|
|
},
|
|
})
|
|
}
|
|
|
|
async function clearPairingCode() {
|
|
manualTokenInput.value = ''
|
|
manualTokenError.value = ''
|
|
connectError.value = ''
|
|
disconnectError.value = ''
|
|
|
|
const query = { ...route.query }
|
|
delete query.token
|
|
|
|
await router.replace({ query })
|
|
}
|
|
|
|
async function connectBridge() {
|
|
if (!hasToken.value || !accessToken.value || !hasAuthenticatedUser.value) {
|
|
return
|
|
}
|
|
|
|
connectLoading.value = true
|
|
connectError.value = ''
|
|
disconnectError.value = ''
|
|
|
|
try {
|
|
await $fetch('/api/bridge/connect', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken.value}`,
|
|
'x-bridge-token': token.value,
|
|
},
|
|
})
|
|
|
|
connectSuccess.value = true
|
|
await fetchStatus(true)
|
|
} catch (err: any) {
|
|
connectError.value =
|
|
err?.data?.statusMessage ||
|
|
err?.response?._data?.statusMessage ||
|
|
err?.message ||
|
|
'Could not link the token.'
|
|
} finally {
|
|
connectLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function disconnectBridge() {
|
|
if (!hasToken.value || !accessToken.value || !hasAuthenticatedUser.value) {
|
|
return
|
|
}
|
|
|
|
disconnectLoading.value = true
|
|
disconnectError.value = ''
|
|
connectError.value = ''
|
|
|
|
try {
|
|
await $fetch('/api/bridge/disconnect', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken.value}`,
|
|
'x-bridge-token': token.value,
|
|
},
|
|
})
|
|
|
|
connectSuccess.value = false
|
|
autoConnectAttemptKey.value = `${token.value}:${accessToken.value}`
|
|
await fetchStatus(true)
|
|
} catch (err: any) {
|
|
disconnectError.value =
|
|
err?.data?.statusMessage ||
|
|
err?.response?._data?.statusMessage ||
|
|
err?.message ||
|
|
'Could not unlink the token.'
|
|
} finally {
|
|
disconnectLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function maybeAutoConnect() {
|
|
if (!hasToken.value || !initialized.value || !hasAuthenticatedUser.value || !accessToken.value || connectLoading.value) {
|
|
return
|
|
}
|
|
|
|
const attemptKey = `${token.value}:${accessToken.value}`
|
|
if (autoConnectAttemptKey.value === attemptKey) {
|
|
return
|
|
}
|
|
|
|
if (connectionStatus.value?.connected) {
|
|
autoConnectAttemptKey.value = attemptKey
|
|
return
|
|
}
|
|
|
|
autoConnectAttemptKey.value = attemptKey
|
|
await connectBridge()
|
|
}
|
|
|
|
async function fetchStatus(force = false) {
|
|
if (!hasToken.value) {
|
|
return
|
|
}
|
|
if (statusRequestActive.value) {
|
|
return
|
|
}
|
|
|
|
statusRequestActive.value = true
|
|
|
|
try {
|
|
const response = await $fetch<BridgeStatusPayload>('/api/bridge/me', {
|
|
headers: {
|
|
'x-bridge-token': token.value,
|
|
},
|
|
})
|
|
|
|
connectionStatus.value = response
|
|
statusError.value = ''
|
|
statusInitialized.value = true
|
|
|
|
if (response.connected && hasAuthenticatedUser.value && response.user?.id === user.value?.id) {
|
|
connectSuccess.value = true
|
|
} else {
|
|
connectSuccess.value = false
|
|
}
|
|
} catch (err: any) {
|
|
statusError.value =
|
|
err?.data?.statusMessage ||
|
|
err?.response?._data?.statusMessage ||
|
|
err?.message ||
|
|
'We could not refresh the status.'
|
|
} finally {
|
|
statusRequestActive.value = false
|
|
}
|
|
}
|
|
|
|
function closeWindow() {
|
|
try {
|
|
window.close()
|
|
} catch {
|
|
autoCloseFailed.value = true
|
|
}
|
|
// window.close() is a no-op for tabs the script didn't open — detect that.
|
|
setTimeout(() => {
|
|
if (!window.closed) {
|
|
autoCloseFailed.value = true
|
|
}
|
|
}, 600)
|
|
}
|
|
|
|
function formatLogTime(ts: number) {
|
|
const d = new Date(ts)
|
|
return d.toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
+ '.' + String(d.getMilliseconds()).padStart(3, '0')
|
|
}
|
|
|
|
function formatSquawk(code: unknown) {
|
|
if (typeof code !== 'number') return '----'
|
|
return String(code).padStart(4, '0')
|
|
}
|
|
|
|
function toggleLogEntry(id: number) {
|
|
const set = new Set(expandedLogIds.value)
|
|
if (set.has(id)) {
|
|
set.delete(id)
|
|
} else {
|
|
set.add(id)
|
|
}
|
|
expandedLogIds.value = set
|
|
}
|
|
|
|
let logPoller: ReturnType<typeof setInterval> | null = null
|
|
let lastLogId = 0
|
|
|
|
async function fetchLog() {
|
|
if (!hasToken.value) return
|
|
try {
|
|
const entries = await $fetch<LogEntry[]>('/api/bridge/log', {
|
|
headers: { 'x-bridge-token': token.value },
|
|
params: { since: lastLogId },
|
|
})
|
|
if (entries.length) {
|
|
logEntries.value.push(...entries)
|
|
const lastEntry = entries[entries.length - 1]!
|
|
lastLogId = lastEntry.id
|
|
|
|
// Extract latest telemetry from data endpoint entries
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
const entry = entries[i]!
|
|
if (entry.endpoint === '/api/bridge/data' && entry.data) {
|
|
latestTelemetry.value = entry.data as unknown as TelemetrySummary
|
|
break
|
|
}
|
|
}
|
|
|
|
// Auto-scroll to bottom
|
|
nextTick(() => {
|
|
if (logScrollContainer.value) {
|
|
logScrollContainer.value.scrollTop = logScrollContainer.value.scrollHeight
|
|
}
|
|
})
|
|
}
|
|
} catch {
|
|
// Ignore log fetch errors
|
|
}
|
|
}
|
|
|
|
function startLogPolling() {
|
|
stopLogPolling()
|
|
if (!hasToken.value) return
|
|
logPolling.value = true
|
|
fetchLog()
|
|
logPoller = setInterval(fetchLog, 2000)
|
|
}
|
|
|
|
function stopLogPolling() {
|
|
logPolling.value = false
|
|
if (logPoller) {
|
|
clearInterval(logPoller)
|
|
logPoller = null
|
|
}
|
|
}
|
|
|
|
let poller: ReturnType<typeof setInterval> | null = null
|
|
|
|
function stopPolling() {
|
|
if (poller) {
|
|
clearInterval(poller)
|
|
poller = null
|
|
}
|
|
}
|
|
|
|
function startPolling() {
|
|
stopPolling()
|
|
|
|
if (!hasToken.value) {
|
|
return
|
|
}
|
|
|
|
fetchStatus(!statusInitialized.value)
|
|
poller = setInterval(() => {
|
|
fetchStatus()
|
|
}, 5000)
|
|
}
|
|
|
|
let autoCloseTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
watch(token, () => {
|
|
connectError.value = ''
|
|
disconnectError.value = ''
|
|
connectSuccess.value = false
|
|
autoConnectAttemptKey.value = ''
|
|
connectionStatus.value = null
|
|
statusInitialized.value = false
|
|
statusError.value = ''
|
|
autoCloseFailed.value = false
|
|
|
|
// Reset log state
|
|
logEntries.value = []
|
|
lastLogId = 0
|
|
expandedLogIds.value = new Set()
|
|
latestTelemetry.value = null
|
|
if (logPanelOpen.value) {
|
|
startLogPolling()
|
|
}
|
|
|
|
startPolling()
|
|
})
|
|
|
|
watch(
|
|
() => hasAuthenticatedUser.value,
|
|
(value) => {
|
|
if (value && hasToken.value) {
|
|
fetchStatus(true)
|
|
return
|
|
}
|
|
connectSuccess.value = false
|
|
},
|
|
)
|
|
|
|
watch(
|
|
[hasToken, initialized, hasAuthenticatedUser, accessToken, () => connectionStatus.value?.connected ?? false],
|
|
() => {
|
|
void maybeAutoConnect()
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
// Once linked, try to close the tab automatically and stop polling.
|
|
watch(alreadyLinked, (linked) => {
|
|
if (!linked) return
|
|
stopPolling()
|
|
if (autoCloseTimer) clearTimeout(autoCloseTimer)
|
|
autoCloseTimer = setTimeout(() => {
|
|
closeWindow()
|
|
}, 2000)
|
|
})
|
|
|
|
watch(logPanelOpen, (open) => {
|
|
if (open) {
|
|
startLogPolling()
|
|
} else {
|
|
stopLogPolling()
|
|
}
|
|
})
|
|
|
|
onMounted(() => {
|
|
if (!initialized.value) {
|
|
auth.fetchUser().catch(() => {})
|
|
}
|
|
startPolling()
|
|
window.addEventListener('keydown', onKeyDown)
|
|
window.addEventListener('keyup', onKeyUp)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
stopPolling()
|
|
stopLogPolling()
|
|
if (pttActive.value) void sendPtt('up')
|
|
window.removeEventListener('keydown', onKeyDown)
|
|
window.removeEventListener('keyup', onKeyUp)
|
|
if (autoCloseTimer) {
|
|
clearTimeout(autoCloseTimer)
|
|
autoCloseTimer = null
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.bridge-ptt-pad {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 1.25rem 1rem;
|
|
border-radius: 1.25rem;
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
background: rgba(0, 0, 0, 0.3);
|
|
color: rgba(255, 255, 255, 0.6);
|
|
cursor: pointer;
|
|
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
|
user-select: none;
|
|
touch-action: none;
|
|
}
|
|
.bridge-ptt-pad:hover {
|
|
border-color: rgba(22, 187, 215, 0.4);
|
|
background: rgba(22, 187, 215, 0.06);
|
|
color: #84e8f6;
|
|
}
|
|
.bridge-ptt-pad--active {
|
|
border-color: rgba(248, 113, 113, 0.5);
|
|
background: rgba(239, 68, 68, 0.1);
|
|
color: #fca5a5;
|
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
|
|
}
|
|
.bridge-kbd {
|
|
display: inline-block;
|
|
padding: 0.05rem 0.35rem;
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
border-radius: 0.25rem;
|
|
font-family: monospace;
|
|
font-size: 0.7rem;
|
|
background: rgba(255, 255, 255, 0.06);
|
|
}
|
|
|
|
.bridge-connect-page {
|
|
position: relative;
|
|
min-height: 100vh;
|
|
overflow: hidden;
|
|
color: #fff;
|
|
background:
|
|
radial-gradient(120% 70% at 18% -10%, rgba(22, 187, 215, 0.16), transparent 60%),
|
|
radial-gradient(110% 65% at 84% -12%, rgba(70, 98, 255, 0.18), transparent 64%),
|
|
linear-gradient(180deg, #070d1d 0%, #070b1a 50%, #050816 100%);
|
|
}
|
|
|
|
.bridge-connect-background {
|
|
pointer-events: none;
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 0;
|
|
}
|
|
|
|
.bridge-connect-background__aurora {
|
|
position: absolute;
|
|
inset: 0;
|
|
background:
|
|
radial-gradient(circle at 28% 14%, rgba(113, 242, 255, 0.12), transparent 48%),
|
|
radial-gradient(circle at 75% 9%, rgba(22, 187, 215, 0.12), transparent 44%),
|
|
radial-gradient(circle at 52% 86%, rgba(6, 182, 212, 0.1), transparent 56%);
|
|
}
|
|
|
|
.bridge-connect-background__photo {
|
|
position: absolute;
|
|
inset: -8%;
|
|
background-image: url('/img/bridge/goldengate_angle.jpeg');
|
|
background-position: center 30%;
|
|
background-size: cover;
|
|
opacity: 0.62;
|
|
filter: saturate(1.1) contrast(1.04) brightness(0.9);
|
|
transform: scale(1.06);
|
|
-webkit-mask-image: radial-gradient(circle at 50% 32%, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.96) 40%, rgba(0, 0, 0, 0.6) 70%, transparent 92%);
|
|
mask-image: radial-gradient(circle at 50% 32%, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.96) 40%, rgba(0, 0, 0, 0.6) 70%, transparent 92%);
|
|
}
|
|
|
|
.bridge-connect-background__noise {
|
|
position: absolute;
|
|
inset: 0;
|
|
background:
|
|
radial-gradient(circle at 50% 50%, rgba(6, 11, 24, 0.04), rgba(6, 11, 24, 0.5));
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.bridge-connect-background__photo {
|
|
opacity: 0.48;
|
|
-webkit-mask-image: radial-gradient(circle at 50% 18%, rgba(0, 0, 0, 0.98) 0%, rgba(0, 0, 0, 0.78) 30%, rgba(0, 0, 0, 0.25) 62%, transparent 88%);
|
|
mask-image: radial-gradient(circle at 50% 18%, rgba(0, 0, 0, 0.98) 0%, rgba(0, 0, 0, 0.78) 30%, rgba(0, 0, 0, 0.25) 62%, transparent 88%);
|
|
}
|
|
}
|
|
|
|
.bridge-card {
|
|
border-radius: 1.6rem;
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
background: linear-gradient(150deg, rgba(13, 23, 46, 0.82), rgba(7, 14, 32, 0.85));
|
|
box-shadow:
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
|
0 22px 60px rgba(2, 7, 22, 0.5);
|
|
backdrop-filter: blur(14px);
|
|
-webkit-backdrop-filter: blur(14px);
|
|
}
|
|
|
|
.bridge-btn-primary {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 1rem;
|
|
background: #16BBD7;
|
|
padding: 0.8rem 1.5rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: #081226;
|
|
transition: background 160ms ease;
|
|
}
|
|
|
|
.bridge-btn-primary:hover {
|
|
background: #13aac5;
|
|
}
|
|
|
|
.bridge-btn-primary:disabled {
|
|
cursor: not-allowed;
|
|
background: rgba(22, 187, 215, 0.45);
|
|
}
|
|
|
|
.bridge-card--success {
|
|
border-color: rgba(52, 211, 153, 0.45);
|
|
background: linear-gradient(150deg, rgba(6, 78, 59, 0.5), rgba(7, 14, 32, 0.88));
|
|
box-shadow:
|
|
inset 0 1px 0 rgba(167, 243, 208, 0.18),
|
|
0 0 0 1px rgba(52, 211, 153, 0.12),
|
|
0 22px 70px rgba(5, 60, 40, 0.45);
|
|
animation: bridge-success-card 600ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
}
|
|
|
|
.bridge-check {
|
|
position: relative;
|
|
display: grid;
|
|
place-items: center;
|
|
width: 4.5rem;
|
|
height: 4.5rem;
|
|
border-radius: 999px;
|
|
border: 2px solid rgba(74, 222, 128, 0.7);
|
|
background: radial-gradient(circle at 30% 30%, rgba(167, 243, 208, 0.9), rgba(16, 185, 129, 0.7) 62%, rgba(5, 150, 105, 0.82));
|
|
box-shadow: 0 0 44px rgba(16, 185, 129, 0.6);
|
|
animation: bridge-pop 520ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
}
|
|
|
|
.bridge-check__mark {
|
|
position: relative;
|
|
z-index: 2;
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
color: rgba(4, 32, 18, 0.85);
|
|
}
|
|
|
|
.bridge-check__ring {
|
|
position: absolute;
|
|
inset: -2px;
|
|
border-radius: 999px;
|
|
border: 2px solid rgba(74, 222, 128, 0.55);
|
|
opacity: 0;
|
|
animation: bridge-ring 1900ms ease-out infinite;
|
|
}
|
|
|
|
.bridge-check__ring--delayed {
|
|
animation-delay: 950ms;
|
|
}
|
|
|
|
.bridge-success-pill {
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(110, 231, 183, 0.4);
|
|
background: rgba(16, 185, 129, 0.18);
|
|
padding: 0.25rem 0.85rem;
|
|
font-size: 0.66rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.24em;
|
|
text-transform: uppercase;
|
|
color: rgba(167, 243, 208, 0.95);
|
|
}
|
|
|
|
@keyframes bridge-ring {
|
|
0% { opacity: 0.6; transform: scale(1); }
|
|
100% { opacity: 0; transform: scale(1.9); }
|
|
}
|
|
|
|
@keyframes bridge-success-card {
|
|
from { transform: translateY(8px) scale(0.99); }
|
|
to { transform: translateY(0) scale(1); }
|
|
}
|
|
|
|
.bridge-spinner {
|
|
width: 2.75rem;
|
|
height: 2.75rem;
|
|
border-radius: 999px;
|
|
border: 3px solid rgba(22, 187, 215, 0.25);
|
|
border-top-color: #16BBD7;
|
|
animation: bridge-spin 700ms linear infinite;
|
|
}
|
|
|
|
@keyframes bridge-spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
@keyframes bridge-pop {
|
|
0% { transform: scale(0.5); opacity: 0; }
|
|
60% { transform: scale(1.1); opacity: 1; }
|
|
100% { transform: scale(1); opacity: 1; }
|
|
}
|
|
|
|
.bridge-log-entry pre {
|
|
scrollbar-width: thin;
|
|
scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
|
|
}
|
|
|
|
.bridge-log-entry pre::-webkit-scrollbar {
|
|
width: 4px;
|
|
height: 4px;
|
|
}
|
|
|
|
.bridge-log-entry pre::-webkit-scrollbar-thumb {
|
|
background: rgba(255, 255, 255, 0.12);
|
|
border-radius: 999px;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.bridge-card--success,
|
|
.bridge-check,
|
|
.bridge-check__ring,
|
|
.bridge-spinner {
|
|
animation: none !important;
|
|
}
|
|
}
|
|
</style>
|