diff options
| author | Dawid Rycerz <dawid@rycerz.xyz> | 2025-07-21 21:56:55 +0300 |
|---|---|---|
| committer | Dawid Rycerz <dawid@rycerz.xyz> | 2025-07-21 21:56:55 +0300 |
| commit | c735556726e75428550a3d28a2cf58e2c8490b7d (patch) | |
| tree | fd0ae29d1636b825abeedff6b99d3376bb383135 /src/components | |
Initial template
Diffstat (limited to 'src/components')
55 files changed, 3005 insertions, 0 deletions
diff --git a/src/components/CustomStyles.astro b/src/components/CustomStyles.astro new file mode 100644 index 0000000..2b27954 --- /dev/null +++ b/src/components/CustomStyles.astro @@ -0,0 +1,63 @@ +--- +import '@fontsource-variable/inter'; + +// 'DM Sans' +// Nunito +// Dosis +// Outfit +// Roboto +// Literata +// 'IBM Plex Sans' +// Karla +// Poppins +// 'Fira Sans' +// 'Libre Franklin' +// Inconsolata +// Raleway +// Oswald +// 'Space Grotesk' +// Urbanist +--- + +<style is:inline> + :root { + --aw-font-sans: 'Inter Variable'; + --aw-font-serif: 'Inter Variable'; + --aw-font-heading: 'Inter Variable'; + + --aw-color-primary: rgb(1 97 239); + --aw-color-secondary: rgb(1 84 207); + --aw-color-accent: rgb(109 40 217); + + --aw-color-text-heading: rgb(0 0 0); + --aw-color-text-default: rgb(16 16 16); + --aw-color-text-muted: rgb(16 16 16 / 66%); + --aw-color-bg-page: rgb(255 255 255); + + --aw-color-bg-page-dark: rgb(3 6 32); + + ::selection { + background-color: lavender; + } + } + + .dark { + --aw-font-sans: 'Inter Variable'; + --aw-font-serif: 'Inter Variable'; + --aw-font-heading: 'Inter Variable'; + + --aw-color-primary: rgb(1 97 239); + --aw-color-secondary: rgb(1 84 207); + --aw-color-accent: rgb(109 40 217); + + --aw-color-text-heading: rgb(247, 248, 248); + --aw-color-text-default: rgb(229 236 246); + --aw-color-text-muted: rgb(229 236 246 / 66%); + --aw-color-bg-page: rgb(3 6 32); + + ::selection { + background-color: black; + color: snow; + } + } +</style> diff --git a/src/components/Favicons.astro b/src/components/Favicons.astro new file mode 100644 index 0000000..fed6696 --- /dev/null +++ b/src/components/Favicons.astro @@ -0,0 +1,10 @@ +--- +import favIcon from '~/assets/favicons/favicon.ico'; +import favIconSvg from '~/assets/favicons/favicon.svg'; +import appleTouchIcon from '~/assets/favicons/apple-touch-icon.png'; +--- + +<link rel="shortcut icon" href={favIcon} /> +<link rel="icon" type="image/svg+xml" href={favIconSvg.src} /> +<link rel="mask-icon" href={favIconSvg.src} color="#8D46E7" /> +<link rel="apple-touch-icon" sizes="180x180" href={appleTouchIcon.src} /> diff --git a/src/components/Logo.astro b/src/components/Logo.astro new file mode 100644 index 0000000..8469792 --- /dev/null +++ b/src/components/Logo.astro @@ -0,0 +1,9 @@ +--- +import { SITE } from 'astrowind:config'; +--- + +<span + class="self-center ml-2 rtl:ml-0 rtl:mr-2 text-2xl md:text-xl font-bold text-gray-900 whitespace-nowrap dark:text-white" +> + 馃殌 {SITE?.name} +</span> diff --git a/src/components/blog/Grid.astro b/src/components/blog/Grid.astro new file mode 100644 index 0000000..1b62be4 --- /dev/null +++ b/src/components/blog/Grid.astro @@ -0,0 +1,14 @@ +--- +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> diff --git a/src/components/blog/GridItem.astro b/src/components/blog/GridItem.astro new file mode 100644 index 0000000..cd02fa8 --- /dev/null +++ b/src/components/blog/GridItem.astro @@ -0,0 +1,71 @@ +--- +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> diff --git a/src/components/blog/Headline.astro b/src/components/blog/Headline.astro new file mode 100644 index 0000000..5d3ccc6 --- /dev/null +++ b/src/components/blog/Headline.astro @@ -0,0 +1,12 @@ +--- +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> diff --git a/src/components/blog/List.astro b/src/components/blog/List.astro new file mode 100644 index 0000000..6a80ae3 --- /dev/null +++ b/src/components/blog/List.astro @@ -0,0 +1,20 @@ +--- +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> diff --git a/src/components/blog/ListItem.astro b/src/components/blog/ListItem.astro new file mode 100644 index 0000000..6a416d6 --- /dev/null +++ b/src/components/blog/ListItem.astro @@ -0,0 +1,120 @@ +--- +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> diff --git a/src/components/blog/Pagination.astro b/src/components/blog/Pagination.astro new file mode 100644 index 0000000..051587c --- /dev/null +++ b/src/components/blog/Pagination.astro @@ -0,0 +1,36 @@ +--- +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> + ) +} diff --git a/src/components/blog/RelatedPosts.astro b/src/components/blog/RelatedPosts.astro new file mode 100644 index 0000000..f4036e9 --- /dev/null +++ b/src/components/blog/RelatedPosts.astro @@ -0,0 +1,31 @@ +--- +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 +} diff --git a/src/components/blog/SinglePost.astro b/src/components/blog/SinglePost.astro new file mode 100644 index 0000000..297cca9 --- /dev/null +++ b/src/components/blog/SinglePost.astro @@ -0,0 +1,103 @@ +--- +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> diff --git a/src/components/blog/Tags.astro b/src/components/blog/Tags.astro new file mode 100644 index 0000000..ae46a24 --- /dev/null +++ b/src/components/blog/Tags.astro @@ -0,0 +1,43 @@ +--- +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> + </> + ) +} diff --git a/src/components/blog/ToBlogLink.astro b/src/components/blog/ToBlogLink.astro new file mode 100644 index 0000000..7fb7a49 --- /dev/null +++ b/src/components/blog/ToBlogLink.astro @@ -0,0 +1,20 @@ +--- +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> diff --git a/src/components/common/Analytics.astro b/src/components/common/Analytics.astro new file mode 100644 index 0000000..a1a553d --- /dev/null +++ b/src/components/common/Analytics.astro @@ -0,0 +1,13 @@ +--- +import { GoogleAnalytics } from '@astrolib/analytics'; +import { ANALYTICS } from 'astrowind:config'; +--- + +{ + ANALYTICS?.vendors?.googleAnalytics?.id ? ( + <GoogleAnalytics + id={String(ANALYTICS.vendors.googleAnalytics.id)} + partytown={ANALYTICS?.vendors?.googleAnalytics?.partytown} + /> + ) : null +} diff --git a/src/components/common/ApplyColorMode.astro b/src/components/common/ApplyColorMode.astro new file mode 100644 index 0000000..d0d97fe --- /dev/null +++ b/src/components/common/ApplyColorMode.astro @@ -0,0 +1,33 @@ +--- +import { UI } from 'astrowind:config'; + +// TODO: This code is temporary +--- + +<script is:inline define:vars={{ defaultTheme: UI.theme || 'system' }}> + function applyTheme(theme) { + if (theme === 'dark') { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + const matches = document.querySelectorAll('[data-aw-toggle-color-scheme] > input'); + + if (matches && matches.length) { + matches.forEach((elem) => { + elem.checked = theme !== 'dark'; + }); + } + } + + if ((defaultTheme && defaultTheme.endsWith(':only')) || (!localStorage.theme && defaultTheme !== 'system')) { + applyTheme(defaultTheme.replace(':only', '')); + } else if ( + localStorage.theme === 'dark' || + (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) + ) { + applyTheme('dark'); + } else { + applyTheme('light'); + } +</script> diff --git a/src/components/common/BasicScripts.astro b/src/components/common/BasicScripts.astro new file mode 100644 index 0000000..c7290b2 --- /dev/null +++ b/src/components/common/BasicScripts.astro @@ -0,0 +1,255 @@ +--- +import { UI } from 'astrowind:config'; +--- + +<script is:inline define:vars={{ defaultTheme: UI.theme }}> + if (window.basic_script) { + return; + } + + window.basic_script = true; + + function applyTheme(theme) { + if (theme === 'dark') { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + } + + const initTheme = function () { + if ((defaultTheme && defaultTheme.endsWith(':only')) || (!localStorage.theme && defaultTheme !== 'system')) { + applyTheme(defaultTheme.replace(':only', '')); + } else if ( + localStorage.theme === 'dark' || + (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) + ) { + applyTheme('dark'); + } else { + applyTheme('light'); + } + }; + initTheme(); + + function attachEvent(selector, event, fn) { + const matches = typeof selector === 'string' ? document.querySelectorAll(selector) : selector; + if (matches && matches.length) { + matches.forEach((elem) => { + elem.addEventListener(event, (e) => fn(e, elem), false); + }); + } + } + + const onLoad = function () { + let lastKnownScrollPosition = window.scrollY; + let ticking = true; + + attachEvent('#header nav', 'click', function () { + document.querySelector('[data-aw-toggle-menu]')?.classList.remove('expanded'); + document.body.classList.remove('overflow-hidden'); + document.getElementById('header')?.classList.remove('h-screen'); + document.getElementById('header')?.classList.remove('expanded'); + document.getElementById('header')?.classList.remove('bg-page'); + document.querySelector('#header nav')?.classList.add('hidden'); + document.querySelector('#header > div > div:last-child')?.classList.add('hidden'); + }); + + attachEvent('[data-aw-toggle-menu]', 'click', function (_, elem) { + elem.classList.toggle('expanded'); + document.body.classList.toggle('overflow-hidden'); + document.getElementById('header')?.classList.toggle('h-screen'); + document.getElementById('header')?.classList.toggle('expanded'); + document.getElementById('header')?.classList.toggle('bg-page'); + document.querySelector('#header nav')?.classList.toggle('hidden'); + document.querySelector('#header > div > div:last-child')?.classList.toggle('hidden'); + }); + + attachEvent('[data-aw-toggle-color-scheme]', 'click', function () { + if (defaultTheme.endsWith(':only')) { + return; + } + document.documentElement.classList.toggle('dark'); + localStorage.theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light'; + }); + + attachEvent('[data-aw-social-share]', 'click', function (_, elem) { + const network = elem.getAttribute('data-aw-social-share'); + const url = encodeURIComponent(elem.getAttribute('data-aw-url')); + const text = encodeURIComponent(elem.getAttribute('data-aw-text')); + + let href; + switch (network) { + case 'facebook': + href = `https://www.facebook.com/sharer.php?u=${url}`; + break; + case 'twitter': + href = `https://twitter.com/intent/tweet?url=${url}&text=${text}`; + break; + case 'linkedin': + href = `https://www.linkedin.com/shareArticle?mini=true&url=${url}&title=${text}`; + break; + case 'whatsapp': + href = `https://wa.me/?text=${text}%20${url}`; + break; + case 'mail': + href = `mailto:?subject=%22${text}%22&body=${text}%20${url}`; + break; + + default: + return; + } + + const newlink = document.createElement('a'); + newlink.target = '_blank'; + newlink.href = href; + newlink.click(); + }); + + const screenSize = window.matchMedia('(max-width: 767px)'); + screenSize.addEventListener('change', function () { + document.querySelector('[data-aw-toggle-menu]')?.classList.remove('expanded'); + document.body.classList.remove('overflow-hidden'); + document.getElementById('header')?.classList.remove('h-screen'); + document.getElementById('header')?.classList.remove('expanded'); + document.getElementById('header')?.classList.remove('bg-page'); + document.querySelector('#header nav')?.classList.add('hidden'); + document.querySelector('#header > div > div:last-child')?.classList.add('hidden'); + }); + + function applyHeaderStylesOnScroll() { + const header = document.querySelector('#header[data-aw-sticky-header]'); + if (!header) return; + if (lastKnownScrollPosition > 60 && !header.classList.contains('scroll')) { + header.classList.add('scroll'); + } else if (lastKnownScrollPosition <= 60 && header.classList.contains('scroll')) { + header.classList.remove('scroll'); + } + ticking = false; + } + applyHeaderStylesOnScroll(); + + attachEvent([document], 'scroll', function () { + lastKnownScrollPosition = window.scrollY; + + if (!ticking) { + window.requestAnimationFrame(() => { + applyHeaderStylesOnScroll(); + }); + ticking = true; + } + }); + }; + const onPageShow = function () { + document.documentElement.classList.add('motion-safe:scroll-smooth'); + const elem = document.querySelector('[data-aw-toggle-menu]'); + if (elem) { + elem.classList.remove('expanded'); + } + document.body.classList.remove('overflow-hidden'); + document.getElementById('header')?.classList.remove('h-screen'); + document.getElementById('header')?.classList.remove('expanded'); + document.querySelector('#header nav')?.classList.add('hidden'); + }; + + window.onload = onLoad; + window.onpageshow = onPageShow; + + document.addEventListener('astro:after-swap', () => { + initTheme(); + onLoad(); + onPageShow(); + }); +</script> + +<script is:inline> + /* Inspired by: https://github.com/heidkaemper/tailwindcss-intersect */ + const Observer = { + observer: null, + delayBetweenAnimations: 100, + animationCounter: 0, + + start() { + const selectors = [ + '[class*=" intersect:"]', + '[class*=":intersect:"]', + '[class^="intersect:"]', + '[class="intersect"]', + '[class*=" intersect "]', + '[class^="intersect "]', + '[class$=" intersect"]', + ]; + + const elements = Array.from(document.querySelectorAll(selectors.join(','))); + + const getThreshold = (element) => { + if (element.classList.contains('intersect-full')) return 0.99; + if (element.classList.contains('intersect-half')) return 0.5; + if (element.classList.contains('intersect-quarter')) return 0.25; + return 0; + }; + + elements.forEach((el) => { + el.setAttribute('no-intersect', ''); + el._intersectionThreshold = getThreshold(el); + }); + + const callback = (entries) => { + entries.forEach((entry) => { + requestAnimationFrame(() => { + const target = entry.target; + const intersectionRatio = entry.intersectionRatio; + const threshold = target._intersectionThreshold; + + if (target.classList.contains('intersect-no-queue')) { + if (entry.isIntersecting) { + target.removeAttribute('no-intersect'); + if (target.classList.contains('intersect-once')) { + this.observer.unobserve(target); + } + } else { + target.setAttribute('no-intersect', ''); + } + return; + } + + if (intersectionRatio >= threshold) { + if (!target.hasAttribute('data-animated')) { + target.removeAttribute('no-intersect'); + target.setAttribute('data-animated', 'true'); + + const delay = this.animationCounter * this.delayBetweenAnimations; + this.animationCounter++; + + target.style.transitionDelay = `${delay}ms`; + target.style.animationDelay = `${delay}ms`; + + if (target.classList.contains('intersect-once')) { + this.observer.unobserve(target); + } + } + } else { + target.setAttribute('no-intersect', ''); + target.removeAttribute('data-animated'); + target.style.transitionDelay = ''; + target.style.animationDelay = ''; + + this.animationCounter = 0; + } + }); + }); + }; + + this.observer = new IntersectionObserver(callback.bind(this), { threshold: [0, 0.25, 0.5, 0.99] }); + + elements.forEach((el) => { + this.observer.observe(el); + }); + }, + }; + + Observer.start(); + + document.addEventListener('astro:after-swap', () => { + Observer.start(); + }); +</script> diff --git a/src/components/common/CommonMeta.astro b/src/components/common/CommonMeta.astro new file mode 100644 index 0000000..aab6dd4 --- /dev/null +++ b/src/components/common/CommonMeta.astro @@ -0,0 +1,8 @@ +--- +import { getAsset } from '~/utils/permalinks'; +--- + +<meta charset="UTF-8" /> +<meta name="viewport" content="width=device-width, initial-scale=1.0" /> + +<link rel="sitemap" href={getAsset('/sitemap-index.xml')} /> diff --git a/src/components/common/Image.astro b/src/components/common/Image.astro new file mode 100644 index 0000000..d113b68 --- /dev/null +++ b/src/components/common/Image.astro @@ -0,0 +1,61 @@ +--- +import type { HTMLAttributes } from 'astro/types'; +import { findImage } from '~/utils/images'; +import { + getImagesOptimized, + astroAssetsOptimizer, + unpicOptimizer, + isUnpicCompatible, + type ImageProps, +} from '~/utils/images-optimization'; + +type Props = ImageProps; +type ImageType = { + src: string; + attributes: HTMLAttributes<'img'>; +}; + +const props = Astro.props; + +if (props.alt === undefined || props.alt === null) { + throw new Error(); +} + +if (typeof props.width === 'string') { + props.width = parseInt(props.width); +} + +if (typeof props.height === 'string') { + props.height = parseInt(props.height); +} + +if (!props.loading) { + props.loading = 'lazy'; +} + +if (!props.decoding) { + props.decoding = 'async'; +} + +const _image = await findImage(props.src); + +let image: ImageType | undefined = undefined; + +if ( + typeof _image === 'string' && + (_image.startsWith('http://') || _image.startsWith('https://')) && + isUnpicCompatible(_image) +) { + image = await getImagesOptimized(_image, props, unpicOptimizer); +} else if (_image) { + image = await getImagesOptimized(_image, props, astroAssetsOptimizer); +} +--- + +{ + !image ? ( + <Fragment /> + ) : ( + <img src={image.src} crossorigin="anonymous" referrerpolicy="no-referrer" {...image.attributes} /> + ) +} diff --git a/src/components/common/Metadata.astro b/src/components/common/Metadata.astro new file mode 100644 index 0000000..a4c573e --- /dev/null +++ b/src/components/common/Metadata.astro @@ -0,0 +1,68 @@ +--- +import merge from 'lodash.merge'; +import { AstroSeo } from '@astrolib/seo'; + +import type { Props as AstroSeoProps } from '@astrolib/seo'; + +import { SITE, METADATA, I18N } from 'astrowind:config'; +import type { MetaData } from '~/types'; +import { getCanonical } from '~/utils/permalinks'; + +import { adaptOpenGraphImages } from '~/utils/images'; + +export interface Props extends MetaData { + dontUseTitleTemplate?: boolean; +} + +const { + title, + ignoreTitleTemplate = false, + canonical = String(getCanonical(String(Astro.url.pathname))), + robots = {}, + description, + openGraph = {}, + twitter = {}, +} = Astro.props; + +const seoProps: AstroSeoProps = merge( + { + title: '', + titleTemplate: '%s', + canonical: canonical, + noindex: true, + nofollow: true, + description: undefined, + openGraph: { + url: canonical, + site_name: SITE?.name, + images: [], + locale: I18N?.language || 'en', + type: 'website', + }, + twitter: { + cardType: openGraph?.images?.length ? 'summary_large_image' : 'summary', + }, + }, + { + title: METADATA?.title?.default, + titleTemplate: METADATA?.title?.template, + noindex: typeof METADATA?.robots?.index !== 'undefined' ? !METADATA.robots.index : undefined, + nofollow: typeof METADATA?.robots?.follow !== 'undefined' ? !METADATA.robots.follow : undefined, + description: METADATA?.description, + openGraph: METADATA?.openGraph, + twitter: METADATA?.twitter, + }, + { + title: title, + titleTemplate: ignoreTitleTemplate ? '%s' : undefined, + canonical: canonical, + noindex: typeof robots?.index !== 'undefined' ? !robots.index : undefined, + nofollow: typeof robots?.follow !== 'undefined' ? !robots.follow : undefined, + description: description, + openGraph: { url: canonical, ...openGraph }, + twitter: twitter, + } +); +--- + +<AstroSeo {...{ ...seoProps, openGraph: await adaptOpenGraphImages(seoProps?.openGraph, Astro.site) }} /> diff --git a/src/components/common/SiteVerification.astro b/src/components/common/SiteVerification.astro new file mode 100644 index 0000000..000baad --- /dev/null +++ b/src/components/common/SiteVerification.astro @@ -0,0 +1,5 @@ +--- +import { SITE } from 'astrowind:config'; +--- + +{SITE.googleSiteVerificationId && <meta name="google-site-verification" content={SITE.googleSiteVerificationId} />} diff --git a/src/components/common/SocialShare.astro b/src/components/common/SocialShare.astro new file mode 100644 index 0000000..d035e8f --- /dev/null +++ b/src/components/common/SocialShare.astro @@ -0,0 +1,65 @@ +--- +import { Icon } from 'astro-icon/components'; + +export interface Props { + text: string; + url: string | URL; + class?: string; +} + +const { text, url, class: className = 'inline-block' } = Astro.props; +--- + +<div class={className}> + <span class="align-super font-bold text-slate-500 dark:text-slate-400">Share:</span> + <button + class="ml-2 rtl:ml-0 rtl:mr-2" + title="Twitter Share" + data-aw-social-share="twitter" + data-aw-url={url} + data-aw-text={text} + ><Icon + name="tabler:brand-x" + class="w-6 h-6 text-gray-400 dark:text-slate-500 hover:text-black dark:hover:text-slate-300" + /> + </button> + <button class="ml-2 rtl:ml-0 rtl:mr-2" title="Facebook Share" data-aw-social-share="facebook" data-aw-url={url} + ><Icon + name="tabler:brand-facebook" + class="w-6 h-6 text-gray-400 dark:text-slate-500 hover:text-black dark:hover:text-slate-300" + /> + </button> + <button + class="ml-2 rtl:ml-0 rtl:mr-2" + title="Linkedin Share" + data-aw-social-share="linkedin" + data-aw-url={url} + data-aw-text={text} + ><Icon + name="tabler:brand-linkedin" + class="w-6 h-6 text-gray-400 dark:text-slate-500 hover:text-black dark:hover:text-slate-300" + /> + </button> + <button + class="ml-2 rtl:ml-0 rtl:mr-2" + title="Whatsapp Share" + data-aw-social-share="whatsapp" + data-aw-url={url} + data-aw-text={text} + ><Icon + name="tabler:brand-whatsapp" + class="w-6 h-6 text-gray-400 dark:text-slate-500 hover:text-black dark:hover:text-slate-300" + /> + </button> + <button + class="ml-2 rtl:ml-0 rtl:mr-2" + title="Email Share" + data-aw-social-share="mail" + data-aw-url={url} + data-aw-text={text} + ><Icon + name="tabler:mail" + class="w-6 h-6 text-gray-400 dark:text-slate-500 hover:text-black dark:hover:text-slate-300" + /> + </button> +</div> diff --git a/src/components/common/SplitbeeAnalytics.astro b/src/components/common/SplitbeeAnalytics.astro new file mode 100644 index 0000000..66651db --- /dev/null +++ b/src/components/common/SplitbeeAnalytics.astro @@ -0,0 +1,6 @@ +--- +const { doNotTrack = true, noCookieMode = false, url = 'https://cdn.splitbee.io/sb.js' } = Astro.props; +--- + +<!-- Splitbee Analytics --> +<script is:inline data-respect-dnt={doNotTrack} data-no-cookie={noCookieMode} async src={url}></script> diff --git a/src/components/common/ToggleMenu.astro b/src/components/common/ToggleMenu.astro new file mode 100644 index 0000000..2d19b16 --- /dev/null +++ b/src/components/common/ToggleMenu.astro @@ -0,0 +1,29 @@ +--- +export interface Props { + label?: string; + class?: string; +} + +const { + label = 'Toggle Menu', + class: className = 'flex flex-col h-12 w-12 rounded justify-center items-center cursor-pointer group', +} = Astro.props; +--- + +<button type="button" class={className} aria-label={label} data-aw-toggle-menu> + <span class="sr-only">{label}</span> + <slot> + <span + aria-hidden="true" + class="h-0.5 w-6 my-1 rounded-full bg-black dark:bg-white transition ease transform duration-200 opacity-80 group-[.expanded]:rotate-45 group-[.expanded]:translate-y-2.5" + ></span> + <span + aria-hidden="true" + class="h-0.5 w-6 my-1 rounded-full bg-black dark:bg-white transition ease transform duration-200 opacity-80 group-[.expanded]:opacity-0" + ></span> + <span + aria-hidden="true" + class="h-0.5 w-6 my-1 rounded-full bg-black dark:bg-white transition ease transform duration-200 opacity-80 group-[.expanded]:-rotate-45 group-[.expanded]:-translate-y-2.5" + ></span> + </slot> +</button> diff --git a/src/components/common/ToggleTheme.astro b/src/components/common/ToggleTheme.astro new file mode 100644 index 0000000..8f3aafb --- /dev/null +++ b/src/components/common/ToggleTheme.astro @@ -0,0 +1,28 @@ +--- +import { Icon } from 'astro-icon/components'; + +import { UI } from 'astrowind:config'; + +export interface Props { + label?: string; + class?: string; + iconClass?: string; + iconName?: string; +} + +const { + label = 'Toggle between Dark and Light mode', + class: + className = 'text-muted dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5 inline-flex items-center', + iconClass = 'w-6 h-6', + iconName = 'tabler:sun', +} = Astro.props; +--- + +{ + !(UI.theme && UI.theme.endsWith(':only')) && ( + <button type="button" class={className} aria-label={label} data-aw-toggle-color-scheme> + <Icon name={iconName} class={iconClass} /> + </button> + ) +} diff --git a/src/components/ui/Background.astro b/src/components/ui/Background.astro new file mode 100644 index 0000000..1093c17 --- /dev/null +++ b/src/components/ui/Background.astro @@ -0,0 +1,11 @@ +--- +export interface Props { + isDark?: boolean; +} + +const { isDark = false } = Astro.props; +--- + +<div class:list={['absolute inset-0', { 'bg-dark dark:bg-transparent': isDark }]}> + <slot /> +</div> diff --git a/src/components/ui/Button.astro b/src/components/ui/Button.astro new file mode 100644 index 0000000..d3c2398 --- /dev/null +++ b/src/components/ui/Button.astro @@ -0,0 +1,40 @@ +--- +import { Icon } from 'astro-icon/components'; +import { twMerge } from 'tailwind-merge'; +import type { CallToAction as Props } from '~/types'; + +const { + variant = 'secondary', + target, + text = Astro.slots.render('default'), + icon = '', + class: className = '', + type, + ...rest +} = Astro.props; + +const variants = { + primary: 'btn-primary', + secondary: 'btn-secondary', + tertiary: 'btn btn-tertiary', + link: 'cursor-pointer hover:text-primary', +}; +--- + +{ + type === 'button' || type === 'submit' || type === 'reset' ? ( + <button type={type} class={twMerge(variants[variant] || '', className)} {...rest}> + <Fragment set:html={text} /> + {icon && <Icon name={icon} class="w-5 h-5 ml-1 -mr-1.5 rtl:mr-1 rtl:-ml-1.5 inline-block" />} + </button> + ) : ( + <a + class={twMerge(variants[variant] || '', className)} + {...(target ? { target: target, rel: 'noopener noreferrer' } : {})} + {...rest} + > + <Fragment set:html={text} /> + {icon && <Icon name={icon} class="w-5 h-5 ml-1 -mr-1.5 rtl:mr-1 rtl:-ml-1.5 inline-block" />} + </a> + ) +} diff --git a/src/components/ui/DListItem.astro b/src/components/ui/DListItem.astro new file mode 100644 index 0000000..36d4072 --- /dev/null +++ b/src/components/ui/DListItem.astro @@ -0,0 +1,22 @@ +--- +// component: DListItem +// +// Mimics the html 'dl' (description list) +// +// The 'dt' item is the item 'term' and is inserted into an 'h6' tag. +// Caller needs to style the 'h6' tag appropriately. +// +// You can put pretty much any content you want between the open and +// closing tags - it's simply contained in an enclosing div with a +// margin left. No need for 'dd' items. +// +const { dt } = Astro.props; +interface Props { + dt: string; +} + +const content: string = await Astro.slots.render('default'); +--- + +<h6 set:html={dt} /> +<div class="dd ml-8" set:html={content} /> diff --git a/src/components/ui/Form.astro b/src/components/ui/Form.astro new file mode 100644 index 0000000..276b39f --- /dev/null +++ b/src/components/ui/Form.astro @@ -0,0 +1,87 @@ +--- +import type { Form as Props } from '~/types'; +import Button from '~/components/ui/Button.astro'; + +const { inputs, textarea, disclaimer, button = 'Contact us', description = '' } = Astro.props; +--- + +<form> + { + inputs && + inputs.map( + ({ type = 'text', name, label = '', autocomplete = 'on', placeholder = '' }) => + name && ( + <div class="mb-6"> + {label && ( + <label for={name} class="block text-sm font-medium"> + {label} + </label> + )} + <input + type={type} + name={name} + id={name} + autocomplete={autocomplete} + placeholder={placeholder} + class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900" + /> + </div> + ) + ) + } + + { + textarea && ( + <div> + <label for="textarea" class="block text-sm font-medium"> + {textarea.label} + </label> + <textarea + id="textarea" + name={textarea.name ? textarea.name : 'message'} + rows={textarea.rows ? textarea.rows : 4} + placeholder={textarea.placeholder} + class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900" + /> + </div> + ) + } + + { + disclaimer && ( + <div class="mt-3 flex items-start"> + <div class="flex mt-0.5"> + <input + id="disclaimer" + name="disclaimer" + type="checkbox" + class="cursor-pointer mt-1 py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900" + /> + </div> + <div class="ml-3"> + <label for="disclaimer" class="cursor-pointer select-none text-sm text-gray-600 dark:text-gray-400"> + {disclaimer.label} + </label> + </div> + </div> + ) + } + + { + button && ( + <div class="mt-10 grid"> + <Button variant="primary" type="submit"> + {button} + </Button> + </div> + ) + } + + { + description && ( + <div class="mt-3 text-center"> + <p class="text-sm text-gray-600 dark:text-gray-400">{description}</p> + </div> + ) + } +</form> diff --git a/src/components/ui/Headline.astro b/src/components/ui/Headline.astro new file mode 100644 index 0000000..621b23d --- /dev/null +++ b/src/components/ui/Headline.astro @@ -0,0 +1,35 @@ +--- +import type { Headline as Props } from '~/types'; +import { twMerge } from 'tailwind-merge'; + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline, + classes = {}, +} = Astro.props; + +const { + container: containerClass = 'max-w-3xl', + title: titleClass = 'text-3xl md:text-4xl ', + subtitle: subtitleClass = 'text-xl', +} = classes; +--- + +{ + (title || subtitle || tagline) && ( + <div class={twMerge('mb-8 md:mx-auto md:mb-12 text-center', containerClass)}> + {tagline && ( + <p class="text-base text-secondary dark:text-blue-200 font-bold tracking-wide uppercase" set:html={tagline} /> + )} + {title && ( + <h2 + class={twMerge('font-bold leading-tighter tracking-tighter font-heading text-heading text-3xl', titleClass)} + set:html={title} + /> + )} + + {subtitle && <p class={twMerge('mt-4 text-muted', subtitleClass)} set:html={subtitle} />} + </div> + ) +} diff --git a/src/components/ui/ItemGrid.astro b/src/components/ui/ItemGrid.astro new file mode 100644 index 0000000..79931b9 --- /dev/null +++ b/src/components/ui/ItemGrid.astro @@ -0,0 +1,65 @@ +--- +import type { ItemGrid as Props } from '~/types'; +import { twMerge } from 'tailwind-merge'; +import Button from './Button.astro'; +import { Icon } from 'astro-icon/components'; + +const { items = [], columns, defaultIcon = '', classes = {} } = Astro.props; + +const { + container: containerClass = '', + panel: panelClass = '', + title: titleClass = '', + description: descriptionClass = '', + icon: defaultIconClass = 'text-primary', + action: actionClass = '', +} = classes; +--- + +{ + items && items.length > 0 && ( + <div + class={twMerge( + `grid mx-auto gap-8 md:gap-y-12 ${ + columns === 4 + ? 'lg:grid-cols-4 md:grid-cols-3 sm:grid-cols-2' + : columns === 3 + ? 'lg:grid-cols-3 sm:grid-cols-2' + : columns === 2 + ? 'sm:grid-cols-2 ' + : '' + }`, + containerClass + )} + > + {items.map(({ title, description, icon, callToAction, classes: itemClasses = {} }) => ( + <div class="intersect-once motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"> + <div class={twMerge('flex flex-row max-w-md', panelClass, itemClasses?.panel)}> + <div class="flex justify-center"> + {(icon || defaultIcon) && ( + <Icon + name={icon || defaultIcon} + class={twMerge('w-7 h-7 mr-2 rtl:mr-0 rtl:ml-2', defaultIconClass, itemClasses?.icon)} + /> + )} + </div> + <div class="mt-0.5"> + {title && <h3 class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)}>{title}</h3>} + {description && ( + <p + class={twMerge(`${title ? 'mt-3' : ''} text-muted`, descriptionClass, itemClasses?.description)} + set:html={description} + /> + )} + {callToAction && ( + <div class={twMerge(`${title || description ? 'mt-3' : ''}`, actionClass, itemClasses?.actionClass)}> + <Button variant="link" {...callToAction} /> + </div> + )} + </div> + </div> + </div> + ))} + </div> + ) +} diff --git a/src/components/ui/ItemGrid2.astro b/src/components/ui/ItemGrid2.astro new file mode 100644 index 0000000..81faadf --- /dev/null +++ b/src/components/ui/ItemGrid2.astro @@ -0,0 +1,59 @@ +--- +import type { ItemGrid as Props } from '~/types'; +import { Icon } from 'astro-icon/components'; +import { twMerge } from 'tailwind-merge'; +import Button from './Button.astro'; + +const { items = [], columns, defaultIcon = '', classes = {} } = Astro.props; + +const { + container: containerClass = '', + // container: containerClass = "sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3", + panel: panelClass = '', + title: titleClass = '', + description: descriptionClass = '', + icon: defaultIconClass = 'text-primary', +} = classes; +--- + +{ + items && items.length > 0 && ( + <div + class={twMerge( + `grid gap-8 gap-x-12 sm:gap-y-8 ${ + columns === 4 + ? 'lg:grid-cols-4 md:grid-cols-3 sm:grid-cols-2' + : columns === 3 + ? 'lg:grid-cols-3 sm:grid-cols-2' + : columns === 2 + ? 'sm:grid-cols-2 ' + : '' + }`, + containerClass + )} + > + {items.map(({ title, description, icon, callToAction, classes: itemClasses = {} }) => ( + <div + class={twMerge( + 'relative flex flex-col intersect-once intersect-quarter intercept-no-queue motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade', + panelClass, + itemClasses?.panel + )} + > + {(icon || defaultIcon) && ( + <Icon name={icon || defaultIcon} class={twMerge('mb-2 w-10 h-10', defaultIconClass, itemClasses?.icon)} /> + )} + <div class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)}>{title}</div> + {description && ( + <p class={twMerge('text-muted mt-2', descriptionClass, itemClasses?.description)} set:html={description} /> + )} + {callToAction && ( + <div class="mt-2"> + <Button {...callToAction} /> + </div> + )} + </div> + ))} + </div> + ) +} diff --git a/src/components/ui/Timeline.astro b/src/components/ui/Timeline.astro new file mode 100644 index 0000000..51e09f0 --- /dev/null +++ b/src/components/ui/Timeline.astro @@ -0,0 +1,60 @@ +--- +import { Icon } from 'astro-icon/components'; +import { twMerge } from 'tailwind-merge'; +import type { Item } from '~/types'; + +export interface Props { + items?: Array<Item>; + defaultIcon?: string; + classes?: Record<string, string>; +} + +const { items = [], classes = {}, defaultIcon } = Astro.props as Props; + +const { + container: containerClass = '', + panel: panelClass = '', + title: titleClass = '', + description: descriptionClass = '', + icon: defaultIconClass = 'text-primary dark:text-slate-200 border-primary dark:border-blue-700', +} = classes; +--- + +{ + items && items.length > 0 && ( + <div class={containerClass}> + {items.map(({ title, description, icon, classes: itemClasses = {} }, index = 0) => ( + <div + class={twMerge( + 'flex intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade', + panelClass, + itemClasses?.panel + )} + > + <div class="flex flex-col items-center mr-4 rtl:mr-0 rtl:ml-4"> + <div> + <div class="flex items-center justify-center"> + {(icon || defaultIcon) && ( + <Icon + name={icon || defaultIcon} + class={twMerge('w-10 h-10 p-2 rounded-full border-2', defaultIconClass, itemClasses?.icon)} + /> + )} + </div> + </div> + {index !== items.length - 1 && <div class="w-px h-full bg-black/10 dark:bg-slate-400/50" />} + </div> + <div class={`pt-1 ${index !== items.length - 1 ? 'pb-8' : ''}`}> + {title && <p class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)} set:html={title} />} + {description && ( + <p + class={twMerge('text-muted mt-2', descriptionClass, itemClasses?.description)} + set:html={description} + /> + )} + </div> + </div> + ))} + </div> + ) +} diff --git a/src/components/ui/WidgetWrapper.astro b/src/components/ui/WidgetWrapper.astro new file mode 100644 index 0000000..c42c751 --- /dev/null +++ b/src/components/ui/WidgetWrapper.astro @@ -0,0 +1,34 @@ +--- +import type { HTMLTag } from 'astro/types'; +import type { Widget } from '~/types'; +import { twMerge } from 'tailwind-merge'; +import Background from './Background.astro'; + +export interface Props extends Widget { + containerClass?: string; + ['as']?: HTMLTag; +} + +const { id, isDark = false, containerClass = '', bg, as = 'section' } = Astro.props; + +const WrapperTag = as; +--- + +<WrapperTag class="relative not-prose scroll-mt-[72px]" {...id ? { id } : {}}> + <div class="absolute inset-0 pointer-events-none -z-[1]" aria-hidden="true"> + <slot name="bg"> + {bg ? <Fragment set:html={bg} /> : <Background isDark={isDark} />} + </slot> + </div> + <div + class:list={[ + twMerge( + 'relative mx-auto max-w-7xl px-4 md:px-6 py-12 md:py-16 lg:py-20 text-default intersect-once intersect-quarter intercept-no-queue motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade', + containerClass + ), + { dark: isDark }, + ]} + > + <slot /> + </div> +</WrapperTag> diff --git a/src/components/widgets/Announcement.astro b/src/components/widgets/Announcement.astro new file mode 100644 index 0000000..b8779b1 --- /dev/null +++ b/src/components/widgets/Announcement.astro @@ -0,0 +1,23 @@ +--- + +--- + +<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-570/" class="text-muted hover:underline dark:text-slate-400 font-medium" + >Astro v5.7 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> diff --git a/src/components/widgets/BlogHighlightedPosts.astro b/src/components/widgets/BlogHighlightedPosts.astro new file mode 100644 index 0000000..75f35a9 --- /dev/null +++ b/src/components/widgets/BlogHighlightedPosts.astro @@ -0,0 +1,64 @@ +--- +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 /> + ) +} diff --git a/src/components/widgets/BlogLatestPosts.astro b/src/components/widgets/BlogLatestPosts.astro new file mode 100644 index 0000000..28f66d4 --- /dev/null +++ b/src/components/widgets/BlogLatestPosts.astro @@ -0,0 +1,63 @@ +--- +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 '~/components/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" set:html={information} />} + </div> + + <Grid posts={posts} /> + </WidgetWrapper> + ) : ( + <Fragment /> + ) +} diff --git a/src/components/widgets/Brands.astro b/src/components/widgets/Brands.astro new file mode 100644 index 0000000..7e42ae1 --- /dev/null +++ b/src/components/widgets/Brands.astro @@ -0,0 +1,38 @@ +--- +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> diff --git a/src/components/widgets/CallToAction.astro b/src/components/widgets/CallToAction.astro new file mode 100644 index 0000000..f51aa91 --- /dev/null +++ b/src/components/widgets/CallToAction.astro @@ -0,0 +1,58 @@ +--- +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import type { CallToAction, Widget } from '~/types'; +import Headline from '~/components/ui/Headline.astro'; +import Button from '~/components/ui/Button.astro'; + +interface Props extends Widget { + title?: string; + subtitle?: string; + tagline?: string; + callToAction?: CallToAction; + actions?: string | CallToAction[]; +} + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline = await Astro.slots.render('tagline'), + actions = await Astro.slots.render('actions'), + + 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="max-w-3xl mx-auto text-center p-6 rounded-md shadow-xl dark:shadow-none dark:border dark:border-slate-600" + > + <Headline + title={title} + subtitle={subtitle} + tagline={tagline} + classes={{ + container: 'mb-0 md:mb-0', + title: 'text-4xl md:text-4xl font-bold tracking-tighter mb-4 font-heading', + subtitle: 'text-xl text-muted dark:text-slate-400', + }} + /> + { + actions && ( + <div class="max-w-xs sm:max-w-md m-auto flex flex-nowrap flex-col sm:flex-row sm:justify-center gap-4 mt-6"> + {Array.isArray(actions) ? ( + actions.map((action) => ( + <div class="flex w-full sm:w-auto"> + <Button {...(action || {})} class="w-full sm:mb-0" /> + </div> + )) + ) : ( + <Fragment set:html={actions} /> + )} + </div> + ) + } + </div> +</WidgetWrapper> diff --git a/src/components/widgets/Contact.astro b/src/components/widgets/Contact.astro new file mode 100644 index 0000000..122f4b0 --- /dev/null +++ b/src/components/widgets/Contact.astro @@ -0,0 +1,40 @@ +--- +import FormContainer from '~/components/ui/Form.astro'; +import Headline from '~/components/ui/Headline.astro'; +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import type { Contact as Props } from '~/types'; + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline = await Astro.slots.render('tagline'), + inputs, + textarea, + disclaimer, + button, + description, + + 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} /> + + { + inputs && ( + <div class="flex flex-col max-w-xl mx-auto rounded-lg backdrop-blur border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900 shadow p-4 sm:p-6 lg:p-8 w-full"> + <FormContainer + inputs={inputs} + textarea={textarea} + disclaimer={disclaimer} + button={button} + description={description} + /> + </div> + ) + } +</WidgetWrapper> diff --git a/src/components/widgets/Content.astro b/src/components/widgets/Content.astro new file mode 100644 index 0000000..694a198 --- /dev/null +++ b/src/components/widgets/Content.astro @@ -0,0 +1,94 @@ +--- +import type { Content as Props } from '~/types'; +import Headline from '~/components/ui/Headline.astro'; +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import Image from '~/components/common/Image.astro'; +import Button from '~/components/ui/Button.astro'; +import ItemGrid from '~/components/ui/ItemGrid.astro'; + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline, + content = await Astro.slots.render('content'), + callToAction, + items = [], + columns, + image = await Astro.slots.render('image'), + isReversed = false, + isAfterContent = false, + + id, + isDark = false, + classes = {}, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<WidgetWrapper + id={id} + isDark={isDark} + containerClass={`max-w-7xl mx-auto ${isAfterContent ? 'pt-0 md:pt-0 lg:pt-0' : ''} ${classes?.container ?? ''}`} + bg={bg} +> + <Headline + title={title} + subtitle={subtitle} + tagline={tagline} + classes={{ + container: 'max-w-xl sm:mx-auto lg:max-w-2xl', + title: 'text-4xl md:text-5xl font-bold tracking-tighter mb-4 font-heading', + subtitle: 'max-w-3xl mx-auto sm:text-center text-xl text-muted dark:text-slate-400', + }} + /> + <div class="mx-auto max-w-7xl p-4 md:px-8"> + <div class={`md:flex ${isReversed ? 'md:flex-row-reverse' : ''} md:gap-16`}> + <div class="md:basis-1/2 self-center"> + {content && <div class="mb-12 text-lg dark:text-slate-400" set:html={content} />} + + { + callToAction && ( + <div class="mt-[-40px] mb-8 text-primary"> + <Button variant="link" {...callToAction} /> + </div> + ) + } + + <ItemGrid + items={items} + columns={columns} + defaultIcon="tabler:check" + classes={{ + container: `gap-y-4 md:gap-y-8`, + panel: 'max-w-none', + title: 'text-lg font-medium leading-6 dark:text-white ml-2 rtl:ml-0 rtl:mr-2', + description: 'text-muted dark:text-slate-400 ml-2 rtl:ml-0 rtl:mr-2', + icon: 'flex h-7 w-7 items-center justify-center rounded-full bg-green-600 dark:bg-green-700 text-gray-50 p-1', + action: 'text-lg font-medium leading-6 dark:text-white ml-2 rtl:ml-0 rtl:mr-2', + }} + /> + </div> + <div aria-hidden="true" class="mt-10 md:mt-0 md:basis-1/2"> + { + image && ( + <div class="relative m-auto max-w-4xl"> + {typeof image === 'string' ? ( + <Fragment set:html={image} /> + ) : ( + <Image + class="mx-auto w-full rounded-lg bg-gray-500 shadow-lg" + width={500} + height={500} + widths={[400, 768]} + sizes="(max-width: 768px) 100vw, 432px" + layout="responsive" + {...image} + /> + )} + </div> + ) + } + </div> + </div> + </div> +</WidgetWrapper> diff --git a/src/components/widgets/FAQs.astro b/src/components/widgets/FAQs.astro new file mode 100644 index 0000000..cba9762 --- /dev/null +++ b/src/components/widgets/FAQs.astro @@ -0,0 +1,33 @@ +--- +import Headline from '~/components/ui/Headline.astro'; +import ItemGrid from '~/components/ui/ItemGrid.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} /> + <ItemGrid + items={items} + columns={columns} + defaultIcon="tabler:chevron-right" + classes={{ + container: `${columns === 1 ? 'max-w-4xl' : ''} gap-y-8 md:gap-y-12`, + panel: 'max-w-none', + icon: 'flex-shrink-0 mt-1 w-6 h-6 text-primary', + }} + /> +</WidgetWrapper> diff --git a/src/components/widgets/Features.astro b/src/components/widgets/Features.astro new file mode 100644 index 0000000..8f42b62 --- /dev/null +++ b/src/components/widgets/Features.astro @@ -0,0 +1,36 @@ +--- +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import ItemGrid from '~/components/ui/ItemGrid.astro'; +import Headline from '~/components/ui/Headline.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'), + items = [], + columns = 2, + + defaultIcon, + + 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}> + <Headline title={title} subtitle={subtitle} tagline={tagline} classes={classes?.headline as Record<string, string>} /> + <ItemGrid + items={items} + columns={columns} + defaultIcon={defaultIcon} + classes={{ + container: '', + title: 'md:text-[1.3rem]', + icon: 'text-white bg-primary rounded-full w-10 h-10 p-2 md:w-12 md:h-12 md:p-3 mr-4 rtl:ml-4 rtl:mr-0', + ...((classes?.items as Record<string, never>) ?? {}), + }} + /> +</WidgetWrapper> diff --git a/src/components/widgets/Features2.astro b/src/components/widgets/Features2.astro new file mode 100644 index 0000000..282337e --- /dev/null +++ b/src/components/widgets/Features2.astro @@ -0,0 +1,38 @@ +--- +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import Headline from '~/components/ui/Headline.astro'; +import ItemGrid2 from '~/components/ui/ItemGrid2.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'), + items = [], + columns = 3, + defaultIcon, + + 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} classes={classes?.headline as Record<string, string>} /> + <ItemGrid2 + items={items} + columns={columns} + defaultIcon={defaultIcon} + classes={{ + container: 'gap-4 md:gap-6', + panel: + 'rounded-lg shadow-[0_4px_30px_rgba(0,0,0,0.1)] dark:shadow-[0_4px_30px_rgba(0,0,0,0.1)] backdrop-blur border border-[#ffffff29] bg-white dark:bg-slate-900 p-6', + // panel: + // "text-center bg-page items-center md:text-left rtl:md:text-right md:items-start p-6 p-6 rounded-md shadow-xl dark:shadow-none dark:border dark:border-slate-800", + icon: 'w-12 h-12 mb-6 text-primary', + ...((classes?.items as Record<string, never>) ?? {}), + }} + /> +</WidgetWrapper> diff --git a/src/components/widgets/Features3.astro b/src/components/widgets/Features3.astro new file mode 100644 index 0000000..62ab475 --- /dev/null +++ b/src/components/widgets/Features3.astro @@ -0,0 +1,70 @@ +--- +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> diff --git a/src/components/widgets/Footer.astro b/src/components/widgets/Footer.astro new file mode 100644 index 0000000..1e51d2a --- /dev/null +++ b/src/components/widgets/Footer.astro @@ -0,0 +1,104 @@ +--- +import { Icon } from 'astro-icon/components'; +import { SITE } from 'astrowind:config'; +import { getHomePermalink } from '~/utils/permalinks'; + +interface Link { + text?: string; + href?: string; + ariaLabel?: string; + icon?: string; +} + +interface Links { + title?: string; + links: Array<Link>; +} + +export interface Props { + links: Array<Links>; + secondaryLinks: Array<Link>; + socialLinks: Array<Link>; + footNote?: string; + theme?: string; +} + +const { socialLinks = [], secondaryLinks = [], links = [], footNote = '', theme = 'light' } = Astro.props; +--- + +<footer class:list={[{ dark: theme === 'dark' }, 'relative border-t border-gray-200 dark:border-slate-800 not-prose']}> + <div class="dark:bg-dark absolute inset-0 pointer-events-none" aria-hidden="true"></div> + <div + class="relative max-w-7xl mx-auto px-4 sm:px-6 dark:text-slate-300 intersect-once intersect-quarter intercept-no-queue motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + > + <div class="grid grid-cols-12 gap-4 gap-y-8 sm:gap-8 py-8 md:py-12"> + <div class="col-span-12 lg:col-span-4"> + <div class="mb-2"> + <a class="inline-block font-bold text-xl" href={getHomePermalink()}>{SITE?.name}</a> + </div> + <div class="text-sm text-muted flex gap-1"> + { + secondaryLinks.map(({ text, href }, index) => ( + <> + {index !== 0 ? ' 路 ' : ''} + <a + class="text-muted hover:text-gray-700 dark:text-gray-400 hover:underline transition duration-150 ease-in-out" + href={href} + set:html={text} + /> + </> + )) + } + </div> + </div> + { + links.map(({ title, links }) => ( + <div class="col-span-6 md:col-span-3 lg:col-span-2"> + <div class="dark:text-gray-300 font-medium mb-2">{title}</div> + {links && Array.isArray(links) && links.length > 0 && ( + <ul class="text-sm"> + {links.map(({ text, href, ariaLabel }) => ( + <li class="mb-2"> + <a + class="text-muted hover:text-gray-700 hover:underline dark:text-gray-400 transition duration-150 ease-in-out" + href={href} + aria-label={ariaLabel} + > + <Fragment set:html={text} /> + </a> + </li> + ))} + </ul> + )} + </div> + )) + } + </div> + <div class="md:flex md:items-center md:justify-between py-6 md:py-8"> + { + socialLinks?.length ? ( + <ul class="flex mb-4 md:order-1 -ml-2 md:ml-4 md:mb-0 rtl:ml-0 rtl:-mr-2 rtl:md:ml-0 rtl:md:mr-4"> + {socialLinks.map(({ ariaLabel, href, text, icon }) => ( + <li> + <a + class="text-muted dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5 inline-flex items-center" + aria-label={ariaLabel} + href={href} + > + {icon && <Icon name={icon} class="w-5 h-5" />} + <Fragment set:html={text} /> + </a> + </li> + ))} + </ul> + ) : ( + '' + ) + } + + <div class="text-sm mr-4 dark:text-muted"> + <Fragment set:html={footNote} /> + </div> + </div> + </div> +</footer> diff --git a/src/components/widgets/Header.astro b/src/components/widgets/Header.astro new file mode 100644 index 0000000..ff4a3aa --- /dev/null +++ b/src/components/widgets/Header.astro @@ -0,0 +1,167 @@ +--- +import { Icon } from 'astro-icon/components'; +import Logo from '~/components/Logo.astro'; +import ToggleTheme from '~/components/common/ToggleTheme.astro'; +import ToggleMenu from '~/components/common/ToggleMenu.astro'; +import Button from '~/components/ui/Button.astro'; + +import { getHomePermalink } from '~/utils/permalinks'; +import { trimSlash, getAsset } from '~/utils/permalinks'; +import type { CallToAction } from '~/types'; + +interface Link { + text?: string; + href?: string; + ariaLabel?: string; + icon?: string; +} + +interface MenuLink extends Link { + links?: Array<MenuLink>; +} + +export interface Props { + id?: string; + links?: Array<MenuLink>; + actions?: Array<CallToAction>; + isSticky?: boolean; + isDark?: boolean; + isFullWidth?: boolean; + showToggleTheme?: boolean; + showRssFeed?: boolean; + position?: string; +} + +const { + id = 'header', + links = [], + actions = [], + isSticky = false, + isDark = false, + isFullWidth = false, + showToggleTheme = false, + showRssFeed = false, + position = 'center', +} = Astro.props; + +const currentPath = `/${trimSlash(new URL(Astro.url).pathname)}`; +--- + +<header + class:list={[ + { sticky: isSticky, relative: !isSticky, dark: isDark }, + 'top-0 z-40 flex-none mx-auto w-full border-b border-gray-50/0 transition-[opacity] ease-in-out', + ]} + {...isSticky ? { 'data-aw-sticky-header': true } : {}} + {...id ? { id } : {}} +> + <div class="absolute inset-0"></div> + <div + class:list={[ + 'relative text-default py-3 px-3 md:px-6 mx-auto w-full', + { + 'md:flex md:justify-between': position !== 'center', + }, + { + 'md:grid md:grid-cols-3 md:items-center': position === 'center', + }, + { + 'max-w-7xl': !isFullWidth, + }, + ]} + > + <div class:list={[{ 'mr-auto rtl:mr-0 rtl:ml-auto': position === 'right' }, 'flex justify-between']}> + <a class="flex items-center" href={getHomePermalink()}> + <Logo /> + </a> + <div class="flex items-center md:hidden"> + <ToggleMenu /> + </div> + </div> + <nav + class="items-center w-full md:w-auto hidden md:flex md:mx-5 text-default overflow-y-auto overflow-x-hidden md:overflow-y-visible md:overflow-x-auto md:justify-self-center" + aria-label="Main navigation" + > + <ul + class="flex flex-col md:flex-row md:self-center w-full md:w-auto text-xl md:text-[0.9375rem] tracking-[0.01rem] font-medium md:justify-center" + > + { + links.map(({ text, href, links }) => ( + <li class={links?.length ? 'dropdown' : ''}> + {links?.length ? ( + <> + <button + type="button" + class="hover:text-link dark:hover:text-white px-4 py-3 flex items-center whitespace-nowrap" + > + {text}{' '} + <Icon name="tabler:chevron-down" class="w-3.5 h-3.5 ml-0.5 rtl:ml-0 rtl:mr-0.5 hidden md:inline" /> + </button> + <ul class="dropdown-menu md:backdrop-blur-md dark:md:bg-dark rounded md:absolute pl-4 md:pl-0 md:hidden font-medium md:bg-white/90 md:min-w-[200px] drop-shadow-xl"> + {links.map(({ text: text2, href: href2 }) => ( + <li> + <a + class:list={[ + 'first:rounded-t last:rounded-b md:hover:bg-gray-100 hover:text-link dark:hover:text-white dark:hover:bg-gray-700 py-2 px-5 block whitespace-no-wrap', + { 'aw-link-active': href2 === currentPath }, + ]} + href={href2} + > + {text2} + </a> + </li> + ))} + </ul> + </> + ) : ( + <a + class:list={[ + 'hover:text-link dark:hover:text-white px-4 py-3 flex items-center whitespace-nowrap', + { 'aw-link-active': href === currentPath }, + ]} + href={href} + > + {text} + </a> + )} + </li> + )) + } + </ul> + </nav> + <div + class:list={[ + { 'ml-auto rtl:ml-0 rtl:mr-auto': position === 'left' }, + 'hidden md:self-center md:flex items-center md:mb-0 fixed w-full md:w-auto md:static justify-end left-0 rtl:left-auto rtl:right-0 bottom-0 p-3 md:p-0 md:justify-self-end', + ]} + > + <div class="items-center flex justify-between w-full md:w-auto"> + <div class="flex"> + {showToggleTheme && <ToggleTheme iconClass="w-6 h-6 md:w-5 md:h-5 md:inline-block" />} + { + showRssFeed && ( + <a + class="text-muted dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5 inline-flex items-center" + aria-label="RSS Feed" + href={getAsset('/rss.xml')} + > + <Icon name="tabler:rss" class="w-5 h-5" /> + </a> + ) + } + </div> + { + actions?.length ? ( + <span class="ml-4 rtl:ml-0 rtl:mr-4"> + {actions.map((btnProps) => ( + <Button {...btnProps} class="ml-2 py-2.5 px-5.5 md:px-6 font-semibold shadow-none text-sm w-auto" /> + ))} + </span> + ) : ( + '' + ) + } + </div> + </div> + </div> +</header> diff --git a/src/components/widgets/Hero.astro b/src/components/widgets/Hero.astro new file mode 100644 index 0000000..700468c --- /dev/null +++ b/src/components/widgets/Hero.astro @@ -0,0 +1,99 @@ +--- +import Image from '~/components/common/Image.astro'; +import Button from '~/components/ui/Button.astro'; + +import type { Hero as Props } from '~/types'; + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline, + + content = await Astro.slots.render('content'), + actions = await Astro.slots.render('actions'), + image = await Astro.slots.render('image'), + + id, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<section class="relative md:-mt-[76px] not-prose" {...id ? { id } : {}}> + <div class="absolute inset-0 pointer-events-none" aria-hidden="true"> + <slot name="bg"> + {bg ? <Fragment set:html={bg} /> : null} + </slot> + </div> + <div class="relative max-w-7xl mx-auto px-4 sm:px-6"> + <div class="pt-0 md:pt-[76px] pointer-events-none"></div> + <div class="py-12 md:py-20"> + <div class="text-center pb-10 md:pb-16 max-w-5xl mx-auto"> + { + tagline && ( + <p + class="text-base text-secondary dark:text-blue-200 font-bold tracking-wide uppercase intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + set:html={tagline} + /> + ) + } + { + title && ( + <h1 + class="text-5xl md:text-6xl font-bold leading-tighter tracking-tighter mb-4 font-heading dark:text-gray-200 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + set:html={title} + /> + ) + } + <div class="max-w-3xl mx-auto"> + { + subtitle && ( + <p + class="text-xl text-muted mb-6 dark:text-slate-300 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + set:html={subtitle} + /> + ) + } + { + actions && ( + <div class="max-w-xs sm:max-w-md m-auto flex flex-nowrap flex-col sm:flex-row sm:justify-center gap-4 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"> + {Array.isArray(actions) ? ( + actions.map((action) => ( + <div class="flex w-full sm:w-auto"> + <Button {...(action || {})} class="w-full sm:mb-0" /> + </div> + )) + ) : ( + <Fragment set:html={actions} /> + )} + </div> + ) + } + </div> + {content && <Fragment set:html={content} />} + </div> + <div + class="intersect-once intercept-no-queue intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + > + { + image && ( + <div class="relative m-auto max-w-5xl"> + {typeof image === 'string' ? ( + <Fragment set:html={image} /> + ) : ( + <Image + class="mx-auto rounded-md w-full" + widths={[400, 768, 1024, 2040]} + sizes="(max-width: 767px) 400px, (max-width: 1023px) 768px, (max-width: 2039px) 1024px, 2040px" + loading="eager" + width={1024} + height={576} + {...image} + /> + )} + </div> + ) + } + </div> + </div> + </div> +</section> diff --git a/src/components/widgets/Hero2.astro b/src/components/widgets/Hero2.astro new file mode 100644 index 0000000..c6daa30 --- /dev/null +++ b/src/components/widgets/Hero2.astro @@ -0,0 +1,98 @@ +--- +import Image from '~/components/common/Image.astro'; +import Button from '~/components/ui/Button.astro'; + +import type { Hero as Props } from '~/types'; + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline, + + content = await Astro.slots.render('content'), + actions = await Astro.slots.render('actions'), + image = await Astro.slots.render('image'), + + id, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<section class="relative md:-mt-[76px] not-prose" {...id ? { id } : {}}> + <div class="absolute inset-0 pointer-events-none" aria-hidden="true"> + <slot name="bg"> + {bg ? <Fragment set:html={bg} /> : null} + </slot> + </div> + <div class="relative max-w-7xl mx-auto px-4 sm:px-6"> + <div class="pt-0 md:pt-[76px] pointer-events-none"></div> + <div class="py-12 md:py-20 lg:py-0 lg:flex lg:items-center lg:h-screen lg:gap-8"> + <div class="basis-1/2 text-center lg:text-left pb-10 md:pb-16 mx-auto"> + { + tagline && ( + <p + class="text-base text-secondary dark:text-blue-200 font-bold tracking-wide uppercase intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter" + set:html={tagline} + /> + ) + } + { + title && ( + <h1 + class="text-5xl md:text-6xl font-bold leading-tighter tracking-tighter mb-4 font-heading dark:text-gray-200 intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter" + set:html={title} + /> + ) + } + <div class="max-w-3xl mx-auto lg:max-w-none"> + { + subtitle && ( + <p + class="text-xl text-muted mb-6 dark:text-slate-300 intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter" + set:html={subtitle} + /> + ) + } + + { + actions && ( + <div class="max-w-xs sm:max-w-md m-auto flex flex-nowrap flex-col sm:flex-row sm:justify-center gap-4 lg:justify-start lg:m-0 lg:max-w-7xl intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter"> + {Array.isArray(actions) ? ( + actions.map((action) => ( + <div class="flex w-full sm:w-auto"> + <Button {...(action || {})} class="w-full sm:mb-0" /> + </div> + )) + ) : ( + <Fragment set:html={actions} /> + )} + </div> + ) + } + </div> + {content && <Fragment set:html={content} />} + </div> + <div class="basis-1/2"> + { + image && ( + <div class="relative m-auto max-w-5xl intersect-once intercept-no-queue motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter"> + {typeof image === 'string' ? ( + <Fragment set:html={image} /> + ) : ( + <Image + class="mx-auto rounded-md w-full" + widths={[400, 768, 1024, 2040]} + sizes="(max-width: 767px) 400px, (max-width: 1023px) 768px, (max-width: 2039px) 1024px, 2040px" + loading="eager" + width={600} + height={600} + {...image} + /> + )} + </div> + ) + } + </div> + </div> + </div> +</section> diff --git a/src/components/widgets/HeroText.astro b/src/components/widgets/HeroText.astro new file mode 100644 index 0000000..fa3e054 --- /dev/null +++ b/src/components/widgets/HeroText.astro @@ -0,0 +1,86 @@ +--- +import type { CallToAction } from '~/types'; +import Button from '~/components/ui/Button.astro'; + +export interface Props { + title?: string; + subtitle?: string; + tagline?: string; + content?: string; + callToAction?: string | CallToAction; + callToAction2?: string | CallToAction; +} + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline, + content = await Astro.slots.render('content'), + callToAction = await Astro.slots.render('callToAction'), + callToAction2 = await Astro.slots.render('callToAction2'), +} = Astro.props; +--- + +<section class="relative md:-mt-[76px] not-prose"> + <div class="absolute inset-0 pointer-events-none" aria-hidden="true"></div> + <div class="relative max-w-7xl mx-auto px-4 sm:px-6"> + <div class="pt-0 md:pt-[76px] pointer-events-none"></div> + <div class="py-12 md:py-20 pb-8 md:pb-8"> + <div class="text-center max-w-5xl mx-auto"> + { + tagline && ( + <p + class="text-base text-secondary dark:text-blue-200 font-bold tracking-wide uppercase intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + set:html={tagline} + /> + ) + } + { + title && ( + <h1 + class="text-5xl md:text-6xl font-bold leading-tighter tracking-tighter mb-4 font-heading dark:text-gray-200 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + set:html={title} + /> + ) + } + <div class="max-w-3xl mx-auto"> + { + subtitle && ( + <p + class="text-xl text-muted mb-6 dark:text-slate-300 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + set:html={subtitle} + /> + ) + } + <div + class="max-w-xs sm:max-w-md m-auto flex flex-nowrap flex-col sm:flex-row sm:justify-center gap-4 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + > + { + callToAction && ( + <div class="flex w-full sm:w-auto"> + {typeof callToAction === 'string' ? ( + <Fragment set:html={callToAction} /> + ) : ( + <Button variant="primary" {...callToAction} /> + )} + </div> + ) + } + { + callToAction2 && ( + <div class="flex w-full sm:w-auto"> + {typeof callToAction2 === 'string' ? ( + <Fragment set:html={callToAction2} /> + ) : ( + <Button {...callToAction2} /> + )} + </div> + ) + } + </div> + </div> + {content && <Fragment set:html={content} />} + </div> + </div> + </div> +</section> diff --git a/src/components/widgets/Note.astro b/src/components/widgets/Note.astro new file mode 100644 index 0000000..3594b39 --- /dev/null +++ b/src/components/widgets/Note.astro @@ -0,0 +1,23 @@ +--- +import { Icon } from 'astro-icon/components'; + +export interface Props { + icon?: string; + title?: string; + description?: string; +} + +const { + icon = 'tabler:info-square', + title = await Astro.slots.render('title'), + description = await Astro.slots.render('description'), +} = Astro.props; +--- + +<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"> + <Icon name={icon} class="w-5 h-5 inline-block align-text-bottom font-bold" /> + <span class="font-bold" set:html={title} /> + <Fragment set:html={description} /> + </div> +</section> diff --git a/src/components/widgets/Pricing.astro b/src/components/widgets/Pricing.astro new file mode 100644 index 0000000..3f20b74 --- /dev/null +++ b/src/components/widgets/Pricing.astro @@ -0,0 +1,83 @@ +--- +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> diff --git a/src/components/widgets/Stats.astro b/src/components/widgets/Stats.astro new file mode 100644 index 0000000..bf76ea0 --- /dev/null +++ b/src/components/widgets/Stats.astro @@ -0,0 +1,46 @@ +--- +import type { Stats as Props } from '~/types'; +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import Headline from '~/components/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> diff --git a/src/components/widgets/Steps.astro b/src/components/widgets/Steps.astro new file mode 100644 index 0000000..3c65bf6 --- /dev/null +++ b/src/components/widgets/Steps.astro @@ -0,0 +1,59 @@ +--- +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> diff --git a/src/components/widgets/Steps2.astro b/src/components/widgets/Steps2.astro new file mode 100644 index 0000000..8f7afb5 --- /dev/null +++ b/src/components/widgets/Steps2.astro @@ -0,0 +1,72 @@ +--- +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> diff --git a/src/components/widgets/Testimonials.astro b/src/components/widgets/Testimonials.astro new file mode 100644 index 0000000..11db7b5 --- /dev/null +++ b/src/components/widgets/Testimonials.astro @@ -0,0 +1,75 @@ +--- +import Headline from '~/components/ui/Headline.astro'; +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import Button from '~/components/ui/Button.astro'; +import Image from '~/components/common/Image.astro'; +import type { Testimonials as Props } from '~/types'; + +const { + title = '', + subtitle = '', + tagline = '', + testimonials = [], + callToAction, + + 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="grid sm:grid-cols-2 lg:grid-cols-3 gap-6"> + { + testimonials && + testimonials.map(({ title, testimonial, name, job, image }) => ( + <div class="flex h-auto intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter"> + <div class="flex flex-col p-4 md:p-6 rounded-md shadow-xl dark:shadow-none dark:border dark:border-slate-600"> + {title && <h2 class="text-lg font-medium leading-6 pb-4">{title}</h2>} + {testimonial && ( + <blockquote class="flex-auto"> + <p class="text-muted">" {testimonial} "</p> + </blockquote> + )} + + <hr class="border-slate-200 dark:border-slate-600 my-4" /> + + <div class="flex items-center"> + {image && ( + <div class="h-10 w-10 rounded-full border border-slate-200 dark:border-slate-600"> + {typeof image === 'string' ? ( + <Fragment set:html={image} /> + ) : ( + <Image + class="h-10 w-10 rounded-full border border-slate-200 dark:border-slate-600 min-w-full min-h-full" + width={40} + height={40} + widths={[400, 768]} + layout="fixed" + {...image} + /> + )} + </div> + )} + + <div class="grow ml-3 rtl:ml-0 rtl:mr-3"> + {name && <p class="text-base font-semibold">{name}</p>} + {job && <p class="text-xs text-muted">{job}</p>} + </div> + </div> + </div> + </div> + )) + } + </div> + { + callToAction && ( + <div class="flex justify-center mx-auto w-fit mt-8 md:mt-12 font-medium"> + <Button {...callToAction} /> + </div> + ) + } +</WidgetWrapper> |
