mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-06 17:53:19 +08:00
merge
This commit is contained in:
Binary file not shown.
@@ -21,20 +21,20 @@
|
||||
</section>
|
||||
|
||||
<footer class="rounded-2xl border border-white/10 bg-white/5 p-6 text-sm text-white/70">
|
||||
<p>Fragen zu diesen AGB beantworten wir unter legal@opensquawk.dev. Für Enterprise- oder Trainingsprogramme erstellen wir individuelle Verträge.</p>
|
||||
<p>Fragen zu diesen AGB beantworten wir unter info@opensquawk.dev. Für Enterprise- oder Trainingsprogramme erstellen wir individuelle Verträge.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const lastUpdated = new Date('2025-01-15').toLocaleDateString('de-DE')
|
||||
const lastUpdated = new Date('2025-09-17').toLocaleDateString('de-DE')
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: '1. Geltungsbereich',
|
||||
paragraphs: [
|
||||
'Diese AGB regeln die Nutzung der Plattform OpenSquawk (“Dienst”), betrieben von der OpenSquawk UG (haftungsbeschränkt). Abweichende Bedingungen des Nutzers werden nicht anerkannt, es sei denn, wir stimmen ihrer Geltung ausdrücklich schriftlich zu.',
|
||||
'Diese AGB regeln die Nutzung der Plattform OpenSquawk (“Dienst”), betrieben von der Faktor Mensch MEDIA UG (haftungsbeschränkt). Abweichende Bedingungen des Nutzers werden nicht anerkannt, es sei denn, wir stimmen ihrer Geltung ausdrücklich schriftlich zu.',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -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.',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -13,7 +13,20 @@
|
||||
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
|
||||
<h2 class="text-xl font-semibold">1. Verantwortlicher</h2>
|
||||
<p class="text-white/70">
|
||||
Verantwortlich für die Datenverarbeitung im Sinne der Datenschutz-Grundverordnung (DSGVO) ist die OpenSquawk UG (haftungsbeschränkt), Musterstraße 12, 10115 Berlin, Deutschland. Kontakt: datenschutz@opensquawk.dev.
|
||||
Verantwortlich für die Datenverarbeitung im Sinne der Datenschutz-Grundverordnung (DSGVO) ist die
|
||||
|
||||
Faktor Mensch MEDIA UG (haftungsbeschränkt),
|
||||
|
||||
Wilhelm-Holzamer-Straße 8
|
||||
|
||||
55129 Mainz,
|
||||
Vertreten durch,
|
||||
|
||||
Geschäftsführer: Dominik Ziegenhagel, Emanuel Leube,
|
||||
|
||||
Telefon: +49 251 981 157 912 0
|
||||
|
||||
E-Mail: info@opensquawk.de
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -74,7 +87,7 @@
|
||||
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
|
||||
<h2 class="text-xl font-semibold">7. Kontakt für Datenschutzanfragen</h2>
|
||||
<p class="text-white/70">
|
||||
Bitte richten Sie Auskunfts- oder Löschersuchen an datenschutz@opensquawk.dev. Zur eindeutigen Zuordnung benötigen wir die bei OpenSquawk registrierte E-Mail-Adresse sowie ggf. weitere Identifikationsmerkmale (z. B. VATSIM-ID).
|
||||
Bitte richten Sie Auskunfts- oder Löschersuchen an info@opensquawk.de. Zur eindeutigen Zuordnung benötigen wir die bei OpenSquawk registrierte E-Mail-Adresse sowie ggf. weitere Identifikationsmerkmale (z. B. VATSIM-ID).
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
@@ -82,7 +95,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const lastUpdated = new Date('2025-02-20').toLocaleDateString('de-DE')
|
||||
const lastUpdated = new Date('2025-09-17').toLocaleDateString('de-DE')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
39
app/pages/logout.vue
Normal 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>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
56
public/img/news/alpha-prototype.svg
Normal file
56
public/img/news/alpha-prototype.svg
Normal 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 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
21
yarn.lock
21
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user