globales css hinzu

This commit is contained in:
itsrubberduck
2025-09-15 00:00:03 +02:00
parent f583ed6211
commit 945c274caf
4 changed files with 546 additions and 349 deletions

32
app/assets/css/global.css Normal file
View File

@@ -0,0 +1,32 @@
/* assets/css/global.css */
/* CI-Variablen */
:root {
--bg: #0b1020;
--bg2: #0a0f1c;
--accent: #22d3ee;
--accent2: #0ea5e9;
--text: #ffffff;
--t2: rgba(255, 255, 255, .80);
--t3: rgba(255, 255, 255, .60);
--border: rgba(255, 255, 255, .10);
}
/* Reset + Base */
html, body, #__nuxt {
min-height: 100vh;
min-width: 100vw;
background: var(--bg);
color: var(--text);
font-family: system-ui, sans-serif;
background: radial-gradient(800px 400px at 80% -10%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 60%),
radial-gradient(600px 300px at 10% -5%, color-mix(in srgb, var(--accent2) 15%, transparent), transparent 60%),
linear-gradient(180deg, var(--bg) 0%, var(--bg) 60%, var(--bg2) 100%);
}
/* Vuetify Overrides global */
.v-field, .v-textarea, .v-switch, .v-slider {
border-radius: 0 !important;
box-shadow: none !important;
}

View File

@@ -1,399 +1,534 @@
<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" data-aos="fade-down">
<nav class="container-outer py-3 flex items-center justify-between">
<NuxtLink to="#" class="flex items-center gap-2 font-semibold tracking-tight">
<v-icon icon="mdi-radar" size="28" class="text-cyan-400" />
<span>OpenSquawk</span>
</NuxtLink>
<div class="hidden md:flex gap-6 text-sm">
<a href="#overview" @click.prevent="panel='overview'" class="hover:text-cyan-300 focus:outline-none focus:ring-2 focus:ring-cyan-400 rounded">Übersicht</a>
<a href="#learn" @click.prevent="panel='module'; openFirstUnfinished(activeModule?.id || modules[0].id)" class="hover:text-cyan-300 focus:outline-none focus:ring-2 focus:ring-cyan-400 rounded">Lernpfad</a>
<a href="#progress" @click.prevent="panel='progress'" class="hover:text-cyan-300 focus:outline-none focus:ring-2 focus:ring-cyan-400 rounded">Fortschritt</a>
<a href="#settings" @click.prevent="panel='settings'" class="hover:text-cyan-300 focus:outline-none focus:ring-2 focus:ring-cyan-400 rounded">Einstellungen</a>
<div class="scene">
<!-- TOP BAR (flach, kantig) -->
<header class="hud" role="banner">
<nav class="hud-inner" aria-label="Global">
<div class="hud-left">
<v-icon size="22" class="text-accent">mdi-airplane-takeoff</v-icon>
<span class="brand">OpenSquawk</span>
<span class="sep">|</span>
<span class="mode">Lernwelt</span>
</div>
<div class="flex items-center gap-2">
<NuxtLink to="#cta" class="btn btn-primary text-sm">Starten</NuxtLink>
<button class="btn btn-ghost text-sm" @click="resetProgress"><v-icon icon="mdi-restore" />Reset</button>
<div class="hud-right">
<span class="chip">Lvl {{ level }}</span>
<span class="chip">{{ xp }} XP</span>
<button class="btn ghost" @click="panel='progress'"><v-icon size="18">mdi-chart-line</v-icon> Fortschritt</button>
<button class="btn ghost" @click="showSettings=true"><v-icon size="18">mdi-cog</v-icon> Einstellungen</button>
<button class="btn ghost" @click="resetAll"><v-icon size="18">mdi-refresh</v-icon> Reset</button>
</div>
</nav>
</header>
<!-- HERO / INTRO -->
<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>
<!-- HERO (MSFS-Anmutung, aber CI-Farben; flach, keine Rundungen/Schatten) -->
<section class="hero" role="region" aria-label="Intro">
<div class="container">
<div class="panel hero-panel" :style="worldTiltStyle" @mousemove="tilt">
<div class="hero-left">
<div class="eyebrow">TRAINING</div>
<h1 class="h1">ATC Lernwelt</h1>
<p class="muted">Geführte Pfade · Missionen · XP · Achievements. Lokal gespeichert, kein Backend.</p>
<div class="actions">
<button class="btn primary" @click="panel='hub'"><v-icon>mdi-play</v-icon> Losfliegen</button>
<button class="btn ghost" @click="panel='progress'">Fortschritt</button>
</div>
<div class="season">
<div class="bar">
<div class="fill" :style="{ width: seasonPct + '%' }"></div>
</div>
<div class="meta"><span>Season 1 · Ground School</span><span>{{ seasonPct }}%</span></div>
</div>
</div>
<div class="container-outer pt-12 pb-10 md:pt-20 md:pb-16">
<div class="max-w-2xl" data-aos="fade-up">
<span class="chip mb-4">Lernpfad · Basics VATSIM</span>
<h1 class="text-4xl md:text-5xl font-semibold leading-tight">ATC verstehen in klaren Schritten</h1>
<p class="mt-4 text-white/80">Geführter Lernmodus mit Scoring, Readback-Check, lokalem Fortschritt. Kein Backend nötig.</p>
<div class="flex flex-col sm:flex-row gap-3 mt-6">
<a href="#learn" @click.prevent="panel='overview'" class="btn btn-primary">Loslegen</a>
<a href="#progress" @click.prevent="panel='progress'" class="btn btn-ghost">Fortschritt ansehen</a>
<div class="hero-right">
<div class="rail-title"><v-icon size="18">mdi-calendar-star</v-icon> Daily Challenges</div>
<div class="rail">
<div v-for="d in dailies" :key="d.id" class="card">
<div class="card-head">
<span class="badge">{{ d.reward }} XP</span>
<v-icon size="18">mdi-lightning-bolt</v-icon>
</div>
<div class="card-title">{{ d.title }}</div>
<div class="card-sub muted">{{ d.sub }}</div>
<button class="btn mini soft" @click="startDaily(d)">Annehmen</button>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- MAIN -->
<main class="py-12 md:py-16">
<div class="container-outer">
<!-- OVERVIEW -->
<section v-show="panel==='overview'" id="overview" data-aos="fade-up">
<div class="max-w-2xl mb-6">
<h2 class="text-3xl md:text-4xl font-semibold">Module</h2>
<p class="text-white/80 mt-2">Basics, Ground, Departure, Arrival, VATSIM.</p>
</div>
<!-- HUB (MSFS-Kachelmenü; flach/kantig) -->
<main v-if="panel==='hub'" class="container" role="main">
<div class="hub-head">
<h2 class="h2">Mission Hub</h2>
<div class="muted">Wähle ein Modul sammle XP, schalte Abzeichen frei.</div>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="m in modules" :key="m.id" class="card" data-aos="fade-up" :data-aos-delay="m.order*60">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<v-icon icon="mdi-school" />
<div>
<div class="text-lg font-semibold">{{ m.title }}</div>
<div class="text-white/70 text-sm">{{ m.subtitle }}</div>
</div>
</div>
<div class="text-xs text-white/60">{{ moduleDoneCount(m.id) }}/{{ m.lessons.length }}</div>
</div>
<p class="text-white/80 mt-3 text-sm">{{ m.desc }}</p>
<div class="mt-4">
<v-progress-linear :model-value="moduleProgressPct(m.id)" height="10" rounded color="cyan" />
<div class="text-xs text-white/60 mt-1">{{ moduleProgressPct(m.id) }}% abgeschlossen</div>
</div>
<div class="mt-4 flex gap-2">
<button class="btn btn-primary" @click="openModule(m.id)"><v-icon icon="mdi-play" />Öffnen</button>
<button class="btn btn-ghost" @click="openFirstUnfinished(m.id)">Weitermachen</button>
</div>
<div class="tiles">
<div
v-for="m in modules"
:key="m.id"
class="tile"
:class="{ locked: !isModuleUnlocked(m.id) }"
>
<div class="tile-media" :style="{ backgroundImage: `url(${m.art})` }"></div>
<div class="tile-body">
<div class="tile-top">
<div class="tile-title"><v-icon size="18">mdi-flag-checkered</v-icon> {{ m.title }}</div>
<div class="muted">{{ doneCount(m.id) }}/{{ m.lessons.length }}</div>
</div>
<div class="muted small">{{ m.subtitle }}</div>
<div class="line"><div class="line-fill" :style="{ width: pct(m.id)+'%' }"></div></div>
<div class="tile-actions">
<button class="btn primary" :disabled="!isModuleUnlocked(m.id)" @click="openModule(m.id)">
<v-icon size="18">mdi-play</v-icon> Start
</button>
<button class="btn soft" @click="quickContinue(m.id)">Weiter</button>
</div>
</div>
</section>
<!-- MODULE -->
<section v-show="panel==='module' && activeModule" id="learn" data-aos="fade-up">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-2xl md:text-3xl font-semibold">{{ activeModule?.title }}</h2>
<p class="text-white/70">{{ activeModule?.subtitle }}</p>
</div>
<div class="min-w-[220px]">
<div class="text-xs mb-1 text-white/60">Fortschritt</div>
<v-progress-linear :model-value="moduleProgressPct(activeModule!.id)" height="10" rounded color="cyan" />
<div class="text-xs mt-1 text-white/60">
{{ moduleDoneCount(activeModule!.id) }}/{{ activeModule!.lessons.length }} · Ø {{ avgModuleScore(activeModule!.id) }}%
</div>
</div>
</div>
<!-- Lessons list -->
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mt-6">
<div v-for="l in activeModule!.lessons" :key="l.id" class="card hover:ring-1 hover:ring-cyan-400/40 transition">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<v-icon icon="mdi-headset" />
<div class="font-semibold">{{ l.title }}</div>
</div>
<span class="chip" :class="lessonState(l).done ? 'bg-emerald-500/20 border-emerald-500/30 text-emerald-200' : ''">
{{ lessonState(l).done ? 'fertig' : 'neu' }}
</span>
</div>
<p class="text-white/80 text-sm mt-2">{{ l.desc }}</p>
<ul class="mt-3 text-white/70 text-sm list-disc list-inside">
<li v-for="k in l.keywords" :key="k">Keyword: <span class="text-white">{{ k }}</span></li>
</ul>
<div class="mt-2 text-xs text-white/60">Ziel: <span class="text-white/80">{{ l.target }}</span></div>
<div class="mt-4 flex gap-2">
<button class="btn btn-primary" @click="startLesson(l)"><v-icon icon="mdi-play" />Üben</button>
<button class="btn btn-ghost" @click="fillWithTarget(l)"><v-icon icon="mdi-auto-fix" />Autotext</button>
</div>
<div v-if="lessonState(l).bestScore" class="mt-3 text-xs text-white/60">
Bester Score: <b>{{ lessonState(l).bestScore }}%</b>
</div>
</div>
</div>
<!-- Active Lesson -->
<div v-if="activeLesson" class="card mt-8">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<v-icon icon="mdi-clipboard-text-outline" />
<div>
<div class="text-xl font-semibold">{{ activeLesson.title }}</div>
<div class="text-white/70 text-sm">{{ activeLesson.desc }}</div>
</div>
</div>
<button class="btn btn-ghost" @click="activeLesson=null"><v-icon icon="mdi-close" /></button>
</div>
<div class="grid md:grid-cols-2 gap-6 mt-4">
<div>
<div class="text-xs text-white/60 mb-1">Zielphrase</div>
<div class="glass rounded-xl p-3 leading-6 border border-white/10">{{ activeLesson.target }}</div>
<div class="mt-4 text-xs text-white/60 mb-1">Hinweise</div>
<ul class="text-sm text-white/80 list-disc list-inside">
<li v-for="hint in activeLesson.hints" :key="hint">{{ hint }}</li>
</ul>
</div>
<div>
<div class="text-xs text-white/60 mb-1">Deine Antwort</div>
<v-textarea
v-model="userInput"
auto-grow
placeholder="MVP: tippen (PTT später)."
class="bg-white/5 rounded-xl border border-white/10"
color="cyan"
rows="3"
/>
<div class="flex items-center gap-2 mt-2">
<button class="btn btn-primary" :disabled="evaluating" @click="evaluate(activeLesson)">
<v-icon icon="mdi-check" /> Prüfen
</button>
<button class="btn btn-ghost" @click="userInput=''"><v-icon icon="mdi-eraser" /> Löschen</button>
<button class="btn btn-ghost" @click="speak(activeLesson.target)"><v-icon icon="mdi-volume-high" /> Abspielen</button>
</div>
<div v-if="lastResult" class="mt-4">
<div class="text-xs text-white/60 mb-1">Ergebnis</div>
<div class="glass rounded-xl p-3 border border-white/10">
<div class="text-lg font-semibold">{{ lastResult.score }}%</div>
<div class="text-white/80 text-sm mt-1">{{ feedbackText(lastResult.score) }}</div>
<div class="mt-3 grid grid-cols-2 gap-2 text-xs">
<div class="p-2 bg-white/5 rounded-lg border border-white/10">
Keyword-Treffer: <span class="font-semibold">{{ lastResult.keywordHits }}/{{ activeLesson.keywords.length }}</span>
</div>
<div class="p-2 bg-white/5 rounded-lg border border-white/10">
Ähnlichkeit: <span class="font-semibold">{{ Math.round(lastResult.similarity*100) }}%</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- PROGRESS -->
<section v-show="panel==='progress'" id="progress" data-aos="fade-up">
<h2 class="text-2xl md:text-3xl font-semibold">Fortschritt</h2>
<p class="text-white/80">Alle Scores nach Modul/Lektion.</p>
<div class="mt-6 grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="m in modules" :key="m.id" class="card">
<div class="flex items-center justify-between">
<div class="font-semibold">{{ m.title }}</div>
<span class="chip">{{ moduleProgressPct(m.id) }}%</span>
</div>
<div class="mt-3 space-y-2">
<div v-for="l in m.lessons" :key="l.id" class="flex items-center justify-between text-sm py-2 border-b border-white/10 last:border-0">
<div>{{ l.title }}</div>
<div class="text-white/70">{{ bestScoreByIds(m.id,l.id) ? bestScoreByIds(m.id,l.id)+'%' : '—' }}</div>
</div>
</div>
</div>
</div>
</section>
<!-- SETTINGS -->
<section v-show="panel==='settings'" id="settings" data-aos="fade-up">
<h2 class="text-2xl md:text-3xl font-semibold">Einstellungen</h2>
<div class="grid md:grid-cols-2 gap-6 mt-4">
<div class="card">
<div class="font-semibold">Audio</div>
<div class="mt-3 flex items-center justify-between">
<span class="text-white/80">Browser-TTS für Zielphrasen</span>
<v-switch v-model="settings.tts" color="cyan" hide-details inset />
</div>
<div class="text-xs text-white/60 mt-2">Web Speech API; Stimme browserabhängig.</div>
</div>
<div class="card">
<div class="font-semibold">Bewertung</div>
<div class="mt-3">
<label class="text-sm text-white/80">Gewichtung Keywords vs. Ähnlichkeit</label>
<v-slider v-model="settings.keywordWeight" :min="0" :max="1" :step="0.05" color="cyan" thumb-label />
<div class="text-xs text-white/60">0 = nur Ähnlichkeit, 1 = nur Keywords. Standard 0.6.</div>
</div>
</div>
</div>
<div class="mt-4">
<button class="btn btn-ghost" @click="resetProgress"><v-icon icon="mdi-restore" /> Fortschritt zurücksetzen</button>
</div>
</section>
</div>
</div>
</main>
<!-- FOOT -->
<footer class="border-t border-white/10 bg-[#0a0f1c] py-6">
<div class="container-outer text-sm text-white/60 flex items-center justify-between">
<span>© OpenSquawk</span>
<span>Nur für Simulator · kein Real-World ATC</span>
</div>
</footer>
<!-- Congrats Dialog -->
<v-dialog v-model="showCongrats" max-width="520">
<div class="card">
<div class="flex items-center gap-3">
<v-icon icon="mdi-check-decagram" class="text-emerald-400" />
<div class="font-semibold">Lektionen abgeschlossen</div>
<!-- MODULE -->
<section v-if="panel==='module' && current" class="container play" aria-label="Modul">
<div class="play-head">
<div class="crumbs">
<button class="link" @click="panel='hub'"><v-icon size="16">mdi-arrow-left</v-icon> Hub</button>
<span class="muted">/ {{ current.title }}</span>
</div>
<p class="text-white/80 mt-2">Weiter zur nächsten Lektion?</p>
<div class="mt-4 flex gap-2 justify-end">
<button class="btn btn-ghost" @click="showCongrats=false">Schließen</button>
<button class="btn btn-primary" @click="goNextLesson"><v-icon icon="mdi-arrow-right" /> Weiter</button>
<div class="stats">
<span class="stat"><v-icon size="18">mdi-progress-check</v-icon> {{ doneCount(current.id) }}/{{ current.lessons.length }}</span>
<span class="stat"><v-icon size="18">mdi-star</v-icon> Ø {{ avgScore(current.id) }}%</span>
</div>
</div>
<div class="lesson-grid">
<button
v-for="l in current.lessons"
:key="l.id"
class="lesson"
:class="{ active: activeLesson?.id===l.id, ok: bestScore(current.id,l.id)>=80 }"
@click="selectLesson(l)"
>
<div class="lesson-top">
<div class="lesson-title"><v-icon size="18">mdi-headset</v-icon> {{ l.title }}</div>
<div class="chip" :class="{ ok: bestScore(current.id,l.id)>=80 }">
{{ bestScore(current.id,l.id) ? bestScore(current.id,l.id)+'%' : 'neu' }}
</div>
</div>
<div class="muted small">{{ l.desc }}</div>
<div class="tags">
<span v-for="k in l.keywords" :key="k" class="tag">{{ k }}</span>
</div>
</button>
</div>
<div v-if="activeLesson" class="console">
<div class="col">
<div class="label">Zielphrase</div>
<div class="panel">{{ activeLesson.target }}</div>
<div class="hints">
<div v-for="h in activeLesson.hints" :key="h" class="hint">
<v-icon size="16">mdi-lightbulb-on-outline</v-icon> {{ h }}
</div>
</div>
<div class="row">
<button class="btn soft" @click="speak(activeLesson.target)"><v-icon>mdi-volume-high</v-icon> Abspielen</button>
<button class="btn ghost" @click="fillTarget"><v-icon>mdi-auto-fix</v-icon> Autotext</button>
</div>
</div>
<div class="col">
<div class="label">Deine Readback</div>
<v-textarea v-model="userInput" rows="4" class="input" placeholder="Tippen (PTT später)"/>
<div class="row">
<button class="btn primary" :disabled="evaluating" @click="evaluate"><v-icon>mdi-check</v-icon> Prüfen</button>
<button class="btn ghost" @click="userInput=''"><v-icon>mdi-eraser</v-icon> Löschen</button>
</div>
<div v-if="result" class="score">
<div class="score-num">{{ result.score }}%</div>
<div class="muted small">
Keywords: {{ result.hits }}/{{ activeLesson.keywords.length }} · Ähnlichkeit: {{ Math.round(result.sim*100) }}%
</div>
</div>
</div>
</div>
</section>
<!-- PROGRESS -->
<section v-if="panel==='progress'" class="container" aria-label="Fortschritt">
<h2 class="h2">Gesamtfortschritt</h2>
<div class="progress-grid">
<div v-for="m in modules" :key="m.id" class="panel">
<div class="row between">
<div class="strong">{{ m.title }}</div>
<div class="muted">{{ pct(m.id) }}%</div>
</div>
<div class="list">
<div v-for="l in m.lessons" :key="l.id" class="list-row">
<span>{{ l.title }}</span>
<span class="muted">{{ bestScore(m.id,l.id) ? bestScore(m.id,l.id)+'%' : '—' }}</span>
</div>
</div>
</div>
</div>
</section>
<!-- SETTINGS -->
<v-dialog v-model="showSettings" max-width="720">
<div class="panel dialog">
<h3 class="h3">Einstellungen</h3>
<div class="settings">
<div class="set-row">
<span>Browser-TTS</span>
<v-switch v-model="cfg.tts" color="cyan" hide-details inset />
</div>
<div class="set-row">
<span>Keyword-Gewichtung</span>
<v-slider v-model="cfg.kwWeight" :min="0" :max="1" :step="0.05" color="cyan" thumb-label />
</div>
</div>
<div class="row end">
<button class="btn soft" @click="showSettings=false">Schließen</button>
</div>
</div>
</v-dialog>
<!-- TOAST -->
<v-snackbar v-model="toast.show" timeout="2200" location="top" color="#22d3ee">
<v-icon class="mr-2">mdi-trophy</v-icon> {{ toast.text }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
/** MODULE DATA (dummy, funktionsfähig) **/
type Lesson = { id:string; title:string; desc:string; target:string; hints:string[]; keywords:string[] }
type ModuleDef = { id:string; order:number; title:string; subtitle:string; desc:string; lessons:Lesson[] }
type ModuleDef = { id:string; title:string; subtitle:string; art:string; lessons:Lesson[] }
const modules = ref<ModuleDef[]>([
{ id:'basics', order:1, title:'Basics', subtitle:'Funkdisziplin & Grundphrasen', desc:'Callsign, Readback, Zahlen.',
{
id:'basics',
title:'Basics',
subtitle:'Callsign · Struktur · Zahlen',
art:'https://images.unsplash.com/photo-1541392822270-85b2ff6c4577?q=80&w=1600&auto=format&fit=crop',
lessons:[
{ id:'callsign', title:'Callsign melden', desc:'Erster Call korrekt.',
{ id:'callsign', title:'Check-in', desc:'Erster Call korrekt.',
target:'Frankfurt Ground, Lufthansa one two three at stand A12, request taxi.',
hints:['Stationsname, Callsign, Position, Intent'], keywords:['Frankfurt Ground','Lufthansa','stand','request taxi'] },
{ id:'readback', title:'Einfacher Readback', desc:'Kurz bestätigen.',
target:'Lufthansa one two three, roger.', hints:['Callsign + roger/affirm'], keywords:['roger','affirm'] }
]},
{ id:'ground', order:2, title:'Ground', subtitle:'Taxi-Clearances', desc:'Taxiways, Hold-Short.',
hints:['Station Callsign Position Intent'], keywords:['Frankfurt Ground','Lufthansa','stand','request taxi'] },
{ id:'readback', title:'Short Readback', desc:'Kurz bestätigen.',
target:'Lufthansa one two three, roger.',
hints:['Callsign + roger/affirm'], keywords:['roger','affirm'] }
]
},
{
id:'ground',
title:'Ground',
subtitle:'Taxi • Hold Short • Handoff',
art:'https://images.unsplash.com/photo-1523961131990-5ea7c61b2107?q=80&w=1600&auto=format&fit=crop',
lessons:[
{ id:'taxi1', title:'Taxi-Clearance lesen', desc:'Via A, A5, B2.',
{ id:'taxi1', title:'Taxi-Clearance', desc:'Via A, A5, B2.',
target:'Lufthansa one two three, taxi to runway two five via A, A five, B two, hold short runway two five.',
hints:['taxi to runway + via + hold short'], keywords:['taxi to runway','via','hold short'] },
{ id:'handoff', title:'Handoff bestätigen', desc:'Frequenzwechsel.',
hints:['Taxi to runway via hold short'], keywords:['taxi to runway','via','hold short'] },
{ id:'handoff', title:'Handoff', desc:'Frequenzwechsel.',
target:'Contact Tower on one one niner decimal five, Lufthansa one two three.',
hints:['Contact Tower on ...','decimal'], keywords:['Contact Tower','decimal'] }
]},
{ id:'departure', order:3, title:'Departure', subtitle:'Line-up & Takeoff', desc:'Line up and wait.',
lessons:[ { id:'lineup', title:'Line up', desc:'Aufrollen und warten.',
target:'Lufthansa one two three, line up and wait runway two five.',
hints:['line up and wait'], keywords:['line up and wait'] } ]},
{ id:'arrival', order:4, title:'Arrival', subtitle:'Approach & Landing', desc:'Vacate & melden.',
lessons:[ { id:'vacate', title:'Runway verlassen', desc:'Verlasse Bahn, melde frei.',
target:'Lufthansa one two three, vacated runway two five via A six.',
hints:['vacated runway','via taxiway'], keywords:['vacated','runway'] } ]},
{ id:'vatsim', order:5, title:'VATSIM', subtitle:'Netiquette & Connect', desc:'Erster Online-Call.',
lessons:[ { id:'checkin', title:'Initial Check-in', desc:'Kurzer, sauberer erster Call.',
target:'Frankfurt Ground, Lufthansa one two three, A320 at stand A12, IFR to Munich, information Bravo, request clearance.',
hints:['IFR/VFR, ATIS Info, Request'], keywords:['IFR','information','request clearance'] } ]}
hints:['Contact Tower on … • decimal'], keywords:['Contact Tower','decimal'] }
]
},
{
id:'departure',
title:'Departure',
subtitle:'Line up • Takeoff',
art:'https://images.unsplash.com/photo-1494412685616-a5d310fbb07d?q=80&w=1600&auto=format&fit=crop',
lessons:[
{ id:'lineup', title:'Line up', desc:'Aufrollen und warten.',
target:'Lufthansa one two three, line up and wait runway two five.',
hints:['line up and wait'], keywords:['line up and wait'] }
]
},
{
id:'arrival',
title:'Arrival',
subtitle:'Approach • Vacate',
art:'https://images.unsplash.com/photo-1542089363-07b2d92aacc3?q=80&w=1600&auto=format&fit=crop',
lessons:[
{ id:'vacate', title:'Vacate', desc:'Verlasse Bahn, melde frei.',
target:'Lufthansa one two three, vacated runway two five via A six.',
hints:['vacated runway • via taxiway'], keywords:['vacated','runway'] }
]
},
{
id:'vatsim',
title:'VATSIM',
subtitle:'Netiquette • Connect',
art:'https://images.unsplash.com/photo-1508264769638-658b34d79f6e?q=80&w=1600&auto=format&fit=crop',
lessons:[
{ id:'checkin', title:'IFR Check-in', desc:'Erster Online-Call.',
target:'Frankfurt Ground, Lufthansa one two three, A320 at stand A12, IFR to Munich, information Bravo, request clearance.',
hints:['IFR/VFR • ATIS Info • Request'], keywords:['IFR','information','request clearance'] }
]
}
])
const panel = ref<'overview'|'module'|'progress'|'settings'>('overview')
const activeModule = ref<ModuleDef | null>(modules.value[0])
const activeLesson = ref<Lesson | null>(null)
/** STATE **/
const panel = ref<'hub'|'module'|'progress'>('hub')
const current = ref<ModuleDef|null>(null)
const activeLesson = ref<Lesson|null>(null)
const userInput = ref('')
const evaluating = ref(false)
const lastResult = ref<{score:number, similarity:number, keywordHits:number} | null>(null)
const showCongrats = ref(false)
const settings = ref({ tts: true, keywordWeight: 0.6 })
// Progress
type LessonProgress = { bestScore:number; done:boolean }
type ModuleProgress = Record<string, LessonProgress>
const progress = ref<Record<string, ModuleProgress>>({})
const toast = ref({ show:false, text:'' })
const showSettings = ref(false)
const cfg = ref({ tts:true, kwWeight:0.6 })
const LS_KEY='opensquawk.learn.progress.v1'
const LS_SETTINGS='opensquawk.learn.settings.v1'
onMounted(()=>{
const p = localStorage.getItem(LS_KEY); if (p) progress.value = JSON.parse(p)
const s = localStorage.getItem(LS_SETTINGS); if (s) settings.value = { ...settings.value, ...JSON.parse(s) }
})
watch(progress, v => localStorage.setItem(LS_KEY, JSON.stringify(v)), { deep:true })
watch(settings, v => localStorage.setItem(LS_SETTINGS, JSON.stringify(v)), { deep:true })
// Gamification
const xp = ref( Number(localStorage.getItem('os_xp')||'0') )
const level = computed(()=> 1 + Math.floor(xp.value / 300))
const seasonPct = computed(()=> Math.min(100, Math.round((xp.value % 1000)/10)))
type Prog = Record<string, Record<string,{best:number;done:boolean}>>
const progress = ref<Prog>( JSON.parse(localStorage.getItem('os_progress')||'{}') )
watch(progress, v => localStorage.setItem('os_progress', JSON.stringify(v)), { deep:true })
watch(xp, v => localStorage.setItem('os_xp', String(v)) )
watch(cfg, v => localStorage.setItem('os_cfg', JSON.stringify(v)), { deep:true })
/** HUB / FLOW **/
function isModuleUnlocked(id:string){
if (id==='basics') return true
const order = modules.value.findIndex(m=>m.id===id)
const prev = modules.value[order-1]
return pct(prev.id) >= 80
}
function openModule(id:string){
activeModule.value = modules.value.find(m=>m.id===id) || null
panel.value='module'; activeLesson.value=null; userInput.value=''; lastResult.value=null
current.value = modules.value.find(m=>m.id===id) || null
activeLesson.value = null
panel.value = 'module'
}
function openFirstUnfinished(modId:string){
openModule(modId)
const m = activeModule.value!; const mp = progress.value[m.id] || {}
function quickContinue(id:string){
openModule(id)
const m = current.value!
const mp = progress.value[m.id] || {}
const next = m.lessons.find(l => !(mp[l.id]?.done))
if (next) startLesson(next)
activeLesson.value = next || m.lessons[0]
}
function startLesson(l:Lesson){ activeLesson.value=l; userInput.value=''; lastResult.value=null }
function fillWithTarget(l:Lesson){ if(!activeLesson.value || activeLesson.value.id!==l.id) startLesson(l); userInput.value=l.target }
function selectLesson(l:Lesson){ activeLesson.value = l; userInput.value='' }
function ensureModuleProgress(modId:string){ if(!progress.value[modId]) progress.value[modId]={} }
function lessonState(l:Lesson){ const mp = activeModule.value ? progress.value[activeModule.value.id]?.[l.id] : undefined; return { done:!!mp?.done, bestScore: mp?.bestScore||0 } }
function bestScoreByIds(modId:string, lesId:string){ return progress.value[modId]?.[lesId]?.bestScore || 0 }
function moduleDoneCount(modId:string){ const m=modules.value.find(x=>x.id===modId); if(!m) return 0; const mp=progress.value[modId]||{}; return m.lessons.filter(ls=>mp[ls.id]?.done).length }
function moduleProgressPct(modId:string){ const m=modules.value.find(x=>x.id===modId); if(!m||!m.lessons.length) return 0; return Math.round(moduleDoneCount(modId)/m.lessons.length*100) }
function avgModuleScore(modId:string){ const m=modules.value.find(x=>x.id===modId); if(!m) return 0; const mp=progress.value[modId]||{}; const scores=m.lessons.map(ls=>mp[ls.id]?.bestScore||0); const sum=scores.reduce((a,b)=>a+b,0); return Math.round(sum/(scores.length||1)) }
function normalize(s:string){ return s.toLowerCase().replace(/[^a-z0-9 ]+/g,' ').replace(/\s+/g,' ').trim() }
function levenshtein(a:string,b:string){ const m=a.length,n=b.length; if(!m) return n; if(!n) return m; const dp=new Array(n+1).fill(0); for(let j=0;j<=n;j++) dp[j]=j; for(let i=1;i<=m;i++){ let prev=dp[0]; dp[0]=i; for(let j=1;j<=n;j++){ const tmp=dp[j]; const cost=a[i-1]===b[j-1]?0:1; dp[j]=Math.min(dp[j]+1, dp[j-1]+1, prev+cost); prev=tmp } } return dp[n] }
function evaluateSimilarity(a:string,b:string){ const s1=normalize(a), s2=normalize(b); const dist=levenshtein(s1,s2); const maxLen=Math.max(s1.length,s2.length)||1; return 1 - dist/maxLen }
function countKeywordHits(text:string, kws:string[]){ const t=normalize(text); return kws.reduce((sum,kw)=> sum + (t.includes(normalize(kw))?1:0), 0) }
async function evaluate(l:Lesson){
evaluating.value=true
await new Promise(r=>setTimeout(r,200))
const sim = evaluateSimilarity(userInput.value, l.target)
const hits = countKeywordHits(userInput.value, l.keywords)
const kwScore = l.keywords.length ? (hits/l.keywords.length) : 1
const weight = settings.value.keywordWeight
const final = Math.round(((kwScore*weight) + (sim*(1-weight))) * 100)
lastResult.value = { score: final, similarity: sim, keywordHits: hits }
if(activeModule.value){
ensureModuleProgress(activeModule.value.id)
const mp = progress.value[activeModule.value.id]
const prev = mp[l.id]?.bestScore || 0
const best = Math.max(prev, final)
mp[l.id] = { bestScore: best, done: best >= 80 }
if (mp[l.id].done) showCongrats.value = true
}
evaluating.value=false
function bestScore(modId:string, lesId:string){ return progress.value[modId]?.[lesId]?.best || 0 }
function doneCount(modId:string){
const m = modules.value.find(x=>x.id===modId); if(!m) return 0
const mp = progress.value[modId] || {}
return m.lessons.filter(l => mp[l.id]?.done).length
}
function goNextLesson(){
showCongrats.value=false
if(!activeModule.value || !activeLesson.value) return
const idx = activeModule.value.lessons.findIndex(x=>x.id===activeLesson.value!.id)
const next = activeModule.value.lessons[idx+1]
if(next) startLesson(next); else { panel.value='overview'; activeLesson.value=null }
function pct(modId:string){
const m = modules.value.find(x=>x.id===modId); if(!m) return 0
return Math.round(doneCount(modId)/m.lessons.length*100)
}
function avgScore(modId:string){
const m = modules.value.find(x=>x.id===modId); if(!m) return 0
const mp = progress.value[modId] || {}
const arr = m.lessons.map(l => mp[l.id]?.best || 0)
const s = arr.reduce((a,b)=>a+b,0)
return Math.round(s/(arr.length||1))
}
/** EVALUATION (flach, schnell) **/
const evaluating = ref(false)
const result = ref<{score:number, sim:number, hits:number}|null>(null)
function norm(s:string){ return s.toLowerCase().replace(/[^a-z0-9 ]+/g,' ').replace(/\s+/g,' ').trim() }
function lev(a:string,b:string){
const m=a.length,n=b.length; if(!m) return n; if(!n) return m
const dp=new Array(n+1).fill(0); for(let j=0;j<=n;j++) dp[j]=j
for(let i=1;i<=m;i++){ let p=dp[0]; dp[0]=i
for(let j=1;j<=n;j++){ const t=dp[j]; const c=a[i-1]===b[j-1]?0:1; dp[j]=Math.min(dp[j]+1, dp[j-1]+1, p+c); p=t }
} return dp[n]
}
function sim(a:string,b:string){ const A=norm(a), B=norm(b); const d=lev(A,B); const M=Math.max(A.length,B.length)||1; return 1 - d/M }
function hits(text:string,kws:string[]){ const T=norm(text); return kws.reduce((s,k)=> s + (T.includes(norm(k))?1:0),0) }
async function evaluate(){
if(!current.value || !activeLesson.value) return
evaluating.value = true
await new Promise(r=>setTimeout(r,180))
const s = sim(userInput.value, activeLesson.value.target)
const h = hits(userInput.value, activeLesson.value.keywords)
const kwScore = activeLesson.value.keywords.length ? (h/activeLesson.value.keywords.length) : 1
const score = Math.round(((kwScore*cfg.value.kwWeight) + (s*(1-cfg.value.kwWeight))) * 100)
result.value = { score, sim:s, hits:h }
const modId = current.value.id, lesId = activeLesson.value.id
if (!progress.value[modId]) progress.value[modId] = {}
const prev = progress.value[modId][lesId]?.best || 0
const best = Math.max(prev, score)
const passedBefore = progress.value[modId][lesId]?.done || false
progress.value[modId][lesId] = { best, done: best>=80 }
// XP
let gained = 0
if (best>=80 && !passedBefore) gained += 40
if (score>=95) gained += 15
if (score>=80 && userInput.value.length <= activeLesson.value.target.length+8) gained += 10
if (gained){ xp.value += gained; toastNow(`+${gained} XP · ${activeLesson.value.title}`) }
evaluating.value = false
}
/** AUDIO **/
function speak(t:string){ if(!cfg.value.tts) return; const u=new SpeechSynthesisUtterance(t); u.rate=.95; u.pitch=1; speechSynthesis.cancel(); speechSynthesis.speak(u) }
function fillTarget(){ if(activeLesson.value) userInput.value = activeLesson.value.target }
/** DAILIES **/
const dailies = ref([
{ id:'d1', title:'3 Readbacks ≥80%', sub:'Belohnung: +50 XP', reward:50 },
{ id:'d2', title:'1 Modul starten', sub:'Belohnung: +20 XP', reward:20 },
{ id:'d3', title:'Zielphrase abspielen', sub:'Belohnung: +10 XP', reward:10 }
])
function startDaily(d:{id:string;reward:number;title:string}){ xp.value += d.reward; toastNow(`Daily: ${d.title} · +${d.reward} XP`); dailies.value = dailies.value.filter(x=>x.id!==d.id) }
/** UI helpers **/
function toastNow(t:string){ toast.value.text=t; toast.value.show=true }
function resetAll(){ localStorage.clear(); location.reload() }
/** MSFS-Tilt ohne Shadow/Radius **/
const worldTiltStyle = ref<any>({})
function tilt(e:MouseEvent){
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const dx = (e.clientX - (rect.left + rect.width/2)) / rect.width
const dy = (e.clientY - (rect.top + rect.height/2)) / rect.height
worldTiltStyle.value = { transform:`perspective(1200px) rotateX(${dy*-3}deg) rotateY(${dx*3}deg)` }
}
function feedbackText(score:number){ if(score>=90) return 'Sehr gut.'; if(score>=80) return 'Gut, kleine Korrekturen.'; if(score>=65) return 'Okay, Struktur schärfen.'; return 'Nochmal: Keywords & Struktur.' }
function speak(text:string){ if(!settings.value.tts) return; const u=new SpeechSynthesisUtterance(text); u.rate=0.95; u.pitch=1.0; window.speechSynthesis.cancel(); window.speechSynthesis.speak(u) }
function resetProgress(){ progress.value={}; lastResult.value=null; activeLesson.value=null }
</script>
<style scoped>
/* CI-Klassen, identisch zum Landing-Page-Stil */
.container-outer { @apply mx-auto max-w-screen-xl px-4; }
/* Hero Gradient */
.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%);
/* === CI TOKENS (aus deinem CI-Dokument) === */
:root{
--bg: #0b1020; /* --brand.bg */
--bg2: #0a0f1c; /* --brand.bg-2 */
--accent: #22d3ee; /* --brand.accent */
--accent2: #0ea5e9; /* --brand.accent-2 */
--text: #ffffff; /* --brand.text */
--t2: rgba(255,255,255,.80);/* --brand.text-2 */
--t3: rgba(255,255,255,.60);/* --brand.text-3 */
--border: rgba(255,255,255,.10); /* --brand.border */
}
/* Glass/Card/Button/Chip wie CI */
.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 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400; }
.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; }
/* === BASICS (kantig: keine Rundungen, keine Schatten) === */
.scene{ min-height:100vh; background: var(--bg); color: var(--text); }
.container{ max-width:1200px; margin:0 auto; padding: 20px; }
.h1{ font-size: clamp(32px, 5vw, 48px); font-weight:600; line-height:1.2 }
.h2{ font-size: clamp(24px, 3.5vw, 32px); font-weight:600; line-height:1.25 }
.h3{ font-size: 20px; font-weight:600 }
.muted{ color: var(--t3) }
.small{ font-size: 13px }
.strong{ font-weight:600 }
/* HUD */
.hud{ position:sticky; top:0; z-index:40; border-bottom:1px solid var(--border); background: color-mix(in srgb, var(--bg) 85%, transparent); }
.hud-inner{ display:flex; align-items:center; justify-content:space-between; padding:10px 20px; }
.hud-left{ display:flex; align-items:center; gap:10px; }
.brand{ font-weight:600 }
.mode{ color: var(--t2) }
.sep{ color: var(--t3) }
.hud-right{ display:flex; gap:8px; align-items:center; }
.text-accent{ color: var(--accent) }
/* Hero */
.hero{ background:
radial-gradient(800px 400px at 80% -10%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 60%),
radial-gradient(600px 300px at 10% -5%, color-mix(in srgb, var(--accent2) 15%, transparent), transparent 60%),
linear-gradient(180deg, var(--bg) 0%, var(--bg) 60%, var(--bg2) 100%);
padding: 18px 0 8px;
}
.hero-panel{ display:grid; grid-template-columns: 1.1fr .9fr; gap:20px; border:1px solid var(--border); background: color-mix(in srgb, var(--text) 6%, transparent); padding:20px; transform-style:preserve-3d; }
.hero-left .eyebrow{ font-size:12px; letter-spacing:.16em; color: var(--t3) }
.actions{ display:flex; gap:10px; margin-top:12px }
.season{ margin-top:14px }
.bar{ height:8px; background: color-mix(in srgb, var(--text) 8%, transparent); border:1px solid var(--border) }
.fill{ height:100%; background: linear-gradient(90deg, var(--accent), var(--accent2)) }
.meta{ display:flex; justify-content:space-between; margin-top:6px; color: var(--t3); font-size:12px }
.hero-right .rail-title{ color: var(--t3); display:flex; align-items:center; gap:6px; margin-bottom:6px }
.rail{ display:flex; gap:10px; overflow:auto }
.card{ border:1px solid var(--border); background: color-mix(in srgb, var(--text) 6%, transparent); padding:12px; min-width:240px }
.card-head{ display:flex; justify-content:space-between; align-items:center }
.badge{ border:1px solid var(--border); padding:2px 6px; font-size:12px; color: var(--t2) }
.card-title{ font-weight:600; margin:6px 0 2px }
/* Buttons (kantig) */
.btn{ display:inline-flex; align-items:center; gap:8px; padding:10px 14px; border:1px solid var(--border); background: color-mix(in srgb, var(--text) 6%, transparent); color: var(--text); font-weight:600 }
.btn:hover{ background: color-mix(in srgb, var(--text) 10%, transparent) }
.btn.primary{ background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 90%, transparent), color-mix(in srgb, var(--accent) 70%, transparent)); color:#061318; border-color: color-mix(in srgb, var(--accent) 60%, transparent) }
.btn.soft{ background: color-mix(in srgb, var(--text) 8%, transparent) }
.btn.ghost{ background: transparent }
.btn.mini{ padding:6px 10px; font-size:12px }
.chip{ display:inline-flex; align-items:center; padding:6px 10px; border:1px solid var(--border); background: color-mix(in srgb, var(--text) 6%, transparent); font-size:12px }
/* HUB tiles */
.hub-head{ margin: 6px 0 10px }
.tiles{ display:grid; grid-template-columns: repeat(auto-fill, minmax(280px,1fr)); gap:12px }
.tile{ border:1px solid var(--border); background: color-mix(in srgb, var(--text) 6%, transparent); display:flex; flex-direction:column }
.tile.locked{ filter:saturate(.7) brightness(.9) }
.tile-media{ height:140px; background-size:cover; background-position:center }
.tile-body{ padding:12px }
.tile-top{ display:flex; justify-content:space-between; align-items:center; margin-bottom:4px }
.tile-title{ font-weight:600; display:flex; align-items:center; gap:6px }
.line{ height:8px; border:1px solid var(--border); background: color-mix(in srgb, var(--text) 8%, transparent) }
.line-fill{ height:100%; background: linear-gradient(90deg, var(--accent), var(--accent2)) }
.tile-actions{ display:flex; gap:8px; margin-top:10px }
/* Play */
.play .play-head{ display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:10px }
.crumbs{ display:flex; align-items:center; gap:8px }
.link{ background:transparent; border:0; color: var(--text); display:inline-flex; align-items:center; gap:6px }
.stats{ display:flex; gap:8px }
.stat{ border:1px solid var(--border); padding:6px 10px; background: color-mix(in srgb, var(--text) 6%, transparent) }
.lesson-grid{ display:grid; grid-template-columns: repeat(auto-fill, minmax(260px,1fr)); gap:10px }
.lesson{ text-align:left; border:1px solid var(--border); padding:12px; background: color-mix(in srgb, var(--text) 5%, transparent); cursor:pointer }
.lesson.active{ outline:1px solid color-mix(in srgb, var(--accent) 50%, transparent) }
.lesson.ok{ border-color: color-mix(in srgb, #4caf50 60%, transparent) }
.lesson-top{ display:flex; justify-content:space-between; align-items:center }
.tags{ display:flex; flex-wrap:wrap; gap:6px; margin-top:8px }
.tag{ border:1px dashed color-mix(in srgb, var(--text) 25%, transparent); color: var(--t3); font-size:11px; padding:2px 6px }
.console{ display:grid; grid-template-columns: 1.1fr .9fr; gap:12px; margin-top:12px }
.col .label{ font-size:12px; letter-spacing:.12em; color: var(--t3); margin-bottom:6px }
.panel{ border:1px solid var(--border); background: color-mix(in srgb, var(--text) 6%, transparent); padding:12px }
.row{ display:flex; gap:8px; margin-top:8px }
.row.between{ justify-content:space-between }
.row.end{ justify-content:flex-end }
.input :deep(textarea){ background: color-mix(in srgb, var(--text) 6%, transparent); border:1px solid var(--border); color: var(--text) }
.score{ margin-top:8px }
.score-num{ font-size:28px; font-weight:700 }
/* Progress */
.progress-grid{ display:grid; grid-template-columns: repeat(auto-fill, minmax(280px,1fr)); gap:12px; margin-top:10px }
.list{ margin-top:8px }
.list-row{ display:flex; justify-content:space-between; border-bottom:1px dashed color-mix(in srgb, var(--text) 12%, transparent); padding:8px 0 }
.list-row:last-child{ border-bottom:0 }
/* Responsive */
@media (max-width: 980px){
.hero-panel{ grid-template-columns:1fr; }
.console{ grid-template-columns:1fr; }
.stats{ display:none }
}
</style>
<!-- am Ende der Datei -->
<!-- GLOBAL: CI-Tokens + Body -->
<style>
</style>
<!-- SCOPED: Komponenten-Styles (ohne :root) -->
<style scoped>
/* alles wie zuvor, aber ohne :root beginnt z.B. mit: */
.scene{ min-height:100vh; background: var(--bg); color: var(--text); }
.container{ max-width:1200px; margin:0 auto; padding:20px; }
/* … rest deines Styles unverändert … */
/* Accessibility: sichtbare Focus-States */
a:focus-visible, button:focus-visible { outline: none; box-shadow: 0 0 0 2px rgba(34,211,238,.8); border-radius: 10px; }
</style>

View File

@@ -1,14 +1,43 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
// nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: {enabled: true},
devtools: { enabled: true },
ssr: false,
modules: ['vuetify-nuxt-module', '@nuxtjs/tailwindcss', 'nuxt-aos'],
aos: {once: true, duration: 600, easing: 'ease-out'},
app: {head: {link: [{rel: 'icon', type: 'image/jpeg', href: '/img/logo.jpeg'}]}},
aos: { once: true, duration: 600, easing: 'ease-out' },
app: { head: { link: [{ rel: 'icon', type: 'image/jpeg', href: '/img/logo.jpeg' }] } },
runtimeConfig: {
openaiKey: process.env.OPENAI_API_KEY, // server-only
openaiKey: process.env.OPENAI_API_KEY,
llmModel: process.env.LLM_MODEL || 'gpt-5-nano',
ttsModel: process.env.TTS_MODEL || 'tts-1',
public: {}
}
},
vuetify: {
vuetifyOptions: {
theme: {
defaultTheme: 'opensquawkDark',
themes: {
opensquawkDark: {
dark: true,
colors: {
background: '#0b1020',
surface: '#0a0f1c',
primary: '#22d3ee',
secondary: '#0ea5e9',
info: '#22d3ee',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
// Text-Kontrast
'on-background': '#ffffff',
'on-surface': '#ffffff'
}
}
}
}
}
},
css: [
'~/assets/css/global.css'
],
})

View File

@@ -8,6 +8,7 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"start": "node .output/server/index.mjs",
"postinstall": "nuxt prepare"
},
"dependencies": {