mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-01 06:06:05 +08:00
Align public messaging and add news banners
This commit is contained in:
86
app/components/NuxtImg.vue
Normal file
86
app/components/NuxtImg.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -54,4 +54,4 @@ export default defineNuxtConfig({
|
||||
css: [
|
||||
'~/assets/css/global.css', '~/assets/css/opensquawk-glass.css'
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
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,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}`)
|
||||
}
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
Reference in New Issue
Block a user