summaryrefslogtreecommitdiff
path: root/src/components/common
diff options
context:
space:
mode:
authorDawid Rycerz <dawid@rycerz.xyz>2025-07-22 15:08:37 +0300
committerDawid Rycerz <dawid@rycerz.xyz>2025-07-22 15:08:37 +0300
commitfcc2f4704e39b0e69b377cc138f75027721dac22 (patch)
tree732fc94b354a26c08fba9cc9059f9c6c900182be /src/components/common
Initial template
Diffstat (limited to 'src/components/common')
-rw-r--r--src/components/common/Analytics.astro13
-rw-r--r--src/components/common/ApplyColorMode.astro33
-rw-r--r--src/components/common/BasicScripts.astro255
-rw-r--r--src/components/common/CommonMeta.astro8
-rw-r--r--src/components/common/Image.astro61
-rw-r--r--src/components/common/Metadata.astro68
-rw-r--r--src/components/common/SiteVerification.astro5
-rw-r--r--src/components/common/SocialShare.astro65
-rw-r--r--src/components/common/SplitbeeAnalytics.astro6
-rw-r--r--src/components/common/ToggleMenu.astro29
-rw-r--r--src/components/common/ToggleTheme.astro28
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>
+ )
+}