This commit is contained in:
itsrubberduck
2025-09-17 19:38:37 +02:00
8 changed files with 141 additions and 200 deletions

Binary file not shown.

39
app/pages/logout.vue Normal file
View File

@@ -0,0 +1,39 @@
<template>
<div class="min-h-screen bg-[#0b1020] text-white">
<div class="mx-auto flex min-h-screen max-w-xl flex-col items-center justify-center gap-6 px-6 text-center">
<div class="space-y-3">
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300/80">OpenSquawk</p>
<h1 class="text-3xl font-semibold">Du wirst abgemeldet</h1>
<p class="text-sm text-white/70">
Wir melden dich vom Alpha-Dienst ab und leiten dich gleich weiter.
</p>
</div>
<v-progress-circular indeterminate size="40" width="3" color="cyan" />
<NuxtLink to="/login" class="text-sm text-cyan-300 underline">Zum Login</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useHead } from '#imports'
import { useAuthStore } from '~/stores/auth'
const router = useRouter()
const auth = useAuthStore()
useHead({
title: 'Logout OpenSquawk',
meta: [
{ name: 'robots', content: 'noindex' },
],
})
onMounted(async () => {
await auth.logout()
router.replace('/login')
})
</script>

View File

@@ -6,7 +6,7 @@
<div>
<p class="text-xs uppercase tracking-[0.35em] text-cyan-400/80">OpenSquawk</p>
<h1 class="text-2xl font-semibold">Pilot Monitoring</h1>
<p class="mt-1 text-sm text-white/70">Decision Tree Enhanced LLM VATSIM</p>
<p class="mt-1 text-sm text-white/70">Alpha Build Decision Tree VATSIM</p>
</div>
<div class="text-right">
<v-chip size="small" :color="currentState?.phase === 'Interrupt' ? 'red' : 'cyan'" variant="flat" class="mb-1">

View File

@@ -10,6 +10,7 @@ export default defineNuxtConfig({
'@pinia/nuxt',
'nuxt-mongoose',
'nuxt-module-hotjar',
'@nuxt/image',
],
aos: {once: true, duration: 600, easing: 'ease-out'},
app: {head: {link: [{rel: 'icon', type: 'image/jpeg', href: '/img/logo.jpeg'}]}},
@@ -55,4 +56,4 @@ export default defineNuxtConfig({
css: [
'~/assets/css/global.css', '~/assets/css/opensquawk-glass.css'
],
})
})

View File

@@ -12,10 +12,12 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/image": "1.11.0",
"@nuxtjs/tailwindcss": "6.14.0",
"@pinia/nuxt": "0.11.2",
"dotenv": "^17.2.2",
"fluent-ffmpeg": "^2.1.3",
"nodemailer": "^6.9.13",
"nuxt": "^4.1.1",
"nuxt-aos": "1.2.5",
"nuxt-module-hotjar": "1.3.4",

View File

@@ -0,0 +1,56 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="720" viewBox="0 0 1600 720" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#041026" />
<stop offset="50%" stop-color="#0a223f" />
<stop offset="100%" stop-color="#134b5f" />
</linearGradient>
<radialGradient id="glow" cx="75%" cy="25%" r="70%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.85" />
<stop offset="55%" stop-color="#22d3ee" stop-opacity="0.25" />
<stop offset="100%" stop-color="#0b1020" stop-opacity="0" />
</radialGradient>
<radialGradient id="glow2" cx="20%" cy="80%" r="65%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.75" />
<stop offset="60%" stop-color="#0ea5e9" stop-opacity="0.18" />
<stop offset="100%" stop-color="#0a0f1c" stop-opacity="0" />
</radialGradient>
<linearGradient id="grid" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#22d3ee" stop-opacity="0.1" />
<stop offset="100%" stop-color="#22d3ee" stop-opacity="0" />
</linearGradient>
<filter id="soft" x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur in="SourceGraphic" stdDeviation="3" />
</filter>
</defs>
<rect width="1600" height="720" fill="url(#bg)" />
<circle cx="1150" cy="180" r="520" fill="url(#glow)" />
<circle cx="360" cy="620" r="460" fill="url(#glow2)" />
<g stroke="url(#grid)" stroke-width="1" fill="none" opacity="0.5">
<path d="M-200 640 L1800 120" />
<path d="M-120 700 L1760 180" />
<path d="M-60 760 L1700 240" />
<path d="M40 760 L1620 320" />
</g>
<g opacity="0.45" filter="url(#soft)">
<circle cx="230" cy="210" r="90" fill="#38bdf8" />
<circle cx="290" cy="210" r="60" fill="#22d3ee" />
<circle cx="350" cy="210" r="30" fill="#0ea5e9" />
</g>
<g transform="translate(180 500) rotate(-6)" opacity="0.3">
<rect x="0" y="0" width="460" height="110" rx="28" ry="28" fill="#0f172a" stroke="#1f2937" stroke-width="2" />
<text x="230" y="68" font-family="'Space Grotesk', 'Segoe UI', sans-serif" font-size="48" fill="#e0f2fe" text-anchor="middle">Alpha Prototype</text>
</g>
<g opacity="0.65">
<text x="150" y="180" font-family="'Space Grotesk', 'Segoe UI', sans-serif" font-size="120" fill="#e0f2fe" letter-spacing="8">OPEN</text>
<text x="150" y="300" font-family="'Space Grotesk', 'Segoe UI', sans-serif" font-size="140" fill="#22d3ee" letter-spacing="10">SQUAWK</text>
</g>
<g transform="translate(980 470)" opacity="0.55">
<circle cx="0" cy="0" r="8" fill="#22d3ee" />
<circle cx="60" cy="-40" r="6" fill="#38bdf8" />
<circle cx="120" cy="-10" r="5" fill="#0ea5e9" />
<circle cx="180" cy="-60" r="4" fill="#38bdf8" />
<circle cx="240" cy="-20" r="6" fill="#22d3ee" />
<polyline points="0,0 60,-40 120,-10 180,-60 240,-20" fill="none" stroke="#bae6fd" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -1,24 +1,5 @@
const ADMIN_EMAIL_FALLBACK = 'info@opensquawk.de'
const RESEND_ENDPOINT = 'https://api.resend.com/emails'
const ADMIN_NOTIFICATION_SUBJECT_PREFIX = process.env.NOTIFY_SUBJECT_PREFIX || '[OpenSquawk Web]'
type NotificationPrimitive = string | number | boolean | Date | null | undefined
type NotificationValue =
| NotificationPrimitive
| NotificationPrimitive[]
| Record<string, NotificationPrimitive>
type NotificationData = Record<string, NotificationValue> | Array<[string, NotificationValue]>
export interface AdminNotificationOptions {
event: string
summary?: string
data?: NotificationData
to?: string
from?: string
}
interface MailOptions {
to: string
subject: string
@@ -30,72 +11,58 @@ interface MailPayload extends MailOptions {
from: string
}
interface SmtpConfig {
host: string
port: number
secure: boolean
user: string
pass: string
}
function resolveFrom(from?: string) {
return from || process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk <no-reply@opensquawk.dev>'
}
async function sendViaResend(payload: MailPayload) {
const apiKey = process.env.NOTIFY_RESEND_API_KEY
if (!apiKey) {
return false
function resolveSmtpConfig(): SmtpConfig | null {
const host = process.env.NOTIFY_SMTP_HOST?.trim()
const user = process.env.NOTIFY_SMTP_USER?.trim()
const pass = process.env.NOTIFY_SMTP_PASS?.trim()
if (!host || !user || !pass) {
console.warn('SMTP notification is not fully configured. Please set NOTIFY_SMTP_HOST, NOTIFY_SMTP_USER and NOTIFY_SMTP_PASS.')
return null
}
try {
const response = await fetch(RESEND_ENDPOINT, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
const secure = (process.env.NOTIFY_SMTP_SECURE || '').toLowerCase() === 'true'
const parsedPort = Number.parseInt(process.env.NOTIFY_SMTP_PORT || '', 10)
const port = Number.isNaN(parsedPort) ? (secure ? 465 : 587) : parsedPort
if (!response.ok) {
const errorText = await response.text().catch(() => '')
console.error('Failed to send email via Resend API', response.status, errorText)
return false
}
return true
} catch (error) {
console.error('Error while sending email via Resend API', error)
return false
}
return { host, user, pass, secure, port }
}
async function sendViaSmtp(payload: MailPayload) {
const host = process.env.NOTIFY_SMTP_HOST
if (!host) {
const config = resolveSmtpConfig()
if (!config) {
return false
}
const user = process.env.NOTIFY_SMTP_USER
const pass = process.env.NOTIFY_SMTP_PASS
if (!user || !pass) {
console.warn('SMTP notification is configured without credentials skipping send.')
return false
}
const port = Number.parseInt(process.env.NOTIFY_SMTP_PORT || '', 10)
const secure = process.env.NOTIFY_SMTP_SECURE === 'true'
let nodemailer: any = null
try {
const module = await import('nodemailer')
nodemailer = module.default ?? module
} catch (error) {
console.warn('nodemailer is not available. Skipping SMTP notification.', error)
console.error('nodemailer is not available. Install the dependency to send SMTP emails.', error)
return false
}
try {
const transporter = nodemailer.createTransport({
host,
port: Number.isNaN(port) ? (secure ? 465 : 587) : port,
secure,
host: config.host,
port: config.port,
secure: config.secure,
auth: {
user,
pass,
user: config.user,
pass: config.pass,
},
})
@@ -112,143 +79,24 @@ async function sendViaSmtp(payload: MailPayload) {
}
}
async function sendMailInternal(options: MailOptions) {
export async function sendMail(options: MailOptions) {
const payload: MailPayload = {
...options,
from: resolveFrom(options.from),
}
const sentViaResend = await sendViaResend(payload)
if (sentViaResend) {
return true
}
const sentViaSmtp = await sendViaSmtp(payload)
if (sentViaSmtp) {
return true
}
return false
}
export async function sendMail(options: MailOptions) {
const success = await sendMailInternal(options)
const success = await sendViaSmtp(payload)
if (!success) {
console.info(`[mail:fallback] ${options.subject}\nEmpfänger: ${options.to}\n${options.text}`)
}
return success
}
function resolveNotificationSubject(event: string) {
const trimmedEvent = event.trim()
if (!trimmedEvent) {
return ADMIN_NOTIFICATION_SUBJECT_PREFIX
}
if (trimmedEvent.toLowerCase().startsWith(ADMIN_NOTIFICATION_SUBJECT_PREFIX.toLowerCase())) {
return trimmedEvent
}
return `${ADMIN_NOTIFICATION_SUBJECT_PREFIX} ${trimmedEvent}`
}
function toNotificationEntries(data?: NotificationData): Array<[string, NotificationValue]> {
if (!data) {
return []
}
if (Array.isArray(data)) {
return data
}
return Object.entries(data)
}
function formatNotificationValue(value: NotificationValue): string {
if (value === null || value === undefined) {
return '—'
}
if (Array.isArray(value)) {
const formatted = value
.map((entry) => formatNotificationValue(entry))
.filter((entry) => entry && entry !== '—')
return formatted.length ? formatted.join(', ') : '—'
}
if (value instanceof Date) {
return value.toISOString()
}
if (typeof value === 'object') {
return JSON.stringify(value, null, 2)
}
if (typeof value === 'boolean') {
return value ? 'Ja' : 'Nein'
}
if (typeof value === 'string') {
const trimmed = value.trim()
return trimmed ? trimmed : '—'
}
return String(value)
}
function formatDataEntries(entries: Array<[string, NotificationValue]>) {
return entries.map(([label, rawValue]) => {
const formattedValue = formatNotificationValue(rawValue)
if (formattedValue.includes('\n')) {
const indented = formattedValue
.split('\n')
.map((line) => ` ${line}`)
.join('\n')
return `${label}:\n${indented}`
}
return `${label}: ${formattedValue}`
})
}
function buildAdminNotificationText(options: AdminNotificationOptions) {
const lines: string[] = []
const summary = options.summary?.trim()
if (summary) {
lines.push(summary)
} else {
lines.push(`Neue Aktivität auf der Website: ${options.event}`)
}
const dataEntries = formatDataEntries([
['Ereignis', options.event],
...toNotificationEntries(options.data),
])
if (dataEntries.length) {
lines.push('')
lines.push('Details:')
lines.push(...dataEntries)
}
return lines.join('\n')
}
export async function sendAdminNotification(options: AdminNotificationOptions) {
const to = options.to || process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
const subject = resolveNotificationSubject(options.event)
const text = buildAdminNotificationText(options)
const success = await sendMailInternal({
to,
subject,
text,
from: options.from,
})
export async function sendAdminNotification(subject: string, text: string) {
const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
const success = await sendMail({ to, subject, text })
if (!success) {
console.info(`[notify:fallback] ${subject}\nEmpfänger: ${to}\n${text}`)
}
return success
}

View File

@@ -772,6 +772,13 @@ __metadata:
languageName: node
linkType: hard
"@fastify/accept-negotiator@npm:^1.1.0":
version: 1.1.0
resolution: "@fastify/accept-negotiator@npm:1.1.0"
checksum: 10c0/1cb9a298c992b812869158ddc6093557a877b30e5f77618a7afea985a0667c50bc7113593bf0f7f9dc9b82b94c16e8ab127a0afc3efde6677fd645539f6d08e5
languageName: node
linkType: hard
"@iconify-json/carbon@npm:^1.1.27":
version: 1.2.13
resolution: "@iconify-json/carbon@npm:1.2.13"
@@ -1208,7 +1215,7 @@ __metadata:
languageName: node
linkType: hard
"@nuxt/kit@npm:4.1.2, @nuxt/kit@npm:^4.1.1":
"@nuxt/kit@npm:4.1.2":
version: 4.1.2
resolution: "@nuxt/kit@npm:4.1.2"
dependencies:
@@ -6793,17 +6800,6 @@ __metadata:
languageName: node
linkType: hard
"nuxt-module-hotjar@npm:1.3.4":
version: 1.3.4
resolution: "nuxt-module-hotjar@npm:1.3.4"
dependencies:
"@hotjar/browser": "npm:^1.0.9"
"@nuxt/kit": "npm:^4.1.1"
defu: "npm:^6.1.4"
checksum: 10c0/cd4bb0a8b321d96611315681a82eb0e9d896b837540b8cc508cbe4cebd4821641cbcb752ad791675cec5346225431907181165a717e5175d6fa55d4772ced128
languageName: node
linkType: hard
"nuxt-mongoose@npm:1.0.6":
version: 1.0.6
resolution: "nuxt-mongoose@npm:1.0.6"
@@ -7062,7 +7058,6 @@ __metadata:
fluent-ffmpeg: "npm:^2.1.3"
nuxt: "npm:^4.1.1"
nuxt-aos: "npm:1.2.5"
nuxt-module-hotjar: "npm:1.3.4"
nuxt-mongoose: "npm:1.0.6"
openai: "npm:^4.66.0"
pinia: "npm:^3.0.3"