This commit is contained in:
itsrubberduck
2025-09-14 21:09:47 +02:00
commit e91c3c6aa9
17 changed files with 9313 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

BIN
.yarn/install-state.gz Normal file

Binary file not shown.

942
.yarn/releases/yarn-4.9.4.cjs vendored Executable file

File diff suppressed because one or more lines are too long

5
.yarnrc.yml Normal file
View File

@@ -0,0 +1,5 @@
nodeLinker: node-modules
npmRegistryServer: "https://registry.npmjs.org"
yarnPath: .yarn/releases/yarn-4.9.4.cjs

75
README.md Normal file
View File

@@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

413
app/pages/index.vue Normal file
View File

@@ -0,0 +1,413 @@
<template>
<div class="bg-[#0b1020] text-white antialiased selection:bg-cyan-400/30">
<!-- NAV -->
<header class="sticky top-0 z-50 bg-[#0b1020]/70 backdrop-blur border-b border-white/10">
<nav class="mx-auto max-w-[1200px] px-4 py-3 flex items-center justify-between">
<a href="#" class="flex items-center gap-2 font-semibold tracking-tight">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" class="text-cyan-400"><path d="M12 3l8 4v10l-8 4-8-4V7l8-4z" stroke="currentColor" stroke-width="1.5"/><path d="M12 7l4 2v6l-4 2-4-2V9l4-2z" fill="currentColor" class="opacity-60"/></svg>
<span class="text-white">OpenSquawk</span>
</a>
<div class="hidden md:flex gap-6 text-sm">
<a href="#features" class="hover:text-cyan-300">Features</a>
<a href="#learn" class="hover:text-cyan-300">Lernpfad</a>
<a href="#pricing" class="hover:text-cyan-300">Preise</a>
<a href="#opensource" class="hover:text-cyan-300">OpenSource</a>
<a href="#faq" class="hover:text-cyan-300">FAQ</a>
</div>
<div class="flex items-center gap-2">
<a href="#demo" class="btn btn-ghost text-sm">LiveDemo</a>
<a href="#cta" class="btn btn-primary text-sm">Frühzugang</a>
</div>
</nav>
</header>
<!-- HERO -->
<section class="gradient-hero relative overflow-hidden">
<div class="absolute inset-0 pointer-events-none">
<div class="absolute -top-24 right-0 w-[600px] h-[600px] bg-cyan-500/10 rounded-full blur-3xl" />
</div>
<div class="mx-auto max-w-[1200px] px-4 pt-16 pb-12 md:pt-24 md:pb-20">
<div class="max-w-2xl">
<span class="chip mb-4">OpenSource · AIATC · Simulator</span>
<h1 class="text-4xl md:text-6xl font-semibold leading-tight">OpenSquawk<br><span class="text-cyan-400">AIATC, offen & bezahlbar</span></h1>
<p class="mt-4 md:mt-6 text-white/80 text-base md:text-lg">Offene Alternative für AIATC im Flugsimulator. Lerne echte Phraseologie, übe sichere Abläufe und steige Schritt für Schritt Richtung VATSIM ein. Optional als gehosteter Plan so günstig wie möglich.</p>
<div class="flex flex-col sm:flex-row gap-3 mt-6">
<a href="#cta" class="btn btn-primary text-base">Frühzugang sichern</a>
<a href="#video" class="btn btn-ghost text-base">1MinÜberblick ansehen</a>
</div>
<div class="mt-6 flex items-center gap-4 text-white/70 text-sm">
<div class="flex -space-x-3">
<img alt="avatar" src="https://i.pravatar.cc/40?img=1" class="w-8 h-8 rounded-full border border-white/10" />
<img alt="avatar" src="https://i.pravatar.cc/40?img=2" class="w-8 h-8 rounded-full border border-white/10" />
<img alt="avatar" src="https://i.pravatar.cc/40?img=3" class="w-8 h-8 rounded-full border border-white/10" />
</div>
<p><b>1500+</b> Pilot:innen im EarlyAccess</p>
</div>
</div>
<div class="mt-10 md:mt-16">
<div class="card relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-r from-cyan-500/10 via-blue-500/5 to-transparent" />
<img src="https://images.unsplash.com/photo-1509749837427-392dfae2dbd1?q=80&w=1600&auto=format&fit=crop" alt="Cockpit" class="rounded-xl w-full object-cover" />
<div class="absolute bottom-3 right-3 text-xs text-white/70 bg-black/40 px-2 py-1 rounded">Symbolbild</div>
</div>
</div>
</div>
</section>
<!-- SOCIAL PROOF / LOGOS -->
<section class="py-10 border-t border-white/10 bg-[#0a0f1c]">
<div class="mx-auto max-w-[1200px] px-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 items-center opacity-80">
<div class="text-center text-white/60">MSFS</div>
<div class="text-center text-white/60">XPlane</div>
<div class="text-center text-white/60">VATSIM* (Lernpfad)</div>
<div class="text-center text-white/60">IVAO* (Lernpfad)</div>
</div>
</div>
</section>
<!-- FEATURES -->
<section id="features" class="py-16 md:py-24 bg-gradient-to-b from-[#0a0f1c] to-[#0b1020]">
<div class="mx-auto max-w-[1200px] px-4">
<div class="max-w-2xl mb-10">
<h2 class="text-3xl md:text-4xl font-semibold">Warum OpenSquawk?</h2>
<p class="mt-3 text-white/80">EchtzeitFunk mit KI, lernfreundliche Erklärungen und offene Architektur damit du souverän taxi, departure, approach & landing beherrschst.</p>
</div>
<div class="grid md:grid-cols-3 gap-6">
<div class="card">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-cyan-500/20 border border-cyan-400/20"><svg width="22" height="22" viewBox="0 0 24 24" fill="none"><path d="M12 3l8 4v10l-8 4-8-4V7l8-4z" stroke="#22d3ee"/></svg></div>
<h3 class="font-semibold text-lg">AIATC in Echtzeit</h3>
</div>
<p class="mt-3 text-white/80">StreamingASR, LLMVerständnis & TTS Stimmen. Klar, schnell, latenzarm optimiert für Phraseologie.</p>
</div>
<div class="card">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-cyan-500/20 border border-cyan-400/20"><svg width="22" height="22" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="#22d3ee"/></svg></div>
<h3 class="font-semibold text-lg">Lernpfad VATSIM</h3>
</div>
<p class="mt-3 text-white/80">Geführte Übungen, Checks & Erklärungen vom Readback bis zu komplexen Clearances. Schrittweise Richtung OnlineNetzwerke.</p>
</div>
<div class="card">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-cyan-500/20 border border-cyan-400/20"><svg width="22" height="22" viewBox="0 0 24 24" fill="none"><path d="M5 12h14M12 5v14" stroke="#22d3ee"/></svg></div>
<h3 class="font-semibold text-lg">OpenSource + Hosted</h3>
</div>
<p class="mt-3 text-white/80">Selbst hosten oder unseren günstigen Plan nutzen. Transparente Architektur, CommunityPlugins & APIs.</p>
</div>
</div>
<div class="grid md:grid-cols-2 gap-6 mt-6">
<div class="card">
<h3 class="font-semibold text-lg">TaxiRouten & Ground Awareness</h3>
<p class="mt-3 text-white/80">OSM/apt.datbasierte TaxiwayGraphen, A*Routing zu Holding Points, progressive Anweisungen mit VisualOverlay.</p>
<ul class="mt-3 space-y-2 text-white/70 text-sm list-disc list-inside">
<li>Taxi to RWY 25C via A, A5, B2</li>
<li>HotspotVermeidung & progressive Readbacks</li>
<li>Optional: NOTAM/ATIS Einbindung</li>
</ul>
</div>
<div class="card">
<h3 class="font-semibold text-lg">TechStack</h3>
<p class="mt-3 text-white/80">StreamingASR, LLMDialog, TTS orchestriert mit WebRTC/WS. OfflineFallbacks möglich.</p>
<div class="mt-4 grid grid-cols-2 gap-3 text-sm">
<div class="glass rounded-xl p-3">ASR: Whisper/Alternativen (API)</div>
<div class="glass rounded-xl p-3">LLM: GPT5Nano/4oMini etc.</div>
<div class="glass rounded-xl p-3">TTS: neural voices</div>
<div class="glass rounded-xl p-3">Routing: A* / Dijkstra</div>
</div>
</div>
</div>
</div>
</section>
<!-- LEARN PATH -->
<section id="learn" class="py-16 md:py-24 bg-[#0b1020]">
<div class="mx-auto max-w-[1200px] px-4">
<div class="grid md:grid-cols-2 gap-8 items-center">
<div>
<h2 class="text-3xl md:text-4xl font-semibold">ATC verstehen Schritt für Schritt</h2>
<p class="mt-3 text-white/80">Geführter Lernmodus: Hören, Nachsprechen, Feedback. Vom ersten Ready to taxi bis zur komplexen STAR/ApproachFreigabe.</p>
<ol class="mt-5 space-y-3 text-white/80">
<li class="flex gap-3"><span class="chip">1</span><span><b>Basics</b>: Alphabet, Zahlen, StandardPhrasen, Readback.</span></li>
<li class="flex gap-3"><span class="chip">2</span><span><b>Ground</b>: TaxiFlows, Hotspots, Holdingshort.</span></li>
<li class="flex gap-3"><span class="chip">3</span><span><b>Departure</b>: SID, Altitudes, Heading/Speed.</span></li>
<li class="flex gap-3"><span class="chip">4</span><span><b>Arrival</b>: STAR, Vectors, Approach Briefing.</span></li>
<li class="flex gap-3"><span class="chip">5</span><span><b>VATSIM</b>: Checklisten, Etiquette, LiveÜbungen.</span></li>
</ol>
<div class="mt-6 flex gap-3">
<a href="#cta" class="btn btn-primary">Jetzt loslegen</a>
<a href="#faq" class="btn btn-ghost">FAQ</a>
</div>
</div>
<div class="card">
<ClientOnly>
<video id="video" class="w-full rounded-xl" autoplay muted loop playsinline poster="https://images.unsplash.com/photo-1518306724291-1f5c9b4d2452?q=80&w=1600&auto=format&fit=crop">
<source src="https://cdn.coverr.co/videos/coverr-airplane-taking-off-8255/1080p.mp4" type="video/mp4" />
</video>
</ClientOnly>
<p class="mt-3 text-xs text-white/60">Kurzclip: Symbolisch für den Lernpfad</p>
</div>
</div>
</div>
</section>
<!-- PRICING -->
<section id="pricing" class="py-16 md:py-24 bg-gradient-to-b from-[#0b1020] to-[#0a0f1c]">
<div class="mx-auto max-w-[1200px] px-4">
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
<div class="max-w-2xl">
<h2 class="text-3xl md:text-4xl font-semibold">Preise</h2>
<p class="mt-3 text-white/80">OpenSource bleibt frei. Der gehostete Plan ist so günstig wie möglich kalkuliert. Umschalten:
<button @click="yearly=!yearly" class="chip ml-2"><span>{{ yearly ? 'jährlich' : 'monatlich' }}</span></button>
</p>
</div>
</div>
<div class="mt-8 grid md:grid-cols-3 gap-6">
<!-- OSS -->
<div class="card relative">
<div class="absolute -top-3 right-4 chip">Community</div>
<h3 class="text-xl font-semibold">OpenSource (Selfhost)</h3>
<p class="mt-2 text-white/80">Volle Kontrolle. Eigene Infrastruktur. APIKeys selbst verwalten.</p>
<div class="mt-5 text-3xl font-semibold">0<span class="text-white/60 text-sm font-normal"> / immer</span></div>
<ul class="mt-5 space-y-2 text-white/80 text-sm">
<li> Voller Funktionsumfang</li>
<li> Plugins & SDK</li>
<li> CommunitySupport</li>
</ul>
<a href="#opensource" class="btn btn-ghost w-full mt-6">Repository ansehen</a>
</div>
<!-- Hosted Basic -->
<div class="card border-2 border-cyan-400/40 relative shadow-glow">
<div class="absolute -top-3 right-4 chip bg-cyan-500/30 border-cyan-400/50">Empfohlen</div>
<h3 class="text-xl font-semibold">Hosted Basic</h3>
<p class="mt-2 text-white/80">Alles fertig eingerichtet. Ideal zum Lernen & Üben.</p>
<div class="mt-5 text-3xl font-semibold"><span>{{ yearly ? '4,00€' : '4,50€' }}</span><span class="text-white/60 text-sm font-normal"> / Monat</span></div>
<ul class="mt-5 space-y-2 text-white/80 text-sm">
<li> FairUse AudioMinuten</li>
<li> Lernpfad & Fortschritt</li>
<li> Updates & CloudScaling</li>
</ul>
<a href="#cta" class="btn btn-primary w-full mt-6">Kostenlos testen</a>
<p class="mt-3 text-xs text-white/60">Preisziel: minimal + Betriebskosten. Änderungen möglich.</p>
</div>
<!-- Hosted Pro -->
<div class="card relative">
<div class="absolute -top-3 right-4 chip">PowerUser</div>
<h3 class="text-xl font-semibold">Hosted Pro</h3>
<p class="mt-2 text-white/80">Mehr Kontingente, eigene Stimmen, APIZugriff.</p>
<div class="mt-5 text-3xl font-semibold"><span>{{ yearly ? '12€' : '14€' }}</span><span class="text-white/60 text-sm font-normal"> / Monat</span></div>
<ul class="mt-5 space-y-2 text-white/80 text-sm">
<li> Höhere Limits</li>
<li> TeamSeats</li>
<li> Priorisierter Support</li>
</ul>
<a href="#cta" class="btn btn-ghost w-full mt-6">Kontakt aufnehmen</a>
</div>
</div>
</div>
</section>
<!-- OPEN SOURCE -->
<section id="opensource" class="py-16 md:py-24 bg-[#0a0f1c]">
<div class="mx-auto max-w-[1200px] px-4">
<div class="grid md:grid-cols-2 gap-8 items-center">
<div>
<h2 class="text-3xl md:text-4xl font-semibold">OpenSource, Communitygetrieben</h2>
<p class="mt-3 text-white/80">Transparente Architektur, klare Roadmap, offene Issues. Baue eigene Voices, Integrationen und Workflows.</p>
<ul class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<li class="glass rounded-xl p-3">MIT/ApacheLizenz (tbd)</li>
<li class="glass rounded-xl p-3">DockerCompose / Helm</li>
<li class="glass rounded-xl p-3">PluginSDK (TS/JS)</li>
<li class="glass rounded-xl p-3">CLI & REST API</li>
</ul>
<div class="mt-6 flex gap-3">
<a href="#cta" class="btn btn-primary">Mitmachen</a>
<a href="#demo" class="btn btn-ghost">Demo</a>
</div>
</div>
<div class="card">
<pre class="text-xs md:text-sm overflow-x-auto"><code>// Beispiel: RESTRoute für TaxiRouten
POST /api/route/taxi
{
"icao": "EDDF",
"from": { "type": "gate", "ref": "A20" },
"to": { "type": "runway", "ref": "25C" }
}
// → Antwort: Liste der Segmente, Geometrie, ReadbackVorschlag</code></pre>
</div>
</div>
</div>
</section>
<!-- HOW IT WORKS -->
<section class="py-16 md:py-24 bg-[#0b1020]">
<div class="mx-auto max-w-[1200px] px-4">
<div class="max-w-2xl mb-10">
<h2 class="text-3xl md:text-4xl font-semibold">So funktionierts</h2>
<p class="mt-3 text-white/80">Audio rein Verständnis Antwort raus. Designed für niedrige Latenz & klare FunkDisziplin.</p>
</div>
<div class="grid md:grid-cols-4 gap-4 md:gap-6">
<div class="card"><h3 class="font-semibold">1 · ASR</h3><p class="mt-2 text-white/80">StreamingSpeechtoText mit FunkTuning.</p></div>
<div class="card"><h3 class="font-semibold">2 · NLU</h3><p class="mt-2 text-white/80">LLM versteht Intention, Kontext, State.</p></div>
<div class="card"><h3 class="font-semibold">3 · Logic</h3><p class="mt-2 text-white/80">Regeln, Flugdaten, TaxiRouting, Validierung.</p></div>
<div class="card"><h3 class="font-semibold">4 · TTS</h3><p class="mt-2 text-white/80">Natürliches VoiceOut mit korrekten Zahlen.</p></div>
</div>
</div>
</section>
<!-- CTA -->
<section id="cta" class="py-16 md:py-24 bg-gradient-to-tr from-cyan-500/20 via-blue-500/10 to-transparent border-y border-white/10">
<div class="mx-auto max-w-[1200px] px-4">
<div class="card md:flex md:items-center md:justify-between">
<div>
<h3 class="text-2xl md:text-3xl font-semibold">Jetzt OpenSquawk ausprobieren</h3>
<p class="mt-2 text-white/80">Starte kostenlos. Upgrade jederzeit. Keine Bindung.</p>
</div>
<form class="mt-4 md:mt-0 flex w-full md:w-auto gap-2" @submit.prevent="submitEmail">
<input v-model="email" aria-label="EMail" type="email" required placeholder="dein@email" class="w-full md:w-72 px-4 py-3 rounded-xl bg-white/5 border border-white/10 placeholder-white/40 outline-none focus:border-cyan-400" />
<button class="btn btn-primary">Einladung anfordern</button>
</form>
</div>
<p v-if="thanks" class="mt-3 text-sm text-green-300">Danke! Wir melden uns in Kürze mit deinem Zugang.</p>
</div>
</section>
<!-- FAQ -->
<section id="faq" class="py-16 md:py-24 bg-[#0a0f1c]">
<div class="mx-auto max-w-[1200px] px-4">
<div class="max-w-2xl mb-10">
<h2 class="text-3xl md:text-4xl font-semibold">FAQ</h2>
<p class="mt-3 text-white/80">Kurz beantwortet.</p>
</div>
<div class="grid md:grid-cols-2 gap-6">
<div class="card">
<h3 class="font-semibold">Ist das für reale Luftfahrt?</h3>
<p class="mt-2 text-white/80">Nein, OpenSquawk ist für Flugsimulatoren und Training. Nicht für den realen Flugfunk.</p>
</div>
<div class="card">
<h3 class="font-semibold">Welche Simulatoren werden unterstützt?</h3>
<p class="mt-2 text-white/80">MSFS, XPlane (weitere geplant). Integrationen per PluginSDK.</p>
</div>
<div class="card">
<h3 class="font-semibold">Kann ich selbst hosten?</h3>
<p class="mt-2 text-white/80">Ja. DockerCompose/Helm bereitgestellt. HostedPlan als bequeme Alternative.</p>
</div>
<div class="card">
<h3 class="font-semibold">Wie günstig ist Hosted Basic?</h3>
<p class="mt-2 text-white/80">Ziel: knapp über Betriebskosten. Pilotphase mit 45 mtl. (vorläufig).</p>
</div>
</div>
</div>
</section>
<!-- FOOTER -->
<footer class="py-12 bg-[#0b1020] border-t border-white/10">
<div class="mx-auto max-w-[1200px] px-4">
<div class="grid md:grid-cols-4 gap-6">
<div>
<div class="flex items-center gap-2 font-semibold"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" class="text-cyan-400"><path d="M12 3l8 4v10l-8 4-8-4V7l8-4z" stroke="currentColor"/></svg>OpenSquawk</div>
<p class="mt-3 text-white/70 text-sm">OpenSource AIATC für Simulatorpiloten. Lernpfad & günstiger HostedPlan.</p>
</div>
<div>
<h4 class="font-semibold mb-3">Produkt</h4>
<ul class="space-y-2 text-white/70 text-sm">
<li><a href="#features" class="hover:text-cyan-300">Features</a></li>
<li><a href="#learn" class="hover:text-cyan-300">Lernpfad</a></li>
<li><a href="#pricing" class="hover:text-cyan-300">Preise</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-3">Ressourcen</h4>
<ul class="space-y-2 text-white/70 text-sm">
<li><a href="#opensource" class="hover:text-cyan-300">OpenSource</a></li>
<li><a href="#demo" class="hover:text-cyan-300">Demo</a></li>
<li><a href="#faq" class="hover:text-cyan-300">FAQ</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-3">Rechtliches</h4>
<ul class="space-y-2 text-white/70 text-sm">
<li><a href="#" class="hover:text-cyan-300">Impressum</a></li>
<li><a href="#" class="hover:text-cyan-300">Datenschutz</a></li>
<li><a href="#" class="hover:text-cyan-300">Nutzungshinweise</a></li>
</ul>
</div>
</div>
<div class="mt-8 pt-6 border-t border-white/10 text-xs text-white/60">© {{ year }} OpenSquawk. Nicht für reale Luftfahrt. *VATSIM/IVAO: Marken der jeweiligen Eigentümer.</div>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useHead } from '#imports'
const yearly = ref(true)
const email = ref('')
const thanks = ref(false)
const year = new Date().getFullYear()
function submitEmail(){
// TODO: replace with real endpoint
console.log('request access:', email.value)
thanks.value = true
}
useHead({
title: 'OpenSquawk OpenSource AIATC für Simulatorpiloten',
meta: [
{ name: 'description', content: 'OpenSquawk ist die offene, günstige Alternative für AIATC im Flugsimulator mit Lernpfad zu echter Funkphraseologie und sanftem Einstieg Richtung VATSIM.' },
{ name: 'theme-color', content: '#0ea5e9' },
{ property: 'og:title', content: 'OpenSquawk OpenSource AIATC' },
{ property: 'og:description', content: 'Open, günstig, lernfreundlich. AIATC mit Lernpfad & gehostetem Plan.' },
{ property: 'og:type', content: 'website' },
{ property: 'og:image', content: 'https://opensquawk.example.com/cover.png' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: 'OpenSquawk OpenSource AIATC' },
{ name: 'twitter:description', content: 'Open, günstig, lernfreundlich. AIATC mit Lernpfad & gehostetem Plan.' },
{ name: 'twitter:image', content: 'https://opensquawk.example.com/cover.png' }
],
script: [
{ src: 'https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js', defer: true },
{ src: 'https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js', defer: true },
{ type: 'application/ld+json', children: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'OpenSquawk',
applicationCategory: 'Simulation',
offers: { '@type': 'AggregateOffer', lowPrice: '0', priceCurrency: 'EUR' },
description: 'OpenSource AIATC für Flugsimulatoren mit Lernpfad & HostedPlan.',
operatingSystem: 'Windows, macOS'
}) }
]
})
onMounted(() => {
const w: any = window as any
const gsap = w.gsap
const ScrollTrigger = w.ScrollTrigger
if (!gsap || !ScrollTrigger) return
gsap.registerPlugin(ScrollTrigger)
gsap.from('header nav', { y: -30, opacity: 0, duration: .6, ease: 'power2'})
gsap.from('.card', { opacity: 0, y: 20, duration: .6, stagger: .08, ease: 'power2', scrollTrigger: { trigger: '#features', start: 'top 80%' }})
gsap.from('#pricing .card', { opacity: 0, y: 24, duration: .6, stagger: .08, ease: 'power2', scrollTrigger: { trigger: '#pricing', start: 'top 80%' }})
gsap.from('#learn .card, #learn h2, #learn ol li', { opacity: 0, y: 18, duration: .6, stagger: .06, ease: 'power2', scrollTrigger: { trigger: '#learn', start: 'top 80%' }})
gsap.from('#opensource .card', { opacity: 0, y: 18, duration: .6, stagger: .06, ease: 'power2', scrollTrigger: { trigger: '#opensource', start: 'top 80%' }})
gsap.from('#cta .card', { opacity: 0, scale: .98, duration: .6, ease: 'power2', scrollTrigger: { trigger: '#cta', start: 'top 90%' }})
})
</script>
<style scoped>
.glass { background: rgba(255,255,255,.06); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,.08); }
.card { @apply glass rounded-2xl p-5 md:p-6; }
.btn { @apply inline-flex items-center justify-center gap-2 rounded-xl px-5 py-3 font-medium transition; }
.btn-primary { @apply bg-cyan-500 text-white hover:bg-cyan-400 shadow-[0_0_40px_rgba(34,211,238,.25)]; }
.btn-ghost { @apply bg-white/5 text-white hover:bg-white/10; }
.chip { @apply inline-flex items-center gap-2 rounded-full bg-white/10 border border-white/15 text-white px-3 py-1 text-xs; }
.gradient-hero { background: radial-gradient(1200px 600px at 10% -10%, rgba(6,182,212,.35), transparent), radial-gradient(900px 480px at 100% 10%, rgba(59,130,246,.25), transparent), linear-gradient(180deg, #0b1020 0%, #0b1020 60%, #0a0f1c 100%); }
</style>

58
app/pages/ptt.vue Normal file
View File

@@ -0,0 +1,58 @@
<template>
<v-container class="py-8" max-width="800">
<h1 class="text-h5 mb-4">ATC Push-to-Talk</h1>
<v-card class="pa-4 mb-4">
<v-btn :color="recording?'red':'primary'"
@mousedown="startRec" @mouseup="stopRec"
@touchstart.prevent="startRec" @touchend.prevent="stopRec">
{{ recording ? 'Recording' : 'Push-to-Talk' }}
</v-btn>
<v-btn class="ml-3" :disabled="!lastBlob" @click="send('text')"> Text</v-btn>
<v-btn class="ml-3" :disabled="!lastBlob" @click="send('tts')"> TTS</v-btn>
<div class="mt-4">
<div><strong>Status:</strong> {{ status }}</div>
<div v-if="pilotText"><strong>Pilot:</strong> {{ pilotText }}</div>
<div v-if="replyText"><strong>ATC:</strong> {{ replyText }}</div>
</div>
<audio v-if="audioUrl" class="mt-3" :src="audioUrl" controls></audio>
</v-card>
</v-container>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const recording = ref(false), status = ref('Bereit');
let mediaRecorder: MediaRecorder | null = null; let chunks: BlobPart[] = [];
const lastBlob = ref<Blob|null>(null), pilotText = ref(''), replyText = ref(''), audioUrl = ref<string|null>(null);
async function initMedia() {
if (mediaRecorder) return;
const stream = await navigator.mediaDevices.getUserMedia({ audio:true });
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
mediaRecorder = new MediaRecorder(stream, { mimeType: mime });
mediaRecorder.ondataavailable = e => { if (e.data.size) chunks.push(e.data); };
mediaRecorder.onstop = () => {
const blob = new Blob(chunks, { type: mediaRecorder?.mimeType || 'audio/webm' });
chunks = []; lastBlob.value = blob;
status.value = `Aufnahme fertig (${Math.round(blob.size/1024)} KB)`;
};
}
async function startRec(){ await initMedia(); if(!mediaRecorder)return;
recording.value=true; status.value='Aufnahme…'; chunks=[]; mediaRecorder.start(10);
}
function stopRec(){ if(!mediaRecorder||!recording.value)return;
recording.value=false; status.value='Verarbeite…'; mediaRecorder.stop();
}
async function send(mode:'text'|'tts'){ if(!lastBlob.value)return;
status.value=`Sende (${mode})…`;
const fd = new FormData(); fd.append('mode', mode); fd.append('file', lastBlob.value, 'ptt.webm');
const res = await fetch('/api/ptt/reply',{ method:'POST', body:fd });
if(!res.ok){ status.value=`Fehler ${res.status}`; return; }
const data = await res.json();
pilotText.value = data.pilotText || ''; replyText.value = data.replyText || '';
if (data.audio?.base64) {
const buf = Uint8Array.from(atob(data.audio.base64), c => c.charCodeAt(0));
audioUrl.value = URL.createObjectURL(new Blob([buf], { type: 'audio/wav' }));
}
status.value='OK';
}
</script>

6
nuxt.config.ts Normal file
View File

@@ -0,0 +1,6 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: ['vuetify-nuxt-module']
})

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "opensquawk",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"dotenv": "^17.2.2",
"nuxt": "^4.1.1",
"openai": "^5.20.2",
"vue": "^3.5.21",
"vue-router": "^4.5.1",
"vuetify": "3.9.0-beta.1",
"vuetify-nuxt-module": "0.18.7"
},
"packageManager": "yarn@4.9.4"
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-Agent: *
Disallow:

View File

@@ -0,0 +1,35 @@
import { readMultipartFormData, createError } from "h3";
import { openai, LLM_MODEL, TTS_MODEL, atcReplyPrompt } from "../../utils/openai";
import { applyRadioEffect } from "../../utils/radio";
import { writeFile, rm } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
export default defineEventHandler(async (event) => {
const parts = await readMultipartFormData(event);
const mode = parts?.find(p=>p.name==="mode")?.data?.toString("utf8") || "text";
const audio = parts?.find(p=>p.type && p.data);
if (!audio) throw createError({ statusCode:400, statusMessage:"No audio" });
const file = new File([audio.data], audio.filename || "audio.webm", { type: audio.type || "audio/webm" });
const tr = await openai.audio.transcriptions.create({ model: "whisper-1", file });
const pilotText = tr.text.trim();
const resp = await openai.responses.create({ model: LLM_MODEL, input: atcReplyPrompt(pilotText) });
const replyText = resp.output_text?.trim() || "";
if (mode === "tts") {
const clean = join(tmpdir(), `tts-${randomUUID()}.wav`);
const radio = join(tmpdir(), `radio-${randomUUID()}.wav`);
const tts = await openai.audio.speech.create({ model: TTS_MODEL, voice: "alloy", format: "wav", input: replyText });
await writeFile(clean, Buffer.from(await tts.arrayBuffer()));
await applyRadioEffect(clean, radio);
const data = await import("node:fs/promises").then(fs => fs.readFile(radio));
await rm(clean).catch(()=>{});
const b64 = Buffer.from(data).toString("base64");
await rm(radio).catch(()=>{});
return { pilotText, replyText, audio:{ mime:"audio/wav", base64:b64 } };
}
return { pilotText, replyText };
});

View File

@@ -0,0 +1,10 @@
import { readMultipartFormData, createError } from "h3";
import { openai } from "../../utils/openai";
export default defineEventHandler(async (event) => {
const parts = await readMultipartFormData(event);
const audio = parts?.find(p=>p.type && p.data);
if (!audio) throw createError({ statusCode:400, statusMessage:"No audio" });
const file = new File([audio.data], audio.filename || "audio.webm", { type: audio.type || "audio/webm" });
const tr = await openai.audio.transcriptions.create({ model: "whisper-1", file });
return { text: tr.text };
});

8
server/utils/openai.ts Normal file
View File

@@ -0,0 +1,8 @@
import OpenAI from "openai";
export const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
export const LLM_MODEL = process.env.LLM_MODEL || "gpt-5-nano";
export const TTS_MODEL = process.env.TTS_MODEL || "tts-1";
export function atcReplyPrompt(userText: string) {
return `You are an ICAO-compliant ATC controller. Reply concisely.
Pilot said: "${userText}"`;
}

8
server/utils/radio.ts Normal file
View File

@@ -0,0 +1,8 @@
import { execFile } from "node:child_process";
export async function applyRadioEffect(input: string, output: string) {
const filter="[0:a]highpass=f=300,lowpass=f=3400,compand=attacks=0.02:decays=0.25:points=-80/-900|-70/-20|0/-10|20/-8:gain=6,volume=1.2[a];anoisesrc=color=white:amplitude=0.02[ns];[a][ns]amix=inputs=2:weights=1 0.25:duration=shortest,volume=1.0,aecho=0.6:0.7:8:0.08,acompressor=threshold=0.6:ratio=6:attack=20:release=200";
await new Promise((res,rej)=>
execFile("ffmpeg",["-y","-i",input,"-filter_complex",filter,"-ar","16000",output],
(err,_,stderr)=>err?rej(new Error(stderr)):res())
);
}

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}

7687
yarn.lock Normal file

File diff suppressed because it is too large Load Diff