Point bridge download to GitHub

This commit is contained in:
Remi
2025-09-24 00:01:00 +02:00
parent f0a04b31f4
commit e3c76f452c
8 changed files with 873 additions and 0 deletions

View File

@@ -0,0 +1,503 @@
<template>
<div class="relative min-h-screen bg-[#0B1020] text-white">
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(22,187,215,0.12),transparent_55%)]"/>
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_bottom,_rgba(79,70,229,0.12),transparent_60%)]"/>
<div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col justify-center px-6 py-16 lg:px-12">
<div class="space-y-10">
<header class="text-center">
<p class="text-xs font-semibold uppercase tracking-[0.45em] text-[#16BBD7]">OpenSquawk Bridge</p>
<h1 class="mt-4 text-3xl font-semibold sm:text-4xl">Bridge mit deinem Account verknüpfen</h1>
<p class="mx-auto mt-4 max-w-3xl text-sm text-white/70">
Dieser Bildschirm verbindet den lokal erzeugten Bridge-Token mit deinem OpenSquawk Account. Nach erfolgreicher
Verknüpfung kannst du in die Bridge App zurückkehren die Anwendung erkennt dich dann automatisch.
</p>
</header>
<div class="flex justify-center">
<NuxtLink
to="/bridge"
class="inline-flex items-center gap-2 rounded-full bg-[#292D3B] px-5 py-2.5 text-sm font-medium text-white/80 transition hover:bg-[#353a4c] hover:text-white"
>
<span aria-hidden="true"></span>
Zur Bridge Übersicht
</NuxtLink>
</div>
<div
v-if="!hasToken"
class="rounded-3xl border border-red-500/30 bg-red-500/10 px-8 py-12 text-center shadow-[0_25px_80px_rgba(64,18,18,0.35)]"
>
<h2 class="text-2xl font-semibold">Token fehlt</h2>
<p class="mt-4 text-sm text-red-100/80">
Bitte öffne diesen Bildschirm direkt über die Bridge App. Nur so kann der Token automatisch übergeben werden.
</p>
<p class="mt-6 text-xs uppercase tracking-[0.4em] text-red-200/60">Bridge-Link neu starten</p>
</div>
<div v-else class="grid gap-6 lg:grid-cols-[1.35fr_1fr]">
<div class="space-y-6">
<section
class="rounded-3xl border border-white/10 bg-[#111832]/85 p-8 shadow-[0_30px_80px_rgba(5,10,30,0.55)] backdrop-blur"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-xl font-semibold">Schritt 1 · Anmelden</h2>
<span class="rounded-full bg-[#292D3B] px-3 py-1 text-xs uppercase tracking-[0.32em] text-white/70">Login</span>
</div>
<p class="mt-3 text-sm text-white/70">
Melde dich mit deinen OpenSquawk Zugangsdaten an. Ohne Anmeldung kann der Token nicht verknüpft werden.
</p>
<div
v-if="isAuthenticated"
class="mt-6 rounded-2xl border border-[#16BBD7]/40 bg-[#16BBD7]/10 px-5 py-4 text-sm"
>
<p class="font-medium text-[#16BBD7]">Bereits angemeldet</p>
<p class="mt-1 text-white/70">
Du bist aktuell als
<span class="font-semibold text-white">{{ authDisplayName }}</span>
angemeldet.
</p>
</div>
<form v-else class="mt-6 space-y-5" @submit.prevent="submitLogin">
<div>
<label class="block text-sm font-medium text-white/70">E-Mail</label>
<input
v-model.trim="loginForm.email"
type="email"
required
autocomplete="email"
class="mt-2 w-full rounded-2xl border border-white/10 bg-[#0B132A]/90 px-4 py-3 text-sm text-white placeholder-white/40 outline-none transition focus:border-[#16BBD7]/60 focus:ring-2 focus:ring-[#16BBD7]/60"
/>
</div>
<div>
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-white/70">Passwort</label>
<NuxtLink
to="/forgot-password"
class="text-xs font-semibold uppercase tracking-[0.28em] text-[#16BBD7] transition hover:text-[#72d9ea]"
>
Passwort vergessen?
</NuxtLink>
</div>
<input
v-model="loginForm.password"
type="password"
required
autocomplete="current-password"
class="mt-2 w-full rounded-2xl border border-white/10 bg-[#0B132A]/90 px-4 py-3 text-sm text-white placeholder-white/40 outline-none transition focus:border-[#16BBD7]/60 focus:ring-2 focus:ring-[#16BBD7]/60"
/>
</div>
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-[#16BBD7] px-5 py-3 text-sm font-semibold text-[#0B1020] transition hover:bg-[#13a7c4] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#72d9ea] disabled:cursor-not-allowed disabled:bg-[#16BBD7]/60"
:disabled="loginLoading"
>
<span v-if="loginLoading" class="flex items-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-[#0B1020]/30 border-t-[#0B1020]"/>
Anmeldung läuft
</span>
<span v-else>Anmelden</span>
</button>
<p v-if="loginError" class="text-sm text-red-300">{{ loginError }}</p>
</form>
</section>
<section
class="rounded-3xl border border-white/10 bg-[#111832]/85 p-8 shadow-[0_30px_80px_rgba(5,10,30,0.55)] backdrop-blur"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-xl font-semibold">Schritt 2 · Bridge verknüpfen</h2>
<span class="rounded-full bg-[#16BBD7]/10 px-3 py-1 text-xs uppercase tracking-[0.32em] text-[#16BBD7]">
Token aktiv
</span>
</div>
<p class="mt-3 text-sm text-white/70">
Der unten stehende Token stammt direkt aus deiner Bridge App. Verknüpfe ihn mit einem Klick mit deinem Account.
</p>
<div class="mt-6 flex flex-wrap items-center gap-3">
<code class="rounded-2xl bg-[#0B132A]/80 px-4 py-2 font-mono text-sm text-[#72d9ea] shadow-inner">
{{ token }}
</code>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/5 px-4 py-2 text-xs font-medium text-white/70 transition hover:border-white/30 hover:text-white"
@click="copyToken"
>
<span aria-hidden="true"></span>
<span>{{ copiedToken ? 'Token kopiert' : 'Token kopieren' }}</span>
</button>
</div>
<div class="mt-8 space-y-4">
<button
type="button"
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-[#16BBD7] px-5 py-3 text-sm font-semibold text-[#0B1020] transition hover:bg-[#13a7c4] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#72d9ea] disabled:cursor-not-allowed disabled:bg-[#16BBD7]/60"
:disabled="!isAuthenticated || connectLoading"
@click="connectBridge"
>
<span v-if="connectLoading" class="flex items-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-[#0B1020]/30 border-t-[#0B1020]"/>
Token wird verbunden
</span>
<span v-else>Bridge jetzt verbinden</span>
</button>
<p v-if="!isAuthenticated" class="text-sm text-white/60">
Bitte melde dich zuerst an, um die Verknüpfung durchführen zu können.
</p>
<p v-if="connectError" class="text-sm text-red-300">{{ connectError }}</p>
<div
v-if="successBannerVisible"
class="rounded-2xl border border-emerald-400/40 bg-emerald-400/10 px-5 py-4 text-sm text-emerald-100 shadow-[0_0_35px_rgba(16,185,129,0.25)]"
>
<p class="font-semibold">Verbindung hergestellt!</p>
<p class="mt-1 text-emerald-100/80">
Du kannst dieses Fenster jetzt schließen und zur Bridge App zurückkehren. Statusupdates werden automatisch
synchronisiert.
</p>
</div>
</div>
</section>
</div>
<aside>
<section
class="h-full rounded-3xl border border-white/10 bg-[#111832]/80 p-8 shadow-[0_30px_80px_rgba(4,8,24,0.55)] backdrop-blur"
:class="{ 'opacity-80': statusLoading }"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<h3 class="text-lg font-semibold">Live Status</h3>
<span class="rounded-full bg-white/5 px-3 py-1 text-[11px] uppercase tracking-[0.3em] text-white/50">
Update alle 5s
</span>
</div>
<div class="mt-6 space-y-4">
<div class="rounded-2xl border border-white/10 bg-[#0B132A]/80 px-5 py-4 shadow-inner">
<p class="text-sm font-semibold text-white/70">Verbindung</p>
<p
class="mt-1 text-base font-medium"
:class="connectionStatus?.connected ? 'text-emerald-300' : 'text-white/50'"
>
{{ connectionLabel }}
</p>
<p v-if="connectionSubLabel" class="text-xs text-white/40">{{ connectionSubLabel }}</p>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div
class="flex items-center gap-3 rounded-2xl border px-4 py-3 transition"
:class="connectionStatus?.simConnected
? 'border-emerald-400/60 bg-emerald-400/10 shadow-[0_0_30px_rgba(16,185,129,0.35)]'
: 'border-white/10 bg-[#0B132A]/70'"
>
<span
class="h-3 w-3 rounded-full"
:class="connectionStatus?.simConnected
? 'bg-emerald-400 shadow-[0_0_18px_rgba(16,185,129,0.85)]'
: 'bg-white/30'"
/>
<div>
<p class="text-sm font-semibold">Simulator</p>
<p class="text-xs text-white/60">
{{ connectionStatus?.simConnected ? 'Simulator erkannt' : 'Kein Simulator aktiv' }}
</p>
</div>
</div>
<div
class="flex items-center gap-3 rounded-2xl border px-4 py-3 transition"
:class="connectionStatus?.flightActive
? 'border-emerald-400/60 bg-emerald-400/10 shadow-[0_0_30px_rgba(16,185,129,0.35)]'
: 'border-white/10 bg-[#0B132A]/70'"
>
<span
class="h-3 w-3 rounded-full"
:class="connectionStatus?.flightActive
? 'bg-emerald-400 shadow-[0_0_18px_rgba(16,185,129,0.85)]'
: 'bg-white/30'"
/>
<div>
<p class="text-sm font-semibold">Flugstatus</p>
<p class="text-xs text-white/60">
{{ connectionStatus?.flightActive ? 'Flug aktiv' : 'Kein Flug erkannt' }}
</p>
</div>
</div>
</div>
<div class="rounded-2xl border border-white/10 bg-[#0B132A]/80 px-5 py-4 text-sm text-white/65">
<div class="flex items-center justify-between">
<span>Verbunden seit</span>
<span class="font-medium text-white">{{ formattedConnectedAt }}</span>
</div>
<div class="mt-2 flex items-center justify-between text-white/60">
<span>Letztes Status-Update</span>
<span class="font-medium text-white">{{ formattedLastStatusAt }}</span>
</div>
</div>
</div>
<p v-if="statusLoading" class="mt-6 text-xs uppercase tracking-[0.4em] text-white/45">Status wird aktualisiert </p>
<p v-if="statusError" class="mt-2 text-sm text-red-300">{{ statusError }}</p>
</section>
</aside>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useHead, useRoute } 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: 'Bridge verbinden · OpenSquawk' })
const route = useRoute()
const auth = useAuthStore()
const { user, accessToken, isAuthenticated, initialized } = storeToRefs(auth)
const loginForm = reactive({ email: '', password: '' })
const loginLoading = ref(false)
const loginError = ref('')
const connectLoading = ref(false)
const connectError = ref('')
const connectSuccess = ref(false)
const connectionStatus = ref<BridgeStatusPayload | null>(null)
const statusInitialized = ref(false)
const statusError = ref('')
const statusLoading = ref(false)
const statusRequestActive = ref(false)
const copiedToken = ref(false)
const token = computed(() => {
const value = route.query.token
if (Array.isArray(value)) {
return value[0] || ''
}
return typeof value === 'string' ? value : ''
})
const hasToken = computed(() => token.value.length > 0)
const authDisplayName = computed(() => {
if (!user.value) return ''
return user.value.name || user.value.email
})
const connectionLabel = computed(() => {
if (!connectionStatus.value?.connected) {
return 'Wartet auf Verbindung'
}
const name = connectionStatus.value.user?.name || connectionStatus.value.user?.email
return name ? `Verbunden als ${name}` : 'Verbunden'
})
const connectionSubLabel = computed(() => {
if (!connectionStatus.value?.connected) return null
return connectionStatus.value.user?.email && connectionStatus.value.user.email !== connectionStatus.value.user?.name
? connectionStatus.value.user.email
: null
})
const formattedConnectedAt = computed(() => formatTimestamp(connectionStatus.value?.connectedAt ?? null))
const formattedLastStatusAt = computed(() => formatTimestamp(connectionStatus.value?.lastStatusAt ?? null))
const successBannerVisible = computed(() => connectSuccess.value || Boolean(connectionStatus.value?.connected))
function formatTimestamp(value: string | null) {
if (!value) {
return '—'
}
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) {
return '—'
}
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'medium',
}).format(parsed)
}
async function submitLogin() {
loginError.value = ''
connectError.value = ''
if (!loginForm.email || !loginForm.password) {
loginError.value = 'Bitte E-Mail und Passwort eingeben.'
return
}
loginLoading.value = true
try {
await auth.login({ email: loginForm.email, password: loginForm.password })
connectSuccess.value = false
await fetchStatus(true)
} catch (err: any) {
loginError.value =
err?.data?.statusMessage ||
err?.response?._data?.statusMessage ||
err?.message ||
'Login fehlgeschlagen.'
} finally {
loginLoading.value = false
}
}
async function connectBridge() {
if (!hasToken.value || !isAuthenticated.value || !accessToken.value) {
return
}
connectLoading.value = true
connectError.value = ''
try {
await $fetch('/api/bridge/connect', {
method: 'POST',
body: { token: token.value },
headers: {
Authorization: `Bearer ${accessToken.value}`,
},
})
connectSuccess.value = true
await fetchStatus(true)
} catch (err: any) {
connectError.value =
err?.data?.statusMessage ||
err?.response?._data?.statusMessage ||
err?.message ||
'Verknüpfung fehlgeschlagen.'
} finally {
connectLoading.value = false
}
}
async function fetchStatus(force = false) {
if (!hasToken.value) {
return
}
if (statusRequestActive.value) {
return
}
statusRequestActive.value = true
if (force || !statusInitialized.value) {
statusLoading.value = true
}
try {
const response = await $fetch<BridgeStatusPayload>('/api/bridge/me', {
params: { token: token.value },
})
connectionStatus.value = response
statusError.value = ''
statusInitialized.value = true
if (response.connected) {
connectSuccess.value = true
}
} catch (err: any) {
statusError.value =
err?.data?.statusMessage ||
err?.response?._data?.statusMessage ||
err?.message ||
'Status konnte nicht geladen werden.'
} finally {
statusRequestActive.value = false
statusLoading.value = false
}
}
async function copyToken() {
if (!hasToken.value) return
if (typeof navigator === 'undefined' || !navigator.clipboard) return
try {
await navigator.clipboard.writeText(token.value)
copiedToken.value = true
setTimeout(() => {
copiedToken.value = false
}, 2000)
} catch {
// Ignorieren Kopie ist optional
}
}
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)
}
watch(token, () => {
connectError.value = ''
connectSuccess.value = false
connectionStatus.value = null
statusInitialized.value = false
statusError.value = ''
startPolling()
})
watch(
() => isAuthenticated.value,
(value) => {
if (value && hasToken.value) {
fetchStatus(true)
}
},
)
onMounted(() => {
if (!initialized.value) {
auth.fetchUser().catch(() => {})
}
startPolling()
})
onBeforeUnmount(() => {
stopPolling()
})
</script>
<style scoped>
code {
word-break: break-all;
}
</style>

164
app/pages/bridge/index.vue Normal file
View File

@@ -0,0 +1,164 @@
<template>
<div class="relative min-h-screen bg-[#0B1020] text-white">
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(22,187,215,0.12),transparent_55%)]"/>
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_bottom,_rgba(14,165,233,0.18),transparent_65%)]"/>
<div class="relative mx-auto w-full max-w-6xl px-6 py-16 lg:px-12">
<header class="text-center">
<p class="text-xs font-semibold uppercase tracking-[0.45em] text-[#16BBD7]">OpenSquawk Bridge</p>
<h1 class="mt-4 text-3xl font-semibold sm:text-4xl">Deine Verbindung zwischen Simulator und OpenSquawk</h1>
<p class="mx-auto mt-4 max-w-3xl text-sm text-white/70">
Die OpenSquawk Bridge streamt Ereignisse aus deinem Simulator direkt zu unserem Netzwerk. Installiere die passende
Version für deine Plattform, starte die App und verknüpfe sie in wenigen Klicks mit deinem Account.
</p>
</header>
<div class="mt-12 grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
<section class="space-y-6">
<div class="rounded-3xl border border-white/10 bg-[#111832]/80 p-8 shadow-[0_30px_80px_rgba(5,10,30,0.55)] backdrop-blur">
<h2 class="text-xl font-semibold">Download-Zentrale</h2>
<p class="mt-2 text-sm text-white/70">
Wähle die Version deiner Bridge. Neue Simulatoren werden automatisch nachgereicht, sobald Builds verfügbar sind.
</p>
<div class="mt-6 grid gap-5 md:grid-cols-2">
<article
v-for="item in downloads"
:key="item.id"
class="flex h-full flex-col justify-between rounded-3xl border border-white/10 bg-[#0B132A]/80 p-6 transition hover:border-white/20 hover:shadow-[0_25px_60px_rgba(12,18,40,0.5)]"
>
<div class="space-y-3">
<span
class="inline-flex items-center gap-2 rounded-full px-3 py-1 text-[11px] uppercase tracking-[0.35em]"
:class="item.available ? 'bg-[#16BBD7]/20 text-[#16BBD7]' : 'bg-white/10 text-white/60'"
>
{{ item.badge }}
</span>
<h3 class="text-lg font-semibold">{{ item.title }}</h3>
<p class="text-sm text-white/65">{{ item.description }}</p>
</div>
<div class="mt-6">
<a
v-if="item.available"
:href="item.href"
target="_blank"
rel="noopener"
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-[#16BBD7] px-4 py-3 text-sm font-semibold text-[#0B1020] transition hover:bg-[#13a7c4] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#72d9ea]"
>
<span aria-hidden="true"></span>
Download starten
</a>
<button
v-else
type="button"
disabled
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-[#292D3B] px-4 py-3 text-sm font-semibold text-white/70 opacity-80"
>
<span aria-hidden="true"></span>
Coming soon
</button>
</div>
</article>
</div>
</div>
<div class="rounded-3xl border border-white/10 bg-[#111832]/70 p-8 shadow-[0_20px_60px_rgba(4,8,24,0.45)] backdrop-blur">
<h2 class="text-xl font-semibold">So funktioniert die Verknüpfung</h2>
<ol class="mt-6 space-y-4 text-sm text-white/70">
<li class="flex items-start gap-4">
<span class="mt-1 flex h-8 w-8 items-center justify-center rounded-full bg-[#16BBD7]/20 text-[#16BBD7]">1</span>
<span>Bridge herunterladen, installieren und starten. Die App erzeugt automatisch deinen Token.</span>
</li>
<li class="flex items-start gap-4">
<span class="mt-1 flex h-8 w-8 items-center justify-center rounded-full bg-[#16BBD7]/20 text-[#16BBD7]">2</span>
<span>Folge dem angezeigten Link <code class="rounded bg-[#0B132A]/80 px-2 py-1 text-xs">opensquawk.de/bridge/connect</code>
der Token wird beim Öffnen automatisch übergeben.</span>
</li>
<li class="flex items-start gap-4">
<span class="mt-1 flex h-8 w-8 items-center justify-center rounded-full bg-[#16BBD7]/20 text-[#16BBD7]">3</span>
<span>Melde dich mit deinem OpenSquawk Account an, bestätige die Verknüpfung und kehre zur App zurück.</span>
</li>
</ol>
<div class="mt-6 rounded-2xl border border-white/10 bg-[#0B132A]/70 px-5 py-4 text-sm text-white/70">
<p class="font-semibold text-white">Hinweis</p>
<p class="mt-1 text-white/65">
Die Bridge zeigt dir jederzeit an, ob der Simulator erkannt wurde und ob ein Flug aktiv ist. Alle Statusmeldungen
werden alle zehn Sekunden an OpenSquawk übertragen.
</p>
</div>
</div>
</section>
<aside class="space-y-6">
<div class="rounded-3xl border border-white/10 bg-[#111832]/80 p-8 shadow-[0_30px_80px_rgba(4,8,24,0.55)] backdrop-blur">
<h2 class="text-xl font-semibold">Systemvoraussetzungen</h2>
<ul class="mt-4 space-y-3 text-sm text-white/70">
<li>Windows 10/11 (64-bit)</li>
<li>.NET 8 Desktop Runtime</li>
<li>Aktive OpenSquawk Mitgliedschaft</li>
<li>Internetverbindung (mind. 5 Mbit/s)</li>
</ul>
</div>
<div class="rounded-3xl border border-white/10 bg-[#111832]/70 p-8 shadow-[0_20px_60px_rgba(4,8,24,0.45)] backdrop-blur">
<h2 class="text-xl font-semibold">Support</h2>
<p class="mt-3 text-sm text-white/70">
Fragen oder Feedback? Unser Team hilft dir gerne weiter. Schreibe uns jederzeit an
<a href="mailto:support@opensquawk.de" class="text-[#16BBD7] underline decoration-dotted underline-offset-4">support@opensquawk.de</a>.
</p>
<div class="mt-6 rounded-2xl border border-[#16BBD7]/30 bg-[#16BBD7]/10 px-5 py-4 text-sm text-white/75">
<p class="font-semibold text-[#16BBD7]">Pro Tipp</p>
<p class="mt-1">
Lass die Bridge App im Hintergrund laufen sie synchronisiert automatisch, sobald dein Simulator startet und ein
Flug aktiv ist.
</p>
</div>
</div>
</aside>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useHead } from '#imports'
useHead({ title: 'Bridge · OpenSquawk' })
const downloads = [
{
id: 'msfs2020',
title: 'Microsoft Flight Simulator 2020',
description: 'Stabile Bridge-Version für den MSFS 2020 mit automatischer Token-Verknüpfung und Status-Updates.',
badge: 'Verfügbar',
available: true,
href: 'https://github.com/itsrubberduck/OpenSquawk-MSFS-Bridge/',
},
{
id: 'msfs2024',
title: 'Microsoft Flight Simulator 2024',
description: 'Die neue Generation des MSFS wird aktuell angebunden. Release der Bridge folgt in Kürze.',
badge: 'In Arbeit',
available: false,
href: '#',
},
{
id: 'xplane',
title: 'X-Plane 12',
description: 'Gemeinsame Bridge-Architektur X-Plane Support ist geplant, sobald die MSFS-Versionen stabil laufen.',
badge: 'In Planung',
available: false,
href: '#',
},
]
</script>
<style scoped>
code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
</style>

View File

@@ -0,0 +1,50 @@
import { createError, readBody } from 'h3'
import { requireUserSession } from '../../utils/auth'
import { normalizeBridgeToken } from '../../utils/bridge'
import { BridgeToken } from '../../models/BridgeToken'
interface ConnectBody {
token?: string
}
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event)
const body = await readBody<ConnectBody>(event)
const token = normalizeBridgeToken(body.token)
if (!token) {
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Token übergeben.' })
}
const now = new Date()
const document = await BridgeToken.findOneAndUpdate(
{ token },
{
$set: {
user: user._id,
connectedAt: now,
},
$setOnInsert: {
token,
},
},
{ upsert: true, new: true, runValidators: true },
).populate('user', 'name email')
if (!document) {
throw createError({ statusCode: 500, statusMessage: 'Token konnte nicht gespeichert werden.' })
}
return {
success: true,
token: document.token,
connectedAt: document.connectedAt?.toISOString() ?? now.toISOString(),
user: {
id: String(user._id),
email: user.email,
name: user.name,
},
}
})

View File

@@ -0,0 +1,44 @@
import { createError, getQuery } from 'h3'
import { BridgeToken } from '../../models/BridgeToken'
import { normalizeBridgeToken } from '../../utils/bridge'
import type { UserDocument } from '../../models/User'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const token = normalizeBridgeToken(query.token as string | undefined)
if (!token) {
throw createError({ statusCode: 400, statusMessage: 'Token muss übergeben werden.' })
}
const document = await BridgeToken.findOne({ token }).populate('user', 'name email')
if (!document || !document.user) {
return {
token,
connected: false,
user: null,
simConnected: false,
flightActive: false,
connectedAt: document?.connectedAt ? document.connectedAt.toISOString() : null,
lastStatusAt: document?.lastStatusAt ? document.lastStatusAt.toISOString() : null,
}
}
const bridgeUser = document.user as UserDocument
return {
token: document.token,
connected: true,
user: {
id: String(bridgeUser._id),
email: bridgeUser.email,
name: bridgeUser.name,
},
simConnected: document.simConnected,
flightActive: document.flightActive,
connectedAt: document.connectedAt?.toISOString() ?? document.updatedAt.toISOString(),
lastStatusAt: document.lastStatusAt ? document.lastStatusAt.toISOString() : null,
}
})

View File

@@ -0,0 +1,63 @@
import { createError, readBody } from 'h3'
import { BridgeToken } from '../../models/BridgeToken'
import { normalizeBridgeToken } from '../../utils/bridge'
import type { UserDocument } from '../../models/User'
interface StatusBody {
token?: string
simConnected?: boolean
flightActive?: boolean
}
export default defineEventHandler(async (event) => {
const body = await readBody<StatusBody>(event)
const token = normalizeBridgeToken(body.token)
if (!token) {
throw createError({ statusCode: 400, statusMessage: 'Token muss übergeben werden.' })
}
const updatePayload: Record<string, unknown> = {
lastStatusAt: new Date(),
}
if (typeof body.simConnected === 'boolean') {
updatePayload.simConnected = body.simConnected
}
if (typeof body.flightActive === 'boolean') {
updatePayload.flightActive = body.flightActive
}
const document = await BridgeToken.findOneAndUpdate(
{ token },
{
$set: updatePayload,
$setOnInsert: { token },
},
{ new: true, upsert: true, runValidators: true },
).populate('user', 'name email')
if (!document) {
throw createError({ statusCode: 500, statusMessage: 'Status konnte nicht aktualisiert werden.' })
}
const user = document.user as UserDocument | undefined
return {
token: document.token,
connected: Boolean(user),
user: user
? {
id: String(user._id),
email: user.email,
name: user.name,
}
: null,
simConnected: document.simConnected,
flightActive: document.flightActive,
connectedAt: document.connectedAt ? document.connectedAt.toISOString() : null,
lastStatusAt: document.lastStatusAt ? document.lastStatusAt.toISOString() : null,
}
})

View File

@@ -9,6 +9,9 @@ export default defineEventHandler(async (event) => {
if (url.pathname.startsWith('/api/service/')) {
return
}
if (url.pathname.startsWith('/api/bridge/')) {
return
}
if (event.node.req.method === 'OPTIONS') {
return
}

View File

@@ -0,0 +1,32 @@
import mongoose from 'mongoose'
import type { UserDocument } from './User'
export interface BridgeTokenDocument extends mongoose.Document {
token: string
user?: mongoose.Types.ObjectId | UserDocument
connectedAt?: Date
lastStatusAt?: Date
simConnected: boolean
flightActive: boolean
createdAt: Date
updatedAt: Date
}
const bridgeTokenSchema = new mongoose.Schema<BridgeTokenDocument>(
{
token: { type: String, required: true, unique: true, index: true },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
connectedAt: { type: Date },
lastStatusAt: { type: Date },
simConnected: { type: Boolean, default: false },
flightActive: { type: Boolean, default: false },
},
{
timestamps: true,
},
)
export const BridgeToken =
(mongoose.models.BridgeToken as mongoose.Model<BridgeTokenDocument> | undefined) ||
mongoose.model<BridgeTokenDocument>('BridgeToken', bridgeTokenSchema)

14
server/utils/bridge.ts Normal file
View File

@@ -0,0 +1,14 @@
export function normalizeBridgeToken(input: unknown) {
if (typeof input !== 'string') {
return null
}
const token = input.trim()
if (!token) {
return null
}
if (token.length < 8 || token.length > 256) {
return null
}
return token
}