Fix remaining German comment

This commit is contained in:
Remi
2025-09-20 09:46:34 +02:00
parent ae0664c17c
commit 9577458482
35 changed files with 667 additions and 1063 deletions

View File

@@ -1,6 +1,6 @@
/* === iOS-Style Glassmorphism Add-on === */
:root {
/* feinere Glas-Parameter */
/* refined glass parameters */
--glass-bg: color-mix(in srgb, var(--text) 7%, transparent);
--glass-border: color-mix(in srgb, var(--text) 16%, transparent);
--glass-highlight: rgba(255, 255, 255, .10);
@@ -11,7 +11,7 @@
--glass-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140' viewBox='0 0 140 140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='table' tableValues='0 0 .03 0'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* generische Glasfläche */
/* generic glass surface */
.glass,
.panel,
.card,
@@ -33,7 +33,7 @@
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
}
/* App Bar (transluzent, sticky) */
/* App bar (translucent, sticky) */
.hud {
background: linear-gradient(180deg,
color-mix(in srgb, var(--bg) 55%, transparent),
@@ -43,12 +43,12 @@
border-radius: 0 0 14px 14px;
}
/* Panels etwas luftiger */
/* Panels with extra breathing room */
.panel {
padding: 14px;
}
/* Buttons iOS-Glanz & Lift */
/* Buttons iOS shine and lift */
.btn {
border-radius: calc(var(--glass-radius) - 6px);
transition: transform .15s ease, box-shadow .15s ease, background .2s ease;
@@ -92,7 +92,7 @@
}
/* Chips feiner Rand, leicht leuchtend */
/* Chips subtle border, gentle glow */
.chip {
background: linear-gradient(180deg,
color-mix(in srgb, var(--text) 7%, transparent),
@@ -100,7 +100,7 @@
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08);
}
/* Karten/Kacheln zarter Hover */
/* Cards/tiles subtle hover */
.card, .tile, .lesson {
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
}
@@ -110,7 +110,7 @@
border-color: color-mix(in srgb, var(--accent) 28%, transparent);
}
/* Fortschrittsbalken Glasfüllung */
/* Progress bar glass fill */
.line {
background: linear-gradient(180deg,
color-mix(in srgb, var(--text) 8%, transparent),
@@ -125,7 +125,7 @@
filter: saturate(130%);
}
/* Textareas / Inputs im Glas */
/* Textareas / inputs in glass */
.input :deep(textarea),
:deep(.v-field__input) {
background: linear-gradient(180deg,
@@ -141,14 +141,14 @@
display: none;
}
/* Dialog runder + stärkerer Blur */
/* Dialog rounder with stronger blur */
.dialog {
border-radius: 22px;
backdrop-filter: blur(22px) saturate(150%);
-webkit-backdrop-filter: blur(22px) saturate(150%);
}
/* iOS-Range (Slider) Track & Thumb */
/* iOS-style range (slider) track & thumb */
.chip.inline input[type="range"] {
height: 24px;
-webkit-appearance: none;
@@ -194,7 +194,7 @@
box-shadow: 0 2px 6px rgba(0, 0, 0, .35), inset 0 1px 0 rgba(255, 255, 255, .85);
}
/* Snackbar glasiger Toast */
/* Snackbar glassy toast */
:deep(.v-snackbar) {
border: 1px solid var(--glass-border);
backdrop-filter: blur(16px) saturate(140%);
@@ -204,14 +204,14 @@
color-mix(in srgb, var(--bg2) 60%, transparent));
}
/* Fokus-Ring iOS-Style */
/* Focus ring iOS style */
*:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
outline-offset: 2px;
border-radius: 12px;
}
/* Weniger Transparenz, falls gewünscht */
/* Reduced transparency if requested */
@media (prefers-reduced-transparency: reduce) {
.glass,
.panel,

View File

@@ -6,14 +6,14 @@
<p class="text-xs uppercase tracking-[0.4em] text-cyan-300/70">OpenSquawk</p>
<h1 class="text-3xl font-semibold">Admin Operations Center</h1>
<p class="text-sm text-white/70">
Vollständiger Überblick über Nutzer, Einladungscodes und Funkprotokolle mit Live-Trace.
Complete overview of users, invitation codes and radio logs with live tracing.
</p>
</div>
<div class="flex flex-col items-start gap-3 text-sm text-white/70 sm:items-end">
<div class="flex items-center gap-2 text-white/80">
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
<span>
Eingeloggt als <strong>{{ auth.user?.email }}</strong>
Signed in as <strong>{{ auth.user?.email }}</strong>
<v-chip size="x-small" color="cyan" variant="outlined" class="ml-2">
{{ auth.user?.role?.toUpperCase() }}
</v-chip>
@@ -27,7 +27,7 @@
prepend-icon="mdi-refresh"
@click="refreshActiveTab"
>
Daten aktualisieren
Refresh data
</v-btn>
</div>
</header>
@@ -38,11 +38,11 @@
slider-color="cyan"
density="comfortable"
>
<v-tab value="overview">Übersicht</v-tab>
<v-tab value="users">Nutzer</v-tab>
<v-tab value="invitations">Einladungen</v-tab>
<v-tab value="waitlist">Warteliste</v-tab>
<v-tab value="logs">Funkprotokolle</v-tab>
<v-tab value="overview">Overview</v-tab>
<v-tab value="users">Users</v-tab>
<v-tab value="invitations">Invitations</v-tab>
<v-tab value="waitlist">Waitlist</v-tab>
<v-tab value="logs">Transmissions</v-tab>
</v-tabs>
<v-window v-model="activeTab" class="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
@@ -50,10 +50,10 @@
<section class="space-y-6">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-2xl font-semibold">Systemmetriken</h2>
<p class="text-sm text-white/70">Letzte Aktualisierung: {{ formatDateTime(overview?.generatedAt) }}</p>
<h2 class="text-2xl font-semibold">System metrics</h2>
<p class="text-sm text-white/70">Last updated: {{ formatDateTime(overview?.generatedAt) }}</p>
</div>
<v-chip v-if="overviewLoading" color="cyan" variant="tonal" size="small">lädt</v-chip>
<v-chip v-if="overviewLoading" color="cyan" variant="tonal" size="small">loading</v-chip>
</div>
<v-alert
@@ -85,12 +85,12 @@
<p class="text-xl font-semibold text-white">{{ overview.users.devs }}</p>
</div>
<div>
<p class="text-xs uppercase tracking-[0.3em] text-white/40">Neu (7 Tage)</p>
<p class="text-xs uppercase tracking-[0.3em] text-white/40">New (7 days)</p>
<p class="text-xl font-semibold text-emerald-300">{{ overview.users.newLast7Days }}</p>
</div>
</div>
<div>
<p class="text-xs uppercase tracking-[0.3em] text-white/40 mb-2">Neueste Accounts</p>
<p class="text-xs uppercase tracking-[0.3em] text-white/40 mb-2">Latest accounts</p>
<ul class="space-y-2 text-sm text-white/80">
<li
v-for="user in overview.users.recent"
@@ -111,21 +111,21 @@
<v-card class="bg-black/40">
<v-card-text class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">Einladungscodes</h3>
<h3 class="text-lg font-semibold">Invitation codes</h3>
<v-chip size="small" color="cyan" variant="outlined">{{ overview.invitations.total }}</v-chip>
</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<p class="text-xs uppercase tracking-[0.3em] text-white/40">Aktiv</p>
<p class="text-xs uppercase tracking-[0.3em] text-white/40">Active</p>
<p class="text-xl font-semibold text-white">{{ overview.invitations.active }}</p>
</div>
<div>
<p class="text-xs uppercase tracking-[0.3em] text-white/40">Läuft in 7 Tagen ab</p>
<p class="text-xs uppercase tracking-[0.3em] text-white/40">Expires in 7 days</p>
<p class="text-xl font-semibold text-orange-300">{{ overview.invitations.expiringSoon }}</p>
</div>
</div>
<div>
<p class="text-xs uppercase tracking-[0.3em] text-white/40 mb-2">Letzte Codes</p>
<p class="text-xs uppercase tracking-[0.3em] text-white/40 mb-2">Latest codes</p>
<ul class="space-y-2 text-sm text-white/80">
<li
v-for="inv in overview.invitations.recent"
@@ -141,7 +141,7 @@
<v-chip v-if="inv.label" size="x-small" color="cyan" variant="tonal">{{ inv.label }}</v-chip>
</div>
<p v-if="inv.expiresAt" class="text-xs text-white/50">
gültig bis {{ formatDateTime(inv.expiresAt) }}
Valid until {{ formatDateTime(inv.expiresAt) }}
</p>
</li>
</ul>
@@ -152,7 +152,7 @@
<v-card class="bg-black/40">
<v-card-text class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">Funkprotokolle</h3>
<h3 class="text-lg font-semibold">Radio logs</h3>
<v-chip size="small" color="cyan" variant="outlined">{{ overview.transmissions.total }}</v-chip>
</div>
<div class="grid grid-cols-2 gap-3 text-sm">
@@ -161,7 +161,7 @@
<p class="text-xl font-semibold text-white">{{ overview.transmissions.last24h }}</p>
</div>
<div>
<p class="text-xs uppercase tracking-[0.3em] text-white/40">Kanäle</p>
<p class="text-xs uppercase tracking-[0.3em] text-white/40">Channels</p>
<div class="flex flex-wrap gap-2 pt-1 text-xs text-white/60">
<span
v-for="(value, key) in overview.transmissions.byChannel"
@@ -175,7 +175,7 @@
</div>
</div>
<div>
<p class="text-xs uppercase tracking-[0.3em] text-white/40 mb-2">Neueste Übertragungen</p>
<p class="text-xs uppercase tracking-[0.3em] text-white/40 mb-2">Latest transmissions</p>
<ul class="space-y-2 text-sm text-white/80">
<li
v-for="log in overview.transmissions.recent"
@@ -198,7 +198,7 @@
</div>
<div v-else-if="overviewLoading" class="py-12 text-center text-white/70">
<v-progress-circular indeterminate color="cyan" class="mb-4" />
<p>Daten werden geladen</p>
<p>Loading data</p>
</div>
</section>
</v-window-item>
@@ -209,7 +209,7 @@
<div class="flex flex-1 flex-col gap-4 md:flex-row md:items-end">
<v-text-field
v-model="userSearch"
label="Suche nach Nutzer"
label="Search users"
density="comfortable"
variant="outlined"
color="cyan"
@@ -221,7 +221,7 @@
<v-select
v-model="userRoleFilter"
:items="userRoleFilterOptions"
label="Rolle filtern"
label="Filter by role"
density="comfortable"
variant="outlined"
color="cyan"
@@ -236,7 +236,7 @@
<v-chip size="x-small" color="cyan" variant="outlined">Admin {{ userRoleStats.admin }}</v-chip>
</div>
<v-btn color="cyan" variant="tonal" @click="fetchUsers(true)" :loading="userLoading">
Anwenden
Apply
</v-btn>
</div>
</div>
@@ -254,7 +254,7 @@
<div v-if="userLoading" class="py-12 text-center text-white/70">
<v-progress-circular indeterminate color="cyan" class="mb-4" />
<p>Nutzerdaten werden geladen</p>
<p>Loading user data</p>
</div>
<div v-else class="space-y-4">
@@ -270,15 +270,15 @@
<h3 class="text-lg font-semibold">{{ user.email }}</h3>
<p v-if="user.name" class="text-sm text-white/60">{{ user.name }}</p>
<div class="flex flex-wrap gap-2 text-xs text-white/50">
<span>Einladungen erstellt: {{ user.invitationCodesIssued }}</span>
<span v-if="user.lastLoginAt">Letzter Login: {{ formatRelative(user.lastLoginAt) }}</span>
<span>Invitations created: {{ user.invitationCodesIssued }}</span>
<span v-if="user.lastLoginAt">Last login: {{ formatRelative(user.lastLoginAt) }}</span>
</div>
</div>
<div class="flex flex-col items-start gap-2 md:items-end">
<v-select
v-model="userRoleDraft[user.id]"
:items="userRoleEditOptions"
label="Rolle"
label="Role"
density="comfortable"
variant="outlined"
color="cyan"
@@ -293,17 +293,17 @@
:disabled="userRoleDraft[user.id] === user.role"
@click="applyRoleChange(user.id)"
>
Aktualisieren
Update role
</v-btn>
</div>
</v-card-text>
</v-card>
</div>
<p v-else class="py-12 text-center text-sm text-white/60">Keine Nutzer gefunden.</p>
<p v-else class="py-12 text-center text-sm text-white/60">No users found.</p>
<div class="flex flex-col items-center justify-between gap-3 sm:flex-row">
<div class="text-xs text-white/50">
Seite {{ userPagination.page }} von {{ userPagination.pages }} · {{ userPagination.total }} Einträge
Page {{ userPagination.page }} of {{ userPagination.pages }} · {{ userPagination.total }} entries
</div>
<div class="flex items-center gap-2">
<v-btn
@@ -312,7 +312,7 @@
:disabled="userPagination.page <= 1"
@click="changeUserPage(userPagination.page - 1)"
>
Zurück
Back
</v-btn>
<v-btn
variant="text"
@@ -320,7 +320,7 @@
:disabled="userPagination.page >= userPagination.pages"
@click="changeUserPage(userPagination.page + 1)"
>
Weiter
Next
</v-btn>
</div>
</div>
@@ -334,7 +334,7 @@
<div class="flex flex-1 flex-col gap-4 md:flex-row md:items-end">
<v-text-field
v-model="invitationSearch"
label="Code oder Label suchen"
label="Search code or label"
density="comfortable"
variant="outlined"
color="cyan"
@@ -356,7 +356,7 @@
<v-select
v-model="invitationChannel"
:items="invitationChannelOptions"
label="Kanal"
label="Channel"
density="comfortable"
variant="outlined"
color="cyan"
@@ -366,10 +366,10 @@
</div>
<div class="flex items-center gap-3">
<v-btn color="cyan" variant="outlined" prepend-icon="mdi-plus" @click="showCreateInvite = true">
Einladungscode erstellen
Create invitation code
</v-btn>
<v-btn color="cyan" variant="tonal" :loading="invitationLoading" @click="fetchInvitations(true)">
Anwenden
Apply
</v-btn>
</div>
</div>
@@ -387,7 +387,7 @@
<div v-if="invitationLoading" class="py-12 text-center text-white/70">
<v-progress-circular indeterminate color="cyan" class="mb-4" />
<p>Einladungscodes werden geladen</p>
<p>Loading invitation codes</p>
</div>
<div v-else class="space-y-4">
@@ -417,18 +417,18 @@
{{ invitationStatusLabel(inv) }}
</strong>
</span>
<span v-if="inv.expiresAt">Gültig bis {{ formatDateTime(inv.expiresAt) }}</span>
<span v-if="inv.createdBy">Erstellt von {{ inv.createdBy.email }}</span>
<span v-if="inv.usedBy">Verwendet von {{ inv.usedBy.email }}</span>
<span v-if="inv.expiresAt">Valid until {{ formatDateTime(inv.expiresAt) }}</span>
<span v-if="inv.createdBy">Created by {{ inv.createdBy.email }}</span>
<span v-if="inv.usedBy">Used by {{ inv.usedBy.email }}</span>
</div>
</v-card-text>
</v-card>
</div>
<p v-else class="py-12 text-center text-sm text-white/60">Keine Einladungen gefunden.</p>
<p v-else class="py-12 text-center text-sm text-white/60">No invitations found.</p>
<div class="flex flex-col items-center justify-between gap-3 sm:flex-row">
<div class="text-xs text-white/50">
Seite {{ invitationPagination.page }} von {{ invitationPagination.pages }} · {{ invitationPagination.total }} Codes
Page {{ invitationPagination.page }} of {{ invitationPagination.pages }} · {{ invitationPagination.total }} codes
</div>
<div class="flex items-center gap-2">
<v-btn
@@ -437,7 +437,7 @@
:disabled="invitationPagination.page <= 1"
@click="changeInvitationPage(invitationPagination.page - 1)"
>
Zurück
Back
</v-btn>
<v-btn
variant="text"
@@ -445,7 +445,7 @@
:disabled="invitationPagination.page >= invitationPagination.pages"
@click="changeInvitationPage(invitationPagination.page + 1)"
>
Weiter
Next
</v-btn>
</div>
</div>
@@ -457,16 +457,16 @@
<section class="space-y-6">
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-2xl font-semibold">Warteliste</h2>
<h2 class="text-2xl font-semibold">Waitlist</h2>
<p class="text-sm text-white/70">
Gesamt: {{ waitlistStats.total }} · Updates: {{ waitlistStats.updates }} · Aktiviert: {{ waitlistStats.activated }}
Total: {{ waitlistStats.total }} · Updates: {{ waitlistStats.updates }} · Activated: {{ waitlistStats.activated }}
</p>
</div>
<div class="flex flex-wrap gap-2 text-xs text-white/60">
<v-chip size="small" color="cyan" variant="outlined">Gesamt: {{ waitlistStats.total }}</v-chip>
<v-chip size="small" color="cyan" variant="outlined">Total: {{ waitlistStats.total }}</v-chip>
<v-chip size="small" color="cyan" variant="text">Updates: {{ waitlistStats.updates }}</v-chip>
<v-chip size="small" color="cyan" variant="text">Aktiviert: {{ waitlistStats.activated }}</v-chip>
<v-chip size="small" color="cyan" variant="text">Wartend: {{ waitlistStats.pending }}</v-chip>
<v-chip size="small" color="cyan" variant="text">Activated: {{ waitlistStats.activated }}</v-chip>
<v-chip size="small" color="cyan" variant="text">Pending: {{ waitlistStats.pending }}</v-chip>
</div>
</div>
@@ -485,7 +485,7 @@
<div class="grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<v-text-field
v-model="waitlistSearch"
label="Name, E-Mail oder Notizen"
label="Name, email or notes"
density="comfortable"
variant="outlined"
color="cyan"
@@ -496,7 +496,7 @@
<v-select
v-model="waitlistSubscription"
:items="waitlistSubscriptionOptions"
label="Opt-In"
label="Opt-in"
density="comfortable"
variant="outlined"
color="cyan"
@@ -512,26 +512,26 @@
hide-details
/>
<v-btn color="cyan" variant="tonal" :loading="waitlistLoading" @click="fetchWaitlist(true)">
Filter anwenden
Apply filters
</v-btn>
</div>
<div class="text-xs text-white/50">
{{ waitlistPagination.total }} Einträge · Seite {{ waitlistPagination.page }} von {{ waitlistPagination.pages }}
{{ waitlistPagination.total }} entries · Page {{ waitlistPagination.page }} of {{ waitlistPagination.pages }}
</div>
</div>
<div v-if="waitlistLoading" class="py-12 text-center text-white/70">
<v-progress-circular indeterminate color="cyan" class="mb-4" />
<p>Warteliste wird geladen</p>
<p>Loading waitlist</p>
</div>
<div v-else class="space-y-4">
<v-table v-if="waitlistEntries.length" density="comfortable" class="text-sm text-white/80">
<thead>
<tr class="text-xs uppercase tracking-[0.3em] text-white/40">
<th class="font-semibold">Kontakt</th>
<th class="font-semibold">Beigetreten</th>
<th class="font-semibold">Opt-In</th>
<th class="font-semibold">Contact</th>
<th class="font-semibold">Joined</th>
<th class="font-semibold">Opt-in</th>
<th class="font-semibold">Status</th>
</tr>
</thead>
@@ -542,7 +542,7 @@
<div v-if="entry.name" class="text-xs text-white/60">{{ entry.name }}</div>
<div v-if="entry.notes" class="text-xs text-white/50">{{ entry.notes }}</div>
<div class="flex flex-wrap gap-2 text-[11px] text-white/40">
<span>Quelle: {{ entry.source || 'landing' }}</span>
<span>Source: {{ entry.source || 'landing' }}</span>
</div>
</td>
<td>
@@ -551,7 +551,7 @@
</td>
<td>
<div class="flex flex-wrap gap-2">
<v-chip size="x-small" color="cyan" variant="outlined">Warteliste</v-chip>
<v-chip size="x-small" color="cyan" variant="outlined">Waitlist</v-chip>
<v-chip
v-if="entry.wantsProductUpdates"
size="x-small"
@@ -562,7 +562,7 @@
</v-chip>
</div>
<div v-if="entry.updatesOptedInAt" class="mt-1 text-[11px] text-white/50">
seit {{ formatDateTime(entry.updatesOptedInAt) }}
since {{ formatDateTime(entry.updatesOptedInAt) }}
</div>
</td>
<td>
@@ -573,21 +573,21 @@
class="text-xs"
>
<template v-if="entry.activatedAt">
Aktiviert · {{ formatRelative(entry.activatedAt) }}
Activated · {{ formatRelative(entry.activatedAt) }}
</template>
<template v-else>
Wartet auf Zugang
Waiting for access
</template>
</v-chip>
</td>
</tr>
</tbody>
</v-table>
<p v-else class="py-12 text-center text-sm text-white/60">Keine Wartelisten-Einträge gefunden.</p>
<p v-else class="py-12 text-center text-sm text-white/60">No waitlist entries found.</p>
<div class="flex flex-col items-center justify-between gap-3 sm:flex-row">
<div class="text-xs text-white/50">
Seite {{ waitlistPagination.page }} von {{ waitlistPagination.pages }} · {{ waitlistPagination.total }} Einträge
Page {{ waitlistPagination.page }} of {{ waitlistPagination.pages }} · {{ waitlistPagination.total }} entries
</div>
<div class="flex items-center gap-2">
<v-btn
@@ -596,7 +596,7 @@
:disabled="waitlistPagination.page <= 1"
@click="changeWaitlistPage(waitlistPagination.page - 1)"
>
Zurück
Back
</v-btn>
<v-btn
variant="text"
@@ -604,7 +604,7 @@
:disabled="waitlistPagination.page >= waitlistPagination.pages"
@click="changeWaitlistPage(waitlistPagination.page + 1)"
>
Weiter
Next
</v-btn>
</div>
</div>
@@ -618,7 +618,7 @@
<div class="grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<v-text-field
v-model="logSearch"
label="Funktext durchsuchen"
label="Search radio transcripts"
density="comfortable"
variant="outlined"
color="cyan"
@@ -629,7 +629,7 @@
<v-select
v-model="logChannel"
:items="logChannelOptions"
label="Kanal"
label="Channel"
density="comfortable"
variant="outlined"
color="cyan"
@@ -638,7 +638,7 @@
<v-select
v-model="logDirection"
:items="logDirectionOptions"
label="Richtung"
label="Direction"
density="comfortable"
variant="outlined"
color="cyan"
@@ -647,7 +647,7 @@
<v-select
v-model="logRole"
:items="logRoleOptions"
label="Rolle"
label="Role"
density="comfortable"
variant="outlined"
color="cyan"
@@ -656,18 +656,18 @@
<v-select
v-model="logTimeframe"
:items="logTimeframeOptions"
label="Zeitraum"
label="Timeframe"
density="comfortable"
variant="outlined"
color="cyan"
hide-details
/>
<v-btn color="cyan" variant="tonal" :loading="logLoading" @click="fetchLogs(true)">
Filter anwenden
Apply filters
</v-btn>
</div>
<div class="text-xs text-white/50">
{{ logPagination.total }} Einträge · Seite {{ logPagination.page }} von {{ logPagination.pages }}
{{ logPagination.total }} entries · Page {{ logPagination.page }} of {{ logPagination.pages }}
</div>
</div>
@@ -684,7 +684,7 @@
<div v-if="logLoading" class="py-12 text-center text-white/70">
<v-progress-circular indeterminate color="cyan" class="mb-4" />
<p>Funkprotokolle werden geladen</p>
<p>Loading radio logs</p>
</div>
<div v-else class="space-y-4">
@@ -709,10 +709,10 @@
<strong class="text-white/70">Normalized:</strong> {{ entry.normalized }}
</p>
<div class="flex flex-wrap gap-3 text-xs text-white/50">
<span v-if="entry.metadata?.moduleId">Modul {{ entry.metadata.moduleId }}</span>
<span v-if="entry.metadata?.moduleId">Module {{ entry.metadata.moduleId }}</span>
<span v-if="entry.metadata?.lessonId">Lesson {{ entry.metadata.lessonId }}</span>
<span v-if="entry.metadata?.autoDecide !== undefined">
Auto Decision: {{ entry.metadata.autoDecide ? 'Ja' : 'Nein' }}
Auto Decision: {{ entry.metadata.autoDecide ? 'Yes' : 'No' }}
</span>
</div>
<div class="flex items-center justify-end">
@@ -723,7 +723,7 @@
prepend-icon="mdi-timeline-text"
@click="toggleLog(entry.id)"
>
{{ expandedLog === entry.id ? 'Tracer schließen' : 'Tracer öffnen' }}
{{ expandedLog === entry.id ? 'Close tracer' : 'Open tracer' }}
</v-btn>
</div>
<v-expand-transition>
@@ -745,10 +745,10 @@
</div>
<div class="flex flex-wrap gap-2 text-[11px] text-white/70">
<v-chip size="x-small" color="cyan" variant="outlined">
Off-Schema: {{ entry.metadata.decision.off_schema ? 'Ja' : 'Nein' }}
Off-script: {{ entry.metadata.decision.off_schema ? 'Yes' : 'No' }}
</v-chip>
<v-chip size="x-small" color="cyan" variant="outlined">
Radio Check: {{ entry.metadata.decision.radio_check ? 'Ja' : 'Nein' }}
Radio check: {{ entry.metadata.decision.radio_check ? 'Yes' : 'No' }}
</v-chip>
</div>
</div>
@@ -761,9 +761,9 @@
>
<div class="flex items-center justify-between text-[11px] text-white/60">
<span>
Schritt: {{ call.stage === 'decision' ? 'Decision' : 'Readback-Check' }}
Step: {{ call.stage === 'decision' ? 'Decision' : 'Readback check' }}
</span>
<span v-if="call.error" class="text-red-300">Fehler beim Aufruf</span>
<span v-if="call.error" class="text-red-300">Call failed</span>
</div>
<div>
<p class="text-[11px] text-white/50">Request</p>
@@ -774,7 +774,7 @@
<pre class="trace-json">{{ formatJson(call.response) }}</pre>
</div>
<div v-else class="rounded-lg border border-white/10 bg-black/40 p-2 text-[11px] text-white/60">
Keine Antwort erhalten.
No response received.
</div>
<div v-if="call.rawResponseText" class="space-y-1">
<p class="text-[11px] text-white/50">Raw Response</p>
@@ -794,11 +794,11 @@
v-if="entry.metadata.decisionTrace.fallback?.used"
class="space-y-1 rounded-xl border border-orange-400/40 bg-orange-500/10 p-3 text-[11px] text-orange-100"
>
<p class="text-xs uppercase tracking-[0.3em] text-orange-200/80">Fallback aktiviert</p>
<p class="text-xs uppercase tracking-[0.3em] text-orange-200/80">Fallback activated</p>
<p>
Grund: {{ entry.metadata.decisionTrace.fallback.reason || 'unbekannt' }}
Reason: {{ entry.metadata.decisionTrace.fallback.reason || 'unknown' }}
<span v-if="entry.metadata.decisionTrace.fallback.selected">
· Pfad: {{ entry.metadata.decisionTrace.fallback.selected }}
· Path: {{ entry.metadata.decisionTrace.fallback.selected }}
</span>
</p>
</div>
@@ -812,11 +812,11 @@
</v-card-text>
</v-card>
</div>
<p v-else class="py-12 text-center text-sm text-white/60">Keine Funkprotokolle gefunden.</p>
<p v-else class="py-12 text-center text-sm text-white/60">No transmissions found.</p>
<div class="flex flex-col items-center justify-between gap-3 sm:flex-row">
<div class="text-xs text-white/50">
Seite {{ logPagination.page }} von {{ logPagination.pages }} · {{ logPagination.total }} Logs
Page {{ logPagination.page }} of {{ logPagination.pages }} · {{ logPagination.total }} logs
</div>
<div class="flex items-center gap-2">
<v-btn
@@ -825,7 +825,7 @@
:disabled="logPagination.page <= 1"
@click="changeLogPage(logPagination.page - 1)"
>
Zurück
Back
</v-btn>
<v-btn
variant="text"
@@ -833,7 +833,7 @@
:disabled="logPagination.page >= logPagination.pages"
@click="changeLogPage(logPagination.page + 1)"
>
Weiter
Next
</v-btn>
</div>
</div>
@@ -845,10 +845,10 @@
<v-dialog v-model="showCreateInvite" max-width="480" persistent>
<v-card class="border border-white/10 bg-[#0b1526] text-white">
<v-card-title class="text-lg font-semibold">Einladungscode erstellen</v-card-title>
<v-card-title class="text-lg font-semibold">Create invitation code</v-card-title>
<v-card-text class="space-y-4">
<p class="text-sm text-white/70">
Der Code wird sofort aktiv und läuft standardmäßig nach 30 Tagen ab. Optional kann ein Label gesetzt werden.
The code becomes active immediately and expires after 30 days by default. Adding a label is optional.
</p>
<v-text-field
v-model="newInviteLabel"
@@ -867,7 +867,7 @@
:step="1"
color="cyan"
thumb-label="always"
label="Gültigkeit in Tagen"
label="Validity in days"
/>
<v-alert
v-if="createInviteError"
@@ -879,15 +879,15 @@
{{ createInviteError }}
</v-alert>
<v-sheet v-if="createInviteResult" class="space-y-2 rounded-xl border border-emerald-400/40 bg-emerald-500/10 p-4">
<p class="text-xs uppercase tracking-[0.3em] text-emerald-200/80">Neuer Code</p>
<p class="text-xs uppercase tracking-[0.3em] text-emerald-200/80">New code</p>
<p class="font-mono text-3xl tracking-widest text-white">{{ createInviteResult.code }}</p>
<p class="text-xs text-white/60">Gültig bis {{ formatDateTime(createInviteResult.expiresAt) }}</p>
<p class="text-xs text-white/60">Valid until {{ formatDateTime(createInviteResult.expiresAt) }}</p>
</v-sheet>
</v-card-text>
<v-card-actions class="justify-between">
<v-btn variant="text" color="white" @click="closeCreateInvite">Abbrechen</v-btn>
<v-btn variant="text" color="white" @click="closeCreateInvite">Cancel</v-btn>
<v-btn color="cyan" variant="flat" :loading="createInviteLoading" @click="submitCreateInvite">
Code generieren
Generate code
</v-btn>
</v-card-actions>
</v-card>
@@ -1053,7 +1053,7 @@ const userSearch = ref('')
const userRoleFilter = ref<'all' | 'user' | 'admin' | 'dev'>('all')
const userRoleStats = reactive({ user: 0, admin: 0, dev: 0 })
const userRoleFilterOptions = [
{ title: 'Alle Rollen', value: 'all' },
{ title: 'All roles', value: 'all' },
{ title: 'User', value: 'user' },
{ title: 'Developer', value: 'dev' },
{ title: 'Admin', value: 'admin' },
@@ -1074,16 +1074,16 @@ const invitationSearch = ref('')
const invitationStatus = ref<'all' | 'active' | 'used' | 'expired'>('active')
const invitationChannel = ref<'all' | 'user' | 'manual' | 'bootstrap' | 'admin'>('all')
const invitationStatusOptions = [
{ title: 'Alle Status', value: 'all' },
{ title: 'Aktiv', value: 'active' },
{ title: 'Verwendet', value: 'used' },
{ title: 'Abgelaufen', value: 'expired' },
{ title: 'All statuses', value: 'all' },
{ title: 'Active', value: 'active' },
{ title: 'Used', value: 'used' },
{ title: 'Expired', value: 'expired' },
]
const invitationChannelOptions = [
{ title: 'Alle Kanäle', value: 'all' },
{ title: 'All channels', value: 'all' },
{ title: 'User', value: 'user' },
{ title: 'Admin', value: 'admin' },
{ title: 'Manuell', value: 'manual' },
{ title: 'Manual', value: 'manual' },
{ title: 'Bootstrap', value: 'bootstrap' },
]
@@ -1095,14 +1095,14 @@ const waitlistSearch = ref('')
const waitlistSubscription = ref<'all' | 'waitlist' | 'updates'>('all')
const waitlistStatus = ref<'all' | 'pending' | 'activated'>('all')
const waitlistSubscriptionOptions = [
{ title: 'Alle Opt-Ins', value: 'all' },
{ title: 'Nur Warteliste', value: 'waitlist' },
{ title: 'Updates-Opt-in', value: 'updates' },
{ title: 'All opt-ins', value: 'all' },
{ title: 'Waitlist only', value: 'waitlist' },
{ title: 'Product updates', value: 'updates' },
]
const waitlistStatusOptions = [
{ title: 'Alle Status', value: 'all' },
{ title: 'Wartend', value: 'pending' },
{ title: 'Aktiviert', value: 'activated' },
{ title: 'All statuses', value: 'all' },
{ title: 'Pending', value: 'pending' },
{ title: 'Activated', value: 'activated' },
]
const waitlistStats = reactive<WaitlistStatsSummary>({ total: 0, updates: 0, activated: 0, pending: 0 })
@@ -1116,26 +1116,26 @@ const logDirection = ref<'all' | 'incoming' | 'outgoing'>('all')
const logRole = ref<'all' | 'pilot' | 'atc'>('all')
const logTimeframe = ref<'24h' | '7d' | '30d' | 'all'>('24h')
const logChannelOptions = [
{ title: 'Alle Kanäle', value: 'all' },
{ title: 'All channels', value: 'all' },
{ title: 'PTT', value: 'ptt' },
{ title: 'Say', value: 'say' },
{ title: 'Text', value: 'text' },
]
const logDirectionOptions = [
{ title: 'Alle Richtungen', value: 'all' },
{ title: 'All directions', value: 'all' },
{ title: 'Incoming', value: 'incoming' },
{ title: 'Outgoing', value: 'outgoing' },
]
const logRoleOptions = [
{ title: 'Alle Rollen', value: 'all' },
{ title: 'All roles', value: 'all' },
{ title: 'Pilot', value: 'pilot' },
{ title: 'ATC', value: 'atc' },
]
const logTimeframeOptions = [
{ title: 'Letzte 24 Stunden', value: '24h' },
{ title: '7 Tage', value: '7d' },
{ title: '30 Tage', value: '30d' },
{ title: 'Alles', value: 'all' },
{ title: 'Last 24 hours', value: '24h' },
{ title: '7 days', value: '7d' },
{ title: '30 days', value: '30d' },
{ title: 'All time', value: 'all' },
]
const expandedLog = ref<string | null>(null)
@@ -1170,7 +1170,7 @@ function formatDateTime(value?: string) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString('de-DE', {
return date.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -1185,12 +1185,12 @@ function formatRelative(value?: string) {
if (Number.isNaN(date.valueOf())) return value
const diffMs = Date.now() - date.getTime()
const diffMinutes = Math.floor(diffMs / (1000 * 60))
if (diffMinutes < 1) return 'vor wenigen Sekunden'
if (diffMinutes < 60) return `vor ${diffMinutes} Min`
if (diffMinutes < 1) return 'a few seconds ago'
if (diffMinutes < 60) return `${diffMinutes} min ago`
const diffHours = Math.floor(diffMinutes / 60)
if (diffHours < 24) return `vor ${diffHours} Std`
if (diffHours < 24) return `${diffHours} h ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 30) return `vor ${diffDays} Tagen`
if (diffDays < 30) return `${diffDays} days ago`
return formatDateTime(value)
}
@@ -1211,9 +1211,9 @@ function isExpired(expiresAt?: string) {
}
function invitationStatusLabel(inv: InvitationItem) {
if (inv.usedAt) return `verwendet ${formatRelative(inv.usedAt)}`
if (inv.expiresAt && isExpired(inv.expiresAt)) return 'abgelaufen'
return 'aktiv'
if (inv.usedAt) return `used ${formatRelative(inv.usedAt)}`
if (inv.expiresAt && isExpired(inv.expiresAt)) return 'expired'
return 'active'
}
function computeSince(timeframe: '24h' | '7d' | '30d' | 'all') {
@@ -1231,7 +1231,7 @@ async function loadOverview(force = false) {
try {
overview.value = await api.get<OverviewData>('/api/admin/overview')
} catch (error) {
overviewError.value = extractErrorMessage(error, 'Übersicht konnte nicht geladen werden.')
overviewError.value = extractErrorMessage(error, 'Could not load overview.')
} finally {
overviewLoading.value = false
}
@@ -1259,7 +1259,7 @@ async function fetchUsers(resetPage = false) {
Object.assign(userRoleStats, response.roles)
usersLoaded.value = true
} catch (error) {
userError.value = extractErrorMessage(error, 'Nutzerliste konnte nicht geladen werden.')
userError.value = extractErrorMessage(error, 'Could not load user list.')
} finally {
userLoading.value = false
}
@@ -1283,7 +1283,7 @@ async function applyRoleChange(userId: string) {
})
await fetchUsers()
} catch (error) {
userError.value = extractErrorMessage(error, 'Rolle konnte nicht aktualisiert werden.')
userError.value = extractErrorMessage(error, 'Could not update role.')
} finally {
userRoleUpdating.value = null
}
@@ -1308,7 +1308,7 @@ async function fetchInvitations(resetPage = false) {
Object.assign(invitationPagination, response.pagination)
invitationsLoaded.value = true
} catch (error) {
invitationError.value = extractErrorMessage(error, 'Einladungen konnten nicht geladen werden.')
invitationError.value = extractErrorMessage(error, 'Could not load invitations.')
} finally {
invitationLoading.value = false
}
@@ -1348,7 +1348,7 @@ async function fetchWaitlist(resetPage = false) {
}
waitlistLoaded.value = true
} catch (error) {
waitlistError.value = extractErrorMessage(error, 'Warteliste konnte nicht geladen werden.')
waitlistError.value = extractErrorMessage(error, 'Could not load waitlist.')
} finally {
waitlistLoading.value = false
}
@@ -1388,7 +1388,7 @@ async function fetchLogs(resetPage = false) {
Object.assign(logPagination, response.pagination)
logsLoaded.value = true
} catch (error) {
logError.value = extractErrorMessage(error, 'Funkprotokolle konnten nicht geladen werden.')
logError.value = extractErrorMessage(error, 'Could not load radio logs.')
} finally {
logLoading.value = false
}
@@ -1419,7 +1419,7 @@ async function submitCreateInvite() {
newInviteLabel.value = ''
await fetchInvitations(true)
} catch (error) {
createInviteError.value = extractErrorMessage(error, 'Einladungscode konnte nicht erstellt werden.')
createInviteError.value = extractErrorMessage(error, 'Could not create invitation code.')
} finally {
createInviteLoading.value = false
}

View File

@@ -3,11 +3,11 @@
<div class="mx-auto max-w-4xl space-y-8">
<header class="space-y-3">
<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
<v-icon icon="mdi-arrow-left" size="18" /> Back to landing page
</NuxtLink>
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300/80">Rechtliches</p>
<h1 class="text-3xl font-semibold">Allgemeine Geschäftsbedingungen (AGB)</h1>
<p class="text-white/70">Stand: {{ lastUpdated }}</p>
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300/80">Legal</p>
<h1 class="text-3xl font-semibold">Terms of Service (TOS)</h1>
<p class="text-white/70">Updated: {{ lastUpdated }}</p>
</header>
<section v-for="section in sections" :key="section.title" class="space-y-3 rounded-2xl border border-white/10 bg-white/5 p-6">
@@ -21,85 +21,85 @@
</section>
<footer class="rounded-2xl border border-white/10 bg-white/5 p-6 text-sm text-white/70">
<p>Fragen zu diesen AGB beantworten wir unter info@opensquawk.dev. r Enterprise- oder Trainingsprogramme erstellen wir individuelle Verträge.</p>
<p>Questions about these terms? Email us at info@opensquawk.dev. We provide tailored contracts for enterprise or training programmes on request.</p>
</footer>
</div>
</div>
</template>
<script setup lang="ts">
const lastUpdated = new Date('2025-09-17').toLocaleDateString('de-DE')
const lastUpdated = new Date('2025-09-17').toLocaleDateString('en-US')
const sections = [
{
title: '1. Geltungsbereich',
title: '1. Scope',
paragraphs: [
'Diese AGB regeln die Nutzung der Plattform OpenSquawk (“Dienst”), betrieben von der Faktor Mensch MEDIA UG (haftungsbeschränkt). Abweichende Bedingungen des Nutzers werden nicht anerkannt, es sei denn, wir stimmen ihrer Geltung ausdrücklich schriftlich zu.',
'These terms govern the use of the OpenSquawk platform (the “Service”), operated by Faktor Mensch MEDIA UG (limited liability). Conflicting user terms only apply if agreed to in writing.',
],
},
{
title: '2. Registrierung & Einladungscodes',
title: '2. Registration & Invitation Codes',
paragraphs: [
'Ein Nutzerkonto darf nur mit gültigem Einladungscode angelegt werden. Einladungscodes sind personalisiert, zeitlich limitiert und dürfen nicht verkauft oder unbefugt weitergegeben werden.',
'Accounts require a valid invitation code. Codes are personal, time-limited and must not be sold or shared without permission.',
],
list: [
'Wartelistenplätze berechtigen nicht automatisch zur Registrierung.',
'Aktive Nutzer können frühestens 14 Tage nach Account-Erstellung bis zu zwei Einladungscodes generieren.',
'OpenSquawk behält sich vor, Einladungscodes nach Missbrauchsverdacht zu sperren.',
'A waitlist spot does not guarantee immediate access.',
'Active users may generate up to two invitation codes after 14 days.',
'We may revoke codes if abuse is suspected.',
],
},
{
title: '3. Vertragsgegenstand',
title: '3. Service Description',
paragraphs: [
'OpenSquawk bietet AI-gestützte Kommunikationssimulation für Flugsimulatoren. Funktionen umfassen Push-to-Talk-Verarbeitung, Text-to-Speech, Lernmodule und Integrationen. Der Dienst ist ausschließlich für Ausbildungs- und Trainingszwecke gedacht nicht für den realen Flugfunk.',
'OpenSquawk provides AI-assisted communication simulation for flight simulators, including push-to-talk processing, text-to-speech, training modules and integrations. The service is strictly for training purposes and must not be used for real-world aviation.',
],
},
{
title: '4. Pflichten der Nutzer',
title: '4. User Responsibilities',
list: [
'Zugangsdaten vertraulich behandeln und Dritten nicht überlassen.',
'Keine rechtswidrigen oder beleidigenden Inhalte übertragen.',
'Systeme nicht übermäßig belasten oder Sicherheitsmechanismen umgehen.',
'Eigenständige Sicherung lokaler Daten/Audioaufnahmen.',
'Keep login credentials confidential and do not share them.',
'Do not transmit unlawful, harmful or offensive content.',
'Avoid excessive load or attempts to circumvent security measures.',
'Back up local recordings or data independently.',
],
paragraphs: [
'Wir behalten uns vor, Accounts bei Verstößen temporär oder dauerhaft zu sperren.',
'We reserve the right to suspend or terminate accounts that breach these terms.',
],
},
{
title: '5. Logging & Datenanalyse',
title: '5. Logging & Analytics',
paragraphs: [
'Zur Qualitätsverbesserung werden sämtliche Funksprüche sowohl Texteingaben als auch PTT-Transkripte zusammen mit Metaangaben (z. B. Modul, Entscheidungspfad, Signalstärke) gespeichert und ausgewertet. Mit Nutzung des Dienstes stimmen Sie dieser Verarbeitung zu.',
'To improve quality we log all radio interactions—text or push-to-talk transcripts—together with metadata such as module, decision path and signal strength. By using the service you consent to this processing.',
],
},
{
title: '6. Preise & Zahlungsbedingungen',
title: '6. Pricing & Payment',
paragraphs: [
'OpenSquawk kann kostenpflichtige Hosted-Pläne anbieten. Preise werden transparent vor Vertragsabschluss angezeigt. Gebühren sind monatlich im Voraus fällig. Erfolgt die Zahlung nicht fristgerecht, kann das Konto gesperrt werden.',
'Hosted plans may incur subscription fees disclosed before purchase. Fees are billed monthly in advance. Overdue payments may lead to account suspension.',
],
},
{
title: '7. Laufzeit & Kündigung',
title: '7. Term & Cancellation',
paragraphs: [
'Kostenlose Konten können jederzeit ohne Frist gelöscht werden. Bei kostenpflichtigen Tarifen gilt sofern nichts anderes vereinbart eine monatliche Laufzeit mit Kündigungsfrist von sieben Tagen zum Monatsende. Kündigungen müssen schriftlich oder per E-Mail erfolgen.',
'Free accounts can be deleted at any time. Paid plans run month-to-month with a seven-day notice period to month-end unless agreed otherwise. Cancellations must be submitted in writing or via email.',
],
},
{
title: '8. Gewährleistung & Haftung',
title: '8. Warranty & Liability',
paragraphs: [
'OpenSquawk stellt einen Alpha-Dienst bereit. Wir übernehmen keine Gewähr für unterbrechungsfreie Verfügbarkeit oder absolute Richtigkeit der generierten Inhalte. Haftung besteht nur bei Vorsatz, grober Fahrlässigkeit sowie bei Verletzung von Leben, Körper oder Gesundheit.',
'OpenSquawk is provided as an alpha service. We do not guarantee uninterrupted availability or absolute accuracy. Liability is limited to intent, gross negligence, or injury to life, body or health.',
],
},
{
title: '9. Änderungen der AGB',
title: '9. Changes to the Terms',
paragraphs: [
'Wir behalten uns vor, diese AGB bei Bedarf anzupassen. Nutzer werden mindestens 14 Tage vor Inkrafttreten informiert. Widerspricht der Nutzer nicht innerhalb der Frist, gelten die neuen Bedingungen als akzeptiert.',
'We may amend these terms with at least 14 days notice. Continued use after the effective date constitutes acceptance of the updated terms.',
],
},
{
title: '10. Schlussbestimmungen',
title: '10. Governing Law',
paragraphs: [
'Es gilt deutsches Recht. Gerichtsstand für Kaufleute ist Berlin. Sollten einzelne Bestimmungen unwirksam sein, bleibt die Wirksamkeit der übrigen Regelungen unberührt.',
'These terms are governed by German law. The place of jurisdiction for merchants is Berlin. If any clause is invalid, the remaining provisions remain effective.',
],
},
]

View File

@@ -3,30 +3,30 @@
<div class="mx-auto max-w-5xl space-y-10">
<header class="space-y-3">
<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
<v-icon icon="mdi-arrow-left" size="18" /> Back to landing page
</NuxtLink>
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300/80">Developer</p>
<h1 class="text-3xl font-semibold">OpenSquawk API Dokumentation</h1>
<p class="text-white/70">Alpha Build Endpunkte können sich ändern. Alle Routen sind JSON-basiert und laufen über HTTPS. Standard-Response enthält <code class="bg-white/10 px-1">success</code> oder ein Fehlerobjekt mit <code class="bg-white/10 px-1">statusMessage</code>.</p>
<h1 class="text-3xl font-semibold">OpenSquawk API documentation</h1>
<p class="text-white/70">Alpha build endpoints may change. All routes use JSON over HTTPS. Standard responses include <code class="bg-white/10 px-1">success</code> or an error object with <code class="bg-white/10 px-1">statusMessage</code>.</p>
</header>
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
<h2 class="text-xl font-semibold">Authentifizierung</h2>
<h2 class="text-xl font-semibold">Authentication</h2>
<p class="text-white/70">
Nach erfolgreichem Login wird ein JWT (Bearer-Token) zurückgegeben und ein HTTP-only-Refresh-Cookie gesetzt. Für alle geschützten Endpunkte muss der Header <code class="bg-white/10 px-1">Authorization: Bearer &lt;token&gt;</code> gesetzt werden. Tokens laufen nach 15 Minuten ab und können über <code>/api/service/auth/refresh</code> erneuert werden.
Successful login returns a JWT (bearer token) and sets an HTTP-only refresh cookie. All protected endpoints require the header <code class="bg-white/10 px-1">Authorization: Bearer &lt;token&gt;</code>. Access tokens expire after 15 minutes and can be refreshed via <code>/api/service/auth/refresh</code>.
</p>
<p class="text-white/70">Nicht authentifizierte Endpunkte befinden sich im Namensraum <code class="bg-white/10 px-1">/api/service/*</code>.</p>
<p class="text-white/70">Public endpoints live under <code class="bg-white/10 px-1">/api/service/*</code>.</p>
</section>
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-6">
<h2 class="text-xl font-semibold">Öffentliche Endpunkte</h2>
<h2 class="text-xl font-semibold">Public endpoints</h2>
<div class="overflow-x-auto">
<table class="min-w-full text-left text-sm">
<thead class="uppercase tracking-[0.2em] text-white/50">
<tr>
<th class="px-3 py-2">Methode</th>
<th class="px-3 py-2">Pfad</th>
<th class="px-3 py-2">Beschreibung</th>
<th class="px-3 py-2">Method</th>
<th class="px-3 py-2">Path</th>
<th class="px-3 py-2">Description</th>
</tr>
</thead>
<tbody class="divide-y divide-white/10">
@@ -41,14 +41,14 @@
</section>
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-6">
<h2 class="text-xl font-semibold">Geschützte Endpunkte</h2>
<h2 class="text-xl font-semibold">Protected endpoints</h2>
<div class="overflow-x-auto">
<table class="min-w-full text-left text-sm">
<thead class="uppercase tracking-[0.2em] text-white/50">
<tr>
<th class="px-3 py-2">Methode</th>
<th class="px-3 py-2">Pfad</th>
<th class="px-3 py-2">Beschreibung</th>
<th class="px-3 py-2">Method</th>
<th class="px-3 py-2">Path</th>
<th class="px-3 py-2">Description</th>
</tr>
</thead>
<tbody class="divide-y divide-white/10">
@@ -63,20 +63,20 @@
</section>
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
<h2 class="text-xl font-semibold">Antwort- und Fehlerformat</h2>
<h2 class="text-xl font-semibold">Response & error format</h2>
<pre class="overflow-x-auto rounded-xl bg-black/60 p-4 text-sm text-white/80">
{
"success": true,
"data": { ... }
}
// Fehler
// Error
{
"statusCode": 401,
"statusMessage": "Authentication required"
}
</pre>
<p class="text-white/70">Weitere Felder wie <code class="bg-white/10 px-1">joinedAt</code> oder <code class="bg-white/10 px-1">invitationStatus</code> werden jeweils im Response dokumentiert.</p>
<p class="text-white/70">Additional fields such as <code class="bg-white/10 px-1">joinedAt</code> or <code class="bg-white/10 px-1">invitationStatus</code> are documented per response.</p>
</section>
</div>
</div>
@@ -84,29 +84,29 @@
<script setup lang="ts">
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 (15) 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.' },
{ method: 'GET', path: '/api/service/invitations/{code}', description: 'Einladungscode prüfen (gültig, abgelaufen, verwendet).' },
{ method: 'POST', path: '/api/service/invitations/bootstrap', description: 'Bootstrap-Einladungscode generieren (aktiv bis 01.07.2024, optionales Label).' },
{ method: 'POST', path: '/api/service/invitations/manual', description: 'Manuellen Einladungscode mit Passwortschutz erstellen (intern).' },
{ method: 'POST', path: '/api/service/waitlist', description: 'Join the waitlist. Requires consent to terms and privacy policy.' },
{ method: 'GET', path: '/api/service/waitlist', description: 'Fetch aggregated waitlist metrics including totals, growth and buffer.' },
{ method: 'POST', path: '/api/service/updates', description: 'Subscribe to product updates and feature launches (consent required).' },
{ method: 'GET', path: '/api/service/roadmap', description: 'Retrieve roadmap items with community averages, total votes and last activity.' },
{ method: 'POST', path: '/api/service/roadmap', description: 'Submit importance ratings (15) for roadmap items; each vote is timestamped.' },
{ method: 'POST', path: '/api/service/roadmap-suggestions', description: 'Send a roadmap suggestion with optional contact details.' },
{ method: 'POST', path: '/api/service/auth/login', description: 'Login with email and password. Returns a JWT and sets a refresh cookie.' },
{ method: 'POST', path: '/api/service/auth/register', description: 'Register with an invitation code and mandatory consents.' },
{ method: 'POST', path: '/api/service/auth/refresh', description: 'Refresh the access token using the refresh cookie.' },
{ method: 'GET', path: '/api/service/invitations/{code}', description: 'Validate an invitation code (valid, expired, already used).' },
{ method: 'POST', path: '/api/service/invitations/bootstrap', description: 'Generate a bootstrap invitation code (active until 2024-07-01, optional label).' },
{ method: 'POST', path: '/api/service/invitations/manual', description: 'Create a manual invitation code via password-protected endpoint (internal).' },
]
const protectedEndpoints = [
{ method: 'GET', path: '/api/auth/me', description: 'Profil des aktuellen Nutzers abrufen.' },
{ method: 'POST', path: '/api/auth/logout', description: 'Aktive Session invalidieren und Refresh-Cookie löschen.' },
{ method: 'GET', path: '/api/auth/invitations', description: 'Eigene erstellte Einladungscodes auflisten.' },
{ method: 'POST', path: '/api/auth/invitations', description: 'Neuen Einladungscode generieren (nach 14 Tagen, max. 2).' },
{ method: 'POST', path: '/api/atc/say', description: 'ATC-Sprachausgabe erzeugen (TTS) mit Logging des Textes.' },
{ method: 'POST', path: '/api/atc/ptt', description: 'Push-to-Talk Audio verarbeiten, transkribieren und loggen.' },
{ method: 'POST', path: '/api/llm/decide', description: 'LLM-gestützte Entscheidungslogik für den aktuellen Flugzustand.' },
{ method: 'GET', path: '/api/vatsim/flightplans', description: 'Gefilterte VATSIM-Flugpläne für eine CID abrufen.' },
{ method: 'GET', path: '/api/auth/me', description: 'Fetch the current user profile.' },
{ method: 'POST', path: '/api/auth/logout', description: 'Invalidate the active session and clear the refresh cookie.' },
{ method: 'GET', path: '/api/auth/invitations', description: 'List invitation codes generated by the current user.' },
{ method: 'POST', path: '/api/auth/invitations', description: 'Generate a new invitation code (available after 14 days, max 2).' },
{ method: 'POST', path: '/api/atc/say', description: 'Trigger ATC text-to-speech output with logging.' },
{ method: 'POST', path: '/api/atc/ptt', description: 'Process push-to-talk audio, transcribe it and store the log.' },
{ method: 'POST', path: '/api/llm/decide', description: 'Run LLM-powered decision logic for the current flight state.' },
{ method: 'GET', path: '/api/vatsim/flightplans', description: 'Retrieve filtered VATSIM flight plans for a CID.' },
]
</script>

View File

@@ -3,91 +3,80 @@
<div class="mx-auto max-w-4xl space-y-8">
<header class="space-y-3">
<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
<v-icon icon="mdi-arrow-left" size="18" /> Back to landing page
</NuxtLink>
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300/80">Rechtliches</p>
<h1 class="text-3xl font-semibold">Datenschutzerklärung</h1>
<p class="text-white/70">Stand: {{ lastUpdated }}</p>
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300/80">Legal</p>
<h1 class="text-3xl font-semibold">Privacy Notice</h1>
<p class="text-white/70">Updated: {{ lastUpdated }}</p>
</header>
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
<h2 class="text-xl font-semibold">1. Verantwortlicher</h2>
<h2 class="text-xl font-semibold">1. Controller</h2>
<p class="text-white/70">
Verantwortlich für die Datenverarbeitung im Sinne der Datenschutz-Grundverordnung (DSGVO) ist die
Faktor Mensch MEDIA UG (haftungsbeschränkt),
Wilhelm-Holzamer-Straße 8
55129 Mainz,
Vertreten durch,
Geschäftsführer: Dominik Ziegenhagel, Emanuel Leube,
Telefon: +49 251 981 157 912 0
E-Mail: info@opensquawk.de
The data controller under the GDPR is Faktor Mensch MEDIA UG (limited liability), Wilhelm-Holzamer-Strasse 8,
55129 Mainz, Germany. Managing directors: Dominik Ziegenhagel, Emanuel Leube. Telephone: +49 251 981 157 912 0.
Email: info@opensquawk.de.
</p>
</section>
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
<h2 class="text-xl font-semibold">2. Verarbeitete Daten</h2>
<h2 class="text-xl font-semibold">2. Data we process</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>
<li><strong>Waitlist:</strong> Name (optional), email, optional notes, time of signup, consent records.</li>
<li><strong>Feature updates:</strong> Email, optional name, opt-in timestamp and marketing consent.</li>
<li><strong>User accounts:</strong> Name, email, password hash, invitation status, creation/login timestamps, invitation history.</li>
<li><strong>Roadmap suggestions:</strong> Title, description, optional contact address, consent flags and timestamps.</li>
<li><strong>Communications:</strong> All radio inputs (typed or push-to-talk transcripts, normalized text, metadata such as module, lesson ID, signal strength, decision context).</li>
<li><strong>Technical logs:</strong> Device details (browser, OS), timestamps, request IDs, error logs.</li>
</ul>
<p class="text-xs text-white/50">Hinweis: Audio-Dateien aus Push-to-Talk werden nur temporär verarbeitet, Transkripte und Kontextdaten speichern wir zur Qualitätsverbesserung.</p>
<p class="text-xs text-white/50">Note: Raw audio from push-to-talk is processed temporarily only. Transcripts and context data are stored to improve quality.</p>
</section>
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
<h2 class="text-xl font-semibold">3. Zwecke und Rechtsgrundlagen</h2>
<h2 class="text-xl font-semibold">3. Purpose & legal basis</h2>
<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 & 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>
<li><strong>Service delivery (Art. 6(1)(b) GDPR):</strong> Managing waitlist entries, accounts and sessions, issuing invitation codes, preparing billing for future paid plans.</li>
<li><strong>Product improvement & security (Art. 6(1)(f) GDPR):</strong> Analysing and logging radio interactions, monitoring errors, preventing abuse.</li>
<li><strong>Communication & community feedback (Art. 6(1)(a) GDPR):</strong> Sending waitlist or product updates after consent and evaluating roadmap suggestions including optional follow-up.</li>
</ul>
</section>
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
<h2 class="text-xl font-semibold">4. Speicherdauer</h2>
<h2 class="text-xl font-semibold">4. Retention</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>
<li>Waitlist entries: until withdrawal or 24 months after the last activity.</li>
<li>Feature updates: until you unsubscribe or withdraw consent.</li>
<li>Roadmap suggestions: until implemented or at most 18 months after submission.</li>
<li>Account data: for the lifetime of the account and thereafter according to statutory retention periods.</li>
<li>Communication logs: at least 12 months for quality assurance; longer if needed to resolve support cases or investigate abuse.</li>
</ul>
</section>
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
<h2 class="text-xl font-semibold">5. Weitergabe & Auftragsverarbeitung</h2>
<h2 class="text-xl font-semibold">5. Sharing & processors</h2>
<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.
OpenSquawk runs on European cloud infrastructure (currently Hetzner Cloud, Germany). Communication data resides in our MongoDB database. External AI providers (e.g. OpenAI) receive only pseudonymised text to power TTS/LLM features. Appropriate processing agreements are in place. Transfers to third countries rely on EU Standard Contractual Clauses where necessary.
</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 info@opensquawk.de aus, versendet über den konfigurierten SMTP- oder Transaktionsmail-Anbieter. Enthalten sind ausschließlich die von dir eingegebenen Angaben zur zügigen Bearbeitung.
Note: Form submissions (waitlist, feature updates, roadmap suggestions) trigger an internal notification email to info@opensquawk.de via our SMTP or transactional provider. We only forward the details you submit so we can respond quickly.
</p>
</section>
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
<h2 class="text-xl font-semibold">6. Rechte der Betroffenen</h2>
<h2 class="text-xl font-semibold">6. Your rights</h2>
<ul class="list-disc space-y-2 pl-6 text-white/70">
<li>Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung (Art. 1518 DSGVO).</li>
<li>Datenübertragbarkeit (Art. 20 DSGVO).</li>
<li>Widerspruch gegen Verarbeitung aus berechtigtem Interesse (Art. 21 DSGVO).</li>
<li>Widerruf erteilter Einwilligungen mit Wirkung für die Zukunft.</li>
<li>Beschwerderecht bei einer Datenschutzaufsichtsbehörde, z. B. Berliner Beauftragte für Datenschutz und Informationsfreiheit.</li>
<li>Access, rectification, erasure and restriction (Art. 1518 GDPR).</li>
<li>Data portability (Art. 20 GDPR).</li>
<li>Objection to processing based on legitimate interest (Art. 21 GDPR).</li>
<li>Withdrawal of consent with future effect.</li>
<li>Complaint to a supervisory authority, e.g. the Berlin Commissioner for Data Protection and Freedom of Information.</li>
</ul>
</section>
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
<h2 class="text-xl font-semibold">7. Kontakt für Datenschutzanfragen</h2>
<h2 class="text-xl font-semibold">7. Contact</h2>
<p class="text-white/70">
Bitte richten Sie Auskunfts- oder Löschersuchen an info@opensquawk.de. Zur eindeutigen Zuordnung benötigen wir die bei OpenSquawk registrierte E-Mail-Adresse sowie ggf. weitere Identifikationsmerkmale (z. B. VATSIM-ID).
To exercise your rights please email info@opensquawk.de. Provide the email address registered with OpenSquawk and, if applicable, additional identifiers (e.g. VATSIM ID) so we can verify your request.
</p>
</section>
</div>
@@ -95,7 +84,7 @@
</template>
<script setup lang="ts">
const lastUpdated = new Date('2025-09-17').toLocaleDateString('de-DE')
const lastUpdated = new Date('2025-09-17').toLocaleDateString('en-US')
</script>
<style scoped>

View File

@@ -3,52 +3,52 @@
<div class="mx-auto max-w-3xl space-y-8">
<header class="space-y-3">
<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
<v-icon icon="mdi-arrow-left" size="18" /> Back to landing page
</NuxtLink>
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300/80">Rechtliches</p>
<h1 class="text-3xl font-semibold">Impressum</h1>
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300/80">Legal</p>
<h1 class="text-3xl font-semibold">Legal notice</h1>
</header>
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
<h2 class="text-xl font-semibold">Angaben gemäß § 5 TMG</h2>
<h2 class="text-xl font-semibold">Company details (§5 TMG)</h2>
<div class="space-y-2 text-white/70">
<p>Faktor Mensch MEDIA UG (haftungsbeschränkt)</p>
<p>Wilhelm-Holzamer-Straße 8</p>
<p>Faktor Mensch MEDIA UG (limited liability)</p>
<p>Wilhelm-Holzamer-Strasse 8</p>
<p>55129 Mainz</p>
</div>
</section>
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
<h2 class="text-xl font-semibold">Vertreten durch</h2>
<p class="text-white/70">Geschäftsführer: Dominik Ziegenhagel, Emanuel Leube</p>
<h2 class="text-xl font-semibold">Represented by</h2>
<p class="text-white/70">Managing directors: Dominik Ziegenhagel, Emanuel Leube</p>
</section>
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
<h2 class="text-xl font-semibold">Kontakt</h2>
<h2 class="text-xl font-semibold">Contact</h2>
<div class="space-y-2 text-white/70">
<p>Telefon: +49 251 981 157 912 0</p>
<p>E-Mail: info@opensquawk.de</p>
<p>Phone: +49 251 981 157 912 0</p>
<p>Email: info@opensquawk.de</p>
</div>
</section>
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
<h2 class="text-xl font-semibold">Registereintrag</h2>
<h2 class="text-xl font-semibold">Register entry</h2>
<div class="space-y-2 text-white/70">
<p>Registergericht: Amtsgericht Mainz</p>
<p>Handelsregisternummer: HRB 53322</p>
<p>USt-IdNr.: DE358028053</p>
<p>Commercial register: Amtsgericht Mainz</p>
<p>Registration number: HRB 53322</p>
<p>VAT ID: DE358028053</p>
</div>
</section>
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
<h2 class="text-xl font-semibold">Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
<p class="text-white/70">Dominik Ziegenhagel, Emanuel Leube Anschrift wie oben.</p>
<h2 class="text-xl font-semibold">Content responsibility (§55 II RStV)</h2>
<p class="text-white/70">Dominik Ziegenhagel, Emanuel Leube address as above.</p>
</section>
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
<h2 class="text-xl font-semibold">Haftungsausschluss</h2>
<h2 class="text-xl font-semibold">Disclaimer</h2>
<p class="text-white/70">
OpenSquawk stellt Trainingsinhalte für Flugsimulatoren bereit. Die Nutzung erfolgt eigenverantwortlich. Für Inhalte externer Links übernehmen wir keine Haftung; für den Inhalt der verlinkten Seiten sind ausschließlich deren Betreiber verantwortlich.
OpenSquawk provides training content for flight simulation. Use is at your own risk. We are not responsible for external links; the respective providers remain liable for their content.
</p>
</section>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -3,13 +3,13 @@
<div class="mx-auto w-full max-w-xl rounded-3xl border border-white/10 bg-white/5 p-8 backdrop-blur">
<div class="mb-8 space-y-1 text-center">
<p class="text-xs uppercase tracking-[0.35em] text-cyan-300/80">OpenSquawk</p>
<h1 class="text-2xl font-semibold">Einladungscode erstellen</h1>
<p class="text-sm text-white/60">Passwortgeschützte Oberfläche r interne Freischaltungen.</p>
<h1 class="text-2xl font-semibold">Generate invitation code</h1>
<p class="text-sm text-white/60">Password-protected surface for internal approvals.</p>
</div>
<form class="space-y-6" @submit.prevent="submit">
<div class="space-y-2">
<label for="invite-password" class="block text-sm font-medium text-white/80">Passwort</label>
<label for="invite-password" class="block text-sm font-medium text-white/80">Password</label>
<input
id="invite-password"
v-model.trim="password"
@@ -17,7 +17,7 @@
autocomplete="off"
required
class="w-full rounded-lg border border-white/10 bg-black/40 px-4 py-3 text-sm outline-none transition focus:border-cyan-300 focus:ring-1 focus:ring-cyan-300"
placeholder="Passwort eingeben"
placeholder="Enter password"
>
</div>
@@ -29,7 +29,7 @@
type="text"
maxlength="80"
class="w-full rounded-lg border border-white/10 bg-black/40 px-4 py-3 text-sm outline-none transition focus:border-cyan-300 focus:ring-1 focus:ring-cyan-300"
placeholder="z. B. Event, Support oder Name"
placeholder="e.g. event, support or name"
>
</div>
@@ -38,10 +38,10 @@
class="flex w-full items-center justify-center gap-2 rounded-lg bg-cyan-500 px-4 py-3 text-sm font-semibold text-[#050910] transition hover:bg-cyan-400 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="loading"
>
<span v-if="!loading">Einladungscode generieren</span>
<span v-if="!loading">Generate invitation code</span>
<span v-else class="flex items-center gap-2">
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white"></span>
Wird erstellt
Creating
</span>
</button>
</form>
@@ -58,17 +58,17 @@
class="mt-8 space-y-4 rounded-2xl border border-emerald-400/30 bg-emerald-400/10 p-6 text-emerald-100"
>
<div>
<p class="text-xs uppercase tracking-[0.3em] text-emerald-200/70">Einladungscode</p>
<p class="text-xs uppercase tracking-[0.3em] text-emerald-200/70">Invitation code</p>
<p class="mt-2 font-mono text-3xl tracking-widest text-white">{{ result.code }}</p>
</div>
<div class="grid gap-2 text-sm text-emerald-100/80 sm:grid-cols-2">
<div>
<p class="text-xs uppercase tracking-[0.3em] text-emerald-200/60">Gültig bis</p>
<p class="text-xs uppercase tracking-[0.3em] text-emerald-200/60">Valid until</p>
<p>{{ expiresAtDisplay }}</p>
</div>
<div>
<p class="text-xs uppercase tracking-[0.3em] text-emerald-200/60">Label</p>
<p>{{ result.label || 'Kein Label gesetzt' }}</p>
<p>{{ result.label || 'No label set' }}</p>
</div>
</div>
</div>
@@ -99,7 +99,7 @@ const expiresAtDisplay = computed(() => {
if (!result.value) return ''
const date = new Date(result.value.expiresAt)
if (Number.isNaN(date.getTime())) return result.value.expiresAt
return date.toLocaleString('de-DE', {
return date.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -108,7 +108,7 @@ const expiresAtDisplay = computed(() => {
})
})
useHead({ title: 'Einladungscode erstellen • OpenSquawk' })
useHead({ title: 'Generate invitation code • OpenSquawk' })
async function submit() {
if (loading.value) return
@@ -131,7 +131,7 @@ async function submit() {
error?.statusMessage ||
error?.data?.message ||
error?.message ||
'Erstellung des Einladungscodes fehlgeschlagen.'
'Creating the invitation code failed.'
errorMessage.value = message
} finally {
loading.value = false

View File

@@ -2,7 +2,7 @@
<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
<v-icon icon="mdi-arrow-left" size="18" /> Back to News & updates
</NuxtLink>
<article v-if="post" class="card space-y-6">
@@ -15,9 +15,9 @@
</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>
<h1 class="text-2xl font-semibold text-white">Article not found</h1>
<p>The requested news article does not exist. Head back to the feed instead.</p>
<NuxtLink to="/news" class="btn btn-primary w-fit">Back to overview</NuxtLink>
</div>
</div>
</div>
@@ -34,11 +34,11 @@ const post = computed(() => getNewsBySlug(slug.value))
useHead(() => {
if (!post.value) {
return {
title: 'Newsbeitrag nicht gefunden OpenSquawk',
title: 'Article not found OpenSquawk',
meta: [{ name: 'robots', content: 'noindex' }],
}
}
const description = post.value.excerpt || 'Update aus dem OpenSquawk Projekt.'
const description = post.value.excerpt || 'Update from the OpenSquawk project.'
return {
title: `${post.value.title} OpenSquawk News`,
meta: [
@@ -49,7 +49,7 @@ useHead(() => {
}
})
const formatNewsDate = (iso: string) => new Date(iso).toLocaleDateString('de-DE', {
const formatNewsDate = (iso: string) => new Date(iso).toLocaleDateString('en-US', {
day: '2-digit',
month: 'long',
year: 'numeric',

View File

@@ -4,14 +4,14 @@
<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
<v-icon icon="mdi-arrow-left" size="18" /> Back to landing page
</NuxtLink>
<h1 class="text-3xl md:text-4xl font-semibold mt-2">News & Updates</h1>
<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.
Content-driven updates covering the alpha build, simulator support and community decisions.
</p>
</div>
<NuxtLink to="/#contributing" class="btn btn-primary w-fit">Zum aktuellen Feed</NuxtLink>
<NuxtLink to="/#contributing" class="btn btn-primary w-fit">View contributing section</NuxtLink>
</div>
</header>
@@ -30,12 +30,12 @@
</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>
<NuxtLink :to="`/news/${post.slug}`" class="text-cyan-300 text-sm font-medium hover:underline">Read more</NuxtLink>
</div>
</article>
</div>
<div v-else class="card text-white/70 text-sm">
Noch keine Beiträge veröffentlicht. Schau bald wieder vorbei!
No posts published yet. Check back soon!
</div>
</section>
</main>
@@ -52,14 +52,14 @@ const posts = computed(() => getAllNews())
useHead({
title: 'News & Updates OpenSquawk',
meta: [
{ name: 'description', content: 'Neuigkeiten zum OpenSquawk Alpha-Prototyp, Simulator-Support und Community-Updates.' },
{ name: 'description', content: 'Updates on the OpenSquawk alpha build, simulator integrations and community news.' },
{ 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.' },
{ property: 'og:description', content: 'Open-source, low-cost AI ATC: follow progress, milestones and community calls to action.' },
],
})
const formatNewsDate = (iso: string) => new Date(iso).toLocaleDateString('de-DE', {
const formatNewsDate = (iso: string) => new Date(iso).toLocaleDateString('en-US', {
day: '2-digit',
month: 'short',
year: 'numeric',

View File

@@ -2011,7 +2011,7 @@ const swapFrequencies = () => {
}
const formatTime = (date: Date): string => {
return date.toLocaleTimeString('de-DE', {
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',

View File

@@ -68,7 +68,7 @@ export default defineEventHandler(async (event) => {
if (channel) {
if (!CHANNELS.has(channel)) {
throw createError({ statusCode: 400, statusMessage: 'Unbekannter Kanal' })
throw createError({ statusCode: 400, statusMessage: 'Unknown channel' })
}
filter.channel = channel
}
@@ -83,7 +83,7 @@ export default defineEventHandler(async (event) => {
filter.usedBy = { $exists: false }
filter.expiresAt = { $lt: now }
} else {
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Status' })
throw createError({ statusCode: 400, statusMessage: 'Invalid status' })
}
}

View File

@@ -69,21 +69,21 @@ export default defineEventHandler(async (event) => {
if (channel) {
if (!CHANNELS.has(channel)) {
throw createError({ statusCode: 400, statusMessage: 'Unbekannter Kanal' })
throw createError({ statusCode: 400, statusMessage: 'Unknown channel' })
}
filter.channel = channel as TransmissionLogDocument['channel']
}
if (direction) {
if (!DIRECTIONS.has(direction)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Richtung' })
throw createError({ statusCode: 400, statusMessage: 'Invalid direction' })
}
filter.direction = direction as TransmissionLogDocument['direction']
}
if (role) {
if (!ROLES.has(role)) {
throw createError({ statusCode: 400, statusMessage: 'Unbekannte Rolle' })
throw createError({ statusCode: 400, statusMessage: 'Unknown role' })
}
filter.role = role
}
@@ -91,7 +91,7 @@ export default defineEventHandler(async (event) => {
if (sinceRaw) {
const since = new Date(sinceRaw)
if (Number.isNaN(since.valueOf())) {
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Zeitraum' })
throw createError({ statusCode: 400, statusMessage: 'Invalid timeframe' })
}
filter.createdAt = { ...(filter.createdAt as any), $gte: since }
}

View File

@@ -45,7 +45,7 @@ export default defineEventHandler(async (event) => {
if (role) {
if (!['user', 'admin', 'dev'].includes(role)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Rolle' })
throw createError({ statusCode: 400, statusMessage: 'Invalid role' })
}
filter.role = role
}

View File

@@ -12,19 +12,19 @@ export default defineEventHandler(async (event) => {
const userId = params?.id
if (!userId) {
throw createError({ statusCode: 400, statusMessage: 'User-ID fehlt' })
throw createError({ statusCode: 400, statusMessage: 'Missing user ID' })
}
const body = await readBody<UpdateRoleBody>(event).catch(() => ({}))
const role = body.role?.trim()
if (!role || !['user', 'admin', 'dev'].includes(role)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Rolle' })
throw createError({ statusCode: 400, statusMessage: 'Invalid role' })
}
const target = await User.findById(userId)
if (!target) {
throw createError({ statusCode: 404, statusMessage: 'Nutzer nicht gefunden' })
throw createError({ statusCode: 404, statusMessage: 'User not found' })
}
const previousRole = target.role

View File

@@ -77,11 +77,11 @@ function decodeAudioPayload(encoded: string): Buffer {
return buffer;
}
// Audio zu WAV konvertieren für bessere Whisper-Kompatibilität
// Convert audio to WAV for better Whisper compatibility
async function convertToWav(inputPath: string, outputPath: string) {
await sh("ffmpeg", [
"-y", "-i", inputPath,
"-ar", "16000", // 16kHz für Whisper
"-ar", "16000", // 16 kHz for Whisper
"-ac", "1", // Mono
"-f", "wav",
outputPath
@@ -104,11 +104,11 @@ export default defineEventHandler(async (event) => {
const tmpAudioWav = join(tmpdir(), `ptt-wav-${id}.wav`);
try {
// 1. Audio aus Base64 dekodieren und speichern
// 1. Decode audio from base64 and save
const audioBuffer = decodeAudioPayload(body.audio);
await writeFile(tmpAudioInput, audioBuffer);
// 2. Zu WAV konvertieren falls nötig (nur wenn FFmpeg verfügbar)
// 2. Convert to WAV if needed (only when FFmpeg is available)
let audioFileForWhisper = tmpAudioInput;
if (format !== 'wav') {
try {
@@ -119,7 +119,7 @@ export default defineEventHandler(async (event) => {
}
}
// 3. OpenAI Whisper für Transkription
// 3. OpenAI Whisper for transcription
const openai = getOpenAIClient();
const transcription = await openai.audio.transcriptions.create({
file: createReadStream(audioFileForWhisper),
@@ -143,7 +143,7 @@ export default defineEventHandler(async (event) => {
let decision: PTTResponse['decision'];
if (shouldAutoDecide) {
// 4. Direkt LLM Decision aufrufen mit transkribiertem Text
// 4. Call the LLM decision directly with the transcribed text
const decisionInput = {
...body.context,
pilot_utterance: transcribedText
@@ -191,7 +191,7 @@ export default defineEventHandler(async (event) => {
return result;
} catch (error: any) {
// Cleanup bei Fehler
// Cleanup on error
await rm(tmpAudioInput).catch(() => {});
await rm(tmpAudioWav).catch(() => {});

View File

@@ -161,7 +161,7 @@ export default defineEventHandler(async (event) => {
let actualMime = mime;
if (useSpeaches) {
// Speaches (bevorzugt klein: MP3, alternativ FLAC/WAV/PCM)
// Speaches (prefer compact: MP3, otherwise FLAC/WAV/PCM)
const baseUrl = runtimeConfig.speachesBaseUrl || "";
const model = runtimeConfig.speechModelId || "speaches-ai/piper-en_US-ryan-low";
if (!baseUrl) {
@@ -169,16 +169,16 @@ export default defineEventHandler(async (event) => {
}
audioBuffer = await speachesTTS(normalized, voice, model, fmt, baseUrl);
modelUsed = model;
// Server liefert korrektes Format gemäß response_format
// Server returns the correct format according to response_format
actualMime = fmtToMime(fmt);
} else if (usePiper) {
// Lokaler Piper
// Local Piper
audioBuffer = await piperTTS(normalized, voice, runtimeConfig.piperPort);
modelUsed = "piper-local";
// Piper liefert WAV
// Piper returns WAV
actualMime = "audio/wav";
} else {
// OpenAI (Fallback)
// OpenAI (fallback)
const tts = await normalize.audio.speech.create({
model: TTS_MODEL,
voice,
@@ -191,7 +191,7 @@ export default defineEventHandler(async (event) => {
actualMime = "audio/wav";
}
// Optional speichern
// Optional persistence
// await ensureDir(baseDir);
// await writeFile(fileOut, audioBuffer);
const meta = {

View File

@@ -14,16 +14,16 @@ export default defineEventHandler(async (event) => {
const twoWeeksMs = 1000 * 60 * 60 * 24 * 14
if (accountAgeMs < twoWeeksMs) {
throw createError({ statusCode: 403, statusMessage: 'Einladungscodes stehen nach 14 Tagen zur Verfügung' })
throw createError({ statusCode: 403, statusMessage: 'Invitation codes become available after 14 days' })
}
if (user.invitationCodesIssued >= 2) {
throw createError({ statusCode: 403, statusMessage: 'Limit von zwei Einladungscodes erreicht' })
throw createError({ statusCode: 403, statusMessage: 'Limit of two invitation codes reached' })
}
const activeCodes = await InvitationCode.countDocuments({ createdBy: user._id, usedBy: { $exists: false } })
if (activeCodes >= 2) {
throw createError({ statusCode: 403, statusMessage: 'Bitte vorhandene Einladungscodes zuerst nutzen' })
throw createError({ statusCode: 403, statusMessage: 'Please use your existing invitation codes first' })
}
const code = generateCode()

View File

@@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
try {
const { decision, trace } = await routeDecision(body)
// Log für Debugging bei off-schema oder radio check
// Log for debugging when off-schema or radio check triggers
if (decision.off_schema) {
console.log(`[ATC] Off-schema response for: "${body.pilot_utterance}"`)
}

View File

@@ -15,7 +15,7 @@ export default defineEventHandler(async (event) => {
const email = body.email?.trim().toLowerCase()
if (!email) {
throw createError({ statusCode: 400, statusMessage: 'Bitte eine E-Mail-Adresse angeben' })
throw createError({ statusCode: 400, statusMessage: 'Please provide an email address' })
}
const user = await User.findOne({ email })
@@ -32,21 +32,21 @@ export default defineEventHandler(async (event) => {
const salutation = user.name ? ` ${user.name}` : ''
const lines = [
`Hallo${salutation},`,
`Hi${salutation},`,
'',
'du hast den Link zum Zurücksetzen deines OpenSquawk-Passworts angefordert.',
`Klicke innerhalb von ${RESET_TOKEN_TTL_MINUTES} Minuten auf den folgenden Link, um ein neues Passwort festzulegen:`,
'You requested a link to reset your OpenSquawk password.',
`Click the link below within ${RESET_TOKEN_TTL_MINUTES} minutes to choose a new password:`,
resetLink,
'',
'Wenn du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.',
'If you did not make this request, you can safely ignore this email.',
'',
'Blue skies,',
'dein OpenSquawk-Team',
'Your OpenSquawk crew',
]
await sendMail({
to: user.email,
subject: 'OpenSquawk Passwort zurücksetzen',
subject: 'Reset your OpenSquawk password',
text: lines.join('\n'),
})
}

View File

@@ -13,17 +13,17 @@ export default defineEventHandler(async (event) => {
const password = body.password?.trim()
if (!email || !password) {
throw createError({ statusCode: 400, statusMessage: 'Bitte E-Mail und Passwort angeben' })
throw createError({ statusCode: 400, statusMessage: 'Please provide email and password' })
}
const user = await User.findOne({ email })
if (!user) {
throw createError({ statusCode: 401, statusMessage: 'Ungültige Zugangsdaten' })
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
}
const valid = await verifyPassword(password, user.passwordHash)
if (!valid) {
throw createError({ statusCode: 401, statusMessage: 'Ungültige Zugangsdaten' })
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
}
user.lastLoginAt = new Date()

View File

@@ -23,36 +23,36 @@ export default defineEventHandler(async (event) => {
const email = emailInput.toLowerCase()
if (!emailInput || !password || !code) {
throw createError({ statusCode: 400, statusMessage: 'Bitte E-Mail, Passwort und Einladungscode angeben' })
throw createError({ statusCode: 400, statusMessage: 'Please provide email, password and invitation code' })
}
if (!body.acceptPrivacy || !body.acceptTerms) {
throw createError({ statusCode: 400, statusMessage: 'Bitte AGB und Datenschutz bestätigen' })
throw createError({ statusCode: 400, statusMessage: 'Please accept the terms and privacy policy' })
}
if (!isValidEmail(emailInput)) {
throw createError({ statusCode: 400, statusMessage: 'Bitte eine gültige E-Mail-Adresse angeben' })
throw createError({ statusCode: 400, statusMessage: 'Please provide a valid email address' })
}
const passwordValidation = validatePasswordStrength(password)
if (!passwordValidation.valid) {
throw createError({ statusCode: 400, statusMessage: passwordValidation.message || 'Passwort ist zu schwach' })
throw createError({ statusCode: 400, statusMessage: passwordValidation.message || 'Password is too weak' })
}
const existingUser = await User.findOne({ email })
if (existingUser) {
throw createError({ statusCode: 409, statusMessage: 'Für diese E-Mail existiert bereits ein Konto' })
throw createError({ statusCode: 409, statusMessage: 'An account already exists for this email address' })
}
const invitation = await InvitationCode.findOne({ code })
if (!invitation) {
throw createError({ statusCode: 404, statusMessage: 'Einladungscode nicht gefunden' })
throw createError({ statusCode: 404, statusMessage: 'Invitation code not found' })
}
if (invitation.usedBy) {
throw createError({ statusCode: 400, statusMessage: 'Einladungscode wurde bereits verwendet' })
throw createError({ statusCode: 400, statusMessage: 'Invitation code has already been used' })
}
if (invitation.expiresAt && invitation.expiresAt < new Date()) {
throw createError({ statusCode: 400, statusMessage: 'Einladungscode ist abgelaufen' })
throw createError({ statusCode: 400, statusMessage: 'Invitation code has expired' })
}
const passwordHash = await hashPassword(password)

View File

@@ -15,24 +15,24 @@ export default defineEventHandler(async (event) => {
const password = body.password?.trim()
if (!token || !password) {
throw createError({ statusCode: 400, statusMessage: 'Token und neues Passwort erforderlich' })
throw createError({ statusCode: 400, statusMessage: 'Token and new password required' })
}
if (password.length < 8) {
throw createError({ statusCode: 400, statusMessage: 'Das Passwort muss mindestens 8 Zeichen enthalten' })
throw createError({ statusCode: 400, statusMessage: 'Password must be at least 8 characters long' })
}
const tokenHash = createHash('sha256').update(token).digest('hex')
const resetToken = await PasswordResetToken.findOne({ tokenHash })
if (!resetToken || (resetToken.expiresAt && resetToken.expiresAt < new Date()) || resetToken.usedAt) {
throw createError({ statusCode: 400, statusMessage: 'Dieser Link ist ungültig oder abgelaufen' })
throw createError({ statusCode: 400, statusMessage: 'This link is invalid or has expired' })
}
const user = await User.findById(resetToken.user)
if (!user) {
await resetToken.deleteOne().catch(() => undefined)
throw createError({ statusCode: 400, statusMessage: 'Dieser Link ist ungültig oder abgelaufen' })
throw createError({ statusCode: 400, statusMessage: 'This link is invalid or has expired' })
}
user.passwordHash = await hashPassword(password)

View File

@@ -7,11 +7,11 @@ const CREATION_DEADLINE = new Date(process.env.BOOTSTRAP_INVITE_DEADLINE ?? '202
export default defineEventHandler(async (event) => {
const now = new Date()
if (Number.isNaN(CREATION_DEADLINE.getTime())) {
throw createError({ statusCode: 500, statusMessage: 'Konfiguration für Bootstrap-Deadline ungültig' })
throw createError({ statusCode: 500, statusMessage: 'Invalid bootstrap deadline configuration' })
}
if (now > CREATION_DEADLINE) {
throw createError({ statusCode: 403, statusMessage: 'Bootstrap-Zeitraum abgelaufen' })
throw createError({ statusCode: 403, statusMessage: 'Bootstrap window has expired' })
}
const body = await readBody<{ label?: string }>(event).catch(() => ({ label: undefined }))

View File

@@ -31,14 +31,14 @@ export default defineEventHandler<ManualInviteResponse>(async (event) => {
const expectedPassword = (config.manualInvitePassword as string | undefined)?.trim() || ''
if (!expectedPassword) {
throw createError({ statusCode: 500, statusMessage: 'Konfiguration für manuellen Einladungscode fehlt' })
throw createError({ statusCode: 500, statusMessage: 'Manual invitation code configuration missing' })
}
const body = await readBody<ManualInviteRequestBody>(event).catch(() => ({}) as ManualInviteRequestBody)
const providedPassword = body.password?.trim() || ''
if (!providedPassword || !safeComparePassword(providedPassword, expectedPassword)) {
throw createError({ statusCode: 401, statusMessage: 'Ungültiges Passwort' })
throw createError({ statusCode: 401, statusMessage: 'Invalid password' })
}
const now = new Date()

View File

@@ -21,19 +21,19 @@ export default defineEventHandler(async (event) => {
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.' })
throw createError({ statusCode: 400, statusMessage: 'Please provide a short title (at least 4 characters).' })
}
if (!details || details.length < MIN_DETAILS_LENGTH) {
throw createError({ statusCode: 400, statusMessage: 'Beschreibe deinen Vorschlag mit mindestens 20 Zeichen.' })
throw createError({ statusCode: 400, statusMessage: 'Please describe your suggestion in at least 20 characters.' })
}
if (!body.consentPrivacy) {
throw createError({ statusCode: 400, statusMessage: 'Wir benötigen deine Einwilligung zur Datenschutzerklärung.' })
throw createError({ statusCode: 400, statusMessage: 'We need your consent to the privacy policy.' })
}
if (body.allowContact && !email) {
throw createError({ statusCode: 400, statusMessage: 'Bitte gib eine E-Mail an, damit wir dich kontaktieren können.' })
throw createError({ statusCode: 400, statusMessage: 'Please provide an email address so we can contact you.' })
}
const suggestion = await RoadmapSuggestion.create({
@@ -45,15 +45,15 @@ export default defineEventHandler(async (event) => {
})
const dataEntries = [
['Titel', title],
['Beschreibung', details],
['E-Mail', email || null],
['Kontaktaufnahme erlaubt', allowContact],
['Title', title],
['Description', details],
['Email', email || null],
['Contact allowed', allowContact],
]
await sendAdminNotification({
event: 'Neuer Roadmap-Vorschlag',
summary: `Neuer Roadmap-Vorschlag: ${title}`,
event: 'New roadmap suggestion',
summary: `New roadmap suggestion: ${title}`,
data: dataEntries,
})

View File

@@ -20,11 +20,11 @@ export default defineEventHandler(async (event) => {
const body = await readBody<{ votes?: RoadmapVotePayload[] }>(event)
if (!body?.votes || !Array.isArray(body.votes) || body.votes.length === 0) {
throw createError({ statusCode: 400, statusMessage: 'Keine Stimmen übermittelt' })
throw createError({ statusCode: 400, statusMessage: 'No votes submitted' })
}
if (body.votes.length > MAX_VOTES_PER_SUBMISSION) {
throw createError({ statusCode: 400, statusMessage: 'Zu viele Stimmen in einer Anfrage' })
throw createError({ statusCode: 400, statusMessage: 'Too many votes in a single request' })
}
const normalized = new Map<string, number>()
@@ -35,17 +35,17 @@ export default defineEventHandler(async (event) => {
}
const key = String(vote.key ?? '').trim()
if (!ROADMAP_ITEM_KEYS.has(key)) {
throw createError({ statusCode: 400, statusMessage: `Unbekannter Roadmap-Eintrag: ${key || '?'}` })
throw createError({ statusCode: 400, statusMessage: `Unknown roadmap entry: ${key || '?'}` })
}
const importance = Math.round(Number(vote.importance ?? 0))
if (!Number.isFinite(importance) || importance < 1 || importance > 5) {
throw createError({ statusCode: 400, statusMessage: 'Bewertung muss zwischen 1 und 5 liegen' })
throw createError({ statusCode: 400, statusMessage: 'Rating must be between 1 and 5' })
}
normalized.set(key, importance)
}
if (normalized.size === 0) {
throw createError({ statusCode: 400, statusMessage: 'Keine gültigen Stimmen gefunden' })
throw createError({ statusCode: 400, statusMessage: 'No valid votes found' })
}
const clientHash = buildClientHash(event)

View File

@@ -17,15 +17,15 @@ export default defineEventHandler(async (event) => {
const source = body.source?.trim() || 'landing-updates'
if (!email) {
throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' })
throw createError({ statusCode: 400, statusMessage: 'Email is required' })
}
if (!body.consentPrivacy) {
throw createError({ statusCode: 400, statusMessage: 'Bitte bestätige die Datenschutzerklärung' })
throw createError({ statusCode: 400, statusMessage: 'Please confirm the privacy policy' })
}
if (!body.consentMarketing) {
throw createError({ statusCode: 400, statusMessage: 'Wir benötigen deine Einwilligung für Produkt-Updates per E-Mail' })
throw createError({ statusCode: 400, statusMessage: 'We need your consent to email you product updates' })
}
const result = await registerUpdateSubscriber({
@@ -38,13 +38,13 @@ export default defineEventHandler(async (event) => {
if (result.created) {
const dataEntries = [
['E-Mail', email],
['Email', email],
['Name', name || null],
['Quelle', source],
['Source', source],
]
await sendAdminNotification({
event: 'Neue Updates-Liste',
summary: `Neue Updates-Anmeldung: ${email}`,
event: 'New updates signup',
summary: `New updates signup: ${email}`,
data: dataEntries,
})
}

View File

@@ -22,11 +22,11 @@ export default defineEventHandler(async (event) => {
const wantsProductUpdates = Boolean(body.wantsProductUpdates)
if (!email) {
throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' })
throw createError({ statusCode: 400, statusMessage: 'Email is required' })
}
if (!body.consentPrivacy || !body.consentTerms) {
throw createError({ statusCode: 400, statusMessage: 'Zustimmung zu AGB und Datenschutz ist erforderlich' })
throw createError({ statusCode: 400, statusMessage: 'Accepting the terms and privacy policy is required' })
}
const now = new Date()
@@ -57,20 +57,20 @@ export default defineEventHandler(async (event) => {
if (!previouslyWantedUpdates && updateResult.created) {
const dataEntries = [
['E-Mail', email],
['Email', email],
]
if (name) {
dataEntries.push(['Name', name])
}
if (notes) {
dataEntries.push(['Notizen', notes])
dataEntries.push(['Notes', notes])
}
dataEntries.push(['Quelle', source])
dataEntries.push(['Opt-In', 'Produkt-Updates'])
dataEntries.push(['Source', source])
dataEntries.push(['Opt-in', 'Product updates'])
await sendAdminNotification({
event: 'Neue Updates-Liste (via Warteliste)',
summary: `Produkt-Updates Opt-in (Warteliste): ${email}`,
event: 'New updates signup (waitlist)',
summary: `Product updates opt-in (waitlist): ${email}`,
data: dataEntries,
})
}
@@ -106,20 +106,20 @@ export default defineEventHandler(async (event) => {
}
const dataEntries = [
['E-Mail', email],
['Email', email],
]
if (name) {
dataEntries.push(['Name', name])
}
if (notes) {
dataEntries.push(['Notizen', notes])
dataEntries.push(['Notes', notes])
}
dataEntries.push(['Quelle', source])
dataEntries.push(['Opt-In', wantsProductUpdates ? 'Produkt-Updates' : 'Nur Warteliste'])
dataEntries.push(['Source', source])
dataEntries.push(['Opt-in', wantsProductUpdates ? 'Product updates' : 'Waitlist only'])
await sendAdminNotification({
event: 'Neue Wartelisten-Anmeldung',
summary: `Neue Wartelisten-Anmeldung: ${email}`,
event: 'New waitlist signup',
summary: `New waitlist signup: ${email}`,
data: dataEntries,
})

View File

@@ -9,81 +9,81 @@ export interface RoadmapItemDefinition {
export const ROADMAP_ITEMS: RoadmapItemDefinition[] = [
{
key: 'touch-ptt-app',
title: 'Touch-Webapp für Handy & Tablet',
title: 'Touch web app for phones & tablets',
description:
'Progressive Web-App mit großem Push-to-Talk-Button, vereinfachten Funk-Prompts und geführten Readbacks ideal zum Üben unterwegs.',
'Progressive web app with a large push-to-talk button, simplified radio prompts and guided readbacks ideal for practice on the go.',
category: 'Training',
icon: 'mdi-cellphone-sound',
},
{
key: 'realism-upgrades',
title: 'Realismus-Boost für Phraseologie',
title: 'Phraseology realism boost',
description:
'Feintuning für Stimmen, Hintergrundrauschen und prozedurale Antworten, damit Clearance, Handoffs und Phraseologie wie am echten Radar klingen.',
'Fine-tune voices, background noise and procedural replies so clearances, handoffs and phraseology sound like real radar.',
category: 'Simulation',
icon: 'mdi-rocket-launch'
},
{
key: 'cockpit-intercom',
title: 'Virtuelles Intercom & Checklisten',
title: 'Virtual intercom & checklists',
description:
'Sprich mit einer KI-Copilot:in, lass dir SOP-Checklisten vorlesen und hake Abläufe via Voice oder Touch ab.',
'Talk to an AI co-pilot, hear SOP checklists read aloud and tick off flows via voice or touch.',
category: 'Crew',
icon: 'mdi-account-voice',
},
{
key: 'emergency-training',
title: 'Mayday & Pan-Pan Trainingsflows',
title: 'Mayday & pan-pan training flows',
description:
'Geführte Szenarien für Notrufe inkl. Standard-Callouts, Priorisierung durch den Tower und Nachbereitung mit Debrief.',
'Guided emergency scenarios with standard callouts, tower prioritization and debrief follow-up.',
category: 'Safety',
icon: 'mdi-alert-decagram',
},
{
key: 'taxi-routing',
title: 'Airport-genaue Taxi-Anweisungen',
title: 'Airport-specific taxi instructions',
description:
'Apt.dat- & OSM-gestütztes Routing mit individuellen Taxi-Flows, Hotspots und visuellen Rollkarten pro Airport.',
'Routing powered by apt.dat and OSM with airport-specific taxi flows, hotspots and visual charts.',
category: 'Ground',
icon: 'mdi-map-marker-path',
},
{
key: 'atc-learning-platform',
title: 'ATC-Only Lernplattform',
title: 'ATC-only learning platform',
description:
'Browser-Trainings zum Hören, Buchstabieren und Störgeräusch-Filtern ICAO-Alphabet, Speed-Drills und Readback-Checks ohne Simulator.',
'Browser trainings for listening, spelling and filtering interference — ICAO alphabet, speed drills and readback checks without a simulator.',
category: 'Academy',
icon: 'mdi-headset',
},
{
key: 'self-hosting',
title: 'Selfhosting mit lokalen Modellen',
title: 'Self-hosting with local models',
description:
'Docker-/Compose-Blueprints plus Offline-ASR/TTS-Optionen für lokales Hosting ohne Cloud-Abhängigkeit.',
'Docker/Compose blueprints plus offline ASR/TTS options for local hosting without cloud dependencies.',
category: 'Infra',
icon: 'mdi-server',
},
{
key: 'premium-api-access',
title: 'Premium-Zugriff auf schnelle APIs',
title: 'Premium access to high-performance APIs',
description:
'Optionale Monatsabos für performantere Speech- & Sim-APIs mit priorisierten Kontingenten zu niedrigen Beträgen.',
'Optional monthly plans for faster speech and sim APIs with prioritized quotas at low prices.',
category: 'Business',
icon: 'mdi-credit-card-clock',
},
{
key: 'multi-voice',
title: 'Mehrere ATC-Stimmen',
title: 'Multiple ATC voices',
description:
'Wechselnde Stimmen je Position, inklusive regionaler Akzente und geschlechtsneutraler Optionen.',
'Rotating voices per position, including regional accents and gender-neutral options.',
category: 'Immersion',
icon: 'mdi-account-multiple',
},
{
key: 'ai-traffic',
title: 'AI-generierter ATC-Traffic',
title: 'AI-generated ATC traffic',
description:
'Simulierte andere Piloten für Frequenzaufkommen, inklusive korrekter Callsigns, Handovers und Konflikt-Handling.',
'Simulated fellow pilots to increase frequency traffic, including correct callsigns, handovers and conflict handling.',
category: 'Traffic',
icon: 'mdi-airplane-takeoff',
},

View File

@@ -16,10 +16,10 @@ export const LLM_MODEL = llmModel;
export const TTS_MODEL = ttsModel;
/* =========================
LLM PROMPTS (überarbeitet)
LLM PROMPTS (refined)
=========================
Ziel: LLM liefert kompakte, maschinenfreundliche ICAO-Zeile, die unser NormalizerTTS perfekt erweitert.
WICHTIG: Zahlen/Marker exakt im unten definierten Output-Format, keine ausgeschriebenen Wörter.
Goal: the LLM returns a compact, machine-friendly ICAO line that our normalizerTTS can expand perfectly.
IMPORTANT: Use the exact output format defined below; do not spell out numbers.
*/
export const ATC_OUTPUT_SPEC = `
@@ -43,9 +43,9 @@ OUTPUT RULES (STRICT):
- Use standard order for the phase (e.g., taxi: destination RWY first, then route, then hold short).
`.trim();
/** System-Prompt: legt Rolle/Regeln fest */
/** System prompt: defines the role and rules */
export function atcSystemPrompt(opts?: {
regionHint?: "EUR" | "US" | "INTL"; // nur als Soft-Hinweis, default INTL
regionHint?: "EUR" | "US" | "INTL"; // soft hint only, defaults to INTL
}) {
const region = opts?.regionHint ?? "INTL";
return [
@@ -56,7 +56,7 @@ export function atcSystemPrompt(opts?: {
].join("\n\n");
}
/** Seed-ATC ohne Pilot-Input (ckwärtskompatible Signatur, aber reicherer Prompt) */
/** Seed ATC without pilot input (backward-compatible signature, but richer prompt) */
export function atcSeedPrompt(s: {
airport: string; // e.g., "EDDF"
aircraft: string; // e.g., "A320"
@@ -66,11 +66,11 @@ export function atcSeedPrompt(s: {
sid?: string; // e.g., "MARUN 7F"
squawk?: string; // "4723"
freq?: string; // "121.800"
runway?: string; // "25R" (optional: falls bekannt)
runway?: string; // "25R" (optional if known)
phase?: "clearance" | "taxi" | "lineup" | "departure" | "handoff" | "approach" | "landing";
notes?: string; // z.B. "TWY N closed between N2N4"
notes?: string; // e.g. "TWY N closed between N2N4"
}) {
// Default-Phase: clearance
// Default phase: clearance
const phase = s.phase || "clearance";
const ctx = [
`Airport ${s.airport}`,
@@ -106,12 +106,12 @@ export function atcSeedPrompt(s: {
].join("\n");
}
/** PilotATC (rückwärtskompatibler Name, aber mit robustem Rahmen) */
/** PilotATC (same legacy name, but with a sturdier framework) */
export function atcReplyPrompt(userText: string, state?: {
airport?: string; runway?: string; sid?: string; dep?: string;
lastSquawk?: string; lastFreq?: string; lastQNH?: string;
phase?: "clearance" | "taxi" | "lineup" | "departure" | "handoff" | "approach" | "landing";
constraints?: string; // z.B. "TWY N closed", "no intersection deps on 25C"
constraints?: string; // e.g. "TWY N closed", "no intersection deps on 25C"
}) {
const ctx = [
state?.airport ? `Airport ${state.airport}` : null,
@@ -137,10 +137,10 @@ export function atcReplyPrompt(userText: string, state?: {
}
/* =========================
Normalizer → TTS (wie zuvor)
Normalizer → TTS (unchanged)
========================= */
// Airline-Telephony (erweiterbar)
// Airline telephony (extensible)
export const CALLSIGN_MAP: Record<string,string> = {
DLH: "Lufthansa",
EWG: "Eurowings",
@@ -185,16 +185,16 @@ export async function speakATC(text: string, filePath = "atc.mp3") {
}
/* =========================
Beispiele
Examples
=========================
— Seed (Clearance):
— Seed (clearance):
const sys = atcSystemPrompt();
const usr = atcSeedPrompt({
airport: "EDDF", aircraft: "A320", type: "IFR", stand: "V155",
dep: "EHAM", sid: "MARUN 7F", runway: "25R", freq: "121.800"
});
// → LLM antwortet z.B.:
// → The LLM might respond:
// "DLH359, cleared to EHAM via MARUN 7F, initial 5000 ft, squawk 4723. QNH 1013."
— Taxi:
@@ -205,12 +205,12 @@ const usrTaxi = atcSeedPrompt({
});
// → "DLH359, taxi to RWY 25R via A3 A N2, hold short."
— PilotATC:
— PilotATC:
const usrReply = atcReplyPrompt(
"DLH359 ready for departure RWY 25R",
{ airport: "EDDF", runway: "25R", phase: "lineup", lastFreq: "121.800" }
);
// → "DLH359, line up and wait RWY 25R."
Nach dem LLM-Output: `speakATC(llmText)` ruft NormalizerTTS.
After receiving the LLM output, call `speakATC(llmText)` to trigger normalizerTTS.
*/

View File

@@ -97,7 +97,7 @@ export async function sendMail(options: MailOptions) {
const success = await sendViaSmtp(payload)
if (!success) {
console.info(`[mail:fallback] ${options.subject}\nEmpfänger: ${options.to}\n${options.text}`)
console.info(`[mail:fallback] ${options.subject}\nRecipient: ${options.to}\n${options.text}`)
}
return success
}
@@ -169,7 +169,7 @@ export async function sendAdminNotification(notification: string | AdminNotifica
const success = await sendMail(mailOptions)
if (!success) {
console.info(`[notify:fallback] ${mailOptions.subject}\nEmpfänger: ${to}\n${mailOptions.text}`)
console.info(`[notify:fallback] ${mailOptions.subject}\nRecipient: ${to}\n${mailOptions.text}`)
}
return success
}

View File

@@ -10,7 +10,7 @@ function ensureOpenAI(): OpenAI {
if (!openaiClient) {
const { openaiKey, openaiProject, llmModel } = getServerRuntimeConfig()
if (!openaiKey) {
throw new Error('OPENAI_API_KEY fehlt. Bitte den Schlüssel setzen, bevor KI-Funktionen genutzt werden.')
throw new Error('OPENAI_API_KEY is missing. Please set the key before using AI features.')
}
const clientOptions: ConstructorParameters<typeof OpenAI>[0] = { apiKey: openaiKey }
if (openaiProject) {
@@ -246,9 +246,9 @@ function extractTemplateVariables(text?: string): string[] {
return matches.map(match => match.slice(1, -1)) // Remove { }
}
// Optimierte aber ausreichende Eingabe für gute Entscheidungen
// Optimized yet sufficient input for reliable decisions
function optimizeInputForLLM(input: LLMDecisionInput) {
// Sammle alle verfügbaren Variablen aus dem Decision Tree
// Collect all available variables from the decision tree
const availableVariables = [
'callsign', 'dest', 'dep', 'runway', 'squawk', 'sid', 'transition',
'initial_altitude_ft', 'climb_altitude_ft', 'cruise_flight_level',
@@ -308,7 +308,7 @@ function optimizeInputForLLM(input: LLMDecisionInput) {
current_role: input.state.role,
state_summary: stateSummary,
candidates: candidates,
available_variables: availableVariables, // Alle verfügbaren Variablen
available_variables: availableVariables, // All available variables
candidate_variables: Array.from(candidateVars), // Variablen die Candidates verwenden
pilot_utterance: input.pilot_utterance,
decision_hints: {
@@ -318,7 +318,7 @@ function optimizeInputForLLM(input: LLMDecisionInput) {
has_interrupt_candidate: input.candidates.some(c => c.id.startsWith('INT_')),
readback_check_state: Boolean(readbackKeys.length)
},
// Nur aktueller Context ohne Werte (für Token-Sparen)
// Current context only without values (to save tokens)
context: {
callsign: input.variables.callsign,
current_unit: input.flags.current_unit,
@@ -459,7 +459,7 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
}
}
// Sofortige Erkennung ohne LLM für häufige Cases
// Instant detection without the LLM for common cases
if (pilotText.includes('radio check') || pilotText.includes('signal test') ||
(pilotText.includes('read') && (pilotText.includes('check') || pilotText.includes('you')))) {
return finalize({
@@ -479,17 +479,17 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
const optimizedInput = optimizeInputForLLM(input)
// Prüfe ob nächste States ATC-Responses brauchen
// Check whether the next states require ATC responses
const atcCandidates = input.candidates.filter(c =>
c.state.role === 'atc' || c.state.say_tpl || c.id.startsWith('INT_')
)
// Wenn keine ATC-States verfügbar, einfache Transition ohne Response
// If no ATC states are available, perform a simple transition without a response
if (atcCandidates.length === 0 && input.candidates.length > 0) {
return finalize({ next_state: input.candidates[0].id })
}
// Kompakter aber informativer Prompt - mit Variable-Info für intelligente Responses
// Compact yet informative prompt — includes variable info for intelligent responses
const system = [
'You are an ATC state router. Return strict JSON.',
'Keys: next_state, controller_say_tpl (optional), off_schema (optional), intent (optional).',
@@ -593,7 +593,7 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
})
}
// Pilot readback oder acknowledgment → keine ATC response nötig
// Pilot readback or acknowledgment → no ATC response required
if (pilotText.includes('wilco') || pilotText.includes('roger') ||
pilotText.includes('cleared') || pilotText.includes('copied')) {
fallbackInfo.selected = 'acknowledge'

View File

@@ -58,7 +58,7 @@ export function getServerRuntimeConfig(): ServerRuntimeConfig {
const openaiKey = String(runtimeConfig.openaiKey || '').trim()
if (!openaiKey && !warnedMissingOpenAIKey) {
console.warn('[OpenSquawk] OPENAI_API_KEY fehlt. Einige KI-Funktionen stehen ohne Schlüssel nicht zur Verfügung.')
console.warn('[OpenSquawk] OPENAI_API_KEY is missing. Some AI features are unavailable without a key.')
warnedMissingOpenAIKey = true
}

View File

@@ -12,16 +12,16 @@ export interface PasswordValidationResult {
export function validatePasswordStrength(password: string): PasswordValidationResult {
const trimmed = password.trim()
if (trimmed.length < 10) {
return { valid: false, message: 'Passwort muss mindestens 10 Zeichen lang sein.' }
return { valid: false, message: 'Password must be at least 10 characters long.' }
}
if (/\s/.test(trimmed)) {
return { valid: false, message: 'Passwort darf keine Leerzeichen enthalten.' }
return { valid: false, message: 'Password cannot contain spaces.' }
}
if (!/[A-Za-zÄÖÜäöüß]/.test(trimmed) || !/[0-9]/.test(trimmed)) {
return { valid: false, message: 'Bitte Buchstaben und Zahlen kombinieren.' }
return { valid: false, message: 'Please use both letters and numbers.' }
}
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(trimmed)) {
return { valid: false, message: 'Mindestens ein Sonderzeichen erhöht die Sicherheit.' }
return { valid: false, message: 'Include at least one special character for better security.' }
}
return { valid: true }
}