Sign in first to enable bridge linking.
Bridge linked
You can close this page and head back to the Bridge app.
@@ -316,6 +356,8 @@ 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
(null)
const statusInitialized = ref(false)
@@ -331,6 +373,9 @@ const liveTelemetryRequestActive = ref(false)
const copiedToken = ref(false)
const manualTokenInput = ref('')
const manualTokenError = ref('')
+const successBlastVisible = ref(false)
+const successBlastKey = ref(0)
+const autoConnectAttemptKey = ref('')
const token = computed(() => {
const value = route.query.token
@@ -379,11 +424,27 @@ const liveTelemetryJson = computed(() => {
})
const successBannerVisible = computed(() => connectSuccess.value || Boolean(connectionStatus.value?.connected))
+const alreadyLinked = computed(() => Boolean(connectionStatus.value?.connected))
+const successSparks = Array.from({ length: 14 }, (_unused, index) => index)
function sanitizePairingCode(input: string) {
return input.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6)
}
+let successBlastTimer: ReturnType | null = null
+
+function triggerSuccessBlast() {
+ successBlastKey.value += 1
+ successBlastVisible.value = true
+ if (successBlastTimer) {
+ clearTimeout(successBlastTimer)
+ }
+ successBlastTimer = setTimeout(() => {
+ successBlastVisible.value = false
+ successBlastTimer = null
+ }, 2200)
+}
+
function onManualTokenInput() {
manualTokenInput.value = sanitizePairingCode(manualTokenInput.value)
if (manualTokenError.value && manualTokenReady.value) {
@@ -429,6 +490,7 @@ async function connectBridge() {
connectLoading.value = true
connectError.value = ''
+ disconnectError.value = ''
try {
await $fetch('/api/bridge/connect', {
method: 'POST',
@@ -450,6 +512,53 @@ async function connectBridge() {
}
}
+async function disconnectBridge() {
+ if (!hasToken.value || !accessToken.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 || !isAuthenticated.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
@@ -557,13 +666,20 @@ function startPolling() {
watch(token, () => {
connectError.value = ''
+ disconnectError.value = ''
connectSuccess.value = false
+ autoConnectAttemptKey.value = ''
connectionStatus.value = null
statusInitialized.value = false
statusError.value = ''
liveTelemetry.value = null
liveTelemetryError.value = ''
liveTelemetryOpen.value = false
+ if (successBlastTimer) {
+ clearTimeout(successBlastTimer)
+ successBlastTimer = null
+ }
+ successBlastVisible.value = false
startPolling()
})
@@ -576,6 +692,23 @@ watch(
},
)
+watch(
+ [hasToken, initialized, isAuthenticated, accessToken, () => connectionStatus.value?.connected ?? false],
+ () => {
+ void maybeAutoConnect()
+ },
+ { immediate: true },
+)
+
+watch(
+ () => successBannerVisible.value,
+ (visible, wasVisible) => {
+ if (visible && !wasVisible) {
+ triggerSuccessBlast()
+ }
+ },
+)
+
onMounted(() => {
if (!initialized.value) {
auth.fetchUser().catch(() => {})
@@ -585,6 +718,10 @@ onMounted(() => {
onBeforeUnmount(() => {
stopPolling()
+ if (successBlastTimer) {
+ clearTimeout(successBlastTimer)
+ successBlastTimer = null
+ }
})
@@ -592,4 +729,203 @@ onBeforeUnmount(() => {
code {
word-break: break-all;
}
+
+.bridge-success-banner {
+ position: relative;
+ overflow: hidden;
+ animation: bridge-success-card-in 520ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+.bridge-success-banner::before {
+ content: '';
+ position: absolute;
+ inset: -1px;
+ background: linear-gradient(112deg, transparent 12%, rgba(167, 243, 208, 0.82) 48%, transparent 82%);
+ transform: translateX(-120%);
+ animation: bridge-success-sheen 1200ms ease-out forwards;
+ pointer-events: none;
+}
+
+.bridge-success-overlay {
+ pointer-events: none;
+ position: fixed;
+ inset: 0;
+ z-index: 40;
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+}
+
+.bridge-success-blast {
+ position: relative;
+ display: grid;
+ place-items: center;
+ width: min(74vmin, 440px);
+ aspect-ratio: 1;
+ animation: bridge-success-fade 2200ms ease-out forwards;
+}
+
+.bridge-success-core {
+ position: relative;
+ z-index: 3;
+ display: grid;
+ place-items: center;
+ width: 6.25rem;
+ height: 6.25rem;
+ border-radius: 999px;
+ border: 2px solid rgba(74, 222, 128, 0.78);
+ background: radial-gradient(circle at 30% 30%, rgba(167, 243, 208, 0.92), rgba(16, 185, 129, 0.72) 62%, rgba(5, 150, 105, 0.84));
+ box-shadow:
+ 0 0 60px rgba(16, 185, 129, 0.85),
+ inset 0 0 28px rgba(220, 252, 231, 0.55);
+ animation: bridge-success-core 700ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.bridge-success-check {
+ font-size: 2.5rem;
+ font-weight: 700;
+ color: rgba(4, 32, 18, 0.8);
+ text-shadow: 0 2px 8px rgba(220, 252, 231, 0.45);
+}
+
+.bridge-success-label {
+ position: absolute;
+ bottom: 14%;
+ z-index: 3;
+ margin: 0;
+ border-radius: 999px;
+ border: 1px solid rgba(167, 243, 208, 0.52);
+ background: rgba(6, 95, 70, 0.72);
+ padding: 0.55rem 1rem;
+ font-size: 0.76rem;
+ letter-spacing: 0.24em;
+ text-transform: uppercase;
+ color: rgba(236, 253, 245, 0.96);
+ animation: bridge-success-label 820ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.bridge-success-ring {
+ position: absolute;
+ inset: auto;
+ border-radius: 999px;
+ border: 2px solid rgba(110, 231, 183, 0.62);
+ opacity: 0;
+}
+
+.bridge-success-ring--one {
+ width: 38%;
+ aspect-ratio: 1;
+ animation: bridge-success-ring 980ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.bridge-success-ring--two {
+ width: 54%;
+ aspect-ratio: 1;
+ animation: bridge-success-ring 980ms 120ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.bridge-success-ring--three {
+ width: 72%;
+ aspect-ratio: 1;
+ animation: bridge-success-ring 980ms 240ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.bridge-success-spark {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 0.33rem;
+ height: 3.6rem;
+ border-radius: 999px;
+ background: linear-gradient(180deg, rgba(236, 253, 245, 1), rgba(16, 185, 129, 0));
+ opacity: 0;
+ transform-origin: 50% 0%;
+ animation: bridge-success-spark 920ms var(--spark-delay) cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+@keyframes bridge-success-card-in {
+ from {
+ opacity: 0;
+ transform: translateY(10px) scale(0.98);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes bridge-success-sheen {
+ to {
+ transform: translateX(120%);
+ }
+}
+
+@keyframes bridge-success-fade {
+ 0% {
+ opacity: 0;
+ transform: scale(0.88);
+ }
+ 15% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 76% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ transform: scale(1.08);
+ }
+}
+
+@keyframes bridge-success-core {
+ 0% {
+ transform: scale(0.45);
+ opacity: 0;
+ }
+ 60% {
+ transform: scale(1.12);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+@keyframes bridge-success-label {
+ from {
+ opacity: 0;
+ transform: translateY(14px) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes bridge-success-ring {
+ 0% {
+ opacity: 0.62;
+ transform: scale(0.25);
+ }
+ 100% {
+ opacity: 0;
+ transform: scale(1.72);
+ }
+}
+
+@keyframes bridge-success-spark {
+ 0% {
+ opacity: 0;
+ transform: rotate(var(--spark-angle)) translateY(-0.5rem) scaleY(0.4);
+ }
+ 24% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ transform: rotate(var(--spark-angle)) translateY(-11.25rem) scaleY(1);
+ }
+}
diff --git a/server/api/bridge/disconnect.post.ts b/server/api/bridge/disconnect.post.ts
new file mode 100644
index 0000000..1ebadca
--- /dev/null
+++ b/server/api/bridge/disconnect.post.ts
@@ -0,0 +1,54 @@
+import { createError } from 'h3'
+import { requireUserSession } from '../../utils/auth'
+import { getBridgeTokenFromHeader } from '../../utils/bridge'
+import { BridgeToken } from '../../models/BridgeToken'
+
+export default defineEventHandler(async (event) => {
+ const user = await requireUserSession(event)
+ const token = getBridgeTokenFromHeader(event)
+
+ if (!token) {
+ throw createError({ statusCode: 401, statusMessage: 'x-bridge-token header fehlt oder ist ungültig.' })
+ }
+
+ console.info(
+ `\x1b[31m[bridge:disconnect]\x1b[0m token=\x1b[96m${token.slice(0, 6)}...\x1b[0m user=\x1b[92m${String(user._id)}\x1b[0m`,
+ )
+
+ const existing = await BridgeToken.findOne({ token }).select('user token')
+ if (!existing || !existing.user) {
+ return {
+ success: true,
+ token,
+ connected: false,
+ }
+ }
+
+ if (String(existing.user) !== String(user._id)) {
+ throw createError({
+ statusCode: 403,
+ statusMessage: 'Dieses Bridge-Token ist mit einem anderen Account verknüpft.',
+ })
+ }
+
+ await BridgeToken.updateOne(
+ { token, user: user._id },
+ {
+ $unset: {
+ user: 1,
+ connectedAt: 1,
+ lastStatusAt: 1,
+ },
+ $set: {
+ simConnected: false,
+ flightActive: false,
+ },
+ },
+ )
+
+ return {
+ success: true,
+ token,
+ connected: false,
+ }
+})