diff options
| author | Dawid Rycerz <dawid@rycerz.xyz> | 2025-07-22 15:08:37 +0300 |
|---|---|---|
| committer | Dawid Rycerz <dawid@rycerz.xyz> | 2025-07-22 15:08:37 +0300 |
| commit | fcc2f4704e39b0e69b377cc138f75027721dac22 (patch) | |
| tree | 732fc94b354a26c08fba9cc9059f9c6c900182be /src/components/common | |
Initial template
Diffstat (limited to 'src/components/common')
| -rw-r--r-- | src/components/common/Analytics.astro | 13 | ||||
| -rw-r--r-- | src/components/common/ApplyColorMode.astro | 33 | ||||
| -rw-r--r-- | src/components/common/BasicScripts.astro | 255 | ||||
| -rw-r--r-- | src/components/common/CommonMeta.astro | 8 | ||||
| -rw-r--r-- | src/components/common/Image.astro | 61 | ||||
| -rw-r--r-- | src/components/common/Metadata.astro | 68 | ||||
| -rw-r--r-- | src/components/common/SiteVerification.astro | 5 | ||||
| -rw-r--r-- | src/components/common/SocialShare.astro | 65 | ||||
| -rw-r--r-- | src/components/common/SplitbeeAnalytics.astro | 6 | ||||
| -rw-r--r-- | src/components/common/ToggleMenu.astro | 29 | ||||
| -rw-r--r-- | src/components/common/ToggleTheme.astro | 28 |
11 files changed, 571 insertions, 0 deletions
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> + ) +} |
