Align public messaging and add news banners

This commit is contained in:
Remi
2025-09-17 19:22:56 +02:00
parent d033f03f88
commit a4b8a0ca2f
14 changed files with 242 additions and 68 deletions

View File

@@ -0,0 +1,86 @@
<template>
<img
:src="resolvedSrc"
:alt="alt"
:loading="loadingAttr"
:sizes="sizes || undefined"
:width="widthAttr"
:height="heightAttr"
:decoding="decoding"
:class="combinedClass"
v-bind="restAttrs"
/>
</template>
<script setup lang="ts">
import { computed, useAttrs } from 'vue'
type LoadingValue = 'lazy' | 'eager'
type DecodingValue = 'auto' | 'sync' | 'async'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
src: string
alt?: string
sizes?: string
loading?: LoadingValue
width?: string | number
height?: string | number
decoding?: DecodingValue
}>(), {
alt: '',
sizes: '',
loading: 'lazy',
decoding: 'async',
})
const attrs = useAttrs()
const resolvedSrc = computed(() => {
if (!props.src) return ''
if (/^(https?:)?\/\//i.test(props.src) || props.src.startsWith('data:')) {
return props.src
}
const trimmed = props.src.replace(/^\/+/, '')
return `/${trimmed}`
})
const loadingAttr = computed<LoadingValue>(() => (props.loading === 'eager' ? 'eager' : 'lazy'))
const widthAttr = computed(() => (props.width ? String(props.width) : undefined))
const heightAttr = computed(() => (props.height ? String(props.height) : undefined))
const toClassString = (value: unknown): string => {
if (!value) return ''
if (Array.isArray(value)) {
return value.map((entry) => toClassString(entry)).filter(Boolean).join(' ')
}
if (typeof value === 'object') {
return Object.entries(value as Record<string, unknown>)
.filter(([, active]) => Boolean(active))
.map(([key]) => key)
.join(' ')
}
return String(value)
}
const combinedClass = computed(() => {
const normalized = toClassString(attrs.class)
return normalized ? `nuxt-img ${normalized}` : 'nuxt-img'
})
const restAttrs = computed(() => {
const keysToOmit = new Set(['class', 'src', 'alt', 'sizes', 'loading', 'width', 'height', 'decoding'])
return Object.fromEntries(
Object.entries(attrs as Record<string, unknown>).filter(([key]) => !keysToOmit.has(key))
)
})
</script>
<style scoped>
.nuxt-img {
display: block;
width: 100%;
}
</style>

View File

@@ -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.',
],
},
{

View File

@@ -7,7 +7,7 @@
</NuxtLink>
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300/80">Developer</p>
<h1 class="text-3xl font-semibold">OpenSquawk API Dokumentation</h1>
<p class="text-white/70">Alle Endpunkte sind JSON-basiert und laufen über HTTPS. Standard-Response enthält <code class="bg-white/10 px-1">success</code> oder ein Fehlerobjekt mit <code class="bg-white/10 px-1">statusMessage</code>.</p>
<p class="text-white/70">Alpha Build Endpunkte können sich ändern. Alle Routen sind JSON-basiert und laufen über HTTPS. Standard-Response enthält <code class="bg-white/10 px-1">success</code> oder ein Fehlerobjekt mit <code class="bg-white/10 px-1">statusMessage</code>.</p>
</header>
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">

View File

@@ -48,7 +48,7 @@
<div class="container">
<div class="panel hero-panel" :style="worldTiltStyle" @mousemove="tilt">
<div class="hero-left">
<div class="eyebrow">TRAINING</div>
<div class="eyebrow">ALPHA BUILD · TRAINING</div>
<h1 class="h1">ATC Lernwelt</h1>
<p class="muted">Geführte Pfade · Missionen · XP · Abzeichen. Lokal gespeichert.</p>
<div class="actions">

View File

@@ -7,7 +7,7 @@
<v-icon icon="mdi-arrow-left" size="18" /> Zurück zur Landingpage
</NuxtLink>
<div class="space-y-4">
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300/80">OpenSquawk Access</p>
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300/80">OpenSquawk Alpha Access</p>
<h1 class="text-3xl md:text-4xl font-semibold">Melde dich an oder sichere dir deinen Invite</h1>
<p class="text-white/70">
Registrierungen sind nur mit Einladungscode möglich. Codes werden nach Eintrag auf die Warteliste vergeben oder von bestehenden Mitgliedern nach zwei Wochen Nutzungsdauer ausgestellt.

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,6 +6,14 @@
</NuxtLink>
<article v-if="post" class="card space-y-6">
<NuxtImg
v-if="bannerSrc"
:src="bannerSrc"
:alt="`${post.title} Beitragsbild`"
class="news-banner"
sizes="sm:100vw md:900px lg:1100px"
loading="lazy"
/>
<header class="space-y-2">
<span class="chip text-[10px] uppercase tracking-[0.3em]">{{ formatNewsDate(post.publishedAt) }}</span>
<h1 class="text-3xl md:text-4xl font-semibold">{{ post.title }}</h1>
@@ -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; }

View File

@@ -11,7 +11,7 @@
Content-getriebene Updates zu Alpha-Builds, Simulator-Support und Community-Entscheidungen.
</p>
</div>
<NuxtLink to="#feed" class="btn btn-primary w-fit">Zum aktuellen Feed</NuxtLink>
<NuxtLink to="/#contributing" class="btn btn-primary w-fit">Zum aktuellen Feed</NuxtLink>
</div>
</header>

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

@@ -54,4 +54,4 @@ export default defineNuxtConfig({
css: [
'~/assets/css/global.css', '~/assets/css/opensquawk-glass.css'
],
})
})

View File

@@ -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",

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,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 <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,
},
})
@@ -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}`)
}

View File

@@ -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 = ''