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',