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:
@@ -1,16 +1,16 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
@layer base {
|
||||
p,
|
||||
li,
|
||||
div {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@layer utilities {
|
||||
.bg-page {
|
||||
background-color: var(--aw-color-bg-page);
|
||||
}
|
||||
@@ -26,78 +26,71 @@
|
||||
.text-muted {
|
||||
color: var(--aw-color-text-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@layer components {
|
||||
.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 {
|
||||
@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 {
|
||||
@apply btn;
|
||||
@apply btn;
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
box-shadow: none;
|
||||
}
|
||||
/* #header.scroll > div:last-child {
|
||||
@apply py-3;
|
||||
} */
|
||||
}
|
||||
|
||||
#header.expanded nav {
|
||||
#header.expanded nav {
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 70px !important;
|
||||
padding: 0 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown:focus .dropdown-menu,
|
||||
.dropdown:focus-within .dropdown-menu,
|
||||
.dropdown:hover .dropdown-menu {
|
||||
.dropdown:focus .dropdown-menu,
|
||||
.dropdown:focus-within .dropdown-menu,
|
||||
.dropdown:hover .dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
[astro-icon].icon-light > * {
|
||||
[astro-icon].icon-light > * {
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
[astro-icon].icon-bold > * {
|
||||
[astro-icon].icon-bold > * {
|
||||
stroke-width: 2.4;
|
||||
}
|
||||
}
|
||||
|
||||
[data-aw-toggle-menu] path {
|
||||
[data-aw-toggle-menu] path {
|
||||
@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];
|
||||
}
|
||||
}
|
||||
|
||||
[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];
|
||||
}
|
||||
|
||||
/* To deprecated */
|
||||
|
||||
.dd *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Tippy.js theme for light/dark mode */
|
||||
.tippy-box[data-theme~='devnet'] {
|
||||
@@ -108,11 +101,13 @@
|
||||
font-size: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.dark .tippy-box[data-theme~='devnet'] {
|
||||
background-color: #23272f;
|
||||
color: #f3f4f6;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.40);
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='devnet'] .tippy-arrow {
|
||||
color: inherit;
|
||||
}
|
||||
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
||||
}
|
@@ -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 && (
|
||||
<>
|
||||
· <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>
|
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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>
|
@@ -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>
|
@@ -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 />
|
||||
)
|
||||
}
|
@@ -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 />
|
||||
)
|
||||
}
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -29,38 +29,6 @@ i18n:
|
||||
language: en
|
||||
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:
|
||||
vendors:
|
||||
googleAnalytics:
|
||||
|
@@ -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,
|
||||
};
|
@@ -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
|
@@ -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.
|
||||
`;
|
||||
}
|
@@ -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}.
|
||||
`;
|
||||
}
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -1,16 +1,6 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { RateLimiterMemory } from 'rate-limiter-flexible';
|
||||
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';
|
||||
|
||||
// Environment variables
|
||||
@@ -205,67 +195,236 @@ export async function sendAdminNotification(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ADMIN_EMAIL || ADMIN_EMAIL.trim() === '') {
|
||||
console.error('Cannot send admin notification: ADMIN_EMAIL is not configured');
|
||||
return false;
|
||||
}
|
||||
const subject = `New Contact Form Submission from ${name}`;
|
||||
const html = `
|
||||
<!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', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
Name: ${name}
|
||||
Email: ${email}
|
||||
Message:
|
||||
${message}
|
||||
${ipAddress ? `IP Address: ${ipAddress}` : ''}
|
||||
${userAgent ? `User Agent: ${userAgent}` : ''}
|
||||
Time: ${new Date().toLocaleString()}
|
||||
|
||||
const props = {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
submittedAt,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
};
|
||||
This message was sent from the contact form on ${WEBSITE_NAME}
|
||||
`;
|
||||
|
||||
const subject = getAdminNotificationSubject();
|
||||
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);
|
||||
return sendEmail(ADMIN_EMAIL, subject, html, text);
|
||||
}
|
||||
|
||||
// Send user confirmation email
|
||||
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() === '') {
|
||||
console.error('Cannot send user confirmation: email is empty');
|
||||
return false;
|
||||
}
|
||||
|
||||
const submittedAt = new Date().toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const subject = `Thank you for contacting ${WEBSITE_NAME}`;
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<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 = {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
submittedAt,
|
||||
websiteName: WEBSITE_NAME,
|
||||
contactEmail: ADMIN_EMAIL,
|
||||
};
|
||||
<div class="message">
|
||||
<h3>Your Message:</h3>
|
||||
<p>${message.replace(/\n/g, '<br>')}</p>
|
||||
</div>
|
||||
|
||||
const subject = getUserConfirmationSubject(WEBSITE_NAME);
|
||||
const html = getUserConfirmationHtml(props);
|
||||
const text = getUserConfirmationText(props);
|
||||
<p>If you have any additional information to share, please don't hesitate to reply to this email.</p>
|
||||
|
||||
<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);
|
||||
}
|
||||
@@ -275,43 +434,17 @@ export function initializeEmailSystem(): void {
|
||||
initializeTransporter();
|
||||
}
|
||||
|
||||
// Initialize on import
|
||||
initializeEmailSystem();
|
||||
|
||||
// Test email function to verify configuration
|
||||
// Test email configuration
|
||||
export async function testEmailConfiguration(): Promise<boolean> {
|
||||
if (!isProduction) {
|
||||
return true;
|
||||
if (!transporter) {
|
||||
initializeTransporter();
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize transporter if not already done
|
||||
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;
|
||||
}
|
||||
|
||||
await transporter.verify();
|
||||
return true;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error('Email configuration test failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Run a test of the email configuration
|
||||
if (isProduction) {
|
||||
testEmailConfiguration();
|
||||
}
|
||||
|
Reference in New Issue
Block a user