Files
OpenSquawk/app/pages/bridge/connect.vue
2026-06-21 13:11:30 +02:00

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>