mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-14 09:25:42 +08:00
Merge branch 'main' of github.com:OpenSquawk/OpenSquawk
This commit is contained in:
9
.githooks/pre-push
Executable file
9
.githooks/pre-push
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Runs automatically before every git push (installed via postinstall → git config core.hooksPath).
|
||||||
|
# Keeps broken TypeScript off main without any manual developer setup.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "→ TypeScript check..."
|
||||||
|
yarn typecheck
|
||||||
|
|
||||||
|
echo "✓ Pre-push checks passed"
|
||||||
Binary file not shown.
@@ -43,6 +43,10 @@
|
|||||||
<v-tab value="invitations">Invitations</v-tab>
|
<v-tab value="invitations">Invitations</v-tab>
|
||||||
<v-tab value="waitlist">Waitlist</v-tab>
|
<v-tab value="waitlist">Waitlist</v-tab>
|
||||||
<v-tab value="logs">Transmissions</v-tab>
|
<v-tab value="logs">Transmissions</v-tab>
|
||||||
|
<v-tab value="bug-reports">
|
||||||
|
Bug Reports
|
||||||
|
<v-badge v-if="bugReportOpenCount > 0" :content="bugReportOpenCount" color="red" inline class="ml-1" />
|
||||||
|
</v-tab>
|
||||||
</v-tabs>
|
</v-tabs>
|
||||||
|
|
||||||
<v-window v-model="activeTab" class="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
|
<v-window v-model="activeTab" class="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
|
||||||
@@ -932,6 +936,145 @@
|
|||||||
</section>
|
</section>
|
||||||
</v-window-item>
|
</v-window-item>
|
||||||
|
|
||||||
|
<!-- Bug Reports Tab -->
|
||||||
|
<v-window-item value="bug-reports">
|
||||||
|
<section class="space-y-6">
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-semibold">Bug Reports</h2>
|
||||||
|
<p class="text-sm text-white/70">Fehlermeldungen von Testern aus der /pm-Seite.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<v-select
|
||||||
|
v-model="bugReportStatusFilter"
|
||||||
|
:items="[{ title: 'Offen', value: 'open' }, { title: 'Erledigt', value: 'resolved' }]"
|
||||||
|
label="Status"
|
||||||
|
density="comfortable"
|
||||||
|
variant="outlined"
|
||||||
|
color="cyan"
|
||||||
|
hide-details
|
||||||
|
class="w-40"
|
||||||
|
/>
|
||||||
|
<v-btn color="cyan" variant="tonal" :loading="bugReportLoading" @click="fetchBugReports(true)">
|
||||||
|
Laden
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-alert v-if="bugReportError" type="warning" variant="tonal" density="comfortable" class="bg-red-500/10 text-red-100">
|
||||||
|
{{ bugReportError }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<div v-if="bugReportLoading && !bugReports.length" class="py-12 text-center text-white/70">
|
||||||
|
<v-progress-circular indeterminate color="cyan" class="mb-4" />
|
||||||
|
<p>Bug Reports laden…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div v-if="bugReports.length" class="space-y-4">
|
||||||
|
<v-card
|
||||||
|
v-for="report in bugReports"
|
||||||
|
:key="report.id"
|
||||||
|
class="border border-white/10 bg-black/40"
|
||||||
|
>
|
||||||
|
<v-card-text class="space-y-3">
|
||||||
|
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div class="space-y-1 min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<v-chip size="x-small" :color="report.status === 'open' ? 'red' : 'green'" variant="tonal">
|
||||||
|
{{ report.status === 'open' ? 'Offen' : 'Erledigt' }}
|
||||||
|
</v-chip>
|
||||||
|
<span class="text-xs text-white/40">{{ formatRelative(report.createdAt) }}</span>
|
||||||
|
<span v-if="report.hasScreenshot" class="text-xs text-cyan-300/60">
|
||||||
|
<v-icon size="12">mdi-image</v-icon> Screenshot
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="font-semibold text-white text-sm">{{ report.contact }}</p>
|
||||||
|
<p v-if="report.user" class="text-xs text-white/50">{{ report.user.email }}</p>
|
||||||
|
<p class="text-sm text-white/80 whitespace-pre-line">{{ report.comment }}</p>
|
||||||
|
<div v-if="report.pmState?.currentStateId" class="text-xs text-white/40 font-mono">
|
||||||
|
State: {{ report.pmState.currentStateId }} · Flow: {{ report.pmState.flowSlug || '—' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 items-start md:items-end shrink-0">
|
||||||
|
<v-btn
|
||||||
|
v-if="report.status === 'open'"
|
||||||
|
size="small"
|
||||||
|
color="green"
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-check"
|
||||||
|
:loading="bugReportResolving === report.id"
|
||||||
|
@click="resolveBugReport(report.id)"
|
||||||
|
>
|
||||||
|
Erledigt
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-else
|
||||||
|
size="small"
|
||||||
|
color="orange"
|
||||||
|
variant="text"
|
||||||
|
prepend-icon="mdi-refresh"
|
||||||
|
:loading="bugReportResolving === report.id"
|
||||||
|
@click="reopenBugReport(report.id)"
|
||||||
|
>
|
||||||
|
Wieder öffnen
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="report.hasScreenshot"
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="cyan"
|
||||||
|
prepend-icon="mdi-image"
|
||||||
|
@click="loadBugReportDetail(report.id)"
|
||||||
|
>
|
||||||
|
Screenshot
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="report.pmState?.flowSlug"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="cyan"
|
||||||
|
prepend-icon="mdi-play-circle-outline"
|
||||||
|
:href="`/pm?restoreBugReport=${report.id}`"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
In /pm öffnen
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screenshot detail (loaded on demand) -->
|
||||||
|
<div v-if="bugReportDetailId === report.id">
|
||||||
|
<div v-if="bugReportDetailLoading" class="py-4 text-center text-white/60">
|
||||||
|
<v-progress-circular indeterminate color="cyan" size="24" />
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
v-else-if="bugReportDetail?.screenshot"
|
||||||
|
:src="bugReportDetail.screenshot"
|
||||||
|
class="w-full rounded-xl border border-white/10"
|
||||||
|
alt="Screenshot"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="py-12 text-center text-sm text-white/60">Keine Bug Reports vorhanden.</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center justify-between gap-3 sm:flex-row">
|
||||||
|
<div class="text-xs text-white/50">
|
||||||
|
Seite {{ bugReportPagination.page }} von {{ bugReportPagination.pages }} · {{ bugReportPagination.total }} Reports
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<v-btn variant="text" color="cyan" :disabled="bugReportPagination.page <= 1" @click="changeBugReportPage(bugReportPagination.page - 1)">Zurück</v-btn>
|
||||||
|
<v-btn variant="text" color="cyan" :disabled="bugReportPagination.page >= bugReportPagination.pages" @click="changeBugReportPage(bugReportPagination.page + 1)">Weiter</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</v-window-item>
|
||||||
|
|
||||||
</v-window>
|
</v-window>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1280,13 +1423,33 @@ interface CreateInviteResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BugReportItem {
|
||||||
|
id: string
|
||||||
|
comment: string
|
||||||
|
contact: string
|
||||||
|
user?: { id: string; email: string; name?: string }
|
||||||
|
pmState?: { flowSlug?: string; scenarioId?: string; currentStateId?: string }
|
||||||
|
status: 'open' | 'resolved'
|
||||||
|
createdAt: string | null
|
||||||
|
hasScreenshot: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BugReportDetail extends BugReportItem {
|
||||||
|
screenshot: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BugReportsResponse {
|
||||||
|
items: BugReportItem[]
|
||||||
|
pagination: { total: number; page: number; pageSize: number; pages: number }
|
||||||
|
}
|
||||||
|
|
||||||
definePageMeta({ middleware: 'require-admin' })
|
definePageMeta({ middleware: 'require-admin' })
|
||||||
useHead({ title: 'Admin • OpenSquawk' })
|
useHead({ title: 'Admin • OpenSquawk' })
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
const activeTab = ref<'overview' | 'users' | 'invitations' | 'waitlist' | 'logs'>('overview')
|
const activeTab = ref<'overview' | 'users' | 'invitations' | 'waitlist' | 'logs' | 'bug-reports'>('overview')
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
|
|
||||||
const overview = ref<OverviewData | null>(null)
|
const overview = ref<OverviewData | null>(null)
|
||||||
@@ -1890,11 +2053,104 @@ watch(activeTab, (tab) => {
|
|||||||
fetchWaitlist(true)
|
fetchWaitlist(true)
|
||||||
} else if (tab === 'logs' && !sessionsLoaded.value) {
|
} else if (tab === 'logs' && !sessionsLoaded.value) {
|
||||||
fetchSessions(true)
|
fetchSessions(true)
|
||||||
|
} else if (tab === 'bug-reports' && !bugReportsLoaded.value) {
|
||||||
|
fetchBugReports(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Bug Reports ──────────────────────────────────────────────────────────────
|
||||||
|
const bugReports = ref<BugReportItem[]>([])
|
||||||
|
const bugReportPagination = reactive({ total: 0, page: 1, pages: 1, pageSize: 20 })
|
||||||
|
const bugReportLoading = ref(false)
|
||||||
|
const bugReportError = ref('')
|
||||||
|
const bugReportsLoaded = ref(false)
|
||||||
|
const bugReportStatusFilter = ref<'open' | 'resolved'>('open')
|
||||||
|
const bugReportOpenCount = ref(0)
|
||||||
|
const bugReportResolving = ref<string | null>(null)
|
||||||
|
const bugReportDetailId = ref<string | null>(null)
|
||||||
|
const bugReportDetail = ref<BugReportDetail | null>(null)
|
||||||
|
const bugReportDetailLoading = ref(false)
|
||||||
|
|
||||||
|
async function fetchBugReports(resetPage = false) {
|
||||||
|
if (resetPage) bugReportPagination.page = 1
|
||||||
|
bugReportLoading.value = true
|
||||||
|
bugReportError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await api.get<BugReportsResponse>('/api/admin/bug-reports', {
|
||||||
|
query: { status: bugReportStatusFilter.value, page: bugReportPagination.page },
|
||||||
|
})
|
||||||
|
bugReports.value = response.items
|
||||||
|
Object.assign(bugReportPagination, response.pagination)
|
||||||
|
bugReportsLoaded.value = true
|
||||||
|
if (bugReportStatusFilter.value === 'open') bugReportOpenCount.value = response.pagination.total
|
||||||
|
} catch (error) {
|
||||||
|
bugReportError.value = extractErrorMessage(error, 'Bug Reports konnten nicht geladen werden.')
|
||||||
|
} finally {
|
||||||
|
bugReportLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBugReportDetail(id: string) {
|
||||||
|
if (bugReportDetailId.value === id) {
|
||||||
|
bugReportDetailId.value = null
|
||||||
|
bugReportDetail.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bugReportDetailId.value = id
|
||||||
|
bugReportDetail.value = null
|
||||||
|
bugReportDetailLoading.value = true
|
||||||
|
try {
|
||||||
|
bugReportDetail.value = await api.get<BugReportDetail>(`/api/admin/bug-reports/${id}`)
|
||||||
|
} catch {
|
||||||
|
bugReportDetailId.value = null
|
||||||
|
} finally {
|
||||||
|
bugReportDetailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveBugReport(id: string) {
|
||||||
|
bugReportResolving.value = id
|
||||||
|
try {
|
||||||
|
await api.request(`/api/admin/bug-reports/${id}`, { method: 'PATCH', body: { status: 'resolved' } })
|
||||||
|
const report = bugReports.value.find((r) => r.id === id)
|
||||||
|
if (report) { report.status = 'resolved'; bugReportOpenCount.value = Math.max(0, bugReportOpenCount.value - 1) }
|
||||||
|
if (bugReportStatusFilter.value === 'open') bugReports.value = bugReports.value.filter((r) => r.id !== id)
|
||||||
|
} catch (error) {
|
||||||
|
bugReportError.value = extractErrorMessage(error, 'Status konnte nicht geändert werden.')
|
||||||
|
} finally {
|
||||||
|
bugReportResolving.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reopenBugReport(id: string) {
|
||||||
|
bugReportResolving.value = id
|
||||||
|
try {
|
||||||
|
await api.request(`/api/admin/bug-reports/${id}`, { method: 'PATCH', body: { status: 'open' } })
|
||||||
|
const report = bugReports.value.find((r) => r.id === id)
|
||||||
|
if (report) { report.status = 'open'; bugReportOpenCount.value++ }
|
||||||
|
if (bugReportStatusFilter.value === 'resolved') bugReports.value = bugReports.value.filter((r) => r.id !== id)
|
||||||
|
} catch (error) {
|
||||||
|
bugReportError.value = extractErrorMessage(error, 'Status konnte nicht geändert werden.')
|
||||||
|
} finally {
|
||||||
|
bugReportResolving.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeBugReportPage(page: number) {
|
||||||
|
if (page < 1 || page > bugReportPagination.pages) return
|
||||||
|
bugReportPagination.page = page
|
||||||
|
fetchBugReports()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(bugReportStatusFilter, () => fetchBugReports(true))
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadOverview(true)
|
loadOverview(true)
|
||||||
|
// Load open bug report count for the badge
|
||||||
|
api.get<BugReportsResponse>('/api/admin/bug-reports', { query: { status: 'open', page: 1 } })
|
||||||
|
.then((r) => { bugReportOpenCount.value = r.pagination.total })
|
||||||
|
.catch(() => {})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,8 +34,8 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<NuxtImg src="/img/bridge/hangar_sleeping.jpeg" alt="Bridge app screenshot" style="aspect-ratio: 2.7/1;object-fit: cover" class="rounded-2xl border border-white/10 shadow-[0_20px_60px_rgba(4,8,24,0.45)]" format="webp" />
|
<NuxtImg src="/img/bridge/hangar_sleeping.jpeg" alt="Bridge app screenshot" style="aspect-ratio: 2.7/1;object-fit: cover" class="rounded-2xl border border-white/10 shadow-[0_20px_60px_rgba(4,8,24,0.45)]" format="webp" />
|
||||||
<p class="text-sm text-white/75 sm:text-base">
|
<p class="text-sm text-white/75 sm:text-base">
|
||||||
We’re actively building this part of the Bridge experience. There isn’t a hosted version
|
We’re actively building this part of the Bridge experience. Not every feature works yet,
|
||||||
yet, but we’re lining everything up for launch in the coming weeks and months.
|
and some areas still use static dummy data, but you can already look around and send us feedback.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -249,20 +249,20 @@ const downloads = [
|
|||||||
id: 'msfs2020',
|
id: 'msfs2020',
|
||||||
title: 'Microsoft Flight Simulator 2020',
|
title: 'Microsoft Flight Simulator 2020',
|
||||||
description: 'One-click installer for the current simulator with live status built in.',
|
description: 'One-click installer for the current simulator with live status built in.',
|
||||||
status: 'In development',
|
status: 'Developer preview',
|
||||||
badgeClass: 'bg-[#F59E0B]/15 text-[#F59E0B]',
|
badgeClass: 'bg-[#F59E0B]/15 text-[#F59E0B]',
|
||||||
state: 'preview',
|
state: 'preview',
|
||||||
href: 'https://github.com/itsrubberduck/OpenSquawk-MSFS-Bridge/',
|
href: 'https://github.com/OpenSquawk/OpenSquawk-Python-Bridge',
|
||||||
icon: 'mdi-microsoft',
|
icon: 'mdi-microsoft',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'msfs2024',
|
id: 'msfs2024',
|
||||||
title: 'Microsoft Flight Simulator 2024',
|
title: 'Microsoft Flight Simulator 2024',
|
||||||
description: 'We’re updating the Bridge for the new sim launch. Watch this space.',
|
description: 'Developer preview for the new simulator with live status built in.',
|
||||||
status: 'Planned',
|
status: 'Developer preview',
|
||||||
badgeClass: 'bg-white/10 text-white/55',
|
badgeClass: 'bg-[#F59E0B]/15 text-[#F59E0B]',
|
||||||
state: 'planned',
|
state: 'preview',
|
||||||
href: '#',
|
href: 'https://github.com/OpenSquawk/OpenSquawk-Python-Bridge',
|
||||||
icon: 'mdi-microsoft',
|
icon: 'mdi-microsoft',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
652
app/pages/pm.vue
652
app/pages/pm.vue
@@ -467,6 +467,29 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hud-right">
|
<div class="hud-right">
|
||||||
|
<v-tooltip
|
||||||
|
v-if="bridgeConnected"
|
||||||
|
:text="bridgeSimActiveFreq ? `SimBridge connected · COM1 ${bridgeSimActiveFreq}` : 'SimBridge connected'"
|
||||||
|
location="bottom"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<span class="bridge-badge" v-bind="props" role="status" aria-label="SimBridge connected">
|
||||||
|
<span class="bridge-badge-dot" aria-hidden="true"></span>
|
||||||
|
<v-icon size="15">mdi-bridge</v-icon>
|
||||||
|
<span class="bridge-badge-label">Bridge</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn ghost"
|
||||||
|
title="Fehler melden"
|
||||||
|
:disabled="bugReportCapturing"
|
||||||
|
@click="openBugReport"
|
||||||
|
>
|
||||||
|
<v-icon size="18">{{ bugReportCapturing ? 'mdi-loading mdi-spin' : 'mdi-bug-outline' }}</v-icon>
|
||||||
|
<span class="btn-label">{{ bugReportCapturing ? '…' : 'Bug' }}</span>
|
||||||
|
</button>
|
||||||
<NuxtLink class="btn ghost" to="/feedback" title="Share feedback or ideas">
|
<NuxtLink class="btn ghost" to="/feedback" title="Share feedback or ideas">
|
||||||
<v-icon size="18">mdi-message-draw</v-icon>
|
<v-icon size="18">mdi-message-draw</v-icon>
|
||||||
<span class="btn-label">Feedback</span>
|
<span class="btn-label">Feedback</span>
|
||||||
@@ -599,6 +622,17 @@
|
|||||||
>
|
>
|
||||||
{{ isRecording ? 'Transmitting' : 'Hold to transmit' }}
|
{{ isRecording ? 'Transmitting' : 'Hold to transmit' }}
|
||||||
</p>
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="bridgePttConnected"
|
||||||
|
class="text-[10px] uppercase tracking-[0.25em] flex items-center justify-center gap-1.5"
|
||||||
|
:class="isRecording ? 'text-red-300' : 'text-cyan-300/70'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block h-1.5 w-1.5 rounded-full"
|
||||||
|
:class="isRecording ? 'bg-red-400 animate-pulse' : 'bg-cyan-300/60'"
|
||||||
|
/>
|
||||||
|
{{ isRecording ? 'Hotkey transmitting' : 'Hotkey armed' }}
|
||||||
|
</p>
|
||||||
<p class="pt-2 text-4xl font-bold font-mono tracking-tight">{{ frequencies.active || '---' }}</p>
|
<p class="pt-2 text-4xl font-bold font-mono tracking-tight">{{ frequencies.active || '---' }}</p>
|
||||||
<p class="text-xs text-white/45">Active frequency</p>
|
<p class="text-xs text-white/45">Active frequency</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1198,6 +1232,126 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Bug Report Dialog -->
|
||||||
|
<v-dialog v-model="showBugReportDialog" max-width="680" scrollable>
|
||||||
|
<v-card class="bg-[#0b101d] border border-white/10 text-white">
|
||||||
|
<v-card-title class="flex items-center gap-2 text-base font-semibold pt-4 px-5">
|
||||||
|
<v-icon icon="mdi-bug-outline" color="#f87171" size="20" />
|
||||||
|
Fehler melden
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text class="space-y-4 px-5 pb-2">
|
||||||
|
<div v-if="bugReportSuccess" class="rounded-xl bg-emerald-500/10 border border-emerald-400/30 p-4 text-center">
|
||||||
|
<v-icon icon="mdi-check-circle-outline" color="emerald" size="32" class="mb-2" />
|
||||||
|
<p class="text-emerald-300 font-semibold">Danke! Bug Report wurde gesendet.</p>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="bugReportScreenshot" class="space-y-2">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<v-icon size="18" color="red" class="mt-0.5">mdi-gesture-tap-button</v-icon>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-white/90">Wo ist der Fehler? Zeichne einen Pfeil hin.</p>
|
||||||
|
<p class="text-xs text-white/55 leading-snug">
|
||||||
|
Klicke auf die Stelle und ziehe mit gedrückter Maustaste (am Handy: mit dem Finger)
|
||||||
|
zur Problemstelle. Du kannst mehrere Pfeile setzen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<v-btn
|
||||||
|
v-if="bugReportArrows.length > 0"
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
color="red"
|
||||||
|
prepend-icon="mdi-undo"
|
||||||
|
class="shrink-0"
|
||||||
|
@click="undoLastArrow"
|
||||||
|
>
|
||||||
|
Pfeil zurück
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
<div class="relative rounded-xl overflow-hidden border border-white/10" style="line-height:0">
|
||||||
|
<img
|
||||||
|
ref="bugReportImgRef"
|
||||||
|
:src="bugReportScreenshot"
|
||||||
|
class="w-full block"
|
||||||
|
alt="Screenshot"
|
||||||
|
@load="setupAnnotationCanvas"
|
||||||
|
/>
|
||||||
|
<canvas
|
||||||
|
ref="bugReportCanvasRef"
|
||||||
|
class="absolute inset-0 w-full h-full cursor-crosshair select-none"
|
||||||
|
style="touch-action:none"
|
||||||
|
@pointerdown="onCanvasMouseDown"
|
||||||
|
@pointermove="onCanvasMouseMove"
|
||||||
|
@pointerup="onCanvasMouseUp"
|
||||||
|
@pointerleave="onCanvasMouseLeave"
|
||||||
|
@pointercancel="onCanvasMouseLeave"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="bugReportArrows.length === 0"
|
||||||
|
class="pointer-events-none absolute inset-0 flex items-center justify-center text-center px-4"
|
||||||
|
>
|
||||||
|
<span class="rounded-full bg-black/55 px-3 py-1 text-xs text-white/85 backdrop-blur">
|
||||||
|
✏️ Hier ziehen, um einen Pfeil zur Fehlerstelle zu zeichnen
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="rounded-xl border border-white/10 bg-white/5 p-4 text-center text-sm text-white/50">
|
||||||
|
Kein Screenshot verfügbar
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-textarea
|
||||||
|
v-model="bugReportComment"
|
||||||
|
label="Was ist kaputt? Was sollte stattdessen passieren?"
|
||||||
|
variant="outlined"
|
||||||
|
color="red"
|
||||||
|
rows="3"
|
||||||
|
auto-grow
|
||||||
|
maxlength="2000"
|
||||||
|
counter="2000"
|
||||||
|
hide-details="auto"
|
||||||
|
:disabled="bugReportLoading"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="bugReportContact"
|
||||||
|
label="Dein Name / Kontakt"
|
||||||
|
variant="outlined"
|
||||||
|
color="red"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details
|
||||||
|
:disabled="bugReportLoading"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="bugReportError"
|
||||||
|
type="error"
|
||||||
|
density="compact"
|
||||||
|
variant="tonal"
|
||||||
|
class="bg-red-500/10 text-red-200"
|
||||||
|
>
|
||||||
|
{{ bugReportError }}
|
||||||
|
</v-alert>
|
||||||
|
</template>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions v-if="!bugReportSuccess" class="justify-end gap-2 px-5 pb-4">
|
||||||
|
<v-btn variant="text" color="grey" :disabled="bugReportLoading" @click="showBugReportDialog = false">
|
||||||
|
Abbrechen
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="red"
|
||||||
|
variant="flat"
|
||||||
|
:loading="bugReportLoading"
|
||||||
|
:disabled="!bugReportComment.trim()"
|
||||||
|
@click="submitBugReport"
|
||||||
|
>
|
||||||
|
Bug melden
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
<!-- Transmission issue dialog -->
|
<!-- Transmission issue dialog -->
|
||||||
<v-dialog v-model="showTransmissionIssueDialog" max-width="420">
|
<v-dialog v-model="showTransmissionIssueDialog" max-width="420">
|
||||||
<v-card class="bg-[#0b101d] border border-white/10 text-white">
|
<v-card class="bg-[#0b101d] border border-white/10 text-white">
|
||||||
@@ -1242,7 +1396,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import useCommunicationsEngine from "../../shared/utils/communicationsEngine";
|
import useCommunicationsEngine from "../../shared/utils/communicationsEngine";
|
||||||
import { normalizeRadioPhrase, normalizeAtisForSpeech, DEFAULT_AIRLINE_TELEPHONY } from '../../shared/utils/radioSpeech';
|
import { normalizeRadioPhrase, normalizeAtisForSpeech, DEFAULT_AIRLINE_TELEPHONY } from '../../shared/utils/radioSpeech';
|
||||||
import { useAuthStore } from '~/stores/auth'
|
import { useAuthStore } from '~/stores/auth'
|
||||||
@@ -1292,6 +1446,7 @@ const engine = useCommunicationsEngine()
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const radioBackend = useRadioBackend()
|
const radioBackend = useRadioBackend()
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
@@ -1992,6 +2147,263 @@ const radioEffectsEnabled = ref(true)
|
|||||||
const readbackEnabled = ref(false)
|
const readbackEnabled = ref(false)
|
||||||
const debugMode = ref(true)
|
const debugMode = ref(true)
|
||||||
|
|
||||||
|
// ── Bug Report ───────────────────────────────────────────────────────────────
|
||||||
|
const showBugReportDialog = ref(false)
|
||||||
|
const bugReportComment = ref('')
|
||||||
|
const bugReportContact = ref('')
|
||||||
|
const bugReportScreenshot = ref<string | null>(null)
|
||||||
|
const bugReportArrows = ref<Array<{ fx: number; fy: number; tx: number; ty: number }>>([])
|
||||||
|
const bugReportLoading = ref(false)
|
||||||
|
const bugReportCapturing = ref(false)
|
||||||
|
const bugReportError = ref('')
|
||||||
|
const bugReportSuccess = ref(false)
|
||||||
|
const bugReportCanvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const bugReportImgRef = ref<HTMLImageElement | null>(null)
|
||||||
|
let _arrowDrawing = false
|
||||||
|
let _arrowStart = { x: 0, y: 0 }
|
||||||
|
|
||||||
|
function setupAnnotationCanvas() {
|
||||||
|
const canvas = bugReportCanvasRef.value
|
||||||
|
const img = bugReportImgRef.value
|
||||||
|
if (!canvas || !img) return
|
||||||
|
canvas.width = img.clientWidth
|
||||||
|
canvas.height = img.clientHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
function _drawArrow(ctx: CanvasRenderingContext2D, fx: number, fy: number, tx: number, ty: number) {
|
||||||
|
const headLen = 14
|
||||||
|
const angle = Math.atan2(ty - fy, tx - fx)
|
||||||
|
ctx.strokeStyle = '#ef4444'
|
||||||
|
ctx.fillStyle = '#ef4444'
|
||||||
|
ctx.lineWidth = 3
|
||||||
|
ctx.lineCap = 'round'
|
||||||
|
ctx.lineJoin = 'round'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(fx, fy)
|
||||||
|
ctx.lineTo(tx, ty)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(tx, ty)
|
||||||
|
ctx.lineTo(tx - headLen * Math.cos(angle - Math.PI / 6), ty - headLen * Math.sin(angle - Math.PI / 6))
|
||||||
|
ctx.lineTo(tx - headLen * Math.cos(angle + Math.PI / 6), ty - headLen * Math.sin(angle + Math.PI / 6))
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
function _redrawAnnotations(preview?: { fx: number; fy: number; tx: number; ty: number }) {
|
||||||
|
const canvas = bugReportCanvasRef.value
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
for (const a of bugReportArrows.value) _drawArrow(ctx, a.fx, a.fy, a.tx, a.ty)
|
||||||
|
if (preview) {
|
||||||
|
ctx.globalAlpha = 0.55
|
||||||
|
_drawArrow(ctx, preview.fx, preview.fy, preview.tx, preview.ty)
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _canvasCoords(e: MouseEvent) {
|
||||||
|
const canvas = bugReportCanvasRef.value!
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCanvasMouseDown(e: MouseEvent) {
|
||||||
|
_arrowStart = _canvasCoords(e)
|
||||||
|
_arrowDrawing = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCanvasMouseMove(e: MouseEvent) {
|
||||||
|
if (!_arrowDrawing) return
|
||||||
|
const { x, y } = _canvasCoords(e)
|
||||||
|
_redrawAnnotations({ fx: _arrowStart.x, fy: _arrowStart.y, tx: x, ty: y })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCanvasMouseUp(e: MouseEvent) {
|
||||||
|
if (!_arrowDrawing) return
|
||||||
|
_arrowDrawing = false
|
||||||
|
const { x, y } = _canvasCoords(e)
|
||||||
|
const dx = x - _arrowStart.x
|
||||||
|
const dy = y - _arrowStart.y
|
||||||
|
if (Math.sqrt(dx * dx + dy * dy) < 8) { _redrawAnnotations(); return }
|
||||||
|
bugReportArrows.value = [...bugReportArrows.value, { fx: _arrowStart.x, fy: _arrowStart.y, tx: x, ty: y }]
|
||||||
|
_redrawAnnotations()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCanvasMouseLeave() {
|
||||||
|
if (!_arrowDrawing) return
|
||||||
|
_arrowDrawing = false
|
||||||
|
_redrawAnnotations()
|
||||||
|
}
|
||||||
|
|
||||||
|
function undoLastArrow() {
|
||||||
|
bugReportArrows.value = bugReportArrows.value.slice(0, -1)
|
||||||
|
_redrawAnnotations()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBugReport() {
|
||||||
|
bugReportError.value = ''
|
||||||
|
bugReportSuccess.value = false
|
||||||
|
bugReportComment.value = ''
|
||||||
|
bugReportArrows.value = []
|
||||||
|
bugReportScreenshot.value = null
|
||||||
|
bugReportContact.value = [auth.user?.name, auth.user?.email].filter(Boolean).join(' — ')
|
||||||
|
|
||||||
|
// Capture the screenshot BEFORE opening the dialog, otherwise the open
|
||||||
|
// dialog overlay would appear in the shot instead of the actual bug state.
|
||||||
|
bugReportCapturing.value = true
|
||||||
|
try {
|
||||||
|
// modern-screenshot renders via a native SVG <foreignObject>, so modern CSS
|
||||||
|
// such as color-mix()/oklch() (used throughout the app) is supported.
|
||||||
|
// html2canvas could not parse those and silently produced no screenshot.
|
||||||
|
const { domToJpeg } = await import('modern-screenshot')
|
||||||
|
bugReportScreenshot.value = await domToJpeg(document.body, {
|
||||||
|
quality: 0.75,
|
||||||
|
scale: 0.55,
|
||||||
|
// Skip assets we cannot read (cross-origin tiles/avatars) instead of failing.
|
||||||
|
filter: (node) => !(node instanceof Element && node.getAttribute?.('data-no-screenshot') === 'true'),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// Screenshot is optional — keep the report flow usable, but surface why.
|
||||||
|
console.warn('[PM] Bug report screenshot capture failed', err)
|
||||||
|
bugReportScreenshot.value = null
|
||||||
|
bugReportError.value = 'Screenshot konnte nicht erstellt werden – Bug-Report ohne Bild möglich.'
|
||||||
|
} finally {
|
||||||
|
bugReportCapturing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
showBugReportDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBugReport() {
|
||||||
|
if (!bugReportComment.value.trim()) { bugReportError.value = 'Bitte einen Kommentar eingeben.'; return }
|
||||||
|
bugReportLoading.value = true
|
||||||
|
bugReportError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
let finalScreenshot: string | undefined
|
||||||
|
if (bugReportScreenshot.value) {
|
||||||
|
const img = bugReportImgRef.value
|
||||||
|
const src = new Image()
|
||||||
|
await new Promise<void>((res) => { src.onload = () => res(); src.src = bugReportScreenshot.value! })
|
||||||
|
const out = document.createElement('canvas')
|
||||||
|
out.width = src.naturalWidth; out.height = src.naturalHeight
|
||||||
|
const ctx = out.getContext('2d')!
|
||||||
|
ctx.drawImage(src, 0, 0)
|
||||||
|
if (bugReportArrows.value.length > 0 && img) {
|
||||||
|
const sx = src.naturalWidth / img.clientWidth
|
||||||
|
const sy = src.naturalHeight / img.clientHeight
|
||||||
|
for (const a of bugReportArrows.value) _drawArrow(ctx, a.fx * sx, a.fy * sy, a.tx * sx, a.ty * sy)
|
||||||
|
}
|
||||||
|
finalScreenshot = out.toDataURL('image/jpeg', 0.8)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pmState = {
|
||||||
|
flowSlug: activeScenario.value?.startFlow || '',
|
||||||
|
scenarioId: activeScenario.value?.id || '',
|
||||||
|
currentStateId: (currentState.value as any)?.id || '',
|
||||||
|
variables: (vars as any)?.value || {},
|
||||||
|
flags: (flags as any)?.value || {},
|
||||||
|
flightContext: (flightContext as any)?.value || {},
|
||||||
|
communicationLog: ((log as any)?.value || [] as any[]).slice(-20),
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.post('/api/bug-reports', {
|
||||||
|
comment: bugReportComment.value.trim(),
|
||||||
|
contact: bugReportContact.value.trim(),
|
||||||
|
screenshot: finalScreenshot,
|
||||||
|
pmState,
|
||||||
|
})
|
||||||
|
|
||||||
|
bugReportSuccess.value = true
|
||||||
|
setTimeout(() => { showBugReportDialog.value = false; bugReportSuccess.value = false }, 2500)
|
||||||
|
} catch (err: any) {
|
||||||
|
bugReportError.value = err?.data?.statusMessage || err?.message || 'Fehler beim Senden.'
|
||||||
|
} finally {
|
||||||
|
bugReportLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a /pm session from a saved bug-report snapshot (admin link
|
||||||
|
* `/pm?restoreBugReport=<id>`). The Python backend has no "resume mid-session"
|
||||||
|
* endpoint, so we recreate a real, working session for the SAME flight and
|
||||||
|
* scenario via startMonitoring(), then overlay the saved variables/flags and
|
||||||
|
* the captured conversation so the admin can reproduce and try out the bug.
|
||||||
|
*/
|
||||||
|
async function restoreBugReportState(restoreId: string) {
|
||||||
|
try {
|
||||||
|
const report = await api.get<any>(`/api/admin/bug-reports/${restoreId}`)
|
||||||
|
const state = report?.pmState
|
||||||
|
if (!state) {
|
||||||
|
error.value = 'Bug-Report enthält keinen gespeicherten State.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate the scenario the report was captured in.
|
||||||
|
const scenario =
|
||||||
|
SCENARIOS.find(s => s.id === state.scenarioId) ||
|
||||||
|
SCENARIOS.find(s => s.startFlow === state.flowSlug)
|
||||||
|
if (!scenario) {
|
||||||
|
error.value = `Bug-Report-Restore: Szenario "${state.scenarioId || state.flowSlug || '?'}" nicht gefunden.`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct a flight plan from the snapshot so startMonitoring resolves the
|
||||||
|
// correct airport/frequencies and creates a backend session for the same flight.
|
||||||
|
const v = state.variables || {}
|
||||||
|
const fc = state.flightContext || {}
|
||||||
|
const dep = v.dep || fc.dep
|
||||||
|
const dest = v.dest || fc.dest
|
||||||
|
const flightPlan: Record<string, any> = {
|
||||||
|
callsign: v.callsign || fc.callsign || 'UNKNOWN',
|
||||||
|
aircraft: v.acf_type || fc.acf_type || 'A320',
|
||||||
|
dep,
|
||||||
|
departure: dep,
|
||||||
|
arr: dest,
|
||||||
|
arrival: dest,
|
||||||
|
route: fc.route || v.route || '',
|
||||||
|
assignedsquawk: v.squawk,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spin up a real session (loads tree, fetches frequencies, creates backend session).
|
||||||
|
await startMonitoring(flightPlan, scenario)
|
||||||
|
// startMonitoring bails out on error without entering the monitor screen.
|
||||||
|
if (currentScreen.value !== 'monitor') return
|
||||||
|
|
||||||
|
// Overlay the exact saved values over the freshly generated ones (stand, SID, …).
|
||||||
|
if (state.variables && Object.keys(state.variables).length) patchVariables(state.variables)
|
||||||
|
if (state.flags && Object.keys(state.flags).length) patchFlags(state.flags)
|
||||||
|
|
||||||
|
// Restore the captured conversation for context.
|
||||||
|
clearCommunicationLog?.()
|
||||||
|
if (Array.isArray(state.communicationLog)) {
|
||||||
|
for (const e of state.communicationLog) {
|
||||||
|
if (!e?.message) continue
|
||||||
|
appendLogEntry(e.speaker || 'system', e.message, e.state || '', {
|
||||||
|
frequency: e.frequency,
|
||||||
|
flow: e.flow,
|
||||||
|
radioCheck: e.radioCheck,
|
||||||
|
offSchema: e.offSchema,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh backend session always starts at the flow's start state, so we
|
||||||
|
// can't fake the local cursor onto the captured mid-flow state without
|
||||||
|
// desyncing transmits. Tell the admin where the bug was captured instead.
|
||||||
|
error.value =
|
||||||
|
`Bug-Report wiederhergestellt: ${scenario.name} · ${flightPlan.callsign} (${dep || '?'}→${dest || '?'}). ` +
|
||||||
|
`Erfasster State: ${state.currentStateId || '?'} (Flow ${state.flowSlug || '?'}).`
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[PM] Bug report restore failed', err)
|
||||||
|
error.value = 'Bug-Report konnte nicht wiederhergestellt werden.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Pre-recording (rolling mic buffer) so the first ~1s of PTT speech isn't clipped
|
// Pre-recording (rolling mic buffer) so the first ~1s of PTT speech isn't clipped
|
||||||
const prerecEnabled = ref(true)
|
const prerecEnabled = ref(true)
|
||||||
const prerecSeconds = ref(1.0)
|
const prerecSeconds = ref(1.0)
|
||||||
@@ -2096,12 +2508,19 @@ const scheduleAirportDataRefresh = () => {
|
|||||||
}, delay)
|
}, delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send unauthenticated visitors to login while preserving where they were
|
||||||
|
// headed (e.g. /pm?token=… so the bridge link survives the round-trip),
|
||||||
|
// instead of dropping them on the classroom fallback after sign-in.
|
||||||
|
const redirectToLogin = () => {
|
||||||
|
router.push({ path: '/login', query: { redirect: route.fullPath } })
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
if (!auth.accessToken) {
|
if (!auth.accessToken) {
|
||||||
const refreshed = await auth.tryRefresh()
|
const refreshed = await auth.tryRefresh()
|
||||||
if (!refreshed) {
|
if (!refreshed) {
|
||||||
router.push('/login')
|
redirectToLogin()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2109,7 +2528,7 @@ onMounted(async () => {
|
|||||||
if (!auth.user) {
|
if (!auth.user) {
|
||||||
await auth.fetchUser().catch((err) => {
|
await auth.fetchUser().catch((err) => {
|
||||||
console.error('Session initialisation failed', err)
|
console.error('Session initialisation failed', err)
|
||||||
router.push('/login')
|
redirectToLogin()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2151,6 +2570,12 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore state from bug report (admin link: /pm?restoreBugReport=<id>)
|
||||||
|
const restoreId = route.query.restoreBugReport as string | undefined
|
||||||
|
if (restoreId) {
|
||||||
|
await restoreBugReportState(restoreId)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
restoringFromStorage = false
|
restoringFromStorage = false
|
||||||
}
|
}
|
||||||
@@ -2161,7 +2586,7 @@ watch(
|
|||||||
(token) => {
|
(token) => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
persistSelectedPlan(null)
|
persistSelectedPlan(null)
|
||||||
router.push('/login')
|
redirectToLogin()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -4409,10 +4834,193 @@ watch(prerecEnabled, (val) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- SimBridge live frequency sync -----------------------------------------
|
||||||
|
// When /pm is opened with ?token=<bridge-token> and that bridge is actively
|
||||||
|
// posting telemetry, mirror the sim's COM1 radio panel (active + standby) into
|
||||||
|
// the radio and surface a "Bridge connected" indicator. The bridge counts as
|
||||||
|
// connected only while fresh telemetry keeps arriving — if it goes quiet we
|
||||||
|
// drop the badge.
|
||||||
|
const bridgeToken = computed(() => {
|
||||||
|
const value = route.query.token
|
||||||
|
const raw = Array.isArray(value) ? value[0] : value
|
||||||
|
return typeof raw === 'string' ? raw.trim() : ''
|
||||||
|
})
|
||||||
|
const bridgeConnected = ref(false)
|
||||||
|
const bridgeSimActiveFreq = ref<string | null>(null)
|
||||||
|
// Telemetry older than this means the bridge stopped posting.
|
||||||
|
const BRIDGE_TELEMETRY_STALE_MS = 12_000
|
||||||
|
const BRIDGE_POLL_INTERVAL_MS = 3_000
|
||||||
|
let bridgePoller: ReturnType<typeof setInterval> | null = null
|
||||||
|
// Last sim active frequency we pushed into the radio — only re-tune active when
|
||||||
|
// the sim value actually changes, so manual/flow tuning isn't constantly
|
||||||
|
// overridden. Standby has no such anchor: while connected it strictly mirrors
|
||||||
|
// the sim's standby radio.
|
||||||
|
let lastSyncedSimActive: string | null = null
|
||||||
|
|
||||||
|
function normalizeSimFreq(value: unknown): string | null {
|
||||||
|
const num = typeof value === 'number' ? value : Number(value)
|
||||||
|
if (!Number.isFinite(num) || num < 118 || num >= 137) return null
|
||||||
|
return num.toFixed(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollBridgeTelemetry() {
|
||||||
|
const token = bridgeToken.value
|
||||||
|
if (!token) return
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ connected: boolean; lastTelemetryAt: string | null; telemetry: any }>(
|
||||||
|
'/api/bridge/live',
|
||||||
|
{ headers: { 'x-bridge-token': token } },
|
||||||
|
)
|
||||||
|
const ts = res.lastTelemetryAt ? Date.parse(res.lastTelemetryAt) : null
|
||||||
|
const fresh = Boolean(res.connected && ts && Date.now() - ts < BRIDGE_TELEMETRY_STALE_MS)
|
||||||
|
bridgeConnected.value = fresh
|
||||||
|
|
||||||
|
if (!fresh) {
|
||||||
|
// Bridge went quiet — drop the active sync anchor so reconnecting re-tunes.
|
||||||
|
lastSyncedSimActive = null
|
||||||
|
bridgeSimActiveFreq.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const simActive = normalizeSimFreq(res.telemetry?.COM_ACTIVE_FREQUENCY)
|
||||||
|
const simStandby = normalizeSimFreq(res.telemetry?.COM_STANDBY_FREQUENCY)
|
||||||
|
bridgeSimActiveFreq.value = simActive
|
||||||
|
|
||||||
|
// Mirror COM1 active: tuning away cuts in-progress ATC speech on the old
|
||||||
|
// channel, same as a manual tune.
|
||||||
|
if (simActive && simActive !== lastSyncedSimActive) {
|
||||||
|
lastSyncedSimActive = simActive
|
||||||
|
if (frequencies.value.active !== simActive) {
|
||||||
|
stopCurrentSpeech()
|
||||||
|
frequencies.value.active = simActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror COM1 standby (no audio side effects — it's just the staged
|
||||||
|
// channel). While connected the standby always reflects the sim's standby
|
||||||
|
// radio, never the previously tuned channel.
|
||||||
|
if (simStandby && frequencies.value.standby !== simStandby) {
|
||||||
|
frequencies.value.standby = simStandby
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
bridgeConnected.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startBridgeSync() {
|
||||||
|
stopBridgeSync()
|
||||||
|
if (!bridgeToken.value) return
|
||||||
|
void pollBridgeTelemetry()
|
||||||
|
bridgePoller = setInterval(pollBridgeTelemetry, BRIDGE_POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopBridgeSync() {
|
||||||
|
if (bridgePoller) {
|
||||||
|
clearInterval(bridgePoller)
|
||||||
|
bridgePoller = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Remote push-to-talk over the bridge link --------------------------------
|
||||||
|
// The OpenSquawk Bridge captures a global hotkey on the PC and POSTs each edge
|
||||||
|
// to /api/bridge/ptt; the backend relays it here over WebSocket so PTT works
|
||||||
|
// while the sim (not this tab) is focused. We reuse the on-screen pad's
|
||||||
|
// startRecording/stopRecording, so behaviour is identical to holding the pad.
|
||||||
|
let pttSocket: WebSocket | null = null
|
||||||
|
let pttReconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let pttClosedByUs = false
|
||||||
|
const bridgePttConnected = ref(false)
|
||||||
|
|
||||||
|
async function handleRemotePtt(state: 'down' | 'up') {
|
||||||
|
if (state === 'down') {
|
||||||
|
// A backgrounded tab can suspend the prerec AudioContext; resume it so the
|
||||||
|
// ring buffer + live capture are running when the edge arrives from the sim.
|
||||||
|
if (prerecCtx && prerecCtx.state === 'suspended') {
|
||||||
|
try { await prerecCtx.resume() } catch {}
|
||||||
|
}
|
||||||
|
void startRecording(false)
|
||||||
|
} else {
|
||||||
|
stopRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePttReconnect() {
|
||||||
|
if (pttReconnectTimer || pttClosedByUs) return
|
||||||
|
pttReconnectTimer = setTimeout(() => {
|
||||||
|
pttReconnectTimer = null
|
||||||
|
connectPttSocket()
|
||||||
|
}, 3_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectPttSocket() {
|
||||||
|
disconnectPttSocket()
|
||||||
|
const token = bridgeToken.value
|
||||||
|
if (!token || typeof window === 'undefined') return
|
||||||
|
pttClosedByUs = false
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const url = `${proto}//${window.location.host}/api/bridge/ws`
|
||||||
|
let socket: WebSocket
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(url)
|
||||||
|
} catch {
|
||||||
|
schedulePttReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pttSocket = socket
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
bridgePttConnected.value = true
|
||||||
|
try { socket.send(JSON.stringify({ type: 'subscribe', token })) } catch {}
|
||||||
|
}
|
||||||
|
socket.onmessage = (ev) => {
|
||||||
|
let data: any
|
||||||
|
try { data = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data)) } catch { return }
|
||||||
|
if (data?.type === 'ptt' && (data.state === 'down' || data.state === 'up')) {
|
||||||
|
void handleRemotePtt(data.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket.onclose = () => {
|
||||||
|
bridgePttConnected.value = false
|
||||||
|
if (pttSocket === socket) pttSocket = null
|
||||||
|
if (!pttClosedByUs) schedulePttReconnect()
|
||||||
|
}
|
||||||
|
socket.onerror = () => {
|
||||||
|
try { socket.close() } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectPttSocket() {
|
||||||
|
pttClosedByUs = true
|
||||||
|
if (pttReconnectTimer) {
|
||||||
|
clearTimeout(pttReconnectTimer)
|
||||||
|
pttReconnectTimer = null
|
||||||
|
}
|
||||||
|
if (pttSocket) {
|
||||||
|
try { pttSocket.close() } catch {}
|
||||||
|
pttSocket = null
|
||||||
|
}
|
||||||
|
bridgePttConnected.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
startBridgeSync()
|
||||||
|
connectPttSocket()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(bridgeToken, () => {
|
||||||
|
bridgeConnected.value = false
|
||||||
|
bridgeSimActiveFreq.value = null
|
||||||
|
lastSyncedSimActive = null
|
||||||
|
startBridgeSync()
|
||||||
|
connectPttSocket()
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopAtisLoop()
|
stopAtisLoop()
|
||||||
stopPrerecCapture()
|
stopPrerecCapture()
|
||||||
cancelAirportDataRefresh()
|
cancelAirportDataRefresh()
|
||||||
|
stopBridgeSync()
|
||||||
|
disconnectPttSocket()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -4518,6 +5126,42 @@ onUnmounted(() => {
|
|||||||
.hud-right .btn-label {
|
.hud-right .btn-label {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.bridge-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(52, 211, 153, 0.4);
|
||||||
|
background: rgba(16, 185, 129, 0.14);
|
||||||
|
color: rgba(167, 243, 208, 0.95);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.bridge-badge-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #34d399;
|
||||||
|
box-shadow: 0 0 8px rgba(52, 211, 153, 0.8);
|
||||||
|
animation: bridge-badge-pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.bridge-badge-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
@keyframes bridge-badge-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.bridge-badge-label { display: none; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bridge-badge-dot { animation: none; }
|
||||||
|
}
|
||||||
.hud-logo {
|
.hud-logo {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"start": "node .output/server/index.mjs",
|
"start": "node .output/server/index.mjs",
|
||||||
"postinstall": "nuxt prepare",
|
"postinstall": "nuxt prepare && (git config core.hooksPath .githooks 2>/dev/null || true)",
|
||||||
"typecheck": "vue-tsc --build",
|
"typecheck": "vue-tsc --build",
|
||||||
"sharp:rebuild": "SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn rebuild sharp",
|
"sharp:rebuild": "SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn rebuild sharp",
|
||||||
"import:decision": "tsx --tsconfig tsconfig.scripts.json scripts/import-decision-tree.ts",
|
"import:decision": "tsx --tsconfig tsconfig.scripts.json scripts/import-decision-tree.ts",
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
|
"modern-screenshot": "^4.7.0",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"nuxt": "^4.3.1",
|
"nuxt": "^4.3.1",
|
||||||
"nuxt-aos": "^1.2.6",
|
"nuxt-aos": "^1.2.6",
|
||||||
|
|||||||
3
public/img/bridge/logo.png
Normal file
3
public/img/bridge/logo.png
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:10cff57568acb96ca4d3ba7219f3fbcdce22012a953c863f8b32ac92449c8a41
|
||||||
|
size 1305604
|
||||||
27
server/api/admin/bug-reports/[id].get.ts
Normal file
27
server/api/admin/bug-reports/[id].get.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineEventHandler, getRouterParam, createError } from 'h3'
|
||||||
|
import { requireAdmin } from '../../../utils/auth'
|
||||||
|
import { BugReport } from '../../../models/BugReport'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdmin(event)
|
||||||
|
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
const doc = await BugReport.findById(id).populate('userId', 'email name role').lean() as any
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Bug report not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(doc._id),
|
||||||
|
comment: doc.comment,
|
||||||
|
contact: doc.contact,
|
||||||
|
user: doc.userId
|
||||||
|
? { id: String((doc.userId as any)._id), email: (doc.userId as any).email, name: (doc.userId as any).name }
|
||||||
|
: undefined,
|
||||||
|
screenshot: doc.screenshot || null,
|
||||||
|
pmState: doc.pmState || null,
|
||||||
|
status: doc.status,
|
||||||
|
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : null,
|
||||||
|
}
|
||||||
|
})
|
||||||
27
server/api/admin/bug-reports/[id].patch.ts
Normal file
27
server/api/admin/bug-reports/[id].patch.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineEventHandler, getRouterParam, readBody, createError } from 'h3'
|
||||||
|
import { requireAdmin } from '../../../utils/auth'
|
||||||
|
import { BugReport } from '../../../models/BugReport'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdmin(event)
|
||||||
|
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
const status = body?.status
|
||||||
|
if (status !== 'open' && status !== 'resolved') {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'status must be "open" or "resolved"' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await BugReport.findByIdAndUpdate(
|
||||||
|
id,
|
||||||
|
{ $set: { status } },
|
||||||
|
{ new: true },
|
||||||
|
).lean() as any
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Bug report not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: String(doc._id), status: doc.status }
|
||||||
|
})
|
||||||
78
server/api/admin/bug-reports/index.get.ts
Normal file
78
server/api/admin/bug-reports/index.get.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { defineEventHandler, getQuery } from 'h3'
|
||||||
|
import { requireAdmin } from '../../../utils/auth'
|
||||||
|
import { BugReport } from '../../../models/BugReport'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdmin(event)
|
||||||
|
|
||||||
|
const query = getQuery(event)
|
||||||
|
const page = Math.max(parseInt(String(query.page ?? '1'), 10) || 1, 1)
|
||||||
|
const pageSize = 20
|
||||||
|
const skip = (page - 1) * pageSize
|
||||||
|
const status = String(query.status ?? 'open')
|
||||||
|
|
||||||
|
const filter: Record<string, any> = {}
|
||||||
|
if (status === 'open' || status === 'resolved') {
|
||||||
|
filter.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
const [docs, total] = await Promise.all([
|
||||||
|
BugReport.aggregate([
|
||||||
|
{ $match: filter },
|
||||||
|
{ $sort: { createdAt: -1 } },
|
||||||
|
{ $skip: skip },
|
||||||
|
{ $limit: pageSize },
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'users',
|
||||||
|
localField: 'userId',
|
||||||
|
foreignField: '_id',
|
||||||
|
as: 'userDoc',
|
||||||
|
pipeline: [{ $project: { email: 1, name: 1, role: 1 } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$project: {
|
||||||
|
comment: 1,
|
||||||
|
contact: 1,
|
||||||
|
userId: 1,
|
||||||
|
userDoc: 1,
|
||||||
|
status: 1,
|
||||||
|
createdAt: 1,
|
||||||
|
'pmState.flowSlug': 1,
|
||||||
|
'pmState.scenarioId': 1,
|
||||||
|
'pmState.currentStateId': 1,
|
||||||
|
hasScreenshot: { $gt: [{ $strLenCP: { $ifNull: ['$screenshot', ''] } }, 0] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]).exec(),
|
||||||
|
BugReport.countDocuments(filter),
|
||||||
|
])
|
||||||
|
|
||||||
|
const items = docs.map((doc: any) => ({
|
||||||
|
id: String(doc._id),
|
||||||
|
comment: doc.comment,
|
||||||
|
contact: doc.contact,
|
||||||
|
user: doc.userDoc?.[0]
|
||||||
|
? { id: String(doc.userDoc[0]._id), email: doc.userDoc[0].email, name: doc.userDoc[0].name }
|
||||||
|
: undefined,
|
||||||
|
pmState: {
|
||||||
|
flowSlug: doc.pmState?.flowSlug,
|
||||||
|
scenarioId: doc.pmState?.scenarioId,
|
||||||
|
currentStateId: doc.pmState?.currentStateId,
|
||||||
|
},
|
||||||
|
status: doc.status,
|
||||||
|
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : null,
|
||||||
|
hasScreenshot: Boolean(doc.hasScreenshot),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
pages: Math.ceil(total / pageSize) || 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -57,6 +57,11 @@ function mapBridgeTelemetry(raw: Record<string, any>): FlightLabTelemetryState {
|
|||||||
TRANSPONDER_CODE: raw.transponder_code ?? raw.TRANSPONDER_CODE ?? 0,
|
TRANSPONDER_CODE: raw.transponder_code ?? raw.TRANSPONDER_CODE ?? 0,
|
||||||
ADF_ACTIVE_FREQUENCY: raw.adf_active_freq ?? raw.ADF_ACTIVE_FREQUENCY ?? 0,
|
ADF_ACTIVE_FREQUENCY: raw.adf_active_freq ?? raw.ADF_ACTIVE_FREQUENCY ?? 0,
|
||||||
ADF_STANDBY_FREQUENCY: raw.adf_standby_freq_hz ?? raw.ADF_STANDBY_FREQUENCY ?? 0,
|
ADF_STANDBY_FREQUENCY: raw.adf_standby_freq_hz ?? raw.ADF_STANDBY_FREQUENCY ?? 0,
|
||||||
|
COM_ACTIVE_FREQUENCY: raw.com_active_frequency ?? raw.COM_ACTIVE_FREQUENCY ?? 0,
|
||||||
|
COM_STANDBY_FREQUENCY: raw.com_standby_frequency ?? raw.COM_STANDBY_FREQUENCY ?? 0,
|
||||||
|
PLANE_LATITUDE: raw.latitude_deg ?? raw.PLANE_LATITUDE ?? 0,
|
||||||
|
PLANE_LONGITUDE: raw.longitude_deg ?? raw.PLANE_LONGITUDE ?? 0,
|
||||||
|
PLANE_HEADING_DEGREES_TRUE: raw.heading_deg ?? raw.PLANE_HEADING_DEGREES_TRUE ?? 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
46
server/api/bridge/ptt.post.ts
Normal file
46
server/api/bridge/ptt.post.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { createError, readBody } from 'h3'
|
||||||
|
import { BridgeToken } from '../../models/BridgeToken'
|
||||||
|
import { getBridgeTokenFromHeader } from '../../utils/bridge'
|
||||||
|
import { logBridgeEvent } from '../../utils/bridgeLog'
|
||||||
|
import { pttBus, type PttState } from '../../utils/pttBus'
|
||||||
|
|
||||||
|
interface PttBody {
|
||||||
|
state?: PttState
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const token = getBridgeTokenFromHeader(event)
|
||||||
|
if (!token) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'x-bridge-token header fehlt oder ist ungültig.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody<PttBody>(event)
|
||||||
|
const state = body?.state
|
||||||
|
if (state !== 'down' && state !== 'up') {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "state muss 'down' oder 'up' sein." })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only relay for a linked token; an unknown/unlinked token is a no-op so the
|
||||||
|
// bus is never driven by an unauthenticated caller.
|
||||||
|
const exists = await BridgeToken.exists({ token })
|
||||||
|
if (!exists) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Bridge-Token ist nicht verknüpft.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`\x1b[33m[bridge:ptt]\x1b[0m token=\x1b[96m${token.slice(0, 6)}...\x1b[0m state=\x1b[92m${state}\x1b[0m`,
|
||||||
|
)
|
||||||
|
|
||||||
|
pttBus.publish(token, state)
|
||||||
|
|
||||||
|
logBridgeEvent(token, {
|
||||||
|
endpoint: '/api/bridge/ptt',
|
||||||
|
method: 'POST',
|
||||||
|
statusCode: 200,
|
||||||
|
color: '#eab308',
|
||||||
|
summary: `ptt=${state}`,
|
||||||
|
data: { state },
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ok: true, state }
|
||||||
|
})
|
||||||
77
server/api/bridge/ws.ts
Normal file
77
server/api/bridge/ws.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// server/api/bridge/ws.ts
|
||||||
|
//
|
||||||
|
// Low-latency push channel for push-to-talk. A /pm tab opens this socket and
|
||||||
|
// sends { type: 'subscribe', token } using its `?token=` bridge link. The
|
||||||
|
// Bridge POSTs key edges to /api/bridge/ptt, which drives pttBus; we relay each
|
||||||
|
// edge to every peer subscribed to that token.
|
||||||
|
import { defineWebSocketHandler } from 'h3'
|
||||||
|
import { normalizeBridgeToken } from '../../utils/bridge'
|
||||||
|
import { pttBus } from '../../utils/pttBus'
|
||||||
|
|
||||||
|
// token → set of connected /pm peers
|
||||||
|
const subscribers = new Map<string, Set<any>>()
|
||||||
|
// peerId → token, so close() can clean up without scanning every set
|
||||||
|
const peerTokens = new Map<string, string>()
|
||||||
|
|
||||||
|
function peerId(peer: any): string {
|
||||||
|
return peer?.id ?? String(peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relay every PTT edge to the peers listening on that token.
|
||||||
|
pttBus.subscribe((token, state) => {
|
||||||
|
const peers = subscribers.get(token)
|
||||||
|
if (!peers) return
|
||||||
|
const payload = JSON.stringify({ type: 'ptt', state })
|
||||||
|
for (const peer of peers) {
|
||||||
|
try { peer.send(payload) } catch {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function unsubscribe(peer: any) {
|
||||||
|
const id = peerId(peer)
|
||||||
|
const token = peerTokens.get(id)
|
||||||
|
if (!token) return
|
||||||
|
peerTokens.delete(id)
|
||||||
|
const peers = subscribers.get(token)
|
||||||
|
if (!peers) return
|
||||||
|
peers.delete(peer)
|
||||||
|
if (peers.size === 0) subscribers.delete(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineWebSocketHandler({
|
||||||
|
message(peer, msg) {
|
||||||
|
let data: any
|
||||||
|
try {
|
||||||
|
data = JSON.parse(typeof msg === 'string' ? msg : msg.toString())
|
||||||
|
} catch {
|
||||||
|
peer.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'subscribe') {
|
||||||
|
const token = normalizeBridgeToken(data.token)
|
||||||
|
if (!token) {
|
||||||
|
peer.send(JSON.stringify({ type: 'error', message: 'Invalid token' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A peer only ever listens to one token; drop any previous binding.
|
||||||
|
unsubscribe(peer)
|
||||||
|
let peers = subscribers.get(token)
|
||||||
|
if (!peers) {
|
||||||
|
peers = new Set()
|
||||||
|
subscribers.set(token, peers)
|
||||||
|
}
|
||||||
|
peers.add(peer)
|
||||||
|
peerTokens.set(peerId(peer), token)
|
||||||
|
peer.send(JSON.stringify({ type: 'subscribed' }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
close(peer) {
|
||||||
|
unsubscribe(peer)
|
||||||
|
},
|
||||||
|
|
||||||
|
error(peer) {
|
||||||
|
unsubscribe(peer)
|
||||||
|
},
|
||||||
|
})
|
||||||
44
server/api/bug-reports/index.post.ts
Normal file
44
server/api/bug-reports/index.post.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { defineEventHandler, readBody, createError } from 'h3'
|
||||||
|
import { requireUserSession } from '../../utils/auth'
|
||||||
|
import { BugReport } from '../../models/BugReport'
|
||||||
|
import { sendMail } from '../../utils/notifications'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUserSession(event)
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
const comment = String(body?.comment ?? '').trim()
|
||||||
|
if (!comment) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Kommentar ist erforderlich' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = String(
|
||||||
|
body?.contact || [user.name, user.email].filter(Boolean).join(' — ')
|
||||||
|
).slice(0, 200)
|
||||||
|
|
||||||
|
const report = await BugReport.create({
|
||||||
|
comment: comment.slice(0, 4000),
|
||||||
|
contact,
|
||||||
|
userId: user._id,
|
||||||
|
screenshot: body?.screenshot || undefined,
|
||||||
|
pmState: body?.pmState || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const adminUrl = `${process.env.APP_URL || 'https://app.opensquawk.de'}/admin`
|
||||||
|
const stateInfo = body?.pmState?.currentStateId
|
||||||
|
? `State: ${body.pmState.currentStateId} (Flow: ${body.pmState.flowSlug || '—'})`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
await sendMail({
|
||||||
|
to: 'emanuel@faktorxmensch.com',
|
||||||
|
subject: `[OpenSquawk Bug] ${contact}`,
|
||||||
|
html: `<h2>Neuer Bug Report</h2>
|
||||||
|
<p><strong>Von:</strong> ${contact}</p>
|
||||||
|
<p><strong>Kommentar:</strong><br>${comment.replace(/\n/g, '<br>')}</p>
|
||||||
|
${stateInfo ? `<p><strong>${stateInfo}</strong></p>` : ''}
|
||||||
|
<p><a href="${adminUrl}">Im Admin-Panel ansehen →</a></p>`,
|
||||||
|
text: `Bug Report von ${contact}\n\n${comment}\n${stateInfo}\n\nAdmin: ${adminUrl}`,
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
|
return { success: true, id: String(report._id) }
|
||||||
|
})
|
||||||
45
server/models/BugReport.ts
Normal file
45
server/models/BugReport.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
export type BugReportStatus = 'open' | 'resolved'
|
||||||
|
|
||||||
|
export interface PmStateSnapshot {
|
||||||
|
flowSlug?: string
|
||||||
|
scenarioId?: string
|
||||||
|
currentStateId?: string
|
||||||
|
variables?: Record<string, any>
|
||||||
|
flags?: Record<string, boolean>
|
||||||
|
flightContext?: Record<string, any>
|
||||||
|
communicationLog?: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BugReportDocument extends mongoose.Document {
|
||||||
|
comment: string
|
||||||
|
contact: string
|
||||||
|
userId?: mongoose.Types.ObjectId
|
||||||
|
screenshot?: string
|
||||||
|
pmState?: PmStateSnapshot
|
||||||
|
status: BugReportStatus
|
||||||
|
createdAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const bugReportSchema = new mongoose.Schema<BugReportDocument>({
|
||||||
|
comment: { type: String, required: true, trim: true, maxlength: 4000 },
|
||||||
|
contact: { type: String, required: true, trim: true, maxlength: 200 },
|
||||||
|
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true },
|
||||||
|
screenshot: { type: String },
|
||||||
|
pmState: {
|
||||||
|
flowSlug: { type: String },
|
||||||
|
scenarioId: { type: String },
|
||||||
|
currentStateId: { type: String },
|
||||||
|
variables: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||||
|
flags: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||||
|
flightContext: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||||
|
communicationLog: { type: [mongoose.Schema.Types.Mixed], default: [] },
|
||||||
|
},
|
||||||
|
status: { type: String, enum: ['open', 'resolved'], default: 'open', index: true },
|
||||||
|
createdAt: { type: Date, default: () => new Date(), index: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
export const BugReport =
|
||||||
|
(mongoose.models.BugReport as mongoose.Model<BugReportDocument> | undefined) ||
|
||||||
|
mongoose.model<BugReportDocument>('BugReport', bugReportSchema)
|
||||||
31
server/utils/pttBus.ts
Normal file
31
server/utils/pttBus.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* In-memory push-to-talk event bus.
|
||||||
|
*
|
||||||
|
* Flow: Bridge → POST /api/bridge/ptt → pttBus → WS (/api/bridge/ws) → /pm
|
||||||
|
*
|
||||||
|
* Keyed by bridge token: the Bridge POSTs with its token, and the /pm tab
|
||||||
|
* subscribes over WebSocket with the same token (from its `?token=` link), so
|
||||||
|
* an edge is delivered only to the matching browser.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PttState = 'down' | 'up'
|
||||||
|
|
||||||
|
type PttListener = (token: string, state: PttState) => void
|
||||||
|
|
||||||
|
class PttBus {
|
||||||
|
private listeners = new Set<PttListener>()
|
||||||
|
|
||||||
|
publish(token: string, state: PttState) {
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
try { listener(token, state) } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: PttListener) {
|
||||||
|
this.listeners.add(listener)
|
||||||
|
return () => { this.listeners.delete(listener) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton — shared across all server handlers
|
||||||
|
export const pttBus = new PttBus()
|
||||||
@@ -38,6 +38,11 @@ export interface FlightLabTelemetryState {
|
|||||||
TRANSPONDER_CODE: number // squawk code (0-7777 octal)
|
TRANSPONDER_CODE: number // squawk code (0-7777 octal)
|
||||||
ADF_ACTIVE_FREQUENCY: number // Hz
|
ADF_ACTIVE_FREQUENCY: number // Hz
|
||||||
ADF_STANDBY_FREQUENCY: number // Hz
|
ADF_STANDBY_FREQUENCY: number // Hz
|
||||||
|
COM_ACTIVE_FREQUENCY?: number // MHz (e.g. 121.900), COM1 active radio
|
||||||
|
COM_STANDBY_FREQUENCY?: number // MHz, COM1 standby radio
|
||||||
|
PLANE_LATITUDE?: number // degrees, WGS84 (N+)
|
||||||
|
PLANE_LONGITUDE?: number // degrees, WGS84 (E+)
|
||||||
|
PLANE_HEADING_DEGREES_TRUE?: number // degrees, 0..360
|
||||||
timestamp?: number
|
timestamp?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
114
tests/server/bugReport.test.ts
Normal file
114
tests/server/bugReport.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for BugReport logic: validation, contact-string building,
|
||||||
|
* and model schema integrity. No database connection required.
|
||||||
|
*/
|
||||||
|
import { describe, it } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { BugReport } from '~~/server/models/BugReport'
|
||||||
|
|
||||||
|
// ── Contact-string helpers (mirror of index.post.ts logic) ───────────────────
|
||||||
|
|
||||||
|
function buildContact(user: { name?: string; email: string }, override?: string): string {
|
||||||
|
return String(
|
||||||
|
override || [user.name, user.email].filter(Boolean).join(' — ')
|
||||||
|
).slice(0, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateComment(raw: unknown): string {
|
||||||
|
const comment = String(raw ?? '').trim()
|
||||||
|
if (!comment) throw new Error('comment_required')
|
||||||
|
return comment.slice(0, 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('BugReport — comment validation', () => {
|
||||||
|
it('accepts a non-empty comment', () => {
|
||||||
|
assert.equal(validateComment('App crashed when I pressed PTT'), 'App crashed when I pressed PTT')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims whitespace before validating', () => {
|
||||||
|
assert.equal(validateComment(' hello '), 'hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty string', () => {
|
||||||
|
assert.throws(() => validateComment(''), { message: 'comment_required' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects whitespace-only string', () => {
|
||||||
|
assert.throws(() => validateComment(' '), { message: 'comment_required' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects null/undefined', () => {
|
||||||
|
assert.throws(() => validateComment(null), { message: 'comment_required' })
|
||||||
|
assert.throws(() => validateComment(undefined), { message: 'comment_required' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('truncates comment at 4000 chars', () => {
|
||||||
|
const long = 'x'.repeat(5000)
|
||||||
|
assert.equal(validateComment(long).length, 4000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BugReport — contact string', () => {
|
||||||
|
it('joins name and email with em dash separator', () => {
|
||||||
|
assert.equal(buildContact({ name: 'Max', email: 'max@example.com' }), 'Max — max@example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits name when absent', () => {
|
||||||
|
assert.equal(buildContact({ email: 'max@example.com' }), 'max@example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses override string when provided', () => {
|
||||||
|
assert.equal(buildContact({ email: 'x@y.com' }, 'Custom Name — x@y.com'), 'Custom Name — x@y.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('truncates contact at 200 chars', () => {
|
||||||
|
const long = 'x'.repeat(300)
|
||||||
|
assert.equal(buildContact({ email: 'a@b.com' }, long).length, 200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BugReport — model schema', () => {
|
||||||
|
it('model can be imported without a DB connection', () => {
|
||||||
|
assert.ok(BugReport, 'BugReport model must be importable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schema defines expected fields', () => {
|
||||||
|
const paths = BugReport.schema.paths
|
||||||
|
assert.ok('comment' in paths, 'schema must have comment')
|
||||||
|
assert.ok('contact' in paths, 'schema must have contact')
|
||||||
|
assert.ok('status' in paths, 'schema must have status')
|
||||||
|
assert.ok('screenshot' in paths, 'schema must have screenshot')
|
||||||
|
assert.ok('createdAt' in paths, 'schema must have createdAt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('status field only allows open or resolved', () => {
|
||||||
|
const statusPath = BugReport.schema.path('status') as any
|
||||||
|
const enumValues: string[] = statusPath?.options?.enum ?? []
|
||||||
|
assert.deepEqual(enumValues.sort(), ['open', 'resolved'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('status defaults to open', () => {
|
||||||
|
const statusPath = BugReport.schema.path('status') as any
|
||||||
|
assert.equal(statusPath?.options?.default, 'open')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BugReport — patch status validation', () => {
|
||||||
|
const validStatuses = ['open', 'resolved']
|
||||||
|
const invalidStatuses = ['', 'done', 'closed', 'pending', undefined, null]
|
||||||
|
|
||||||
|
it('accepts valid statuses', () => {
|
||||||
|
for (const s of validStatuses) {
|
||||||
|
assert.ok(s === 'open' || s === 'resolved', `${s} should be valid`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid statuses', () => {
|
||||||
|
for (const s of invalidStatuses) {
|
||||||
|
assert.ok(s !== 'open' && s !== 'resolved', `${s} should be invalid`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
47
tests/smoke/apiHandlers.smoke.test.ts
Normal file
47
tests/smoke/apiHandlers.smoke.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Smoke tests: verify that API handler files can be imported and export a
|
||||||
|
* default function. Catches broken imports, missing exports, and top-level
|
||||||
|
* syntax/runtime errors without needing a running server or database.
|
||||||
|
*/
|
||||||
|
import { describe, it } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
describe('API handler imports — bug reports', async () => {
|
||||||
|
it('POST /api/bug-reports exports a handler', async () => {
|
||||||
|
const mod = await import('~~/server/api/bug-reports/index.post')
|
||||||
|
assert.equal(typeof mod.default, 'function', 'handler must be a function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET /api/admin/bug-reports exports a handler', async () => {
|
||||||
|
const mod = await import('~~/server/api/admin/bug-reports/index.get')
|
||||||
|
assert.equal(typeof mod.default, 'function', 'handler must be a function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET /api/admin/bug-reports/[id] exports a handler', async () => {
|
||||||
|
// dynamic import with bracket filename
|
||||||
|
const mod = await import('~~/server/api/admin/bug-reports/[id].get')
|
||||||
|
assert.equal(typeof mod.default, 'function', 'handler must be a function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PATCH /api/admin/bug-reports/[id] exports a handler', async () => {
|
||||||
|
const mod = await import('~~/server/api/admin/bug-reports/[id].patch')
|
||||||
|
assert.equal(typeof mod.default, 'function', 'handler must be a function')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('API handler imports — admin core', async () => {
|
||||||
|
it('GET /api/admin/overview exports a handler', async () => {
|
||||||
|
const mod = await import('~~/server/api/admin/overview.get')
|
||||||
|
assert.equal(typeof mod.default, 'function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET /api/admin/users exports a handler', async () => {
|
||||||
|
const mod = await import('~~/server/api/admin/users.get')
|
||||||
|
assert.equal(typeof mod.default, 'function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET /api/admin/invitations exports a handler', async () => {
|
||||||
|
const mod = await import('~~/server/api/admin/invitations.get')
|
||||||
|
assert.equal(typeof mod.default, 'function')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7313,6 +7313,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"modern-screenshot@npm:^4.7.0":
|
||||||
|
version: 4.7.0
|
||||||
|
resolution: "modern-screenshot@npm:4.7.0"
|
||||||
|
checksum: 10c0/9c5fbe4f4c73d3dcf0ba7fb76c1671ad148fdedffc84700e0b8ca1c1640a6ab1930718ae1424b0a6517b98d8f18c898ea0e62ee0ca57a25bfce7b914df721a63
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"mongodb-connection-string-url@npm:^3.0.2":
|
"mongodb-connection-string-url@npm:^3.0.2":
|
||||||
version: 3.0.2
|
version: 3.0.2
|
||||||
resolution: "mongodb-connection-string-url@npm:3.0.2"
|
resolution: "mongodb-connection-string-url@npm:3.0.2"
|
||||||
@@ -7976,6 +7983,7 @@ __metadata:
|
|||||||
"@types/three": "npm:^0.183.0"
|
"@types/three": "npm:^0.183.0"
|
||||||
dotenv: "npm:^17.3.1"
|
dotenv: "npm:^17.3.1"
|
||||||
fluent-ffmpeg: "npm:^2.1.3"
|
fluent-ffmpeg: "npm:^2.1.3"
|
||||||
|
modern-screenshot: "npm:^4.7.0"
|
||||||
nodemailer: "npm:^8.0.1"
|
nodemailer: "npm:^8.0.1"
|
||||||
nuxt: "npm:^4.3.1"
|
nuxt: "npm:^4.3.1"
|
||||||
nuxt-aos: "npm:^1.2.6"
|
nuxt-aos: "npm:^1.2.6"
|
||||||
|
|||||||
Reference in New Issue
Block a user