mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-07 01:55:51 +08:00
Revise landing messaging and add news feed
This commit is contained in:
@@ -86,8 +86,10 @@
|
||||
const publicEndpoints = [
|
||||
{ method: 'POST', path: '/api/service/waitlist', description: 'Person auf die Warteliste setzen. Erfordert Zustimmung zu AGB & Datenschutz.' },
|
||||
{ method: 'GET', path: '/api/service/waitlist', description: 'Aggregierte Wartelistenstatistiken mit Gesamtanzahl, Wachstum und sichtbarem Puffer abrufen.' },
|
||||
{ method: 'POST', path: '/api/service/updates', description: 'E-Mail für Produkt-Updates und neue Features eintragen (Einwilligung erforderlich).' },
|
||||
{ method: 'GET', path: '/api/service/roadmap', description: 'Roadmap-Items inklusive Community-Durchschnitt, Gesamtstimmen und letzter Aktivität auslesen.' },
|
||||
{ method: 'POST', path: '/api/service/roadmap', description: 'Wichtigkeit (1–5) für einzelne Roadmap-Punkte voten; speichert jeden Vote mit Zeitstempel.' },
|
||||
{ method: 'POST', path: '/api/service/roadmap-suggestions', description: 'Neuen Roadmap-Vorschlag mit optionaler Kontaktadresse einreichen.' },
|
||||
{ method: 'POST', path: '/api/service/auth/login', description: 'Login mit E-Mail & Passwort. Gibt JWT zurück und setzt Refresh-Cookie.' },
|
||||
{ method: 'POST', path: '/api/service/auth/register', description: 'Registrierung mit Einladungscode und Einwilligungen.' },
|
||||
{ method: 'POST', path: '/api/service/auth/refresh', description: 'Access-Token anhand des Refresh-Cookies erneuern.' },
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
<h2 class="text-xl font-semibold">2. Verarbeitete Daten</h2>
|
||||
<ul class="list-disc space-y-2 pl-6 text-white/70">
|
||||
<li><strong>Warteliste:</strong> Name (optional), E-Mail-Adresse, freiwillige Hinweise, Zeitpunkt des Eintrags, Einwilligungen.</li>
|
||||
<li><strong>Feature-Benachrichtigungen:</strong> E-Mail-Adresse, optionaler Name, Opt-in-Zeitpunkt, Zustimmung für Produkt-Updates.</li>
|
||||
<li><strong>Nutzerkonto:</strong> Name, E-Mail-Adresse, Passwort-Hash, Einladungsstatus, Erstellungs- und Login-Zeitpunkte, Einladungscode-Historie.</li>
|
||||
<li><strong>Roadmap-Vorschläge:</strong> Titel, Beschreibung, optional angegebene Kontaktadresse, Einwilligungen und Zeitstempel.</li>
|
||||
<li><strong>Kommunikationsdaten:</strong> Sämtliche eingegebenen oder per Push-to-Talk übermittelten Funksprüche (Transkripte, normalisierte Texte, Metaangaben wie Modul, Lesson-ID, Stärke, Entscheidungskontext).</li>
|
||||
<li><strong>Technische Protokolle:</strong> Geräteinformationen (Browser, OS), Zeitstempel, Request-IDs, Fehlerlogs.</li>
|
||||
</ul>
|
||||
@@ -33,7 +35,7 @@
|
||||
<ul class="space-y-3 text-white/70">
|
||||
<li><strong>Bereitstellung des Dienstes (Art. 6 Abs. 1 lit. b DSGVO):</strong> Verwaltung von Warteliste, Accounts und Sessions, Ausgabe von Einladungscodes, Abrechnung zukünftiger Tarife.</li>
|
||||
<li><strong>Produktverbesserung & Sicherheit (Art. 6 Abs. 1 lit. f DSGVO):</strong> Analyse und Logging sämtlicher Funksprüche, Fehlerüberwachung, Missbrauchsprävention.</li>
|
||||
<li><strong>Kommunikation (Art. 6 Abs. 1 lit. a DSGVO):</strong> Versand von Wartelisten-Updates, Einladungen und produktspezifischen Informationen nach ausdrücklicher Einwilligung.</li>
|
||||
<li><strong>Kommunikation & Community-Feedback (Art. 6 Abs. 1 lit. a DSGVO):</strong> Versand von Wartelisten-Updates und Produkt-News nach ausdrücklicher Einwilligung sowie Auswertung eingereichter Roadmap-Vorschläge inkl. optionaler Kontaktaufnahme.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -41,6 +43,8 @@
|
||||
<h2 class="text-xl font-semibold">4. Speicherdauer</h2>
|
||||
<ul class="space-y-2 text-white/70">
|
||||
<li>Wartelisteneinträge: bis zum Widerruf oder maximal 24 Monate nach letzter Aktivität.</li>
|
||||
<li>Feature-Benachrichtigungen: bis zur Abmeldung (Opt-out) oder Widerruf der Einwilligung.</li>
|
||||
<li>Roadmap-Vorschläge: bis zur Umsetzung oder spätestens 18 Monate nach Einreichung.</li>
|
||||
<li>Accountdaten: solange das Nutzerkonto besteht, anschließend entsprechend gesetzlicher Aufbewahrungspflichten.</li>
|
||||
<li>Kommunikationslogs: mindestens 12 Monate zur Qualitätsverbesserung; bei Supportfällen oder Missbrauchsvorwürfen bis zur abschließenden Klärung.</li>
|
||||
</ul>
|
||||
@@ -51,6 +55,9 @@
|
||||
<p class="text-white/70">
|
||||
Wir hosten OpenSquawk auf europäischen Cloud-Plattformen (derzeit Hetzner Cloud, Deutschland). Kommunikationsdaten werden in unserer MongoDB-Datenbank gespeichert. Externe KI-Dienstleister (z. B. OpenAI) erhalten ausschließlich pseudonymisierte Texte zur Verarbeitung von TTS/LLM-Funktionen. Es gelten entsprechende Auftragsverarbeitungsverträge. Eine Übermittlung in Drittstaaten erfolgt nur unter Nutzung von EU-Standardvertragsklauseln.
|
||||
</p>
|
||||
<p class="text-xs text-white/60">
|
||||
Hinweis: Formularübermittlungen (Warteliste, Feature-Benachrichtigungen, Roadmap-Vorschläge) lösen eine interne Benachrichtigungs-E-Mail an opensquawk@faktorxmensch.com aus, versendet über den konfigurierten SMTP- oder Transaktionsmail-Anbieter. Enthalten sind ausschließlich die von dir eingegebenen Angaben zur zügigen Bearbeitung.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
|
||||
@@ -75,7 +82,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const lastUpdated = new Date('2025-01-15').toLocaleDateString('de-DE')
|
||||
const lastUpdated = new Date('2025-02-20').toLocaleDateString('de-DE')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<h2 class="text-xl font-semibold">Kontakt</h2>
|
||||
<div class="space-y-2 text-white/70">
|
||||
<p>Telefon: +49 251 981 157 912 0</p>
|
||||
<p>E-Mail: info@faktorxmensch.com</p>
|
||||
<p>E-Mail: opensquawk@faktorxmensch.com</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -8,15 +8,17 @@
|
||||
<span class="text-white">OpenSquawk</span>
|
||||
</NuxtLink>
|
||||
<div class="hidden md:flex gap-6 text-sm">
|
||||
<NuxtLink to="#features" class="hover:text-cyan-300">Features</NuxtLink>
|
||||
<NuxtLink to="#features" class="hover:text-cyan-300">Vision</NuxtLink>
|
||||
<NuxtLink to="#roadmap" class="hover:text-cyan-300">Roadmap</NuxtLink>
|
||||
<NuxtLink to="#news" class="hover:text-cyan-300">News</NuxtLink>
|
||||
<NuxtLink to="#learn" class="hover:text-cyan-300">Lernpfad</NuxtLink>
|
||||
<NuxtLink to="#pricing" class="hover:text-cyan-300">Preise</NuxtLink>
|
||||
<NuxtLink to="#opensource" class="hover:text-cyan-300">Open‑Source</NuxtLink>
|
||||
<NuxtLink to="#contributing" class="hover:text-cyan-300">Mitmachen</NuxtLink>
|
||||
<NuxtLink to="#faq" class="hover:text-cyan-300">FAQ</NuxtLink>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink to="#demo" class="btn btn-ghost text-sm">Live‑Demo</NuxtLink>
|
||||
<NuxtLink to="/news" class="btn btn-ghost text-sm">News</NuxtLink>
|
||||
<NuxtLink to="#cta" class="btn btn-primary text-sm">Frühzugang</NuxtLink>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -30,20 +32,77 @@
|
||||
|
||||
<div class="container-outer pt-16 pb-12 md:pt-24 md:pb-20">
|
||||
<div class="max-w-2xl" data-aos="fade-up">
|
||||
<span class="chip mb-4">Open‑Source · AI‑ATC · Simulator</span>
|
||||
<h1 class="text-4xl md:text-6xl font-semibold leading-tight">OpenSquawk<br/><span class="text-cyan-400">AI‑ATC, offen & bezahlbar</span></h1>
|
||||
<p class="mt-4 md:mt-6 text-white/80 text-base md:text-lg">Offene Alternative für AI‑ATC 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>
|
||||
<span class="chip mb-4">Alpha · Open-Source AI ATC</span>
|
||||
<h1 class="text-4xl md:text-6xl font-semibold leading-tight">
|
||||
OpenSquawk<br/>
|
||||
<span class="text-cyan-400">Open-source, low-cost AI ATC</span>
|
||||
</h1>
|
||||
<p class="mt-4 md:mt-6 text-white/80 text-base md:text-lg">
|
||||
Wir bauen eine offene, bezahlbare KI-Flugsicherung für Flugsimulatoren – community-getrieben, mit selbst gehosteten
|
||||
und gehosteten Optionen in Planung.
|
||||
</p>
|
||||
<ul class="mt-6 space-y-2 text-white/70 text-sm md:text-base">
|
||||
<li class="flex items-start gap-2">
|
||||
<v-icon icon="mdi-account-group" size="18" class="mt-[3px] text-cyan-300" />
|
||||
<span>Community-Roadmap bestimmt Prioritäten. Features landen nur, wenn sie euch wirklich helfen.</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<v-icon icon="mdi-airplane" size="18" class="mt-[3px] text-cyan-300" />
|
||||
<span><strong>Simulator-Support</strong>: MSFS zuerst, X-Plane als nächstes – mit Blick auf VATSIM-kompatible Trainings.</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-4 text-sm text-cyan-200/80">
|
||||
Alpha-Prototyp verfügbar – läuft lokal und setzt leichte Programmierkenntnisse voraus.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-white/70">
|
||||
Wir suchen Mitentwickler:innen (Node/Nuxt, ATC SMEs, Tester, Infra/Kosten). Meld dich via
|
||||
<a class="text-cyan-300 underline" href="mailto:opensquawk@faktorxmensch.com">opensquawk@faktorxmensch.com</a>.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-3 mt-6">
|
||||
<NuxtLink to="#cta" class="btn btn-primary text-base">Frühzugang sichern</NuxtLink>
|
||||
<NuxtLink to="#video" class="btn btn-ghost text-base">1‑Min‑Überblick ansehen</NuxtLink>
|
||||
<NuxtLink to="/news" class="btn btn-ghost text-base">News lesen</NuxtLink>
|
||||
</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 Early‑Access</p>
|
||||
<div class="mt-8 max-w-xl" data-aos="fade-up" data-aos-delay="120">
|
||||
<form class="rounded-2xl border border-white/10 bg-black/30 p-5 space-y-4" @submit.prevent="submitUpdates">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-lg font-semibold">Neue Features zuerst erfahren</h3>
|
||||
<p class="text-sm text-white/70">Trag dich in die Feature-Liste ein und erhalte Updates zu Releases, Drops & Lerninhalten.</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<input
|
||||
v-model.trim="updatesForm.email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="dein@email"
|
||||
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm placeholder-white/40 outline-none focus:border-cyan-400"
|
||||
aria-label="E-Mail für Produkt-Updates"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary w-full sm:w-auto"
|
||||
:disabled="!updatesFormValid || updatesSubmitting"
|
||||
>
|
||||
<span v-if="updatesSubmitting" class="flex items-center gap-2">
|
||||
<v-progress-circular indeterminate size="16" width="2" color="white" />
|
||||
Speichere…
|
||||
</span>
|
||||
<span v-else>Benachrichtigt mich</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2 text-xs text-white/60">
|
||||
<label class="flex items-start gap-3">
|
||||
<input type="checkbox" v-model="updatesForm.consentMarketing" class="mt-1" required />
|
||||
<span>Ja, informiert mich per E-Mail über neue Features, Wartelisten-Drops & Lerninhalte.</span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3">
|
||||
<input type="checkbox" v-model="updatesForm.consentPrivacy" class="mt-1" required />
|
||||
<span>Ich habe die <NuxtLink to="/datenschutz" class="text-cyan-300 underline">Datenschutzerklärung</NuxtLink> gelesen.</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="updatesSuccess" class="text-sm text-green-300">Danke! Wir informieren dich, sobald neue Features live gehen.</p>
|
||||
<p v-else-if="updatesError" class="text-sm text-red-300">{{ updatesError }}</p>
|
||||
<p v-else class="text-xs text-white/50">Kein Spam – maximal relevante Produkt-Updates. Abmeldung jederzeit per Link möglich.</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden mt-10 md:mt-16" data-aos="zoom-in" data-aos-delay="100">
|
||||
@@ -59,11 +118,11 @@
|
||||
<!-- SOCIAL PROOF / LOGOS -->
|
||||
<section class="py-10 border-t border-white/10 bg-[#0a0f1c]" data-aos="fade-up">
|
||||
<div class="container-outer">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 items-center opacity-80">
|
||||
<div class="flex items-center justify-center gap-2 text-white/60"><v-icon icon="mdi-microsoft-xbox" class="opacity-70"/>MSFS</div>
|
||||
<div class="flex items-center justify-center gap-2 text-white/60"><v-icon icon="mdi-airplane" class="opacity-70"/>X‑Plane</div>
|
||||
<div class="flex items-center justify-center gap-2 text-white/60"><v-icon icon="mdi-headset" class="opacity-70"/>VATSIM* (Lernpfad)</div>
|
||||
<div class="flex items-center justify-center gap-2 text-white/60"><v-icon icon="mdi-radar" class="opacity-70"/>IVAO* (Lernpfad)</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 items-center opacity-80 text-sm md:text-base">
|
||||
<div class="flex items-center justify-center gap-2 text-white/60"><v-icon icon="mdi-test-tube" class="opacity-70"/>Alpha-Status</div>
|
||||
<div class="flex items-center justify-center gap-2 text-white/60"><v-icon icon="mdi-microsoft-xbox" class="opacity-70"/>MSFS (Fokus)</div>
|
||||
<div class="flex items-center justify-center gap-2 text-white/60"><v-icon icon="mdi-airplane" class="opacity-70"/>X‑Plane (als nächstes)</div>
|
||||
<div class="flex items-center justify-center gap-2 text-white/60"><v-icon icon="mdi-headset" class="opacity-70"/>VATSIM-Lernpfad (geplant)</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -72,58 +131,93 @@
|
||||
<section id="features" class="py-16 md:py-24 bg-gradient-to-b from-[#0a0f1c] to-[#0b1020]">
|
||||
<div class="container-outer">
|
||||
<div class="max-w-2xl mb-10" data-aos="fade-up">
|
||||
<h2 class="text-3xl md:text-4xl font-semibold">Warum OpenSquawk?</h2>
|
||||
<p class="mt-3 text-white/80">Echtzeit‑Funk mit KI, lernfreundliche Erklärungen und offene Architektur – damit du souverän taxi, departure, approach & landing beherrschst.</p>
|
||||
<h2 class="text-3xl md:text-4xl font-semibold">Vision & Fokus</h2>
|
||||
<p class="mt-3 text-white/80">Open-source, low-cost AI ATC für Flugsimulatoren. Community-getriebene Features, transparente Kosten und Wahlfreiheit zwischen Self-host und späterem Hosting.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<div class="card" data-aos="fade-up" data-aos-delay="0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-cyan-500/20 border border-cyan-400/20"><v-icon icon="mdi-tower-fire" class="text-cyan-300"/></div>
|
||||
<h3 class="font-semibold text-lg">AI‑ATC in Echtzeit</h3>
|
||||
<h3 class="font-semibold text-lg">Offene AI-ATC-Grundlage</h3>
|
||||
</div>
|
||||
<p class="mt-3 text-white/80">Streaming‑ASR, LLM‑Verständnis & TTS Stimmen. Klar, schnell, latenzarm – optimiert für Phraseologie.</p>
|
||||
<p class="mt-3 text-white/80">Wir entwickeln eine erschwingliche Kernplattform für Funkanalyse, Entscheidungslogik und Voices, die jede:r inspizieren und erweitern kann.</p>
|
||||
</div>
|
||||
<div class="card" data-aos="fade-up" data-aos-delay="100">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-cyan-500/20 border border-cyan-400/20"><v-icon icon="mdi-school" class="text-cyan-300"/></div>
|
||||
<h3 class="font-semibold text-lg">Lernpfad → VATSIM</h3>
|
||||
<div class="p-2 rounded-lg bg-cyan-500/20 border border-cyan-400/20"><v-icon icon="mdi-vote" class="text-cyan-300"/></div>
|
||||
<h3 class="font-semibold text-lg">Community entscheidet</h3>
|
||||
</div>
|
||||
<p class="mt-3 text-white/80">Geführte Übungen, Checks & Erklärungen – vom Readback bis zu komplexen Clearances. Schrittweise Richtung Online‑Netzwerke.</p>
|
||||
<p class="mt-3 text-white/80">Roadmap-Votes und Vorschläge aus der Community lenken unsere Prioritäten – Training, Tools oder Integrationen entstehen mit euch.</p>
|
||||
</div>
|
||||
<div class="card" data-aos="fade-up" data-aos-delay="200">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-cyan-500/20 border border-cyan-400/20"><v-icon icon="mdi-source-repository" class="text-cyan-300"/></div>
|
||||
<h3 class="font-semibold text-lg">Open‑Source + Hosted</h3>
|
||||
<h3 class="font-semibold text-lg">Self-host & Hosted Optionen</h3>
|
||||
</div>
|
||||
<p class="mt-3 text-white/80">Selbst hosten oder unseren günstigen Plan nutzen. Transparente Architektur, Community‑Plugins & APIs.</p>
|
||||
<p class="mt-3 text-white/80">Beta-Ziel: eigene Instanz per Docker in Minuten oder später fair kalkulierte Hosting-Pläne – offen, transparent, bezahlbar.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6 mt-6">
|
||||
<div class="card" data-aos="fade-up" data-aos-delay="0">
|
||||
<h3 class="font-semibold text-lg">Taxi‑Routen & Ground Awareness</h3>
|
||||
<p class="mt-3 text-white/80">OSM/apt.dat‑basierte Taxiway‑Graphen, A*‑Routing zu Holding Points, progressive Anweisungen mit Visual‑Overlay.</p>
|
||||
<h3 class="font-semibold text-lg">Simulator-Support (Plan)</h3>
|
||||
<p class="mt-3 text-white/80">Wir fokussieren zuerst Microsoft Flight Simulator, arbeiten parallel am X-Plane-Konzept und sammeln Feedback für weitere Plattformen.</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>Hotspot‑Vermeidung & progressive Readbacks</li>
|
||||
<li>Optional: NOTAM/ATIS Einbindung</li>
|
||||
<li>MSFS Alpha: lokale Anbindung + Funkworkflows im Test</li>
|
||||
<li>X-Plane: Plugin-Skizze, Community-Review willkommen</li>
|
||||
<li>VATSIM/IVAO: Lernpfad & Phraseologie-Übungen geplant</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card" data-aos="fade-up" data-aos-delay="100">
|
||||
<h3 class="font-semibold text-lg">Tech‑Stack</h3>
|
||||
<p class="mt-3 text-white/80">Streaming‑ASR, LLM‑Dialog, TTS – orchestriert mit WebRTC/WS. Offline‑Fallbacks möglich.</p>
|
||||
<h3 class="font-semibold text-lg">Alpha-Prototyp</h3>
|
||||
<p class="mt-3 text-white/80">Der aktuelle Build läuft lokal (Node/Nuxt + Services). Zum Setup brauchst du Terminal-Basics und Lust auf Experimente.</p>
|
||||
<div class="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div class="glass rounded-xl p-3 flex items-center gap-2"><v-icon icon="mdi-waveform"/>ASR: Whisper/Alternativen</div>
|
||||
<div class="glass rounded-xl p-3 flex items-center gap-2"><v-icon icon="mdi-robot"/>LLM: GPT‑5‑Nano/4o‑Mini</div>
|
||||
<div class="glass rounded-xl p-3 flex items-center gap-2"><v-icon icon="mdi-microphone"/>TTS: neural voices</div>
|
||||
<div class="glass rounded-xl p-3 flex items-center gap-2"><v-icon icon="mdi-routes"/>Routing: A* / Dijkstra</div>
|
||||
<div class="glass rounded-xl p-3 flex items-center gap-2"><v-icon icon="mdi-console"/>Setup via CLI</div>
|
||||
<div class="glass rounded-xl p-3 flex items-center gap-2"><v-icon icon="mdi-docker"/>Docker Compose optional</div>
|
||||
<div class="glass rounded-xl p-3 flex items-center gap-2"><v-icon icon="mdi-account-hard-hat"/>Fehler melden erwünscht</div>
|
||||
<div class="glass rounded-xl p-3 flex items-center gap-2"><v-icon icon="mdi-source-branch"/>PRs & Roadmap-Votes willkommen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- NEWS -->
|
||||
<section id="news" class="py-16 md:py-24 bg-[#0b1020] border-y border-white/10">
|
||||
<div class="container-outer space-y-8">
|
||||
<div class="max-w-2xl" data-aos="fade-up">
|
||||
<h2 class="text-3xl md:text-4xl font-semibold">News & Updates</h2>
|
||||
<p class="mt-3 text-white/80">Content-driven Beiträge halten dich auf dem Laufenden. Alle Artikel liegen als Markdown vor – neue Stories landen automatisch hier.</p>
|
||||
</div>
|
||||
<div v-if="latestNews.length" class="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
<article
|
||||
v-for="post in latestNews"
|
||||
:key="post.slug"
|
||||
class="card flex flex-col gap-4"
|
||||
data-aos="fade-up"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<span class="chip text-[10px] uppercase tracking-[0.3em]">{{ formatNewsDate(post.publishedAt) }}</span>
|
||||
<h3 class="text-xl font-semibold">{{ post.title }}</h3>
|
||||
<p class="text-sm text-white/70">{{ post.excerpt }}</p>
|
||||
</div>
|
||||
<div class="mt-auto flex items-center justify-between text-xs text-white/60">
|
||||
<span>{{ post.readingTime }}</span>
|
||||
<NuxtLink :to="`/news/${post.slug}`" class="text-cyan-300 text-sm font-medium hover:underline">Weiterlesen</NuxtLink>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="card text-sm text-white/70" data-aos="fade-up">
|
||||
Noch keine News veröffentlicht – der erste Beitrag folgt in Kürze.
|
||||
</div>
|
||||
<div class="flex items-center gap-3" data-aos="fade-up">
|
||||
<NuxtLink to="/news" class="btn btn-ghost">Alle News ansehen</NuxtLink>
|
||||
<span class="text-xs text-white/50">Schreib uns, falls du eigene Erfahrungsberichte teilen willst.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ROADMAP -->
|
||||
<section id="roadmap" class="py-16 md:py-24 bg-[#0b1020] border-y border-white/10">
|
||||
<div class="container-outer space-y-10">
|
||||
@@ -179,22 +273,25 @@
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between text-[11px] uppercase tracking-[0.3em] text-white/50">
|
||||
<span>Weniger</span>
|
||||
<span>Priorität</span>
|
||||
<span>Priorität wählen</span>
|
||||
<span v-if="roadmapTouched[item.key]" class="text-cyan-300">markiert</span>
|
||||
</div>
|
||||
<div class="roadmap-scale" role="group" :aria-label="`Priorität für ${item.title}`">
|
||||
<button
|
||||
v-for="value in ROADMAP_SCALE"
|
||||
:key="value"
|
||||
type="button"
|
||||
class="roadmap-pill"
|
||||
:class="{ 'is-active': roadmapSelections[item.key] === value }"
|
||||
:aria-pressed="roadmapSelections[item.key] === value"
|
||||
@click="selectRoadmapImportance(item.key, value)"
|
||||
>
|
||||
<span class="text-sm font-semibold">{{ value }}</span>
|
||||
<span class="label">{{ roadmapImportanceShortLabel(value) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="5"
|
||||
step="1"
|
||||
v-model.number="roadmapSelections[item.key]"
|
||||
@change="markRoadmapTouched(item.key)"
|
||||
class="roadmap-range"
|
||||
:aria-label="`Priorität für ${item.title}`"
|
||||
/>
|
||||
<div class="text-sm text-white/80">
|
||||
{{ roadmapImportanceLabel(roadmapSelections[item.key]) }}
|
||||
<span v-if="roadmapTouched[item.key]" class="text-cyan-300">• markiert</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,11 +320,71 @@
|
||||
<p v-if="roadmapSuccess" class="text-center text-sm text-green-300 md:text-left">Danke! Deine Stimmen sind angekommen.</p>
|
||||
<p v-else-if="roadmapError" class="text-center text-sm text-red-300 md:text-left">{{ roadmapError }}</p>
|
||||
<p v-else class="text-center text-xs text-white/50 md:text-left">
|
||||
Tipp: Slider nur verändern, wenn du zu dem Punkt Feedback hast.
|
||||
Tipp: Prioritäten nur anpassen, wenn du zu dem Punkt Feedback hast.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card space-y-4" data-aos="fade-up">
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-lg font-semibold">Fehlt dir etwas in der Roadmap?</h4>
|
||||
<p class="text-sm text-white/70">Teile deinen Feature-Wunsch oder ein Problem, das wir lösen sollen. Wir priorisieren Community-Ideen.</p>
|
||||
</div>
|
||||
<form class="space-y-3" @submit.prevent="submitRoadmapSuggestion">
|
||||
<input
|
||||
v-model.trim="roadmapSuggestionForm.title"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Kurzer Titel – z. B. 'ATIS-Integration für EDDF'"
|
||||
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm placeholder-white/40 outline-none focus:border-cyan-400"
|
||||
aria-label="Titel für Roadmap-Vorschlag"
|
||||
/>
|
||||
<textarea
|
||||
v-model.trim="roadmapSuggestionForm.details"
|
||||
rows="4"
|
||||
required
|
||||
placeholder="Beschreibe, warum das wichtig ist oder wie es dir helfen würde."
|
||||
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm placeholder-white/40 outline-none focus:border-cyan-400"
|
||||
/>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input
|
||||
v-model.trim="roadmapSuggestionForm.email"
|
||||
type="email"
|
||||
placeholder="E-Mail (optional)"
|
||||
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm placeholder-white/40 outline-none focus:border-cyan-400"
|
||||
/>
|
||||
<label class="flex items-start gap-3 text-xs text-white/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="roadmapSuggestionForm.allowContact"
|
||||
class="mt-1"
|
||||
:disabled="!roadmapSuggestionForm.email"
|
||||
/>
|
||||
<span>Gern per E-Mail nachhaken, falls Rückfragen auftauchen.</span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex items-start gap-3 text-xs text-white/60">
|
||||
<input type="checkbox" v-model="roadmapSuggestionForm.consentPrivacy" class="mt-1" required />
|
||||
<span>Ich habe die <NuxtLink to="/datenschutz" class="text-cyan-300 underline">Datenschutzerklärung</NuxtLink> gelesen und willige in die Verarbeitung meines Vorschlags ein.</span>
|
||||
</label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary w-full sm:w-auto"
|
||||
:disabled="!roadmapSuggestionFormValid || roadmapSuggestionSubmitting"
|
||||
>
|
||||
<span v-if="roadmapSuggestionSubmitting" class="flex items-center gap-2">
|
||||
<v-progress-circular indeterminate size="16" width="2" color="white" />
|
||||
Sende Vorschlag…
|
||||
</span>
|
||||
<span v-else>Vorschlag senden</span>
|
||||
</button>
|
||||
<p v-if="roadmapSuggestionSuccess" class="text-sm text-green-300">Danke! Wir prüfen deinen Vorschlag und melden uns ggf. zurück.</p>
|
||||
<p v-else-if="roadmapSuggestionError" class="text-sm text-red-300">{{ roadmapSuggestionError }}</p>
|
||||
<p v-else class="text-xs text-white/50">Wir lesen jeden Vorschlag persönlich. Optionales Kontaktfeld nur für Rückfragen.</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -237,18 +394,18 @@
|
||||
<div class="container-outer">
|
||||
<div class="grid md:grid-cols-2 gap-8 items-center">
|
||||
<div data-aos="fade-right">
|
||||
<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/Approach‑Freigabe.</p>
|
||||
<h2 class="text-3xl md:text-4xl font-semibold">Lernpfad (in Arbeit)</h2>
|
||||
<p class="mt-3 text-white/80">Gemeinsam mit der Community strukturieren wir Trainingsmodule – vom ersten Funkkontakt bis zu Netzwerken wie VATSIM. Feedback aus Tests fließt direkt in jedes Kapitel.</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, Standard‑Phrasen, Readback.</span></li>
|
||||
<li class="flex gap-3"><span class="chip">2</span><span><b>Ground</b>: Taxi‑Flows, Hotspots, Holding‑short.</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>
|
||||
<li class="flex gap-3"><span class="chip">1</span><span><b>Basics</b> (Konzept): Funkalphabet, Zahlen, Standard-Readbacks.</span></li>
|
||||
<li class="flex gap-3"><span class="chip">2</span><span><b>Ground</b> (Alpha): Taxi-Flows, Hotspots, Holding-short.</span></li>
|
||||
<li class="flex gap-3"><span class="chip">3</span><span><b>Departure</b> (geplant): SID-Handling, Altitude-/Speed-Calls.</span></li>
|
||||
<li class="flex gap-3"><span class="chip">4</span><span><b>Arrival</b> (geplant): STARs, Vectors, Approach-Briefing.</span></li>
|
||||
<li class="flex gap-3"><span class="chip">5</span><span><b>VATSIM</b> (Community-Beta): Checklisten, Etiquette, Live-Übungen.</span></li>
|
||||
</ol>
|
||||
<div class="mt-6 flex gap-3">
|
||||
<NuxtLink to="/learn" class="btn btn-primary">Jetzt loslegen</NuxtLink>
|
||||
<NuxtLink to="#faq" class="btn btn-ghost">FAQ</NuxtLink>
|
||||
<NuxtLink to="#roadmap" class="btn btn-primary">Roadmap ansehen</NuxtLink>
|
||||
<NuxtLink to="/news" class="btn btn-ghost">News lesen</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" data-aos="fade-left">
|
||||
@@ -268,8 +425,8 @@
|
||||
<div class="container-outer">
|
||||
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-4" data-aos="fade-up">
|
||||
<div class="max-w-2xl">
|
||||
<h2 class="text-3xl md:text-4xl font-semibold">Preise</h2>
|
||||
<p class="mt-3 text-white/80">Open‑Source bleibt frei. Der gehostete Plan ist so günstig wie möglich kalkuliert. Umschalten:
|
||||
<h2 class="text-3xl md:text-4xl font-semibold">Preise (Zielbild)</h2>
|
||||
<p class="mt-3 text-white/80">Wir arbeiten an einer fairen Kalkulation für Hosting. Zahlen unten sind grobe Zielwerte – Feedback willkommen.
|
||||
<button @click="yearly=!yearly" class="chip ml-2"><span>{{ yearly ? 'jährlich' : 'monatlich' }}</span></button>
|
||||
</p>
|
||||
</div>
|
||||
@@ -293,26 +450,26 @@
|
||||
<div class="card border-2 border-cyan-400/40 relative shadow-[0_0_40px_rgba(34,211,238,.25)]" data-aos="fade-up" data-aos-delay="100">
|
||||
<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>
|
||||
<p class="mt-2 text-white/80">Geplanter Rundum-Service: Kein Setup, Updates inklusive. Aktuell nur als Konzept.</p>
|
||||
<div class="mt-5 text-3xl font-semibold"><span>{{ yearly ? '≈4€' : '≈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>✔ Fair‑Use Audio‑Minuten</li>
|
||||
<li>✔ Lernpfad & Fortschritt</li>
|
||||
<li>✔ Updates & Cloud‑Scaling</li>
|
||||
<li>✔ Fair-Use Audio-Minuten (Ausarbeitung)</li>
|
||||
<li>✔ Lernpfad & Fortschritt (abhängig von Community-Feedback)</li>
|
||||
<li>✔ Updates & Cloud-Scaling (Kostenmodell in Arbeit)</li>
|
||||
</ul>
|
||||
<NuxtLink to="#cta" class="btn btn-primary w-full mt-6">Kostenlos testen</NuxtLink>
|
||||
<p class="mt-3 text-xs text-white/60">Preisziel: minimal + Betriebskosten. Änderungen möglich.</p>
|
||||
<NuxtLink to="#cta" class="btn btn-primary w-full mt-6">Interesse melden</NuxtLink>
|
||||
<p class="mt-3 text-xs text-white/60">* Zielwert, finale Preise folgen nach Kostentest.</p>
|
||||
</div>
|
||||
<!-- Hosted Pro -->
|
||||
<div class="card relative" data-aos="fade-up" data-aos-delay="200">
|
||||
<div class="absolute -top-3 right-4 chip">Power‑User</div>
|
||||
<h3 class="text-xl font-semibold">Hosted – Pro</h3>
|
||||
<p class="mt-2 text-white/80">Mehr Kontingente, eigene Stimmen, API‑Zugriff.</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>
|
||||
<p class="mt-2 text-white/80">Konzept für größere Gruppen & virtuelle Airlines. Preise werden gemeinsam kalkuliert.</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>✔ Team‑Seats</li>
|
||||
<li>✔ Priorisierter Support</li>
|
||||
<li>✔ Höhere Limits (nach Bedarf)</li>
|
||||
<li>✔ Team-Seats & Rollen (Diskussion offen)</li>
|
||||
<li>✔ Priorisierter Support (Community + optional SLA)</li>
|
||||
</ul>
|
||||
<NuxtLink to="#cta" class="btn btn-ghost w-full mt-6">Kontakt aufnehmen</NuxtLink>
|
||||
</div>
|
||||
@@ -335,7 +492,7 @@
|
||||
</ul>
|
||||
<div class="mt-6 flex gap-3">
|
||||
<NuxtLink to="#cta" class="btn btn-primary">Mitmachen</NuxtLink>
|
||||
<NuxtLink to="#demo" class="btn btn-ghost">Demo</NuxtLink>
|
||||
<NuxtLink to="/news" class="btn btn-ghost">News</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" data-aos="fade-left">
|
||||
@@ -368,6 +525,38 @@ POST /api/route/taxi
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CONTRIBUTING -->
|
||||
<section id="contributing" class="py-16 md:py-24 bg-[#0a0f1c] border-y border-white/10">
|
||||
<div class="container-outer">
|
||||
<div class="grid md:grid-cols-2 gap-8 items-start">
|
||||
<div data-aos="fade-right">
|
||||
<h2 class="text-3xl md:text-4xl font-semibold">Mitentwickeln</h2>
|
||||
<p class="mt-3 text-white/80">Issues labeled <code class="text-xs bg-white/10 px-1.5 py-0.5 rounded">help-wanted</code> markieren Aufgaben, bei denen wir Support brauchen. Alles passiert offen auf GitHub.</p>
|
||||
<ul class="mt-5 space-y-3 text-white/80 text-sm">
|
||||
<li class="flex gap-3"><v-icon icon="mdi-nodejs" class="text-cyan-300 mt-[2px]"/><span>Node/Nuxt Devs für Backend & Frontend.</span></li>
|
||||
<li class="flex gap-3"><v-icon icon="mdi-headset" class="text-cyan-300 mt-[2px]"/><span>ATC SMEs für Phraseologie, Verfahren und Trainingsfeedback.</span></li>
|
||||
<li class="flex gap-3"><v-icon icon="mdi-test-tube" class="text-cyan-300 mt-[2px]"/><span>Tester:innen für Alpha-Builds & Simulator-Integration.</span></li>
|
||||
<li class="flex gap-3"><v-icon icon="mdi-cash-multiple" class="text-cyan-300 mt-[2px]"/><span>Infra- & Kosten-Benchmarking, damit Hosting bezahlbar bleibt.</span></li>
|
||||
</ul>
|
||||
<p class="mt-5 text-sm text-white/70">Schreib uns an <a href="mailto:opensquawk@faktorxmensch.com" class="text-cyan-300 underline">opensquawk@faktorxmensch.com</a> oder kommentiere direkt im Issue.</p>
|
||||
<div class="mt-6 flex gap-3">
|
||||
<NuxtLink to="#roadmap" class="btn btn-primary">Roadmap mitgestalten</NuxtLink>
|
||||
<NuxtLink to="/news" class="btn btn-ghost">Neuigkeiten</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" data-aos="fade-left">
|
||||
<h3 class="font-semibold text-lg">Aktuelle Fokus-Themen</h3>
|
||||
<ul class="mt-3 space-y-2 text-white/70 text-sm list-disc list-inside">
|
||||
<li>Alpha-Prototyp stabilisieren & Setup vereinfachen</li>
|
||||
<li>MSFS & X-Plane Anbindung verifizieren</li>
|
||||
<li>Roadmap-Ideen bewerten & priorisieren</li>
|
||||
<li>Kostenmodell für Hosting durchrechnen</li>
|
||||
</ul>
|
||||
</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="container-outer">
|
||||
@@ -376,9 +565,9 @@ POST /api/route/taxi
|
||||
<div>
|
||||
<h3 class="text-2xl md:text-3xl font-semibold">Frühzugang & Warteliste</h3>
|
||||
<p class="mt-2 text-white/80">
|
||||
Trag dich ein und sichere dir deinen Platz. Aktuell warten
|
||||
Trag dich ein und sichere dir deinen Platz für den Alpha-Build. Aktuell (inkl. interner Tests) warten
|
||||
<span class="font-semibold text-cyan-300">{{ waitlistCountDisplay }}</span>
|
||||
Pilot:innen auf den nächsten Invite-Drop.
|
||||
Interessierte auf den nächsten Invite-Drop.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-black/30 p-4 space-y-4">
|
||||
@@ -439,6 +628,10 @@ POST /api/route/taxi
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2 text-xs text-white/60">
|
||||
<label class="flex items-start gap-3">
|
||||
<input type="checkbox" v-model="waitlistForm.subscribeUpdates" class="mt-1" />
|
||||
<span>Ja, informiert mich, wenn neue Features, Drops oder Lerninhalte starten.</span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3">
|
||||
<input type="checkbox" v-model="waitlistForm.consentTerms" class="mt-1" required />
|
||||
<span>
|
||||
@@ -465,6 +658,7 @@ POST /api/route/taxi
|
||||
</button>
|
||||
<p v-if="waitlistSuccess" class="text-sm text-green-300">
|
||||
Danke! Wir haben dich auf der Warteliste eingetragen und melden uns, sobald Plätze frei werden.
|
||||
<span v-if="waitlistLastOptInUpdates" class="mt-1 block text-xs text-green-200/80">Zusätzlich erhältst du Produkt-Updates, sobald neue Features live gehen.</span>
|
||||
</p>
|
||||
<p v-else-if="waitlistError" class="text-sm text-red-300">{{ waitlistError }}</p>
|
||||
</form>
|
||||
@@ -486,15 +680,15 @@ POST /api/route/taxi
|
||||
</div>
|
||||
<div class="card" data-aos="fade-up" data-aos-delay="100">
|
||||
<h3 class="font-semibold">Welche Simulatoren werden unterstützt?</h3>
|
||||
<p class="mt-2 text-white/80">MSFS, X‑Plane (weitere geplant). Integrationen per Plugin‑SDK.</p>
|
||||
<p class="mt-2 text-white/80">MSFS zuerst (Alpha-Fokus), X‑Plane als nächstes. Weitere Simulatoren richten wir nach Community-Bedarf aus.</p>
|
||||
</div>
|
||||
<div class="card" data-aos="fade-up" data-aos-delay="200">
|
||||
<h3 class="font-semibold">Kann ich selbst hosten?</h3>
|
||||
<p class="mt-2 text-white/80">Ja. Docker‑Compose/Helm bereitgestellt. Hosted‑Plan als bequeme Alternative.</p>
|
||||
<p class="mt-2 text-white/80">Alpha: Docker-Compose + Node-Services sind verfügbar. Doku & Installer werden gerade ergänzt. Hosting-Optionen folgen.</p>
|
||||
</div>
|
||||
<div class="card" data-aos="fade-up" data-aos-delay="300">
|
||||
<h3 class="font-semibold">Wie günstig ist „Hosted – Basic“?</h3>
|
||||
<p class="mt-2 text-white/80">Ziel: knapp über Betriebskosten. Pilotphase mit 4–5€ mtl. (vorläufig).</p>
|
||||
<p class="mt-2 text-white/80">Zielwert aktuell ~4–5 € monatlich, final nach Kosten-Benchmarking. Wir teilen Ergebnisse transparent im Blog.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -506,22 +700,24 @@ POST /api/route/taxi
|
||||
<div class="grid md:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 font-semibold"><v-icon icon="mdi-radar" class="text-cyan-400"/>OpenSquawk</div>
|
||||
<p class="mt-3 text-white/70 text-sm">Open‑Source AI‑ATC für Simulatorpiloten. Lernpfad & günstiger Hosted‑Plan.</p>
|
||||
<p class="mt-3 text-white/70 text-sm">Open-source, low-cost AI ATC für Flugsimulatoren. Alpha-Prototyp verfügbar – benötigt leichte Programmierkenntnisse.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold mb-3">Produkt</h4>
|
||||
<ul class="space-y-2 text-white/70 text-sm">
|
||||
<li><NuxtLink to="#features" class="hover:text-cyan-300">Features</NuxtLink></li>
|
||||
<li><NuxtLink to="#features" class="hover:text-cyan-300">Vision</NuxtLink></li>
|
||||
<li><NuxtLink to="#learn" class="hover:text-cyan-300">Lernpfad</NuxtLink></li>
|
||||
<li><NuxtLink to="#pricing" class="hover:text-cyan-300">Preise</NuxtLink></li>
|
||||
<li><NuxtLink to="#faq" class="hover:text-cyan-300">FAQ</NuxtLink></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold mb-3">Ressourcen</h4>
|
||||
<ul class="space-y-2 text-white/70 text-sm">
|
||||
<li><NuxtLink to="#opensource" class="hover:text-cyan-300">Open‑Source</NuxtLink></li>
|
||||
<li><NuxtLink to="#demo" class="hover:text-cyan-300">Demo</NuxtLink></li>
|
||||
<li><NuxtLink to="#faq" class="hover:text-cyan-300">FAQ</NuxtLink></li>
|
||||
<li><NuxtLink to="#news" class="hover:text-cyan-300">News</NuxtLink></li>
|
||||
<li><NuxtLink to="#contributing" class="hover:text-cyan-300">Mitmachen</NuxtLink></li>
|
||||
<li><a href="mailto:opensquawk@faktorxmensch.com" class="hover:text-cyan-300">opensquawk@faktorxmensch.com</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@@ -542,14 +738,17 @@ POST /api/route/taxi
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useHead } from '#imports'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { getAllNews } from '~~/shared/utils/news'
|
||||
import type { NewsPost } from '~~/shared/utils/news'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('de-DE')
|
||||
const formatNumber = (value: number | null | undefined) => numberFormatter.format(Math.max(0, Math.round(value ?? 0)))
|
||||
const ROADMAP_SCALE = [1, 2, 3, 4, 5] as const
|
||||
|
||||
interface WaitlistStats {
|
||||
count: number
|
||||
@@ -588,14 +787,39 @@ const waitlistForm = reactive({
|
||||
notes: '',
|
||||
consentPrivacy: false,
|
||||
consentTerms: false,
|
||||
subscribeUpdates: false,
|
||||
})
|
||||
|
||||
const waitlistSubmitting = ref(false)
|
||||
const waitlistSuccess = ref(false)
|
||||
const waitlistError = ref('')
|
||||
const waitlistLastOptInUpdates = ref(false)
|
||||
const waitlistStats = ref<WaitlistStats | null>(null)
|
||||
const waitlistLoading = ref(false)
|
||||
|
||||
const updatesForm = reactive({
|
||||
email: '',
|
||||
consentPrivacy: false,
|
||||
consentMarketing: false,
|
||||
})
|
||||
const updatesSubmitting = ref(false)
|
||||
const updatesSuccess = ref(false)
|
||||
const updatesError = ref('')
|
||||
|
||||
const roadmapSuggestionForm = reactive({
|
||||
title: '',
|
||||
details: '',
|
||||
email: '',
|
||||
allowContact: false,
|
||||
consentPrivacy: false,
|
||||
})
|
||||
const roadmapSuggestionSubmitting = ref(false)
|
||||
const roadmapSuggestionSuccess = ref(false)
|
||||
const roadmapSuggestionError = ref('')
|
||||
|
||||
const allNewsEntries = computed<NewsPost[]>(() => getAllNews())
|
||||
const latestNews = computed<NewsPost[]>(() => allNewsEntries.value.slice(0, 3))
|
||||
|
||||
const waitlistCountDisplay = computed(() => formatNumber(waitlistStats.value?.displayCount ?? 0))
|
||||
const waitlistRecent7 = computed(() => waitlistStats.value?.recent7Days ?? 0)
|
||||
const waitlistRecent30 = computed(() => waitlistStats.value?.recent30Days ?? 0)
|
||||
@@ -608,6 +832,32 @@ const waitlistLastJoinedFormatted = computed(() => {
|
||||
const waitlistFormValid = computed(() =>
|
||||
Boolean(waitlistForm.email && waitlistForm.consentPrivacy && waitlistForm.consentTerms)
|
||||
)
|
||||
const updatesFormValid = computed(() =>
|
||||
Boolean(updatesForm.email && updatesForm.consentPrivacy && updatesForm.consentMarketing)
|
||||
)
|
||||
const roadmapSuggestionFormValid = computed(() => {
|
||||
const title = roadmapSuggestionForm.title.trim()
|
||||
const details = roadmapSuggestionForm.details.trim()
|
||||
const email = roadmapSuggestionForm.email.trim()
|
||||
const wantsContact = roadmapSuggestionForm.allowContact
|
||||
|
||||
return (
|
||||
title.length >= 4 &&
|
||||
details.length >= 20 &&
|
||||
roadmapSuggestionForm.consentPrivacy &&
|
||||
(!wantsContact || email.length > 0)
|
||||
)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => roadmapSuggestionForm.email,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
roadmapSuggestionForm.allowContact = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async function loadWaitlistStats() {
|
||||
try {
|
||||
@@ -627,9 +877,23 @@ async function submitWaitlist() {
|
||||
waitlistSubmitting.value = true
|
||||
waitlistError.value = ''
|
||||
waitlistSuccess.value = false
|
||||
waitlistLastOptInUpdates.value = false
|
||||
|
||||
try {
|
||||
await api.post('/api/service/waitlist', { ...waitlistForm }, { auth: false })
|
||||
const wantsProductUpdates = waitlistForm.subscribeUpdates
|
||||
await api.post(
|
||||
'/api/service/waitlist',
|
||||
{
|
||||
name: waitlistForm.name,
|
||||
email: waitlistForm.email,
|
||||
notes: waitlistForm.notes,
|
||||
consentPrivacy: waitlistForm.consentPrivacy,
|
||||
consentTerms: waitlistForm.consentTerms,
|
||||
wantsProductUpdates,
|
||||
},
|
||||
{ auth: false },
|
||||
)
|
||||
waitlistLastOptInUpdates.value = wantsProductUpdates
|
||||
waitlistSuccess.value = true
|
||||
await loadWaitlistStats()
|
||||
waitlistForm.name = ''
|
||||
@@ -637,6 +901,7 @@ async function submitWaitlist() {
|
||||
waitlistForm.notes = ''
|
||||
waitlistForm.consentPrivacy = false
|
||||
waitlistForm.consentTerms = false
|
||||
waitlistForm.subscribeUpdates = false
|
||||
} catch (err: any) {
|
||||
const message = err?.data?.statusMessage || err?.message || 'Anmeldung fehlgeschlagen'
|
||||
waitlistError.value = message
|
||||
@@ -645,6 +910,36 @@ async function submitWaitlist() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUpdates() {
|
||||
if (!updatesFormValid.value || updatesSubmitting.value) return
|
||||
|
||||
updatesSubmitting.value = true
|
||||
updatesError.value = ''
|
||||
updatesSuccess.value = false
|
||||
|
||||
try {
|
||||
await api.post(
|
||||
'/api/service/updates',
|
||||
{
|
||||
email: updatesForm.email,
|
||||
consentPrivacy: updatesForm.consentPrivacy,
|
||||
consentMarketing: updatesForm.consentMarketing,
|
||||
source: 'landing-hero',
|
||||
},
|
||||
{ auth: false },
|
||||
)
|
||||
updatesSuccess.value = true
|
||||
updatesForm.email = ''
|
||||
updatesForm.consentPrivacy = false
|
||||
updatesForm.consentMarketing = false
|
||||
} catch (err: any) {
|
||||
const message = err?.data?.statusMessage || err?.message || 'Konnte Anmeldung nicht speichern'
|
||||
updatesError.value = message
|
||||
} finally {
|
||||
updatesSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatWaitlistDate = (iso: string) => {
|
||||
if (!iso) return 'unbekannt'
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
@@ -685,6 +980,17 @@ const formatRelativeFromNow = (iso?: string | null) => {
|
||||
|
||||
const formatAverage = (value: number) => value.toFixed(1).replace('.', ',')
|
||||
|
||||
const formatNewsDate = (iso: string) => {
|
||||
if (!iso) return 'tbd'
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return 'tbd'
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const roadmapItems = ref<RoadmapItemWithStats[]>([])
|
||||
const roadmapLoading = ref(false)
|
||||
const roadmapTotals = ref(0)
|
||||
@@ -707,8 +1013,22 @@ const roadmapImportanceLabel = (value?: number) => {
|
||||
}
|
||||
return labels[value ?? 0] || 'Noch nicht bewertet'
|
||||
}
|
||||
const roadmapImportanceShortLabel = (value: number) => {
|
||||
const labels: Record<number, string> = {
|
||||
1: 'Optional',
|
||||
2: 'Später',
|
||||
3: 'Wichtig',
|
||||
4: 'Dringend',
|
||||
5: 'Top',
|
||||
}
|
||||
return labels[value] || ''
|
||||
}
|
||||
|
||||
const markRoadmapTouched = (key: string) => {
|
||||
const selectRoadmapImportance = (key: string, value: number) => {
|
||||
if (!ROADMAP_SCALE.includes(value as (typeof ROADMAP_SCALE)[number])) {
|
||||
return
|
||||
}
|
||||
roadmapSelections[key] = value
|
||||
roadmapTouched[key] = true
|
||||
roadmapSuccess.value = false
|
||||
}
|
||||
@@ -774,18 +1094,51 @@ async function submitRoadmapVotes() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRoadmapSuggestion() {
|
||||
if (!roadmapSuggestionFormValid.value || roadmapSuggestionSubmitting.value) return
|
||||
|
||||
roadmapSuggestionSubmitting.value = true
|
||||
roadmapSuggestionError.value = ''
|
||||
roadmapSuggestionSuccess.value = false
|
||||
|
||||
try {
|
||||
await api.post(
|
||||
'/api/service/roadmap-suggestions',
|
||||
{
|
||||
title: roadmapSuggestionForm.title,
|
||||
details: roadmapSuggestionForm.details,
|
||||
email: roadmapSuggestionForm.email || undefined,
|
||||
allowContact: roadmapSuggestionForm.allowContact,
|
||||
consentPrivacy: roadmapSuggestionForm.consentPrivacy,
|
||||
},
|
||||
{ auth: false },
|
||||
)
|
||||
roadmapSuggestionSuccess.value = true
|
||||
roadmapSuggestionForm.title = ''
|
||||
roadmapSuggestionForm.details = ''
|
||||
roadmapSuggestionForm.email = ''
|
||||
roadmapSuggestionForm.allowContact = false
|
||||
roadmapSuggestionForm.consentPrivacy = false
|
||||
} catch (err: any) {
|
||||
const message = err?.data?.statusMessage || err?.message || 'Konnte Vorschlag nicht speichern'
|
||||
roadmapSuggestionError.value = message
|
||||
} finally {
|
||||
roadmapSuggestionSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: 'OpenSquawk – Open‑Source AI‑ATC für Simulatorpiloten',
|
||||
title: 'OpenSquawk – Open-source, low-cost AI ATC für Flugsimulatoren',
|
||||
meta: [
|
||||
{ name: 'description', content: 'OpenSquawk ist die offene, günstige Alternative für AI‑ATC im Flugsimulator – mit Lernpfad zu echter Funkphraseologie und sanftem Einstieg Richtung VATSIM.' },
|
||||
{ name: 'description', content: 'Wir bauen OpenSquawk: Open-source, low-cost AI ATC für Flugsimulatoren. Community-Roadmap, Self-host & Hosting in Planung, Alpha-Prototyp verfügbar.' },
|
||||
{ name: 'theme-color', content: '#0ea5e9' },
|
||||
{ property: 'og:title', content: 'OpenSquawk – Open‑Source AI‑ATC' },
|
||||
{ property: 'og:description', content: 'Open, günstig, lernfreundlich. AI‑ATC mit Lernpfad & gehostetem Plan.' },
|
||||
{ property: 'og:title', content: 'OpenSquawk – Open-source, low-cost AI ATC' },
|
||||
{ property: 'og:description', content: 'Alpha-Prototyp für Simulatorpiloten. Community-getriebene Features, Self-host & geplante Hosting-Optionen.' },
|
||||
{ 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 – Open‑Source AI‑ATC' },
|
||||
{ name: 'twitter:description', content: 'Open, günstig, lernfreundlich. AI‑ATC mit Lernpfad & gehostetem Plan.' },
|
||||
{ name: 'twitter:title', content: 'OpenSquawk – Open-source, low-cost AI ATC' },
|
||||
{ name: 'twitter:description', content: 'Alpha-Prototyp mit Community-Roadmap. Self-host heute, Hosting morgen.' },
|
||||
{ name: 'twitter:image', content: 'https://opensquawk.example.com/cover.png' }
|
||||
]
|
||||
})
|
||||
@@ -817,28 +1170,42 @@ onMounted(async () => {
|
||||
.btn-ghost { @apply bg-white/5 text-white hover:bg-white/10; }
|
||||
.card { @apply glass rounded-2xl p-5 md:p-6; }
|
||||
.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; }
|
||||
.roadmap-range {
|
||||
width: 100%;
|
||||
accent-color: #22d3ee;
|
||||
cursor: pointer;
|
||||
.roadmap-scale {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.roadmap-range:focus {
|
||||
outline: none;
|
||||
.roadmap-pill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 0.85rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 0.875rem;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.roadmap-range::-webkit-slider-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #22d3ee;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.25);
|
||||
.roadmap-pill:hover {
|
||||
background: rgba(34, 211, 238, 0.18);
|
||||
border-color: rgba(34, 211, 238, 0.4);
|
||||
color: #fff;
|
||||
}
|
||||
.roadmap-range::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #22d3ee;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.25);
|
||||
.roadmap-pill.is-active {
|
||||
background: rgba(34, 211, 238, 0.28);
|
||||
border-color: rgba(34, 211, 238, 0.65);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 24px rgba(34, 211, 238, 0.25);
|
||||
}
|
||||
.roadmap-pill .label {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.roadmap-pill.is-active .label {
|
||||
color: rgba(224, 242, 254, 0.85);
|
||||
}
|
||||
</style>
|
||||
|
||||
73
app/pages/news/[slug].vue
Normal file
73
app/pages/news/[slug].vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="bg-[#0b1020] text-white min-h-screen">
|
||||
<div class="container-outer py-10 md:py-16 space-y-8">
|
||||
<NuxtLink to="/news" class="inline-flex items-center gap-2 text-sm text-white/60 hover:text-cyan-300">
|
||||
<v-icon icon="mdi-arrow-left" size="18" /> Zurück zu News & Updates
|
||||
</NuxtLink>
|
||||
|
||||
<article v-if="post" class="card space-y-6">
|
||||
<header class="space-y-2">
|
||||
<span class="chip text-[10px] uppercase tracking-[0.3em]">{{ formatNewsDate(post.publishedAt) }}</span>
|
||||
<h1 class="text-3xl md:text-4xl font-semibold">{{ post.title }}</h1>
|
||||
<p class="text-sm text-white/60">{{ post.readingTime }}</p>
|
||||
</header>
|
||||
<div class="news-body" v-html="post.html" />
|
||||
</article>
|
||||
|
||||
<div v-else class="card space-y-4 text-white/70 text-sm">
|
||||
<h1 class="text-2xl font-semibold text-white">Beitrag nicht gefunden</h1>
|
||||
<p>Der angeforderte News-Artikel existiert nicht (mehr). Schau dir stattdessen den aktuellen Feed an.</p>
|
||||
<NuxtLink to="/news" class="btn btn-primary w-fit">Zur Übersicht</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useHead, useRoute } from '#imports'
|
||||
import { getNewsBySlug } from '~~/shared/utils/news'
|
||||
|
||||
const route = useRoute()
|
||||
const slug = computed(() => String(route.params.slug ?? ''))
|
||||
const post = computed(() => getNewsBySlug(slug.value))
|
||||
|
||||
useHead(() => {
|
||||
if (!post.value) {
|
||||
return {
|
||||
title: 'Newsbeitrag nicht gefunden – OpenSquawk',
|
||||
meta: [{ name: 'robots', content: 'noindex' }],
|
||||
}
|
||||
}
|
||||
const description = post.value.excerpt || 'Update aus dem OpenSquawk Projekt.'
|
||||
return {
|
||||
title: `${post.value.title} – OpenSquawk News`,
|
||||
meta: [
|
||||
{ name: 'description', content: description },
|
||||
{ property: 'og:title', content: `${post.value.title} – OpenSquawk News` },
|
||||
{ property: 'og:description', content: description },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const formatNewsDate = (iso: string) => new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container-outer { @apply mx-auto max-w-screen-lg px-4; }
|
||||
.card { @apply glass rounded-2xl p-6 md:p-10; }
|
||||
.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)]; }
|
||||
.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; }
|
||||
.news-body :deep(h2) { @apply text-2xl md:text-3xl font-semibold mt-6 mb-3 text-white; }
|
||||
.news-body :deep(h3) { @apply text-xl font-semibold mt-5 mb-2 text-white; }
|
||||
.news-body :deep(p) { @apply text-white/80 leading-relaxed mb-4; }
|
||||
.news-body :deep(ul) { @apply list-disc list-inside text-white/75 mb-4 space-y-2; }
|
||||
.news-body :deep(code) { @apply bg-white/10 px-1 py-0.5 rounded text-xs; }
|
||||
.news-body :deep(a) { @apply text-cyan-300 underline; }
|
||||
.glass { background: rgba(255,255,255,.06); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,.08); }
|
||||
</style>
|
||||
76
app/pages/news/index.vue
Normal file
76
app/pages/news/index.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="bg-[#0b1020] text-white min-h-screen">
|
||||
<header class="border-b border-white/10 bg-[#0b1020]/80 backdrop-blur">
|
||||
<div class="container-outer py-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<NuxtLink to="/" class="inline-flex items-center gap-2 text-sm text-white/60 hover:text-cyan-300">
|
||||
<v-icon icon="mdi-arrow-left" size="18" /> Zurück zur Startseite
|
||||
</NuxtLink>
|
||||
<h1 class="text-3xl md:text-4xl font-semibold mt-2">News & Updates</h1>
|
||||
<p class="text-white/70 text-sm md:text-base mt-2">
|
||||
Content-getriebene Updates zu Alpha-Builds, Simulator-Support und Community-Entscheidungen.
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink to="#feed" class="btn btn-primary w-fit">Zum aktuellen Feed</NuxtLink>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container-outer py-12 md:py-16 space-y-10" id="feed">
|
||||
<section>
|
||||
<div v-if="posts.length" class="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
<article
|
||||
v-for="post in posts"
|
||||
:key="post.slug"
|
||||
class="card h-full flex flex-col gap-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<span class="chip text-[10px] uppercase tracking-[0.3em]">{{ formatNewsDate(post.publishedAt) }}</span>
|
||||
<h2 class="text-xl font-semibold">{{ post.title }}</h2>
|
||||
<p class="text-sm text-white/70">{{ post.excerpt }}</p>
|
||||
</div>
|
||||
<div class="mt-auto flex items-center justify-between text-xs text-white/60">
|
||||
<span>{{ post.readingTime }}</span>
|
||||
<NuxtLink :to="`/news/${post.slug}`" class="text-cyan-300 text-sm font-medium hover:underline">Weiterlesen</NuxtLink>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="card text-white/70 text-sm">
|
||||
Noch keine Beiträge veröffentlicht. Schau bald wieder vorbei!
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useHead } from '#imports'
|
||||
import { getAllNews } from '~~/shared/utils/news'
|
||||
|
||||
const posts = computed(() => getAllNews())
|
||||
|
||||
useHead({
|
||||
title: 'News & Updates – OpenSquawk',
|
||||
meta: [
|
||||
{ name: 'description', content: 'Neuigkeiten zum OpenSquawk Alpha-Prototyp, Simulator-Support und Community-Updates.' },
|
||||
{ name: 'robots', content: 'index,follow' },
|
||||
{ property: 'og:title', content: 'News & Updates – OpenSquawk' },
|
||||
{ property: 'og:description', content: 'Open-source, low-cost AI ATC: Wir teilen Fortschritte, Milestones und Aufrufe zum Mitmachen.' },
|
||||
],
|
||||
})
|
||||
|
||||
const formatNewsDate = (iso: string) => new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container-outer { @apply mx-auto max-w-screen-xl px-4; }
|
||||
.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)]; }
|
||||
.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; }
|
||||
.glass { background: rgba(255,255,255,.06); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,.08); }
|
||||
</style>
|
||||
33
content/news/alpha-prototype.md
Normal file
33
content/news/alpha-prototype.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: "Alpha-Prototyp bereit für frühe Tests"
|
||||
date: "2025-01-20"
|
||||
summary: "Unser erster OpenSquawk Alpha-Build läuft lokal für MSFS und zeigt, wohin die Reise Richtung community-getriebener AI-ATC geht."
|
||||
---
|
||||
|
||||
Wir haben den Alpha-Prototyp von OpenSquawk intern stabilisiert und geben ihn jetzt an neugierige Mitentwickler:innen raus. Der Build kombiniert eine Nuxt-Oberfläche mit Node-Services für Speech-to-Text, Entscheidungslogik und TTS. Alles lässt sich lokal betreiben, solange du ein wenig Terminal-Erfahrung mitbringst.
|
||||
|
||||
## Was dich erwartet
|
||||
|
||||
- Self-host Setup: `yarn install`, `.env` befüllen, danach `yarn dev` oder `docker compose up` für das volle Paket.
|
||||
- Simulator-Fokus: Microsoft Flight Simulator (MSFS) zuerst, X-Plane steht bereits in der Konzeptphase.
|
||||
- Zielbild: Community-driven Features, damit Trainingsrichtung VATSIM realitätsnah bleibt und Hosting langfristig bezahlbar wird.
|
||||
|
||||
## Startvoraussetzungen
|
||||
|
||||
- Grundkenntnisse in Node/Nuxt (Logs lesen, Pakete aktualisieren, `.env` konfigurieren).
|
||||
- Zugriff auf MSFS (PC) für die ersten Funkexperimente.
|
||||
- Bereitschaft, Issues zu dokumentieren und kleine Tweaks per Pull Request zu liefern.
|
||||
|
||||
## Wie du helfen kannst
|
||||
|
||||
- Teste den Alpha-Build und melde Findings als Issues – am besten mit Logs oder kurzen Screenshots.
|
||||
- Schau in die Issues labeled `help-wanted`, dort liegen konkrete Aufgaben für Node/Nuxt Devs, ATC SMEs, Tester:innen und Infra-/Kosten-Benchmarking.
|
||||
- Teile eigene Feature-Ideen direkt in der Roadmap oder schreib uns via [opensquawk@faktorxmensch.com](mailto:opensquawk@faktorxmensch.com).
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
- X-Plane Plugin als nächstes Milestone.
|
||||
- Lernpfad-Module iterieren (Ground → Departure → Arrival → VATSIM).
|
||||
- Hosting-Kosten transparent benchmarken und im Blog teilen.
|
||||
|
||||
Danke für jedes Feedback – gemeinsam bringen wir OpenSquawk Richtung „Open-source, low-cost AI ATC für Flugsimulatoren".
|
||||
60
server/api/service/roadmap-suggestions.post.ts
Normal file
60
server/api/service/roadmap-suggestions.post.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { readBody, createError } from 'h3'
|
||||
import { RoadmapSuggestion } from '../../models/RoadmapSuggestion'
|
||||
import { sendAdminNotification } from '../../utils/notifications'
|
||||
|
||||
interface RoadmapSuggestionBody {
|
||||
title?: string
|
||||
details?: string
|
||||
email?: string
|
||||
allowContact?: boolean
|
||||
consentPrivacy?: boolean
|
||||
}
|
||||
|
||||
const MIN_TITLE_LENGTH = 4
|
||||
const MIN_DETAILS_LENGTH = 20
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<RoadmapSuggestionBody>(event)
|
||||
const title = body.title?.trim()
|
||||
const details = body.details?.trim()
|
||||
const email = body.email?.trim().toLowerCase()
|
||||
const allowContact = Boolean(body.allowContact && email)
|
||||
|
||||
if (!title || title.length < MIN_TITLE_LENGTH) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bitte gib einen kurzen Titel (mindestens 4 Zeichen) an.' })
|
||||
}
|
||||
|
||||
if (!details || details.length < MIN_DETAILS_LENGTH) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Beschreibe deinen Vorschlag mit mindestens 20 Zeichen.' })
|
||||
}
|
||||
|
||||
if (!body.consentPrivacy) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Wir benötigen deine Einwilligung zur Datenschutzerklärung.' })
|
||||
}
|
||||
|
||||
if (body.allowContact && !email) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bitte gib eine E-Mail an, damit wir dich kontaktieren können.' })
|
||||
}
|
||||
|
||||
const suggestion = await RoadmapSuggestion.create({
|
||||
title,
|
||||
details,
|
||||
email,
|
||||
allowContact,
|
||||
consentPrivacy: true,
|
||||
})
|
||||
|
||||
const lines = [
|
||||
`Titel: ${title}`,
|
||||
`Beschreibung: ${details}`,
|
||||
`Kontakt: ${email || '—'}`,
|
||||
allowContact ? 'Kontaktaufnahme erwünscht' : 'Keine Kontaktaufnahme erwünscht',
|
||||
]
|
||||
|
||||
await sendAdminNotification('[OpenSquawk] Neuer Roadmap-Vorschlag', lines.join('\n'))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
suggestionId: suggestion._id.toString(),
|
||||
}
|
||||
})
|
||||
52
server/api/service/updates.post.ts
Normal file
52
server/api/service/updates.post.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { readBody, createError } from 'h3'
|
||||
import { registerUpdateSubscriber } from '../../utils/subscribers'
|
||||
import { sendAdminNotification } from '../../utils/notifications'
|
||||
|
||||
interface UpdatesRequestBody {
|
||||
email?: string
|
||||
name?: string
|
||||
consentPrivacy?: boolean
|
||||
consentMarketing?: boolean
|
||||
source?: string
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<UpdatesRequestBody>(event)
|
||||
const email = body.email?.trim().toLowerCase()
|
||||
const name = body.name?.trim()
|
||||
const source = body.source?.trim() || 'landing-updates'
|
||||
|
||||
if (!email) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' })
|
||||
}
|
||||
|
||||
if (!body.consentPrivacy) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bitte bestätige die Datenschutzerklärung' })
|
||||
}
|
||||
|
||||
if (!body.consentMarketing) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Wir benötigen deine Einwilligung für Produkt-Updates per E-Mail' })
|
||||
}
|
||||
|
||||
const result = await registerUpdateSubscriber({
|
||||
email,
|
||||
name,
|
||||
source,
|
||||
consentPrivacy: true,
|
||||
consentMarketing: true,
|
||||
})
|
||||
|
||||
if (result.created) {
|
||||
const lines = [
|
||||
`E-Mail: ${email}`,
|
||||
name ? `Name: ${name}` : null,
|
||||
`Quelle: ${source}`,
|
||||
].filter(Boolean)
|
||||
await sendAdminNotification('[OpenSquawk] Neue Updates-Liste', lines.join('\n'))
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
alreadySubscribed: !result.created,
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import { readBody, createError } from 'h3'
|
||||
import { WaitlistEntry } from '../../models/WaitlistEntry'
|
||||
import { sendAdminNotification } from '../../utils/notifications'
|
||||
import { registerUpdateSubscriber } from '../../utils/subscribers'
|
||||
|
||||
interface WaitlistRequestBody {
|
||||
email?: string
|
||||
@@ -8,6 +10,7 @@ interface WaitlistRequestBody {
|
||||
consentPrivacy?: boolean
|
||||
consentTerms?: boolean
|
||||
source?: string
|
||||
wantsProductUpdates?: boolean
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -16,6 +19,7 @@ export default defineEventHandler(async (event) => {
|
||||
const name = body.name?.trim()
|
||||
const notes = body.notes?.trim()
|
||||
const source = body.source?.trim() || 'landing'
|
||||
const wantsProductUpdates = Boolean(body.wantsProductUpdates)
|
||||
|
||||
if (!email) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' })
|
||||
@@ -30,12 +34,41 @@ export default defineEventHandler(async (event) => {
|
||||
const existing = await WaitlistEntry.findOne({ email })
|
||||
|
||||
if (existing) {
|
||||
const previouslyWantedUpdates = Boolean(existing.wantsProductUpdates)
|
||||
existing.name = name || existing.name
|
||||
existing.notes = notes || existing.notes
|
||||
existing.source = source
|
||||
existing.consentPrivacy = true
|
||||
existing.consentTerms = true
|
||||
if (wantsProductUpdates && !previouslyWantedUpdates) {
|
||||
existing.wantsProductUpdates = true
|
||||
existing.updatesOptedInAt = now
|
||||
}
|
||||
await existing.save()
|
||||
|
||||
if (wantsProductUpdates) {
|
||||
const updateResult = await registerUpdateSubscriber({
|
||||
email,
|
||||
name,
|
||||
source: `${source}-waitlist`,
|
||||
consentPrivacy: true,
|
||||
consentMarketing: true,
|
||||
})
|
||||
|
||||
if (!previouslyWantedUpdates && updateResult.created) {
|
||||
const lines = [
|
||||
`E-Mail: ${email}`,
|
||||
name ? `Name: ${name}` : null,
|
||||
notes ? `Notizen: ${notes}` : null,
|
||||
`Quelle: ${source}`,
|
||||
].filter(Boolean)
|
||||
await sendAdminNotification(
|
||||
'[OpenSquawk] Neue Updates-Liste (via Warteliste)',
|
||||
`${lines.join('\n')}\nOpt-In: Produkt-Updates`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
alreadyJoined: true,
|
||||
@@ -51,8 +84,29 @@ export default defineEventHandler(async (event) => {
|
||||
consentPrivacy: true,
|
||||
consentTerms: true,
|
||||
joinedAt: now,
|
||||
wantsProductUpdates,
|
||||
updatesOptedInAt: wantsProductUpdates ? now : undefined,
|
||||
})
|
||||
|
||||
if (wantsProductUpdates) {
|
||||
await registerUpdateSubscriber({
|
||||
email,
|
||||
name,
|
||||
source: `${source}-waitlist`,
|
||||
consentPrivacy: true,
|
||||
consentMarketing: true,
|
||||
})
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`E-Mail: ${email}`,
|
||||
name ? `Name: ${name}` : null,
|
||||
notes ? `Notizen: ${notes}` : null,
|
||||
`Quelle: ${source}`,
|
||||
wantsProductUpdates ? 'Opt-In: Produkt-Updates' : 'Opt-In: nur Warteliste',
|
||||
].filter(Boolean)
|
||||
await sendAdminNotification('[OpenSquawk] Neue Wartelisten-Anmeldung', lines.join('\n'))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
alreadyJoined: false,
|
||||
|
||||
25
server/models/RoadmapSuggestion.ts
Normal file
25
server/models/RoadmapSuggestion.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import mongoose from 'mongoose'
|
||||
|
||||
export interface RoadmapSuggestionDocument extends mongoose.Document {
|
||||
title: string
|
||||
details: string
|
||||
email?: string
|
||||
allowContact: boolean
|
||||
consentPrivacy: boolean
|
||||
submittedAt: Date
|
||||
}
|
||||
|
||||
const roadmapSuggestionSchema = new mongoose.Schema<RoadmapSuggestionDocument>({
|
||||
title: { type: String, required: true, trim: true, maxlength: 160 },
|
||||
details: { type: String, required: true, trim: true, maxlength: 4000 },
|
||||
email: { type: String, trim: true, lowercase: true },
|
||||
allowContact: { type: Boolean, default: false },
|
||||
consentPrivacy: { type: Boolean, default: false },
|
||||
submittedAt: { type: Date, default: () => new Date() },
|
||||
})
|
||||
|
||||
roadmapSuggestionSchema.index({ submittedAt: -1 })
|
||||
|
||||
export const RoadmapSuggestion =
|
||||
(mongoose.models.RoadmapSuggestion as mongoose.Model<RoadmapSuggestionDocument> | undefined) ||
|
||||
mongoose.model<RoadmapSuggestionDocument>('RoadmapSuggestion', roadmapSuggestionSchema)
|
||||
32
server/models/UpdateSubscriber.ts
Normal file
32
server/models/UpdateSubscriber.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import mongoose from 'mongoose'
|
||||
|
||||
export interface UpdateSubscriberDocument extends mongoose.Document {
|
||||
email: string
|
||||
name?: string
|
||||
source: string
|
||||
consentPrivacy: boolean
|
||||
consentMarketing: boolean
|
||||
subscribedAt: Date
|
||||
lastUpdatedAt: Date
|
||||
}
|
||||
|
||||
const updateSubscriberSchema = new mongoose.Schema<UpdateSubscriberDocument>({
|
||||
email: { type: String, required: true, lowercase: true, trim: true, unique: true },
|
||||
name: { type: String, trim: true },
|
||||
source: { type: String, default: 'landing-updates' },
|
||||
consentPrivacy: { type: Boolean, default: false },
|
||||
consentMarketing: { type: Boolean, default: false },
|
||||
subscribedAt: { type: Date, default: () => new Date() },
|
||||
lastUpdatedAt: { type: Date, default: () => new Date() },
|
||||
})
|
||||
|
||||
updateSubscriberSchema.pre('save', function updateTimestamp(this: UpdateSubscriberDocument, next) {
|
||||
if (this.isModified()) {
|
||||
this.set('lastUpdatedAt', new Date())
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
export const UpdateSubscriber =
|
||||
(mongoose.models.UpdateSubscriber as mongoose.Model<UpdateSubscriberDocument> | undefined) ||
|
||||
mongoose.model<UpdateSubscriberDocument>('UpdateSubscriber', updateSubscriberSchema)
|
||||
@@ -9,6 +9,8 @@ export interface WaitlistEntryDocument extends mongoose.Document {
|
||||
consentTerms: boolean
|
||||
joinedAt: Date
|
||||
activatedAt?: Date
|
||||
wantsProductUpdates?: boolean
|
||||
updatesOptedInAt?: Date
|
||||
}
|
||||
|
||||
const waitlistSchema = new mongoose.Schema<WaitlistEntryDocument>({
|
||||
@@ -20,6 +22,8 @@ const waitlistSchema = new mongoose.Schema<WaitlistEntryDocument>({
|
||||
consentTerms: { type: Boolean, default: false },
|
||||
joinedAt: { type: Date, default: () => new Date() },
|
||||
activatedAt: { type: Date },
|
||||
wantsProductUpdates: { type: Boolean, default: false },
|
||||
updatesOptedInAt: { type: Date },
|
||||
})
|
||||
|
||||
export const WaitlistEntry =
|
||||
|
||||
103
server/utils/notifications.ts
Normal file
103
server/utils/notifications.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
const ADMIN_EMAIL_FALLBACK = 'opensquawk@faktorxmensch.com'
|
||||
|
||||
const RESEND_ENDPOINT = 'https://api.resend.com/emails'
|
||||
|
||||
async function sendViaResend(subject: string, text: string) {
|
||||
const apiKey = process.env.NOTIFY_RESEND_API_KEY
|
||||
if (!apiKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
|
||||
const from = process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk <no-reply@opensquawk.dev>'
|
||||
|
||||
try {
|
||||
const response = await fetch(RESEND_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => '')
|
||||
console.error('Failed to send notification via Resend API', response.status, errorText)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error while sending notification via Resend API', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendViaSmtp(subject: string, text: string) {
|
||||
const host = process.env.NOTIFY_SMTP_HOST
|
||||
if (!host) {
|
||||
return false
|
||||
}
|
||||
|
||||
const user = process.env.NOTIFY_SMTP_USER
|
||||
const pass = process.env.NOTIFY_SMTP_PASS
|
||||
if (!user || !pass) {
|
||||
console.warn('SMTP notification is configured without credentials – skipping send.')
|
||||
return false
|
||||
}
|
||||
|
||||
const port = Number.parseInt(process.env.NOTIFY_SMTP_PORT || '', 10)
|
||||
const secure = process.env.NOTIFY_SMTP_SECURE === 'true'
|
||||
|
||||
let nodemailer: any = null
|
||||
try {
|
||||
const module = await import('nodemailer')
|
||||
nodemailer = module.default ?? module
|
||||
} catch (error) {
|
||||
console.warn('nodemailer is not available. Skipping SMTP notification.', error)
|
||||
return false
|
||||
}
|
||||
|
||||
const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
|
||||
const from = process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk <no-reply@opensquawk.dev>'
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port: Number.isNaN(port) ? secure ? 465 : 587 : port,
|
||||
secure,
|
||||
auth: {
|
||||
user,
|
||||
pass,
|
||||
},
|
||||
})
|
||||
|
||||
await transporter.sendMail({ from, to, subject, text })
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to send notification via SMTP', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendAdminNotification(subject: string, text: string) {
|
||||
const sentViaResend = await sendViaResend(subject, text)
|
||||
if (sentViaResend) {
|
||||
return true
|
||||
}
|
||||
|
||||
const sentViaSmtp = await sendViaSmtp(subject, text)
|
||||
if (sentViaSmtp) {
|
||||
return true
|
||||
}
|
||||
|
||||
const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
|
||||
console.info(`[notify:fallback] ${subject}\nEmpfänger: ${to}\n${text}`)
|
||||
return false
|
||||
}
|
||||
38
server/utils/subscribers.ts
Normal file
38
server/utils/subscribers.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { UpdateSubscriber, UpdateSubscriberDocument } from '../models/UpdateSubscriber'
|
||||
|
||||
interface RegisterSubscriberOptions {
|
||||
email: string
|
||||
name?: string
|
||||
source?: string
|
||||
consentPrivacy: boolean
|
||||
consentMarketing: boolean
|
||||
}
|
||||
|
||||
export async function registerUpdateSubscriber(options: RegisterSubscriberOptions) {
|
||||
const { email, name, source = 'landing-updates', consentPrivacy, consentMarketing } = options
|
||||
const now = new Date()
|
||||
|
||||
const existing = await UpdateSubscriber.findOne({ email })
|
||||
|
||||
if (existing) {
|
||||
existing.name = name || existing.name
|
||||
existing.source = source || existing.source
|
||||
existing.consentPrivacy = existing.consentPrivacy || consentPrivacy
|
||||
existing.consentMarketing = existing.consentMarketing || consentMarketing
|
||||
existing.lastUpdatedAt = now
|
||||
await existing.save()
|
||||
return { created: false, document: existing as UpdateSubscriberDocument }
|
||||
}
|
||||
|
||||
const document = await UpdateSubscriber.create({
|
||||
email,
|
||||
name,
|
||||
source,
|
||||
consentPrivacy,
|
||||
consentMarketing,
|
||||
subscribedAt: now,
|
||||
lastUpdatedAt: now,
|
||||
})
|
||||
|
||||
return { created: true, document }
|
||||
}
|
||||
238
shared/utils/news.ts
Normal file
238
shared/utils/news.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
export interface NewsPost {
|
||||
slug: string
|
||||
title: string
|
||||
excerpt: string
|
||||
publishedAt: string
|
||||
body: string
|
||||
html: string
|
||||
readingTime: string
|
||||
}
|
||||
|
||||
interface FrontMatter {
|
||||
title?: string
|
||||
date?: string
|
||||
summary?: string
|
||||
excerpt?: string
|
||||
description?: string
|
||||
readingTime?: string
|
||||
}
|
||||
|
||||
const rawNewsModules = import.meta.glob('~~/content/news/*.md', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
eager: true,
|
||||
}) as Record<string, string>
|
||||
|
||||
const parsedNews: NewsPost[] = Object.entries(rawNewsModules)
|
||||
.map(([path, raw]) => parseNewsFile(path, raw))
|
||||
.filter((entry): entry is NewsPost => Boolean(entry))
|
||||
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())
|
||||
|
||||
export function getAllNews(): NewsPost[] {
|
||||
return parsedNews.map((post) => ({ ...post }))
|
||||
}
|
||||
|
||||
export function getNewsBySlug(slug: string): NewsPost | null {
|
||||
const found = parsedNews.find((post) => post.slug === slug)
|
||||
return found ? { ...found } : null
|
||||
}
|
||||
|
||||
function parseNewsFile(path: string, raw: string): NewsPost | null {
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { body, attributes } = extractFrontMatter(raw)
|
||||
const slug = extractSlug(path)
|
||||
const title = attributes.title?.trim() || toTitleFromSlug(slug)
|
||||
const publishedAt = parseDate(attributes.date) || new Date().toISOString()
|
||||
const cleanBody = body.trim()
|
||||
const excerptSource = attributes.summary || attributes.excerpt || attributes.description || ''
|
||||
const excerpt = truncateText(excerptSource || firstParagraph(cleanBody), 220)
|
||||
const html = markdownToHtml(cleanBody)
|
||||
const readingTime = attributes.readingTime || formatReadingTime(cleanBody)
|
||||
|
||||
return {
|
||||
slug,
|
||||
title,
|
||||
excerpt,
|
||||
publishedAt,
|
||||
body: cleanBody,
|
||||
html,
|
||||
readingTime,
|
||||
}
|
||||
}
|
||||
|
||||
function extractFrontMatter(raw: string): { body: string; attributes: FrontMatter } {
|
||||
const FRONT_MATTER_REGEX = /^---\n([\s\S]*?)\n---\n?/
|
||||
const match = raw.match(FRONT_MATTER_REGEX)
|
||||
if (!match) {
|
||||
return { body: raw, attributes: {} }
|
||||
}
|
||||
const [, frontMatter] = match
|
||||
const rest = raw.slice(match[0].length)
|
||||
return { body: rest, attributes: parseFrontMatter(frontMatter) }
|
||||
}
|
||||
|
||||
function parseFrontMatter(source: string): FrontMatter {
|
||||
const attributes: Record<string, string> = {}
|
||||
const lines = source.split(/\r?\n/)
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const colonIndex = trimmed.indexOf(':')
|
||||
if (colonIndex === -1) continue
|
||||
const key = trimmed.slice(0, colonIndex).trim()
|
||||
const rawValue = trimmed.slice(colonIndex + 1).trim()
|
||||
const value = rawValue.replace(/^['"]|['"]$/g, '').trim()
|
||||
if (key) {
|
||||
attributes[key] = value
|
||||
}
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
function extractSlug(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/').split('/')
|
||||
const file = normalized[normalized.length - 1] || ''
|
||||
return file.replace(/\.md$/i, '')
|
||||
}
|
||||
|
||||
function toTitleFromSlug(slug: string): string {
|
||||
return slug
|
||||
.split('-')
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function parseDate(value?: string): string | null {
|
||||
if (!value) return null
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null
|
||||
}
|
||||
return parsed.toISOString()
|
||||
}
|
||||
|
||||
function firstParagraph(body: string): string {
|
||||
const lines = body.split(/\r?\n/)
|
||||
const collected: string[] = []
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim()
|
||||
if (!line) {
|
||||
if (collected.length) break
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('#') || line.startsWith('- ')) {
|
||||
if (collected.length) break
|
||||
continue
|
||||
}
|
||||
collected.push(line)
|
||||
if (line.endsWith('.')) break
|
||||
}
|
||||
return collected.join(' ')
|
||||
}
|
||||
|
||||
function truncateText(text: string, maxLength: number): string {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.length <= maxLength) return trimmed
|
||||
return `${trimmed.slice(0, maxLength - 1).trimEnd()}…`
|
||||
}
|
||||
|
||||
function formatReadingTime(content: string): string {
|
||||
const words = content.split(/\s+/).filter(Boolean)
|
||||
const minutes = Math.max(1, Math.round(words.length / 180))
|
||||
return `${minutes} Min Lesezeit`
|
||||
}
|
||||
|
||||
function markdownToHtml(markdown: string): string {
|
||||
const lines = markdown.split(/\r?\n/)
|
||||
let html = ''
|
||||
let inList = false
|
||||
|
||||
const closeList = () => {
|
||||
if (inList) {
|
||||
html += '</ul>'
|
||||
inList = false
|
||||
}
|
||||
}
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim()
|
||||
if (!line) {
|
||||
closeList()
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('#')) {
|
||||
closeList()
|
||||
const level = Math.min(line.match(/^#+/)?.[0].length ?? 1, 6)
|
||||
const content = line.replace(/^#+/, '').trim()
|
||||
html += `<h${level}>${formatInline(content)}</h${level}>`
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('- ')) {
|
||||
if (!inList) {
|
||||
html += '<ul>'
|
||||
inList = true
|
||||
}
|
||||
html += `<li>${formatInline(line.slice(2).trim())}</li>`
|
||||
continue
|
||||
}
|
||||
closeList()
|
||||
html += `<p>${formatInline(line)}</p>`
|
||||
}
|
||||
|
||||
closeList()
|
||||
return html
|
||||
}
|
||||
|
||||
function formatInline(value: string): string {
|
||||
let text = escapeHtml(value)
|
||||
text = text.replace(/\[([^\]]+)]\(([^)]+)\)/g, (_match, label, href) => {
|
||||
const url = escapeAttribute(String(href))
|
||||
return `<a href="${url}" class="text-cyan-300 underline">${String(label)}</a>`
|
||||
})
|
||||
text = text.replace(/`([^`]+)`/g, (_match, code) => `<code>${String(code)}</code>`)
|
||||
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
return text
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) => {
|
||||
switch (char) {
|
||||
case '&':
|
||||
return '&'
|
||||
case '<':
|
||||
return '<'
|
||||
case '>':
|
||||
return '>'
|
||||
case '"':
|
||||
return '"'
|
||||
case '\'':
|
||||
return '''
|
||||
default:
|
||||
return char
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) => {
|
||||
switch (char) {
|
||||
case '&':
|
||||
return '&'
|
||||
case '<':
|
||||
return '<'
|
||||
case '>':
|
||||
return '>'
|
||||
case '"':
|
||||
return '"'
|
||||
case '\'':
|
||||
return '''
|
||||
default:
|
||||
return char
|
||||
}
|
||||
})
|
||||
}
|
||||
5
types/nodemailer.d.ts
vendored
Normal file
5
types/nodemailer.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module 'nodemailer' {
|
||||
const nodemailer: any
|
||||
export function createTransport(options: any): any
|
||||
export default nodemailer
|
||||
}
|
||||
Reference in New Issue
Block a user