Remove blog components and configurations to streamline the project

- Deleted multiple blog-related components including Grid, GridItem, Headline, List, ListItem, Pagination, RelatedPosts, SinglePost, and Tags to simplify the codebase.
- Removed associated configurations from src/config.yaml, eliminating unused blog settings.
- Cleaned up email templates and layouts related to blog functionality to enhance maintainability.
- Updated styles in tailwind.css for consistency following the removal of blog components.
This commit is contained in:
becarta
2025-06-12 22:39:23 +02:00
parent 7f2a7f859c
commit 1d80c4156c
34 changed files with 260 additions and 1810 deletions

View File

@@ -1,16 +1,16 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
p, p,
li, li,
div { div {
line-height: 1.5; line-height: 1.5;
} }
} }
@layer utilities { @layer utilities {
.bg-page { .bg-page {
background-color: var(--aw-color-bg-page); background-color: var(--aw-color-bg-page);
} }
@@ -26,78 +26,71 @@
.text-muted { .text-muted {
color: var(--aw-color-text-muted); color: var(--aw-color-text-muted);
} }
} }
@layer components { @layer components {
.btn { .btn {
@apply inline-flex items-center justify-center rounded-full border-gray-400 border bg-transparent font-medium text-center text-base text-page leading-snug transition py-3.5 px-6 md:px-8 ease-in duration-200 focus:ring-blue-500 focus:ring-offset-blue-200 focus:ring-2 focus:ring-offset-2 hover:bg-gray-100 hover:border-gray-600 dark:text-slate-300 dark:border-slate-500 dark:hover:bg-slate-800 dark:hover:border-slate-800 cursor-pointer; @apply inline-flex items-center justify-center rounded-full border-gray-400 border bg-transparent font-medium text-center text-base text-page leading-snug transition py-3.5 px-6 md:px-8 ease-in duration-200 focus:ring-blue-500 focus:ring-offset-blue-200 focus:ring-2 focus:ring-offset-2 hover:bg-gray-100 hover:border-gray-600 dark:text-slate-300 dark:border-slate-500 dark:hover:bg-slate-800 dark:hover:border-slate-800 cursor-pointer;
} }
.btn-primary { .btn-primary {
@apply btn font-semibold bg-primary text-white border-primary hover:bg-secondary hover:border-secondary hover:text-white dark:text-white dark:bg-primary dark:border-primary dark:hover:border-secondary dark:hover:bg-secondary; @apply btn font-semibold bg-primary text-white border-primary hover:bg-secondary hover:border-secondary hover:text-white dark:text-white dark:bg-primary dark:border-primary dark:hover:border-secondary dark:hover:bg-secondary;
} }
.btn-secondary { .btn-secondary {
@apply btn; @apply btn;
} }
.btn-tertiary { .btn-tertiary {
@apply btn border-none shadow-none text-muted hover:text-gray-900 dark:text-gray-400 dark:hover:text-white; @apply btn border-none shadow-none text-muted hover:text-gray-900 dark:text-gray-400 dark:hover:text-white;
} }
} }
#header.scroll > div:first-child { #header.scroll > div:first-child {
@apply bg-page md:bg-white/90 md:backdrop-blur-md; @apply bg-page md:bg-white/90 md:backdrop-blur-md;
box-shadow: 0 0.375rem 1.5rem 0 rgb(140 152 164 / 13%); box-shadow: 0 0.375rem 1.5rem 0 rgb(140 152 164 / 13%);
} }
.dark #header.scroll > div:first-child,
#header.scroll.dark > div:first-child { .dark #header.scroll > div:first-child,
#header.scroll.dark > div:first-child {
@apply bg-page md:bg-[#030621e6] border-b border-gray-500/20; @apply bg-page md:bg-[#030621e6] border-b border-gray-500/20;
box-shadow: none; box-shadow: none;
} }
/* #header.scroll > div:last-child {
@apply py-3;
} */
#header.expanded nav { #header.expanded nav {
position: fixed; position: fixed;
top: 70px; top: 70px;
left: 0; left: 0;
right: 0; right: 0;
bottom: 70px !important; bottom: 70px !important;
padding: 0 5px; padding: 0 5px;
} }
.dropdown:focus .dropdown-menu, .dropdown:focus .dropdown-menu,
.dropdown:focus-within .dropdown-menu, .dropdown:focus-within .dropdown-menu,
.dropdown:hover .dropdown-menu { .dropdown:hover .dropdown-menu {
display: block; display: block;
} }
[astro-icon].icon-light > * { [astro-icon].icon-light > * {
stroke-width: 1.2; stroke-width: 1.2;
} }
[astro-icon].icon-bold > * { [astro-icon].icon-bold > * {
stroke-width: 2.4; stroke-width: 2.4;
} }
[data-aw-toggle-menu] path { [data-aw-toggle-menu] path {
@apply transition; @apply transition;
} }
[data-aw-toggle-menu].expanded g > path:first-child {
[data-aw-toggle-menu].expanded g > path:first-child {
@apply -rotate-45 translate-y-[15px] translate-x-[-3px]; @apply -rotate-45 translate-y-[15px] translate-x-[-3px];
} }
[data-aw-toggle-menu].expanded g > path:last-child { [data-aw-toggle-menu].expanded g > path:last-child {
@apply rotate-45 translate-y-[-8px] translate-x-[14px]; @apply rotate-45 translate-y-[-8px] translate-x-[14px];
} }
/* To deprecated */
.dd *:first-child {
margin-top: 0;
}
/* Custom Tippy.js theme for light/dark mode */ /* Custom Tippy.js theme for light/dark mode */
.tippy-box[data-theme~='devnet'] { .tippy-box[data-theme~='devnet'] {
@@ -108,11 +101,13 @@
font-size: 1rem; font-size: 1rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
} }
.dark .tippy-box[data-theme~='devnet'] { .dark .tippy-box[data-theme~='devnet'] {
background-color: #23272f; background-color: #23272f;
color: #f3f4f6; color: #f3f4f6;
box-shadow: 0 4px 24px rgba(0,0,0,0.40); box-shadow: 0 4px 24px rgba(0,0,0,0.40);
} }
.tippy-box[data-theme~='devnet'] .tippy-arrow { .tippy-box[data-theme~='devnet'] .tippy-arrow {
color: inherit; color: inherit;
} }

View File

@@ -1,14 +0,0 @@
---
import Item from '~/components/blog/GridItem.astro';
import type { Post } from '~/types';
export interface Props {
posts: Array<Post>;
}
const { posts } = Astro.props;
---
<div class="grid gap-6 row-gap-5 md:grid-cols-2 lg:grid-cols-4 -mb-6">
{posts.map((post) => <Item post={post} />)}
</div>

View File

@@ -1,71 +0,0 @@
---
import { APP_BLOG } from 'astrowind:config';
import type { Post } from '~/types';
import Image from '~/components/common/Image.astro';
import { findImage } from '~/utils/images';
import { getPermalink } from '~/utils/permalinks';
export interface Props {
post: Post;
}
const { post } = Astro.props;
const image = await findImage(post.image);
const link = APP_BLOG?.post?.isEnabled ? getPermalink(post.permalink, 'post') : '';
---
<article
class="mb-6 transition intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"
>
<div class="relative md:h-64 bg-gray-400 dark:bg-slate-700 rounded shadow-lg mb-6">
{
image &&
(link ? (
<a href={link}>
<Image
src={image}
class="w-full md:h-full rounded shadow-lg bg-gray-400 dark:bg-slate-700"
widths={[400, 900]}
width={400}
sizes="(max-width: 900px) 400px, 900px"
alt={post.title}
aspectRatio="16:9"
layout="cover"
loading="lazy"
decoding="async"
/>
</a>
) : (
<Image
src={image}
class="w-full md:h-full rounded shadow-lg bg-gray-400 dark:bg-slate-700"
widths={[400, 900]}
width={400}
sizes="(max-width: 900px) 400px, 900px"
alt={post.title}
aspectRatio="16:9"
layout="cover"
loading="lazy"
decoding="async"
/>
))
}
</div>
<h3 class="text-xl sm:text-2xl font-bold leading-tight mb-2 font-heading dark:text-slate-300">
{
link ? (
<a class="inline-block hover:text-primary dark:hover:text-blue-700 transition ease-in duration-200" href={link}>
{post.title}
</a>
) : (
post.title
)
}
</h3>
<p class="text-muted dark:text-slate-400 text-lg">{post.excerpt}</p>
</article>

View File

@@ -1,12 +0,0 @@
---
const { title = await Astro.slots.render('default'), subtitle = await Astro.slots.render('subtitle') } = Astro.props;
---
<header class="mb-8 md:mb-16 text-center max-w-3xl mx-auto">
<h1 class="text-4xl md:text-5xl font-bold leading-tighter tracking-tighter font-heading" set:html={title} />
{
subtitle && (
<div class="mt-2 md:mt-3 mx-auto text-xl text-gray-500 dark:text-slate-400 font-medium" set:html={subtitle} />
)
}
</header>

View File

@@ -1,20 +0,0 @@
---
import Item from '~/components/blog/ListItem.astro';
import type { Post } from '~/types';
export interface Props {
posts: Array<Post>;
}
const { posts } = Astro.props;
---
<ul>
{
posts.map((post) => (
<li class="mb-12 md:mb-20">
<Item post={post} />
</li>
))
}
</ul>

View File

@@ -1,120 +0,0 @@
---
import type { ImageMetadata } from 'astro';
import { Icon } from 'astro-icon/components';
import Image from '~/components/common/Image.astro';
import PostTags from '~/components/blog/Tags.astro';
import { APP_BLOG } from 'astrowind:config';
import type { Post } from '~/types';
import { getPermalink } from '~/utils/permalinks';
import { findImage } from '~/utils/images';
import { getFormattedDate } from '~/utils/utils';
export interface Props {
post: Post;
}
const { post } = Astro.props;
const image = (await findImage(post.image)) as ImageMetadata | undefined;
const link = APP_BLOG?.post?.isEnabled ? getPermalink(post.permalink, 'post') : '';
---
<article
class={`max-w-md mx-auto md:max-w-none grid gap-6 md:gap-8 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade ${image ? 'md:grid-cols-2' : ''}`}
>
{
image &&
(link ? (
<a class="relative block group" href={link ?? 'javascript:void(0)'}>
<div class="relative h-0 pb-[56.25%] md:pb-[75%] md:h-72 lg:pb-[56.25%] overflow-hidden bg-gray-400 dark:bg-slate-700 rounded shadow-lg">
{image && (
<Image
src={image}
class="absolute inset-0 object-cover w-full h-full mb-6 rounded shadow-lg bg-gray-400 dark:bg-slate-700"
widths={[400, 900]}
width={900}
sizes="(max-width: 900px) 400px, 900px"
alt={post.title}
aspectRatio="16:9"
loading="lazy"
decoding="async"
/>
)}
</div>
</a>
) : (
<div class="relative h-0 pb-[56.25%] md:pb-[75%] md:h-72 lg:pb-[56.25%] overflow-hidden bg-gray-400 dark:bg-slate-700 rounded shadow-lg">
{image && (
<Image
src={image}
class="absolute inset-0 object-cover w-full h-full mb-6 rounded shadow-lg bg-gray-400 dark:bg-slate-700"
widths={[400, 900]}
width={900}
sizes="(max-width: 900px) 400px, 900px"
alt={post.title}
aspectRatio="16:9"
loading="lazy"
decoding="async"
/>
)}
</div>
))
}
<div class="mt-2">
<header>
<div class="mb-1">
<span class="text-sm">
<Icon name="tabler:clock" class="w-3.5 h-3.5 inline-block -mt-0.5 dark:text-gray-400" />
<time datetime={String(post.publishDate)} class="inline-block">{getFormattedDate(post.publishDate)}</time>
{
post.author && (
<>
{' '}
· <Icon name="tabler:user" class="w-3.5 h-3.5 inline-block -mt-0.5 dark:text-gray-400" />
<span>{post.author.replaceAll('-', ' ')}</span>
</>
)
}
{
post.category && (
<>
{' '}
·{' '}
<a class="hover:underline" href={getPermalink(post.category.slug, 'category')}>
{post.category.title}
</a>
</>
)
}
</span>
</div>
<h2 class="text-xl sm:text-2xl font-bold leading-tight mb-2 font-heading dark:text-slate-300">
{
link ? (
<a
class="inline-block hover:text-primary dark:hover:text-blue-700 transition ease-in duration-200"
href={link}
>
{post.title}
</a>
) : (
post.title
)
}
</h2>
</header>
{post.excerpt && <p class="flex-grow text-muted dark:text-slate-400 text-lg">{post.excerpt}</p>}
{
post.tags && Array.isArray(post.tags) ? (
<footer class="mt-5">
<PostTags tags={post.tags} />
</footer>
) : (
<Fragment />
)
}
</div>
</article>

View File

@@ -1,36 +0,0 @@
---
import { Icon } from 'astro-icon/components';
import { getPermalink } from '~/utils/permalinks';
import Button from '~/components/ui/Button.astro';
export interface Props {
prevUrl?: string;
nextUrl?: string;
prevText?: string;
nextText?: string;
}
const { prevUrl, nextUrl, prevText = 'Newer posts', nextText = 'Older posts' } = Astro.props;
---
{
(prevUrl || nextUrl) && (
<div class="container flex">
<div class="flex flex-row mx-auto container justify-between">
<Button
variant="tertiary"
class={`md:px-3 px-3 mr-2 ${!prevUrl ? 'invisible' : ''}`}
href={getPermalink(prevUrl)}
>
<Icon name="tabler:chevron-left" class="w-6 h-6" />
<p class="ml-2">{prevText}</p>
</Button>
<Button variant="tertiary" class={`md:px-3 px-3 ${!nextUrl ? 'invisible' : ''}`} href={getPermalink(nextUrl)}>
<span class="mr-2">{nextText}</span>
<Icon name="tabler:chevron-right" class="w-6 h-6" />
</Button>
</div>
</div>
)
}

View File

@@ -1,31 +0,0 @@
---
import { APP_BLOG } from 'astrowind:config';
import { getRelatedPosts } from '~/utils/blog';
import BlogHighlightedPosts from '../widgets/BlogHighlightedPosts.astro';
import type { Post } from '~/types';
import { getBlogPermalink } from '~/utils/permalinks';
export interface Props {
post: Post;
}
const { post } = Astro.props;
const relatedPosts = post.tags ? await getRelatedPosts(post, 4) : [];
---
{
APP_BLOG.isRelatedPostsEnabled ? (
<BlogHighlightedPosts
classes={{
container:
'pt-0 lg:pt-0 md:pt-0 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade',
}}
title="Related Posts"
linkText="View All Posts"
linkUrl={getBlogPermalink()}
postIds={relatedPosts.map((post) => post.id)}
/>
) : null
}

View File

@@ -1,103 +0,0 @@
---
import { Icon } from 'astro-icon/components';
import Image from '~/components/common/Image.astro';
import PostTags from '~/components/blog/Tags.astro';
import SocialShare from '~/components/common/SocialShare.astro';
import { getPermalink } from '~/utils/permalinks';
import { getFormattedDate } from '~/utils/utils';
import type { Post } from '~/types';
export interface Props {
post: Post;
url: string | URL;
}
const { post, url } = Astro.props;
---
<section class="py-8 sm:py-16 lg:py-20 mx-auto">
<article>
<header
class={post.image
? 'intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade'
: 'intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade'}
>
<div class="flex justify-between flex-col sm:flex-row max-w-3xl mx-auto mt-0 mb-2 px-4 sm:px-6 sm:items-center">
<p>
<Icon name="tabler:clock" class="w-4 h-4 inline-block -mt-0.5 dark:text-gray-400" />
<time datetime={String(post.publishDate)} class="inline-block">{getFormattedDate(post.publishDate)}</time>
{
post.author && (
<>
{' '}
· <Icon name="tabler:user" class="w-4 h-4 inline-block -mt-0.5 dark:text-gray-400" />
<span class="inline-block">{post.author}</span>
</>
)
}
{
post.category && (
<>
{' '}
·{' '}
<a class="hover:underline inline-block" href={getPermalink(post.category.slug, 'category')}>
{post.category.title}
</a>
</>
)
}
{
post.readingTime && (
<>
&nbsp;· <span>{post.readingTime}</span> min read
</>
)
}
</p>
</div>
<h1
class="px-4 sm:px-6 max-w-3xl mx-auto text-4xl md:text-5xl font-bold leading-tighter tracking-tighter font-heading"
>
{post.title}
</h1>
<p
class="max-w-3xl mx-auto mt-4 mb-8 px-4 sm:px-6 text-xl md:text-2xl text-muted dark:text-slate-400 text-justify"
>
{post.excerpt}
</p>
{
post.image ? (
<Image
src={post.image}
class="max-w-full lg:max-w-[900px] mx-auto mb-6 sm:rounded-md bg-gray-400 dark:bg-slate-700"
widths={[400, 900]}
sizes="(max-width: 900px) 400px, 900px"
alt={post?.excerpt || ''}
width={900}
height={506}
loading="eager"
decoding="async"
/>
) : (
<div class="max-w-3xl mx-auto px-4 sm:px-6">
<div class="border-t dark:border-slate-700" />
</div>
)
}
</header>
<div
class="mx-auto px-6 sm:px-6 max-w-3xl prose prose-md lg:prose-xl dark:prose-invert dark:prose-headings:text-slate-300 prose-headings:font-heading prose-headings:leading-tighter prose-headings:tracking-tighter prose-headings:font-bold prose-a:text-primary dark:prose-a:text-blue-400 prose-img:rounded-md prose-img:shadow-lg mt-8 prose-headings:scroll-mt-[80px] prose-li:my-0"
>
<slot />
</div>
<div class="mx-auto px-6 sm:px-6 max-w-3xl mt-8 flex justify-between flex-col sm:flex-row">
<PostTags tags={post.tags} class="mr-5 rtl:mr-0 rtl:ml-5" />
<SocialShare url={url} text={post.title} class="mt-5 sm:mt-1 align-middle text-gray-500 dark:text-slate-600" />
</div>
</article>
</section>

View File

@@ -1,43 +0,0 @@
---
import { getPermalink } from '~/utils/permalinks';
import { APP_BLOG } from 'astrowind:config';
import type { Post } from '~/types';
export interface Props {
tags: Post['tags'];
class?: string;
title?: string | undefined;
isCategory?: boolean;
}
const { tags, class: className = 'text-sm', title = undefined, isCategory = false } = Astro.props;
---
{
tags && Array.isArray(tags) && (
<>
{title !== undefined && (
<span class="align-super font-normal underline underline-offset-4 decoration-2 dark:text-slate-400">
{title}
</span>
)}
<ul class={className}>
{tags.map((tag) => (
<li class="bg-gray-100 dark:bg-slate-700 inline-block mr-2 rtl:mr-0 rtl:ml-2 mb-2 py-0.5 px-2 lowercase font-medium">
{!APP_BLOG?.tag?.isEnabled ? (
tag.title
) : (
<a
href={getPermalink(tag.slug, isCategory ? 'category' : 'tag')}
class="text-muted dark:text-slate-300 hover:text-primary dark:hover:text-gray-200"
>
{tag.title}
</a>
)}
</li>
))}
</ul>
</>
)
}

View File

@@ -1,20 +0,0 @@
---
import { Icon } from 'astro-icon/components';
import { getBlogPermalink } from '~/utils/permalinks';
import { I18N } from 'astrowind:config';
import Button from '~/components/ui/Button.astro';
const { textDirection } = I18N;
---
<div class="mx-auto px-6 sm:px-6 max-w-3xl pt-8 md:pt-4 pb-12 md:pb-20">
<Button variant="tertiary" class="px-3 md:px-3" href={getBlogPermalink()}>
{
textDirection === 'rtl' ? (
<Icon name="tabler:chevron-right" class="w-5 h-5 mr-1 -ml-1.5 rtl:-mr-1.5 rtl:ml-1" />
) : (
<Icon name="tabler:chevron-left" class="w-5 h-5 mr-1 -ml-1.5 rtl:-mr-1.5 rtl:ml-1" />
)
} Back to Blog
</Button>
</div>

View File

@@ -1,23 +0,0 @@
---
---
<div
class="dark text-muted text-sm bg-black dark:bg-transparent dark:border-b dark:border-slate-800 dark:text-slate-400 hidden md:flex gap-1 overflow-hidden px-3 py-2 relative text-ellipsis whitespace-nowrap"
>
<span
class="dark:bg-slate-700 bg-white/40 dark:text-slate-300 font-semibold px-1 py-0.5 text-xs mr-0.5 rtl:mr-0 rtl:ml-0.5 inline-block"
>NEW</span
>
<a href="https://astro.build/blog/astro-5/" class="text-muted hover:underline dark:text-slate-400 font-medium"
>Astro v5.0.0 is now available! »</a
>
<a
target="_blank"
rel="noopener"
class="ltr:ml-auto rtl:mr-auto w-[5.6rem] h-[1.25rem] ml-auto bg-contain inline-block bg-[url(https://img.shields.io/github/stars/onwidget/astrowind.svg?style=social&label=Stars&maxAge=86400)]"
title="If you like AstroWind, give us a star."
href="https://github.com/onwidget/astrowind"
>
</a>
</div>

View File

@@ -1,64 +0,0 @@
---
import { APP_BLOG } from 'astrowind:config';
import Grid from '~/components/blog/Grid.astro';
import { getBlogPermalink } from '~/utils/permalinks';
import { findPostsByIds } from '~/utils/blog';
import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
import type { Widget } from '~/types';
export interface Props extends Widget {
title?: string;
linkText?: string;
linkUrl?: string | URL;
information?: string;
postIds: string[];
}
const {
title = await Astro.slots.render('title'),
linkText = 'View all posts',
linkUrl = getBlogPermalink(),
information = await Astro.slots.render('information'),
postIds = [],
id,
isDark = false,
classes = {},
bg = await Astro.slots.render('bg'),
} = Astro.props;
const posts = APP_BLOG.isEnabled ? await findPostsByIds(postIds) : [];
---
{
APP_BLOG.isEnabled ? (
<WidgetWrapper id={id} isDark={isDark} containerClass={classes?.container as string} bg={bg}>
<div class="flex flex-col lg:justify-between lg:flex-row mb-8">
{title && (
<div class="md:max-w-sm">
<h2
class="text-3xl font-bold tracking-tight sm:text-4xl sm:leading-none group font-heading mb-2"
set:html={title}
/>
{APP_BLOG.list.isEnabled && linkText && linkUrl && (
<a
class="text-muted dark:text-slate-400 hover:text-primary transition ease-in duration-200 block mb-6 lg:mb-0"
href={linkUrl}
>
{linkText} »
</a>
)}
</div>
)}
{information && <p class="text-muted dark:text-slate-400 lg:text-sm lg:max-w-md" set:html={information} />}
</div>
<Grid posts={posts} />
</WidgetWrapper>
) : (
<Fragment />
)
}

View File

@@ -1,65 +0,0 @@
---
import { APP_BLOG } from 'astrowind:config';
import Grid from '~/components/blog/Grid.astro';
import { getBlogPermalink } from '~/utils/permalinks';
import { findLatestPosts } from '~/utils/blog';
import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
import type { Widget } from '~/types';
import Button from '../ui/Button.astro';
export interface Props extends Widget {
title?: string;
linkText?: string;
linkUrl?: string | URL;
information?: string;
count?: number;
}
const {
title = await Astro.slots.render('title'),
linkText = 'View all posts',
linkUrl = getBlogPermalink(),
information = await Astro.slots.render('information'),
count = 4,
id,
isDark = false,
classes = {},
bg = await Astro.slots.render('bg'),
} = Astro.props;
const posts = APP_BLOG.isEnabled ? await findLatestPosts({ count }) : [];
---
{
APP_BLOG.isEnabled ? (
<WidgetWrapper id={id} isDark={isDark} containerClass={classes?.container as string} bg={bg}>
<div class="flex flex-col lg:justify-between lg:flex-row mb-8">
{title && (
<div class="md:max-w-sm">
<h2
class="text-3xl font-bold tracking-tight sm:text-4xl sm:leading-none group font-heading mb-2"
set:html={title}
/>
{APP_BLOG.list.isEnabled && linkText && linkUrl && (
<Button variant="link" href={linkUrl}>
{' '}
{linkText} »
</Button>
)}
</div>
)}
{information && (
<p class="text-muted dark:text-slate-400 lg:text-sm lg:max-w-md text-sm" set:html={information} />
)}
</div>
<Grid posts={posts} />
</WidgetWrapper>
) : (
<Fragment />
)
}

View File

@@ -1,38 +0,0 @@
---
import { Icon } from 'astro-icon/components';
import type { Brands as Props } from '~/types';
import Image from '~/components/common/Image.astro';
import Headline from '~/components/ui/Headline.astro';
import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
const {
title = '',
subtitle = '',
tagline = '',
icons = [],
images = [],
id,
isDark = false,
classes = {},
bg = await Astro.slots.render('bg'),
} = Astro.props;
---
<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-6xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
<Headline title={title} subtitle={subtitle} tagline={tagline} />
<div class="flex flex-wrap justify-center gap-x-6 sm:gap-x-12 lg:gap-x-24">
{icons && icons.map((icon) => <Icon name={icon} class="py-3 lg:py-5 w-12 h-auto mx-auto sm:mx-0 text-gray-500" />)}
{
images &&
images.map(
(image) =>
image.src && (
<div class="flex justify-center col-span-1 my-2 lg:my-4 py-1 px-3 rounded-md dark:bg-gray-200">
<Image src={image.src} alt={image.alt || ''} class="max-h-12" width={120} height={48} layout="fixed" />
</div>
)
)
}
</div>
</WidgetWrapper>

View File

@@ -1,24 +0,0 @@
---
import Headline from '~/components/ui/Headline.astro';
import Accordion from '~/components/ui/Accordion.astro';
import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
import type { Faqs as Props } from '~/types';
const {
title = '',
subtitle = '',
tagline = '',
items = [],
columns = 2,
id,
isDark = false,
classes = {},
bg = await Astro.slots.render('bg'),
} = Astro.props;
---
<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
<Headline title={title} subtitle={subtitle} tagline={tagline} />
<Accordion items={items} />
</WidgetWrapper>

View File

@@ -1,70 +0,0 @@
---
import Headline from '~/components/ui/Headline.astro';
import ItemGrid from '~/components/ui/ItemGrid.astro';
import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
import Image from '~/components/common/Image.astro';
import type { Features as Props } from '~/types';
const {
title = await Astro.slots.render('title'),
subtitle = await Astro.slots.render('subtitle'),
tagline = await Astro.slots.render('tagline'),
image,
items = [],
columns,
defaultIcon,
isBeforeContent,
isAfterContent,
id,
isDark = false,
classes = {},
bg = await Astro.slots.render('bg'),
} = Astro.props;
---
<WidgetWrapper
id={id}
isDark={isDark}
containerClass={`${isBeforeContent ? 'md:pb-8 lg:pb-12' : ''} ${isAfterContent ? 'pt-0 md:pt-0 lg:pt-0' : ''} ${
classes?.container ?? ''
}`}
bg={bg}
>
<Headline title={title} subtitle={subtitle} tagline={tagline} classes={classes?.headline as Record<string, string>} />
<div aria-hidden="true" class="aspect-w-16 aspect-h-7">
{
image && (
<div class="w-full h-80 object-cover rounded-xl mx-auto bg-gray-500 shadow-lg">
{typeof image === 'string' ? (
<Fragment set:html={image} />
) : (
<Image
class="w-full h-80 object-cover rounded-xl mx-auto bg-gray-500 shadow-lg"
width="auto"
height={320}
widths={[400, 768]}
layout="fullWidth"
{...image}
/>
)}
</div>
)
}
</div>
<ItemGrid
items={items}
columns={columns}
defaultIcon={defaultIcon}
classes={{
container: 'mt-12',
panel: 'max-w-full sm:max-w-md',
title: 'text-lg font-semibold',
description: 'mt-0.5',
icon: 'flex-shrink-0 mt-1 text-primary w-6 h-6',
...((classes?.items as object) ?? {}),
}}
/>
</WidgetWrapper>

View File

@@ -1,11 +0,0 @@
---
import { Icon } from 'astro-icon/components';
---
<section class="bg-blue-50 dark:bg-slate-800 not-prose">
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-4 text-md text-center font-medium">
<span class="font-bold">
<Icon name="tabler:info-square" class="w-5 h-5 inline-block align-text-bottom" /> Philosophy:</span
> Simplicity, Best Practices and High Performance
</div>
</section>

View File

@@ -1,83 +0,0 @@
---
import { Icon } from 'astro-icon/components';
import Button from '~/components/ui/Button.astro';
import Headline from '~/components/ui/Headline.astro';
import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
import type { Pricing as Props } from '~/types';
const {
title = '',
subtitle = '',
tagline = '',
prices = [],
id,
isDark = false,
classes = {},
bg = await Astro.slots.render('bg'),
} = Astro.props;
---
<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
<Headline title={title} subtitle={subtitle} tagline={tagline} />
<div class="flex items-stretch justify-center">
<div class="grid grid-cols-3 gap-4 dark:text-white sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3">
{
prices &&
prices.map(({ title, subtitle, price, period, items, callToAction, hasRibbon = false, ribbonTitle }) => (
<div class="col-span-3 mx-auto flex w-full sm:col-span-1 md:col-span-1 lg:col-span-1 xl:col-span-1 intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter">
{price && period && (
<div class="rounded-lg backdrop-blur border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900 shadow px-6 py-8 flex w-full max-w-sm flex-col justify-between text-center">
{hasRibbon && ribbonTitle && (
<div class="absolute right-[-5px] 2xl:right-[-8px] rtl:right-auto rtl:left-[-8px] rtl:2xl:left-[-10px] top-[-5px] 2xl:top-[-10px] z-[1] h-[100px] w-[100px] overflow-hidden text-right">
<span class="absolute top-[19px] right-[-21px] rtl:right-auto rtl:left-[-21px] block w-full rotate-45 rtl:-rotate-45 bg-green-700 text-center text-[10px] font-bold uppercase leading-5 text-white shadow-[0_3px_10px_-5px_rgba(0,0,0,0.3)] before:absolute before:left-0 before:top-full before:z-[-1] before:border-[3px] before:border-r-transparent before:border-b-transparent before:border-l-green-800 before:border-t-green-800 before:content-[''] after:absolute after:right-0 after:top-full after:z-[-1] after:border-[3px] after:border-l-transparent after:border-b-transparent after:border-r-green-800 after:border-t-green-800 after:content-['']">
{ribbonTitle}
</span>
</div>
)}
<div class="px-2 py-0">
{title && (
<h3 class="text-center text-xl font-semibold uppercase leading-6 tracking-wider mb-2">{title}</h3>
)}
{subtitle && <p class="font-light sm:text-lg text-gray-600 dark:text-slate-400">{subtitle}</p>}
<div class="my-8">
<div class="flex items-center justify-center text-center mb-1">
<span class="text-5xl">$</span>
<span class="text-6xl font-extrabold">{price}</span>
</div>
<span class="text-base leading-6 lowercase text-gray-600 dark:text-slate-400">{period}</span>
</div>
{items && (
<ul class="my-8 md:my-10 space-y-2 text-left">
{items.map(
({ description, icon }) =>
description && (
<li class="mb-1.5 flex items-start space-x-3 leading-7">
<div class="rounded-full bg-primary mt-1">
<Icon name={icon ? icon : 'tabler:check'} class="w-5 h-5 font-bold p-1 text-white" />
</div>
<span>{description}</span>
</li>
)
)}
</ul>
)}
</div>
{callToAction && (
<div class={`flex justify-center`}>
{typeof callToAction === 'string' ? (
<Fragment set:html={callToAction} />
) : (
callToAction &&
callToAction.href && <Button {...(hasRibbon ? { variant: 'primary' } : {})} {...callToAction} />
)}
</div>
)}
</div>
)}
</div>
))
}
</div>
</div>
</WidgetWrapper>

View File

@@ -1,46 +0,0 @@
---
import type { Stats as Props } from '~/types';
import WidgetWrapper from '../ui/WidgetWrapper.astro';
import Headline from '../ui/Headline.astro';
import { Icon } from 'astro-icon/components';
const {
title = await Astro.slots.render('title'),
subtitle = await Astro.slots.render('subtitle'),
tagline,
stats = [],
id,
isDark = false,
classes = {},
bg = await Astro.slots.render('bg'),
} = Astro.props;
---
<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-6xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
<Headline title={title} subtitle={subtitle} tagline={tagline} />
<div class="flex flex-wrap justify-center -m-4 text-center">
{
stats &&
stats.map(({ amount, title, icon }) => (
<div class="p-4 md:w-1/4 sm:w-1/2 w-full min-w-[220px] text-center md:border-r md:last:border-none dark:md:border-slate-500 intersect-once motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade intersect-quarter">
{icon && (
<div class="flex items-center justify-center mx-auto mb-4 text-primary">
<Icon name={icon} class="w-10 h-10" />
</div>
)}
{amount && (
<div class="font-heading text-primary text-[2.6rem] font-bold dark:text-white lg:text-5xl xl:text-6xl">
{amount}
</div>
)}
{title && (
<div class="text-sm font-medium uppercase tracking-widest text-gray-800 dark:text-slate-400 lg:text-base">
{title}
</div>
)}
</div>
))
}
</div>
</WidgetWrapper>

View File

@@ -1,59 +0,0 @@
---
import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
import Timeline from '~/components/ui/Timeline.astro';
import Headline from '~/components/ui/Headline.astro';
import Image from '~/components/common/Image.astro';
import type { Steps as Props } from '~/types';
const {
title = await Astro.slots.render('title'),
subtitle = await Astro.slots.render('subtitle'),
tagline = await Astro.slots.render('tagline'),
items = [],
image = await Astro.slots.render('image'),
isReversed = false,
id,
isDark = false,
classes = {},
bg = await Astro.slots.render('bg'),
} = Astro.props;
---
<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-5xl ${classes?.container ?? ''}`} bg={bg}>
<div class:list={['flex flex-col gap-8 md:gap-12', { 'md:flex-row-reverse': isReversed }, { 'md:flex-row': image }]}>
<div class:list={['md:py-4 md:self-center', { 'md:basis-1/2': image }, { 'w-full': !image }]}>
<Headline
title={title}
subtitle={subtitle}
tagline={tagline}
classes={{
container: 'text-left rtl:text-right',
title: 'text-3xl lg:text-4xl',
...((classes?.headline as object) ?? {}),
}}
/>
<Timeline items={items} classes={classes?.items as Record<string, never>} />
</div>
{
image && (
<div class="relative md:basis-1/2">
{typeof image === 'string' ? (
<Fragment set:html={image} />
) : (
<Image
class="inset-0 object-cover object-top w-full rounded-md shadow-lg md:absolute md:h-full bg-gray-400 dark:bg-slate-700"
widths={[400, 768]}
sizes="(max-width: 768px) 100vw, 432px"
width={432}
height={768}
layout="cover"
src={image?.src}
alt={image?.alt || ''}
/>
)}
</div>
)
}
</div>
</WidgetWrapper>

View File

@@ -1,72 +0,0 @@
---
import { Icon } from 'astro-icon/components';
import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
import Headline from '~/components/ui/Headline.astro';
import Button from '~/components/ui/Button.astro';
import type { Steps as Props } from '~/types';
const {
title = await Astro.slots.render('title'),
subtitle = await Astro.slots.render('subtitle'),
tagline,
callToAction = await Astro.slots.render('callToAction'),
items = [],
isReversed = false,
id,
isDark = false,
classes = {},
bg = await Astro.slots.render('bg'),
} = Astro.props;
---
<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-6xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
<div class={`flex flex-col gap-8 md:gap-12 md:flex-row ${isReversed ? 'md:flex-row-reverse' : ''}`}>
<div class={`w-full lg:w-1/2 gap-8 md:gap-12 ${isReversed ? 'lg:ml-16 md:ml-8 ml-0' : 'lg:mr-16 md:mr-8 mr-0'}`}>
<Headline
title={title}
subtitle={subtitle}
tagline={tagline}
classes={{
container: 'text-center md:text-left rtl:md:text-right mb-4 md:mb-8',
title: 'mb-4 text-3xl lg:text-4xl font-bold font-heading',
subtitle: 'mb-8 text-xl text-muted dark:text-slate-400',
// ...((classes?.headline as {}) ?? {}),
}}
/>
<div class="w-full text-center md:text-left rtl:md:text-right">
{
typeof callToAction === 'string' ? (
<Fragment set:html={callToAction} />
) : (
callToAction &&
callToAction.text &&
callToAction.href && <Button variant="primary" {...callToAction} class="mb-12 w-auto" />
)
}
</div>
</div>
<div class="w-full lg:w-1/2 px-0">
<ul class="space-y-10">
{
items && items.length
? items.map(({ title: title2, description, icon }, index) => (
<li class="flex md:-mx-4">
<div class="pr-4 sm:pl-4 rtl:pr-0 rtl:pl-4 rtl:sm:pl-0 rtl:sm:pr-4">
<span class="flex w-16 h-16 mx-auto items-center justify-center text-2xl font-bold rounded-full bg-blue-100 text-primary">
{icon ? <Icon name={icon} class="w-6 h-6 icon-bold" /> : index + 1}
</span>
</div>
<div class="pl-4 rtl:pl-0 rtl:pr-4">
<h3 class="mb-4 text-xl font-semibold font-heading" set:html={title2} />
<p class="text-muted dark:text-gray-400" set:html={description} />
</div>
</li>
))
: ''
}
</ul>
</div>
</div>
</WidgetWrapper>

View File

@@ -29,38 +29,6 @@ i18n:
language: en language: en
textDirection: ltr textDirection: ltr
apps:
blog:
isEnabled: true
postsPerPage: 6
post:
isEnabled: true
permalink: 'blog/%slug%' # Variables: %slug%, %year%, %month%, %day%, %hour%, %minute%, %second%, %category%
robots:
index: true
list:
isEnabled: true
pathname: 'blog' # Blog main path, you can change this to "articles" (/articles)
robots:
index: true
category:
isEnabled: true
pathname: 'category' # Category main path /category/some-category, you can change this to "group" (/group/some-category)
robots:
index: true
tag:
isEnabled: true
pathname: 'tag' # Tag main path /tag/some-tag, you can change this to "topics" (/topics/some-category)
robots:
index: false
isRelatedPostsEnabled: true
relatedPostsCount: 4
analytics: analytics:
vendors: vendors:
googleAnalytics: googleAnalytics:

View File

@@ -1,70 +0,0 @@
import { z, defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
const metadataDefinition = () =>
z
.object({
title: z.string().optional(),
ignoreTitleTemplate: z.boolean().optional(),
canonical: z.string().url().optional(),
robots: z
.object({
index: z.boolean().optional(),
follow: z.boolean().optional(),
})
.optional(),
description: z.string().optional(),
openGraph: z
.object({
url: z.string().optional(),
siteName: z.string().optional(),
images: z
.array(
z.object({
url: z.string(),
width: z.number().optional(),
height: z.number().optional(),
})
)
.optional(),
locale: z.string().optional(),
type: z.string().optional(),
})
.optional(),
twitter: z
.object({
handle: z.string().optional(),
site: z.string().optional(),
cardType: z.string().optional(),
})
.optional(),
})
.optional();
const postCollection = defineCollection({
loader: glob({ pattern: ['*.md', '*.mdx'], base: 'src/data/post' }),
schema: z.object({
publishDate: z.date().optional(),
updateDate: z.date().optional(),
draft: z.boolean().optional(),
title: z.string(),
excerpt: z.string().optional(),
image: z.string().optional(),
category: z.string().optional(),
tags: z.array(z.string()).optional(),
author: z.string().optional(),
metadata: metadataDefinition(),
}),
});
export const collections = {
post: postCollection,
};

View File

@@ -1,72 +0,0 @@
# Email Handling System
This directory contains the email templates and utilities for the contact form email handling system.
## Features
- **Secure SMTP Authentication**: Uses environment variables for credentials
- **Email Templates**: Customizable templates for both user confirmation and admin notification emails
- **Rate Limiting**: Prevents abuse by limiting the number of submissions per IP address
- **CSRF Protection**: Prevents cross-site request forgery attacks
- **Email Validation**: Ensures valid email addresses are provided
- **Spam Prevention**: Multiple checks to detect and block spam submissions
- **Error Handling**: Proper error handling with client feedback
- **Logging**: Comprehensive logging of email sending attempts
## Configuration
The email system is configured using environment variables in the `.env` file:
```
# SMTP Configuration
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your-email@example.com
SMTP_PASS=your-password
# Email Settings
ADMIN_EMAIL=admin@example.com
WEBSITE_NAME=Your Website Name
# Environment
NODE_ENV=development
```
In development mode, emails are logged to the console instead of being sent. Set `NODE_ENV=production` to send actual emails.
## Files
- `admin-notification.ts`: Template for emails sent to the admin
- `user-confirmation.ts`: Template for confirmation emails sent to users
- `../utils/email-handler.ts`: Core email handling functionality
## How It Works
1. When a user submits the contact form, the client-side JavaScript validates the form and sends it to the `/api/contact` endpoint.
2. The endpoint validates the form data, checks for CSRF token validity, and performs rate limiting and spam detection.
3. If all checks pass, two emails are sent:
- A notification email to the admin with the form data
- A confirmation email to the user acknowledging receipt of their message
4. The system logs all email sending attempts for monitoring and debugging.
## Development vs. Production
- In development mode (`NODE_ENV=development`), emails are logged to the console instead of being sent.
- In production mode (`NODE_ENV=production`), emails are sent using the configured SMTP server.
## Security Considerations
- SMTP credentials are stored in environment variables, not in the code
- CSRF tokens are used to prevent cross-site request forgery
- Rate limiting prevents abuse of the contact form
- Form data is validated both on the client and server side
- Spam detection helps prevent unwanted messages
## Testing
To test the email system:
1. Configure the `.env` file with your SMTP settings
2. Submit the contact form on the website
3. Check the logs for email sending attempts
4. In production mode, check your inbox for the actual emails

View File

@@ -1,111 +0,0 @@
interface AdminNotificationProps {
name: string;
email: string;
message: string;
submittedAt: string;
ipAddress?: string;
userAgent?: string;
}
export function getAdminNotificationSubject(): string {
return `New Contact Form Submission from ${process.env.WEBSITE_NAME || '365devnet.eu'}`;
}
export function getAdminNotificationHtml(props: AdminNotificationProps): string {
const { name, email, message, submittedAt, ipAddress, userAgent } = props;
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Contact Form Submission</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f5f5f5;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.content {
background-color: #ffffff;
padding: 15px;
border-radius: 5px;
border: 1px solid #e0e0e0;
}
.message-box {
background-color: #f9f9f9;
padding: 15px;
border-radius: 5px;
border-left: 3px solid #007bff;
margin: 15px 0;
}
.footer {
font-size: 12px;
color: #777;
margin-top: 20px;
padding-top: 10px;
border-top: 1px solid #e0e0e0;
}
.meta {
font-size: 12px;
color: #777;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="header">
<h2>New Contact Form Submission</h2>
</div>
<div class="content">
<p><strong>From:</strong> ${name} (${email})</p>
<p><strong>Submitted on:</strong> ${submittedAt}</p>
<div class="message-box">
<p><strong>Message:</strong></p>
<p>${message.replace(/\n/g, '<br>')}</p>
</div>
<div class="meta">
<p><strong>Additional Information:</strong></p>
<p>IP Address: ${ipAddress || 'Not available'}</p>
<p>User Agent: ${userAgent || 'Not available'}</p>
</div>
</div>
<div class="footer">
<p>This is an automated email from your website contact form.</p>
</div>
</body>
</html>
`;
}
export function getAdminNotificationText(props: AdminNotificationProps): string {
const { name, email, message, submittedAt, ipAddress, userAgent } = props;
return `
New Contact Form Submission
From: ${name} (${email})
Submitted on: ${submittedAt}
Message:
${message}
Additional Information:
IP Address: ${ipAddress || 'Not available'}
User Agent: ${userAgent || 'Not available'}
This is an automated email from your website contact form.
`;
}

View File

@@ -1,120 +0,0 @@
interface UserConfirmationProps {
name: string;
email: string;
message: string;
submittedAt: string;
websiteName?: string;
contactEmail?: string;
}
export function getUserConfirmationSubject(websiteName: string = process.env.WEBSITE_NAME || '365devnet.eu'): string {
return `Thank you for contacting ${websiteName}`;
}
export function getUserConfirmationHtml(props: UserConfirmationProps): string {
const {
name,
message,
submittedAt,
websiteName = process.env.WEBSITE_NAME || '365devnet.eu',
contactEmail = process.env.ADMIN_EMAIL || 'richard@bergsma.it',
} = props;
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thank you for contacting us</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f5f5f5;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.content {
background-color: #ffffff;
padding: 15px;
border-radius: 5px;
border: 1px solid #e0e0e0;
}
.message-box {
background-color: #f9f9f9;
padding: 15px;
border-radius: 5px;
border-left: 3px solid #28a745;
margin: 15px 0;
}
.footer {
font-size: 12px;
color: #777;
margin-top: 20px;
padding-top: 10px;
border-top: 1px solid #e0e0e0;
}
</style>
</head>
<body>
<div class="header">
<h2>Thank you for contacting ${websiteName}</h2>
</div>
<div class="content">
<p>Dear ${name},</p>
<p>Thank you for reaching out to us. We have received your message and will get back to you as soon as possible.</p>
<div class="message-box">
<p><strong>Your message (submitted on ${submittedAt}):</strong></p>
<p>${message.replace(/\n/g, '<br>')}</p>
</div>
<p>If you have any additional questions or information to provide, please feel free to reply to this email.</p>
<p>Best regards,<br>
The ${websiteName} Team</p>
</div>
<div class="footer">
<p>If you did not submit this contact form, please disregard this email or contact us at ${contactEmail}.</p>
</div>
</body>
</html>
`;
}
export function getUserConfirmationText(props: UserConfirmationProps): string {
const {
name,
message,
submittedAt,
websiteName = process.env.WEBSITE_NAME || '365devnet.eu',
contactEmail = process.env.ADMIN_EMAIL || 'richard@bergsma.it',
} = props;
return `
Thank you for contacting ${websiteName}
Dear ${name},
Thank you for reaching out to us. We have received your message and will get back to you as soon as possible.
Your message (submitted on ${submittedAt}):
${message}
If you have any additional questions or information to provide, please feel free to reply to this email.
Best regards,
The ${websiteName} Team
If you did not submit this contact form, please disregard this email or contact us at ${contactEmail}.
`;
}

View File

@@ -1,35 +0,0 @@
---
import PageLayout from '~/layouts/PageLayout.astro';
import Header from '~/components/widgets/Header.astro';
import { headerData } from '~/navigation';
import type { MetaData } from '~/types';
export interface Props {
metadata?: MetaData;
}
const { metadata } = Astro.props;
---
<PageLayout metadata={metadata}>
<Fragment slot="announcement">
<slot name="announcement" />
</Fragment>
<Fragment slot="header">
<slot name="header">
<Header
links={headerData?.links[2] ? [headerData.links[2]] : undefined}
actions={[
{
text: 'Download',
href: 'https://github.com/onwidget/astrowind',
},
]}
showToggleTheme
position="right"
/>
</slot>
</Fragment>
<slot />
</PageLayout>

View File

@@ -1,28 +0,0 @@
---
import Layout from '~/layouts/PageLayout.astro';
import type { MetaData } from '~/types';
export interface Props {
frontmatter: {
title?: string;
};
}
const { frontmatter } = Astro.props;
const metadata: MetaData = {
title: frontmatter?.title,
};
---
<Layout metadata={metadata}>
<section class="px-4 py-16 sm:px-6 mx-auto lg:px-8 lg:py-20 max-w-4xl">
<h1 class="font-bold font-heading text-4xl md:text-5xl leading-tighter tracking-tighter">{frontmatter.title}</h1>
<div
class="mx-auto prose prose-lg max-w-4xl dark:prose-invert dark:prose-headings:text-slate-300 prose-md prose-headings:font-heading prose-headings:leading-tighter prose-headings:tracking-tighter prose-headings:font-bold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-img:rounded-md prose-img:shadow-lg mt-8"
>
<slot />
</div>
</section>
</Layout>

View File

@@ -1,52 +0,0 @@
---
import type { InferGetStaticPropsType, GetStaticPaths } from 'astro';
import Layout from '~/layouts/PageLayout.astro';
import BlogList from '~/components/blog/List.astro';
import Headline from '~/components/blog/Headline.astro';
import Pagination from '~/components/blog/Pagination.astro';
// import PostTags from "~/components/blog/Tags.astro";
import { blogListRobots, getStaticPathsBlogList } from '~/utils/blog';
export const prerender = true;
export const getStaticPaths = (async ({ paginate }) => {
return await getStaticPathsBlogList({ paginate });
}) satisfies GetStaticPaths;
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { page } = Astro.props as Props;
const currentPage = page.currentPage ?? 1;
// const allCategories = await findCategories();
// const allTags = await findTags();
const metadata = {
title: `Blog${currentPage > 1 ? ` — Page ${currentPage}` : ''}`,
robots: {
index: blogListRobots?.index && currentPage === 1,
follow: blogListRobots?.follow,
},
openGraph: {
type: 'blog',
},
};
---
<Layout metadata={metadata}>
<section class="px-6 sm:px-6 py-12 sm:py-16 lg:py-20 mx-auto max-w-4xl">
<Headline
subtitle="A statically generated blog example with news, tutorials, resources and other interesting content related to AstroWind"
>
The Blog
</Headline>
<BlogList posts={page.data} />
<Pagination prevUrl={page.url.prev} nextUrl={page.url.next} />
<!--
<PostTags tags={allCategories} class="mb-2" title="Search by Categories:" isCategory />
<PostTags tags={allTags} title="Search by Tags:" />
-->
</section>
</Layout>

View File

@@ -1,37 +0,0 @@
---
import type { InferGetStaticPropsType, GetStaticPaths } from 'astro';
import { blogCategoryRobots, getStaticPathsBlogCategory } from '~/utils/blog';
import Layout from '~/layouts/PageLayout.astro';
import BlogList from '~/components/blog/List.astro';
import Headline from '~/components/blog/Headline.astro';
import Pagination from '~/components/blog/Pagination.astro';
export const prerender = true;
export const getStaticPaths = (async ({ paginate }) => {
return await getStaticPathsBlogCategory({ paginate });
}) satisfies GetStaticPaths;
type Props = InferGetStaticPropsType<typeof getStaticPaths> & { category: Record<string, string> };
const { page, category } = Astro.props as Props;
const currentPage = page.currentPage ?? 1;
const metadata = {
title: `Category '${category.title}' ${currentPage > 1 ? ` — Page ${currentPage}` : ''}`,
robots: {
index: blogCategoryRobots?.index,
follow: blogCategoryRobots?.follow,
},
};
---
<Layout metadata={metadata}>
<section class="px-4 md:px-6 py-12 sm:py-16 lg:py-20 mx-auto max-w-4xl">
<Headline>{category.title}</Headline>
<BlogList posts={page.data} />
<Pagination prevUrl={page.url.prev} nextUrl={page.url.next} />
</section>
</Layout>

View File

@@ -1,37 +0,0 @@
---
import type { InferGetStaticPropsType, GetStaticPaths } from 'astro';
import { blogTagRobots, getStaticPathsBlogTag } from '~/utils/blog';
import Layout from '~/layouts/PageLayout.astro';
import BlogList from '~/components/blog/List.astro';
import Headline from '~/components/blog/Headline.astro';
import Pagination from '~/components/blog/Pagination.astro';
export const prerender = true;
export const getStaticPaths = (async ({ paginate }) => {
return await getStaticPathsBlogTag({ paginate });
}) satisfies GetStaticPaths;
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { page, tag } = Astro.props as Props;
const currentPage = page.currentPage ?? 1;
const metadata = {
title: `Posts by tag '${tag.title}'${currentPage > 1 ? ` — Page ${currentPage} ` : ''}`,
robots: {
index: blogTagRobots?.index,
follow: blogTagRobots?.follow,
},
};
---
<Layout metadata={metadata}>
<section class="px-4 md:px-6 py-12 sm:py-16 lg:py-20 mx-auto max-w-4xl">
<Headline>Tag: {tag.title}</Headline>
<BlogList posts={page.data} />
<Pagination prevUrl={page.url.prev} nextUrl={page.url.next} />
</section>
</Layout>

View File

@@ -1,59 +0,0 @@
---
import type { InferGetStaticPropsType, GetStaticPaths } from 'astro';
import merge from 'lodash.merge';
import type { ImageMetadata } from 'astro';
import Layout from '~/layouts/PageLayout.astro';
import SinglePost from '~/components/blog/SinglePost.astro';
import ToBlogLink from '~/components/blog/ToBlogLink.astro';
import { getCanonical, getPermalink } from '~/utils/permalinks';
import { getStaticPathsBlogPost, blogPostRobots } from '~/utils/blog';
import { findImage } from '~/utils/images';
import type { MetaData } from '~/types';
import RelatedPosts from '~/components/blog/RelatedPosts.astro';
import { getBlogTranslation, supportedLanguages } from '~/i18n/translations.blog';
export const prerender = true;
export const getStaticPaths = (async () => {
return await getStaticPathsBlogPost();
}) satisfies GetStaticPaths;
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { post } = Astro.props as Props;
// Try to extract lang from post or fallback to 'en'
const lang = post?.lang && supportedLanguages.includes(post.lang) ? post.lang : 'en';
const blogT = getBlogTranslation(lang);
const url = getCanonical(getPermalink(post.permalink, 'post'));
const image = (await findImage(post.image)) as ImageMetadata | string | undefined;
const metadata = merge(
{
title: blogT.title,
description: blogT.information,
robots: {
index: blogPostRobots?.index,
follow: blogPostRobots?.follow,
},
openGraph: {
type: 'article',
...(image
? { images: [{ url: image, width: (image as ImageMetadata)?.width, height: (image as ImageMetadata)?.height }] }
: {}),
},
},
{ ...(post?.metadata ? { ...post.metadata, canonical: post.metadata?.canonical || url } : {}) }
) as MetaData;
---
<Layout metadata={metadata}>
<SinglePost post={{ ...post, image: image }} url={url}>
{post.Content ? <post.Content /> : <Fragment set:html={post.content || ''} />}
</SinglePost>
<ToBlogLink />
<RelatedPosts post={post} />
</Layout>

View File

@@ -1,16 +1,6 @@
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { RateLimiterMemory } from 'rate-limiter-flexible'; import { RateLimiterMemory } from 'rate-limiter-flexible';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import {
getAdminNotificationHtml,
getAdminNotificationText,
getAdminNotificationSubject,
} from '../email-templates/admin-notification';
import {
getUserConfirmationHtml,
getUserConfirmationText,
getUserConfirmationSubject,
} from '../email-templates/user-confirmation';
import 'dotenv/config'; import 'dotenv/config';
// Environment variables // Environment variables
@@ -205,67 +195,236 @@ export async function sendAdminNotification(
return false; return false;
} }
if (!ADMIN_EMAIL || ADMIN_EMAIL.trim() === '') { const subject = `New Contact Form Submission from ${name}`;
console.error('Cannot send admin notification: ADMIN_EMAIL is not configured'); const html = `
return false; <!DOCTYPE html>
} <html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Contact Form Submission</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #2563eb;
color: white;
padding: 20px;
text-align: center;
border-radius: 8px 8px 0 0;
}
.content {
background-color: #f8fafc;
padding: 20px;
border: 1px solid #e2e8f0;
border-top: none;
border-radius: 0 0 8px 8px;
}
.field {
margin-bottom: 15px;
}
.field-label {
font-weight: 600;
color: #4b5563;
margin-bottom: 5px;
}
.field-value {
background-color: white;
padding: 10px;
border-radius: 4px;
border: 1px solid #e2e8f0;
}
.message-content {
white-space: pre-wrap;
background-color: white;
padding: 15px;
border-radius: 4px;
border: 1px solid #e2e8f0;
margin: 10px 0;
}
.footer {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e2e8f0;
font-size: 0.9em;
color: #6b7280;
}
.meta-info {
font-size: 0.85em;
color: #6b7280;
margin-top: 20px;
padding-top: 10px;
border-top: 1px solid #e2e8f0;
}
</style>
</head>
<body>
<div class="header">
<h1>New Contact Form Submission</h1>
</div>
<div class="content">
<div class="field">
<div class="field-label">Name</div>
<div class="field-value">${name}</div>
</div>
<div class="field">
<div class="field-label">Email</div>
<div class="field-value">${email}</div>
</div>
<div class="field">
<div class="field-label">Message</div>
<div class="message-content">${message.replace(/\n/g, '<br>')}</div>
</div>
<div class="meta-info">
${ipAddress ? `<div><strong>IP Address:</strong> ${ipAddress}</div>` : ''}
${userAgent ? `<div><strong>User Agent:</strong> ${userAgent}</div>` : ''}
<div><strong>Time:</strong> ${new Date().toLocaleString()}</div>
</div>
<div class="footer">
<p>This message was sent from the contact form on ${WEBSITE_NAME}</p>
</div>
</div>
</body>
</html>
`;
const text = `
New Contact Form Submission
const submittedAt = new Date().toLocaleString('en-US', { Name: ${name}
year: 'numeric', Email: ${email}
month: 'long', Message:
day: 'numeric', ${message}
hour: '2-digit', ${ipAddress ? `IP Address: ${ipAddress}` : ''}
minute: '2-digit', ${userAgent ? `User Agent: ${userAgent}` : ''}
}); Time: ${new Date().toLocaleString()}
const props = { This message was sent from the contact form on ${WEBSITE_NAME}
name, `;
email,
message,
submittedAt,
ipAddress,
userAgent,
};
const subject = getAdminNotificationSubject(); return sendEmail(ADMIN_EMAIL, subject, html, text);
const html = getAdminNotificationHtml(props);
const text = getAdminNotificationText(props);
// Add a backup email address to ensure delivery
const recipients = ADMIN_EMAIL;
// Uncomment and modify the line below to add a backup email address
// const recipients = `${ADMIN_EMAIL}, your-backup-email@example.com`;
return sendEmail(recipients, subject, html, text);
} }
// Send user confirmation email // Send user confirmation email
export async function sendUserConfirmation(name: string, email: string, message: string): Promise<boolean> { export async function sendUserConfirmation(name: string, email: string, message: string): Promise<boolean> {
// Validate inputs
if (!name || name.trim() === '') {
console.error('Cannot send user confirmation: name is empty');
return false;
}
if (!email || email.trim() === '') { if (!email || email.trim() === '') {
console.error('Cannot send user confirmation: email is empty'); console.error('Cannot send user confirmation: email is empty');
return false; return false;
} }
const submittedAt = new Date().toLocaleString('en-US', { const subject = `Thank you for contacting ${WEBSITE_NAME}`;
year: 'numeric', const html = `
month: 'long', <!DOCTYPE html>
day: 'numeric', <html>
hour: '2-digit', <head>
minute: '2-digit', <meta charset="utf-8">
}); <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thank you for your message</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #2563eb;
color: white;
padding: 20px;
text-align: center;
border-radius: 8px 8px 0 0;
}
.content {
background-color: #f8fafc;
padding: 20px;
border: 1px solid #e2e8f0;
border-top: none;
border-radius: 0 0 8px 8px;
}
.message {
background-color: white;
padding: 15px;
border-radius: 4px;
border: 1px solid #e2e8f0;
margin: 20px 0;
}
.footer {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e2e8f0;
font-size: 0.9em;
color: #6b7280;
}
.button {
display: inline-block;
background-color: #2563eb;
color: white;
padding: 12px 24px;
text-decoration: none;
border-radius: 6px;
margin: 20px 0;
}
.button:hover {
background-color: #1d4ed8;
}
</style>
</head>
<body>
<div class="header">
<h1>Thank you for your message</h1>
</div>
<div class="content">
<p>Dear ${name},</p>
<p>Thank you for contacting ${WEBSITE_NAME}. We have received your message and will get back to you as soon as possible.</p>
const props = { <div class="message">
name, <h3>Your Message:</h3>
email, <p>${message.replace(/\n/g, '<br>')}</p>
message, </div>
submittedAt,
websiteName: WEBSITE_NAME,
contactEmail: ADMIN_EMAIL,
};
const subject = getUserConfirmationSubject(WEBSITE_NAME); <p>If you have any additional information to share, please don't hesitate to reply to this email.</p>
const html = getUserConfirmationHtml(props);
const text = getUserConfirmationText(props); <a href="https://www.365devnet.eu" class="button">Visit Our Website</a>
<div class="footer">
<p>Best regards,<br>${WEBSITE_NAME} Team</p>
<p><small>This is an automated message, please do not reply directly to this email.</small></p>
</div>
</div>
</body>
</html>
`;
const text = `
Thank you for your message
Dear ${name},
Thank you for contacting ${WEBSITE_NAME}. We have received your message and will get back to you as soon as possible.
Here's a copy of your message:
${message}
If you have any additional information to share, please don't hesitate to reply to this email.
Best regards,
${WEBSITE_NAME} Team
This is an automated message, please do not reply directly to this email.
`;
return sendEmail(email, subject, html, text); return sendEmail(email, subject, html, text);
} }
@@ -275,43 +434,17 @@ export function initializeEmailSystem(): void {
initializeTransporter(); initializeTransporter();
} }
// Initialize on import // Test email configuration
initializeEmailSystem();
// Test email function to verify configuration
export async function testEmailConfiguration(): Promise<boolean> { export async function testEmailConfiguration(): Promise<boolean> {
if (!isProduction) { if (!transporter) {
return true; initializeTransporter();
} }
try { try {
// Initialize transporter if not already done await transporter.verify();
if (!transporter) {
initializeTransporter();
}
// Verify connection to SMTP server
const connectionResult = await new Promise<boolean>((resolve) => {
transporter.verify(function (error, _success) {
if (error) {
resolve(false);
} else {
resolve(true);
}
});
});
if (!connectionResult) {
return false;
}
return true; return true;
} catch { } catch (error) {
console.error('Email configuration test failed:', error);
return false; return false;
} }
} }
// Run a test of the email configuration
if (isProduction) {
testEmailConfiguration();
}