diff --git a/app/components/NuxtImg.vue b/app/components/NuxtImg.vue
new file mode 100644
index 0000000..d449bdb
--- /dev/null
+++ b/app/components/NuxtImg.vue
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
diff --git a/app/pages/agb.vue b/app/pages/agb.vue
index 5a2571f..73ba29e 100644
--- a/app/pages/agb.vue
+++ b/app/pages/agb.vue
@@ -87,7 +87,7 @@ const sections = [
{
title: '8. Gewährleistung & Haftung',
paragraphs: [
- 'OpenSquawk stellt einen Beta-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 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.',
],
},
{
diff --git a/app/pages/api-docs.vue b/app/pages/api-docs.vue
index aac5ad5..1a9f22a 100644
--- a/app/pages/api-docs.vue
+++ b/app/pages/api-docs.vue
@@ -7,7 +7,7 @@
Developer
OpenSquawk API – Dokumentation
- Alle Endpunkte sind JSON-basiert und laufen über HTTPS. Standard-Response enthält success oder ein Fehlerobjekt mit statusMessage.
+ Alpha Build – Endpunkte können sich ändern. Alle Routen sind JSON-basiert und laufen über HTTPS. Standard-Response enthält success oder ein Fehlerobjekt mit statusMessage.
diff --git a/app/pages/learn.vue b/app/pages/learn.vue
index 39fad7b..23d5a05 100644
--- a/app/pages/learn.vue
+++ b/app/pages/learn.vue
@@ -48,7 +48,7 @@
-
TRAINING
+
ALPHA BUILD · TRAINING
ATC Lernwelt
Geführte Pfade · Missionen · XP · Abzeichen. Lokal gespeichert.
diff --git a/app/pages/login.vue b/app/pages/login.vue
index 850b65f..4b1b525 100644
--- a/app/pages/login.vue
+++ b/app/pages/login.vue
@@ -7,7 +7,7 @@
Zurück zur Landingpage
-
OpenSquawk Access
+
OpenSquawk Alpha Access
Melde dich an oder sichere dir deinen Invite
Registrierungen sind nur mit Einladungscode möglich. Codes werden nach Eintrag auf die Warteliste vergeben oder von bestehenden Mitgliedern nach zwei Wochen Nutzungsdauer ausgestellt.
diff --git a/app/pages/logout.vue b/app/pages/logout.vue
new file mode 100644
index 0000000..5692322
--- /dev/null
+++ b/app/pages/logout.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
OpenSquawk
+
Du wirst abgemeldet…
+
+ Wir melden dich vom Alpha-Dienst ab und leiten dich gleich weiter.
+
+
+
+
Zum Login
+
+
+
+
+
+
+
diff --git a/app/pages/news/[slug].vue b/app/pages/news/[slug].vue
index 78c548a..edfc268 100644
--- a/app/pages/news/[slug].vue
+++ b/app/pages/news/[slug].vue
@@ -6,6 +6,14 @@
+
{{ formatNewsDate(post.publishedAt) }}
{{ post.title }}
@@ -31,6 +39,7 @@ import { getNewsBySlug } from '~~/shared/utils/news'
const route = useRoute()
const slug = computed(() => String(route.params.slug ?? ''))
const post = computed(() => getNewsBySlug(slug.value))
+const bannerSrc = computed(() => (post.value ? `/img/news/${post.value.bannerFile}` : ''))
useHead(() => {
if (!post.value) {
@@ -63,6 +72,7 @@ const formatNewsDate = (iso: string) => new Date(iso).toLocaleDateString('de-DE'
.btn { @apply inline-flex items-center justify-center gap-2 rounded-xl px-5 py-3 font-medium transition; }
.btn-primary { @apply bg-cyan-500 text-white hover:bg-cyan-400 shadow-[0_0_40px_rgba(34,211,238,.25)]; }
.chip { @apply inline-flex items-center gap-2 rounded-full bg-white/10 border border-white/15 text-white px-3 py-1 text-xs; }
+.news-banner { @apply w-full rounded-xl object-cover; max-height: 420px; }
.news-body :deep(h2) { @apply text-2xl md:text-3xl font-semibold mt-6 mb-3 text-white; }
.news-body :deep(h3) { @apply text-xl font-semibold mt-5 mb-2 text-white; }
.news-body :deep(p) { @apply text-white/80 leading-relaxed mb-4; }
diff --git a/app/pages/news/index.vue b/app/pages/news/index.vue
index 67e34f5..ce3acf9 100644
--- a/app/pages/news/index.vue
+++ b/app/pages/news/index.vue
@@ -11,7 +11,7 @@
Content-getriebene Updates zu Alpha-Builds, Simulator-Support und Community-Entscheidungen.
-
Zum aktuellen Feed
+
Zum aktuellen Feed
diff --git a/app/pages/pm.vue b/app/pages/pm.vue
index 3e38d48..28f29bf 100644
--- a/app/pages/pm.vue
+++ b/app/pages/pm.vue
@@ -6,7 +6,7 @@
OpenSquawk
Pilot Monitoring
-
Decision Tree • Enhanced LLM • VATSIM
+
Alpha Build • Decision Tree • VATSIM
diff --git a/nuxt.config.ts b/nuxt.config.ts
index b60d601..436ce82 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -54,4 +54,4 @@ export default defineNuxtConfig({
css: [
'~/assets/css/global.css', '~/assets/css/opensquawk-glass.css'
],
-})
\ No newline at end of file
+})
diff --git a/package.json b/package.json
index 8f79d34..0d7597b 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"nuxt": "^4.1.1",
"nuxt-aos": "1.2.5",
"nuxt-mongoose": "1.0.6",
+ "nodemailer": "^6.9.13",
"openai": "^4.66.0",
"pinia": "^3.0.3",
"vue": "^3.5.21",
diff --git a/public/img/news/alpha-prototype.svg b/public/img/news/alpha-prototype.svg
new file mode 100644
index 0000000..5fc8cf7
--- /dev/null
+++ b/public/img/news/alpha-prototype.svg
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Alpha Prototype
+
+
+ OPEN
+ SQUAWK
+
+
+
+
+
+
+
+
+
+
diff --git a/server/utils/notifications.ts b/server/utils/notifications.ts
index 6115421..9689e78 100644
--- a/server/utils/notifications.ts
+++ b/server/utils/notifications.ts
@@ -1,7 +1,5 @@
const ADMIN_EMAIL_FALLBACK = 'info@opensquawk.de'
-const RESEND_ENDPOINT = 'https://api.resend.com/emails'
-
interface MailOptions {
to: string
subject: string
@@ -13,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 '
}
-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,
},
})
@@ -95,27 +79,13 @@ 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}`)
}
@@ -124,7 +94,7 @@ export async function sendMail(options: MailOptions) {
export async function sendAdminNotification(subject: string, text: string) {
const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
- const success = await sendMailInternal({ to, subject, text })
+ const success = await sendMail({ to, subject, text })
if (!success) {
console.info(`[notify:fallback] ${subject}\nEmpfänger: ${to}\n${text}`)
}
diff --git a/shared/utils/news.ts b/shared/utils/news.ts
index a4da2bb..554860c 100644
--- a/shared/utils/news.ts
+++ b/shared/utils/news.ts
@@ -6,6 +6,7 @@ export interface NewsPost {
body: string
html: string
readingTime: string
+ bannerFile: string
}
interface FrontMatter {
@@ -15,6 +16,7 @@ interface FrontMatter {
excerpt?: string
description?: string
readingTime?: string
+ banner?: string
}
const rawNewsModules = import.meta.glob('~~/content/news/*.md', {
@@ -51,6 +53,7 @@ function parseNewsFile(path: string, raw: string): NewsPost | null {
const excerpt = truncateText(excerptSource || firstParagraph(cleanBody), 220)
const html = markdownToHtml(cleanBody)
const readingTime = attributes.readingTime || formatReadingTime(cleanBody)
+ const bannerFile = normalizeBannerFile(attributes.banner, slug)
return {
slug,
@@ -60,6 +63,7 @@ function parseNewsFile(path: string, raw: string): NewsPost | null {
body: cleanBody,
html,
readingTime,
+ bannerFile,
}
}
@@ -146,6 +150,14 @@ function formatReadingTime(content: string): string {
return `${minutes} Min Lesezeit`
}
+function normalizeBannerFile(banner: string | undefined, slug: string): string {
+ const value = (banner || '').trim()
+ if (!value) {
+ return `${slug}.svg`
+ }
+ return value.includes('.') ? value : `${value}.svg`
+}
+
function markdownToHtml(markdown: string): string {
const lines = markdown.split(/\r?\n/)
let html = ''