Updated site completely
This commit is contained in:
@@ -5,12 +5,18 @@ const { lang = 'en' } = Astro.params;
|
||||
const t = getTranslation(lang);
|
||||
---
|
||||
|
||||
<div id="cookie-banner" class="fixed bottom-0 left-0 right-0 z-50 p-4 content-backdrop shadow-lg transform transition-transform duration-300 translate-y-full" style="display: none;">
|
||||
<div
|
||||
id="cookie-banner"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 p-4 content-backdrop shadow-lg transform transition-transform duration-300 translate-y-full"
|
||||
style="display: none;"
|
||||
>
|
||||
<div class="container mx-auto max-w-6xl flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="text-sm text-gray-800 dark:text-gray-200 font-medium">
|
||||
<p>
|
||||
{t.cookies.message}
|
||||
<a href={`/${lang}/privacy#cookie-usage`} class="text-blue-600 dark:text-blue-400 hover:underline">{t.cookies.learnMore}</a>
|
||||
{t.cookies.message}
|
||||
<a href={`/${lang}/privacy#cookie-usage`} class="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>{t.cookies.learnMore}</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
@@ -41,7 +47,7 @@ const t = getTranslation(lang);
|
||||
// Helper function to set cookie with expiration
|
||||
function setCookie(name, value, days) {
|
||||
const date = new Date();
|
||||
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
const expires = `expires=${date.toUTCString()}`;
|
||||
document.cookie = `${name}=${value}; ${expires}; path=/; SameSite=Lax`;
|
||||
}
|
||||
@@ -49,7 +55,7 @@ const t = getTranslation(lang);
|
||||
function setupCookieBanner() {
|
||||
const cookieBanner = document.getElementById('cookie-banner');
|
||||
const acceptButton = document.getElementById('accept-cookies');
|
||||
|
||||
|
||||
if (!cookieBanner || !acceptButton) return;
|
||||
|
||||
// Check if user has already accepted cookies
|
||||
@@ -83,7 +89,7 @@ const t = getTranslation(lang);
|
||||
acceptButton.addEventListener('click', () => {
|
||||
// Store consent in cookie (primary storage)
|
||||
setCookie('cookieConsentAccepted', 'true', 365);
|
||||
|
||||
|
||||
// Also store in localStorage as backup
|
||||
try {
|
||||
localStorage.setItem('cookieConsentAccepted', 'true');
|
||||
@@ -91,14 +97,14 @@ const t = getTranslation(lang);
|
||||
console.error('Error setting localStorage:', e);
|
||||
// Continue with cookie storage
|
||||
}
|
||||
|
||||
|
||||
// Hide the banner with animation
|
||||
cookieBanner.classList.add('translate-y-full');
|
||||
|
||||
|
||||
// Remove from DOM after animation completes
|
||||
setTimeout(() => {
|
||||
cookieBanner.style.display = 'none';
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@@ -66,5 +66,4 @@ import '@fontsource-variable/inter';
|
||||
color: snow;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
@@ -8,16 +8,16 @@ interface Props {
|
||||
|
||||
const { currentLang } = Astro.props;
|
||||
|
||||
type SupportedLanguage = typeof supportedLanguages[number];
|
||||
type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
const languages = [
|
||||
{ code: 'en' as SupportedLanguage, name: 'English', flag: 'gb' },
|
||||
{ code: 'nl' as SupportedLanguage, name: 'Dutch', flag: 'nl' },
|
||||
{ code: 'de' as SupportedLanguage, name: 'German', flag: 'de' },
|
||||
{ code: 'fr' as SupportedLanguage, name: 'French', flag: 'fr' },
|
||||
].filter(lang => supportedLanguages.includes(lang.code));
|
||||
].filter((lang) => supportedLanguages.includes(lang.code));
|
||||
|
||||
const currentLanguage = languages.find(lang => lang.code === currentLang) || languages[0];
|
||||
const currentLanguage = languages.find((lang) => lang.code === currentLang) || languages[0];
|
||||
---
|
||||
|
||||
<div class="relative inline-block text-left language-dropdown">
|
||||
@@ -32,9 +32,9 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
|
||||
>
|
||||
<Icon name={`circle-flags:${currentLanguage.flag}`} class="inline-block w-5 h-5 mr-2" />
|
||||
<span id="selected-language">{currentLanguage.name}</span>
|
||||
<Icon
|
||||
name="tabler:chevron-down"
|
||||
class="ml-2 -mr-1 h-5 w-5 transition-transform duration-200"
|
||||
<Icon
|
||||
name="tabler:chevron-down"
|
||||
class="ml-2 -mr-1 h-5 w-5 transition-transform duration-200"
|
||||
aria-hidden="true"
|
||||
id="chevron-icon"
|
||||
/>
|
||||
@@ -51,28 +51,35 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
|
||||
style="max-height: min(300px, 70vh);"
|
||||
>
|
||||
<div class="py-1" role="none">
|
||||
{languages.map(lang => (
|
||||
<button
|
||||
type="button"
|
||||
data-lang-code={lang.code}
|
||||
class="text-gray-700 dark:text-gray-300 block w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
aria-label={`Switch to ${lang.name} language`}
|
||||
>
|
||||
<Icon name={`circle-flags:${lang.flag}`} class="inline-block w-5 h-5 mr-2" />
|
||||
{lang.name}
|
||||
</button>
|
||||
))}
|
||||
{
|
||||
languages.map((lang) => (
|
||||
<button
|
||||
type="button"
|
||||
data-lang-code={lang.code}
|
||||
class="text-gray-700 dark:text-gray-300 block w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
aria-label={`Switch to ${lang.name} language`}
|
||||
>
|
||||
<Icon name={`circle-flags:${lang.flag}`} class="inline-block w-5 h-5 mr-2" />
|
||||
{lang.name}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select id="language-select" class="block w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white focus:bg-gray-100 dark:focus:bg-gray-700 focus:text-gray-900 dark:focus:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 transition-colors duration-200 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 language-select">
|
||||
{languages.map(lang => (
|
||||
<option value={lang.code} selected={lang.code === currentLang}>
|
||||
{lang.name}
|
||||
</option>
|
||||
))}
|
||||
|
||||
<select
|
||||
id="language-select"
|
||||
class="block w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white focus:bg-gray-100 dark:focus:bg-gray-700 focus:text-gray-900 dark:focus:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 transition-colors duration-200 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 language-select"
|
||||
>
|
||||
{
|
||||
languages.map((lang) => (
|
||||
<option value={lang.code} selected={lang.code === currentLang}>
|
||||
{lang.name}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +96,7 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
|
||||
.language-dropdown {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
|
||||
/* Keep the native select hidden even on mobile */
|
||||
.language-select {
|
||||
display: none;
|
||||
@@ -168,7 +175,7 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
|
||||
// First show the menu to calculate its height
|
||||
menu.classList.remove('hidden');
|
||||
menu.classList.remove('open-upward', 'open-downward');
|
||||
|
||||
|
||||
// Calculate available space
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
@@ -176,15 +183,12 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
|
||||
const spaceBelow = viewportHeight - buttonRect.bottom;
|
||||
const spaceAbove = buttonRect.top;
|
||||
const menuHeight = Math.min(menuRect.height, 300); // Cap at 300px
|
||||
|
||||
|
||||
// Determine if menu should open upward
|
||||
const shouldOpenUpward = spaceBelow < menuHeight && spaceAbove > spaceBelow;
|
||||
|
||||
// Position menu
|
||||
menu.style.maxHeight = `${Math.min(
|
||||
shouldOpenUpward ? spaceAbove - 8 : spaceBelow - 8,
|
||||
300
|
||||
)}px`;
|
||||
menu.style.maxHeight = `${Math.min(shouldOpenUpward ? spaceAbove - 8 : spaceBelow - 8, 300)}px`;
|
||||
|
||||
if (shouldOpenUpward) {
|
||||
menu.classList.add('open-upward');
|
||||
@@ -214,7 +218,7 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
|
||||
});
|
||||
|
||||
// Handle language selection
|
||||
languageButtons.forEach(langButton => {
|
||||
languageButtons.forEach((langButton) => {
|
||||
langButton.addEventListener('click', () => {
|
||||
const langCode = langButton.dataset.langCode;
|
||||
if (!langCode) return;
|
||||
@@ -238,13 +242,13 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
|
||||
const currentPath = currentUrl.pathname.replace(/\/$/, '');
|
||||
const currentHash = currentUrl.hash;
|
||||
const pathSegments = currentPath.split('/').filter(Boolean);
|
||||
|
||||
|
||||
// Check if we're on a language-specific path
|
||||
const isLangPath = supportedLanguages.includes(pathSegments[0]);
|
||||
|
||||
|
||||
// Get the previous language code
|
||||
const previousLangCode = isLangPath ? pathSegments[0] : 'en';
|
||||
|
||||
|
||||
// Extract the page path without language
|
||||
let pagePath = '';
|
||||
if (isLangPath && pathSegments.length > 1) {
|
||||
@@ -254,48 +258,48 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
|
||||
// If we're not on a language-specific path, use the current path
|
||||
pagePath = `/${pathSegments.join('/')}`;
|
||||
}
|
||||
|
||||
|
||||
// Handle special case for root path
|
||||
const isRootPath = pathSegments.length === 0 || (isLangPath && pathSegments.length === 1);
|
||||
|
||||
|
||||
// Construct the new URL
|
||||
let newUrl = isRootPath ? `/${langCode}` : `/${langCode}${pagePath}`;
|
||||
|
||||
|
||||
// Clean up any potential double slashes
|
||||
newUrl = newUrl.replace(/\/+/g, '/');
|
||||
|
||||
|
||||
// Append hash fragment if it exists
|
||||
if (currentHash) {
|
||||
newUrl += currentHash;
|
||||
}
|
||||
|
||||
|
||||
// Store the language preference in localStorage and cookies
|
||||
if (window.languageUtils) {
|
||||
window.languageUtils.storeLanguagePreference(langCode);
|
||||
} else {
|
||||
// Fallback if languageUtils is not available
|
||||
localStorage.setItem('preferredLanguage', langCode);
|
||||
|
||||
|
||||
// Also set a cookie for server-side detection
|
||||
const expirationDate = new Date();
|
||||
expirationDate.setFullYear(expirationDate.getFullYear() + 1);
|
||||
document.cookie = `preferredLanguage=${langCode}; expires=${expirationDate.toUTCString()}; path=/; SameSite=Lax`;
|
||||
}
|
||||
|
||||
|
||||
// Dispatch the language changed event
|
||||
const reloadEvent = new CustomEvent('languageChanged', {
|
||||
detail: {
|
||||
langCode,
|
||||
previousLangCode,
|
||||
path: newUrl,
|
||||
willReload: true
|
||||
}
|
||||
willReload: true,
|
||||
},
|
||||
});
|
||||
document.dispatchEvent(reloadEvent);
|
||||
|
||||
|
||||
// Construct the full URL
|
||||
const newFullUrl = `${window.location.origin}${newUrl}`;
|
||||
|
||||
|
||||
// Force a complete page reload to ensure all content is updated to the new language
|
||||
// This bypasses any client-side caching and ensures a fresh server render
|
||||
window.location.href = newFullUrl + '?t=' + Date.now();
|
||||
@@ -312,13 +316,13 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
|
||||
const currentPath = currentUrl.pathname.replace(/\/$/, '');
|
||||
const currentHash = currentUrl.hash;
|
||||
const pathSegments = currentPath.split('/').filter(Boolean);
|
||||
|
||||
|
||||
// Check if we're on a language-specific path
|
||||
const isLangPath = supportedLanguages.includes(pathSegments[0]);
|
||||
|
||||
|
||||
// Get the previous language code
|
||||
const previousLangCode = isLangPath ? pathSegments[0] : 'en';
|
||||
|
||||
|
||||
// Extract the page path without language
|
||||
let pagePath = '';
|
||||
if (isLangPath && pathSegments.length > 1) {
|
||||
@@ -328,48 +332,48 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
|
||||
// If we're not on a language-specific path, use the current path
|
||||
pagePath = `/${pathSegments.join('/')}`;
|
||||
}
|
||||
|
||||
|
||||
// Handle special case for root path
|
||||
const isRootPath = pathSegments.length === 0 || (isLangPath && pathSegments.length === 1);
|
||||
|
||||
|
||||
// Construct the new URL
|
||||
let newUrl = isRootPath ? `/${langCode}` : `/${langCode}${pagePath}`;
|
||||
|
||||
|
||||
// Clean up any potential double slashes
|
||||
newUrl = newUrl.replace(/\/+/g, '/');
|
||||
|
||||
|
||||
// Append hash fragment if it exists
|
||||
if (currentHash) {
|
||||
newUrl += currentHash;
|
||||
}
|
||||
|
||||
|
||||
// Store the language preference in localStorage and cookies
|
||||
if (window.languageUtils) {
|
||||
window.languageUtils.storeLanguagePreference(langCode);
|
||||
} else {
|
||||
// Fallback if languageUtils is not available
|
||||
localStorage.setItem('preferredLanguage', langCode);
|
||||
|
||||
|
||||
// Also set a cookie for server-side detection
|
||||
const expirationDate = new Date();
|
||||
expirationDate.setFullYear(expirationDate.getFullYear() + 1);
|
||||
document.cookie = `preferredLanguage=${langCode}; expires=${expirationDate.toUTCString()}; path=/; SameSite=Lax`;
|
||||
}
|
||||
|
||||
|
||||
// Dispatch the language changed event
|
||||
const reloadEvent = new CustomEvent('languageChanged', {
|
||||
detail: {
|
||||
langCode,
|
||||
previousLangCode,
|
||||
path: newUrl,
|
||||
willReload: true
|
||||
}
|
||||
willReload: true,
|
||||
},
|
||||
});
|
||||
document.dispatchEvent(reloadEvent);
|
||||
|
||||
|
||||
// Construct the full URL
|
||||
const newFullUrl = `${window.location.origin}${newUrl}`;
|
||||
|
||||
|
||||
// Force a complete page reload to ensure all content is updated to the new language
|
||||
// This bypasses any client-side caching and ensures a fresh server render
|
||||
window.location.href = newFullUrl + '?t=' + Date.now();
|
||||
@@ -385,10 +389,10 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
|
||||
|
||||
// Re-run setup when the page content is updated (e.g., after navigation)
|
||||
document.addEventListener('astro:page-load', setupLanguageDropdown);
|
||||
|
||||
|
||||
// Listen for popstate events (browser back/forward buttons)
|
||||
window.addEventListener('popstate', (_event) => {
|
||||
// No need to manually update anything here as the browser will
|
||||
// automatically load the correct URL, and Astro will handle the rendering
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
@@ -96,7 +96,7 @@ declare global {
|
||||
storeLanguagePreference: (langCode) => {
|
||||
localStorage.setItem('preferredLanguage', langCode);
|
||||
setLanguageCookie(langCode);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,4 +109,4 @@ declare global {
|
||||
|
||||
// Re-run setup when the page content is updated (e.g., after navigation)
|
||||
document.addEventListener('astro:page-load', setupLanguagePersistence);
|
||||
</script>
|
||||
</script>
|
||||
|
@@ -8,4 +8,4 @@ interface Props {
|
||||
const { defaultLang } = Astro.props;
|
||||
---
|
||||
|
||||
<LanguageSelectorComponent client:load defaultLang={defaultLang} />
|
||||
<LanguageSelectorComponent client:load defaultLang={defaultLang} />
|
||||
|
@@ -41,21 +41,14 @@ export default function LanguageSelectorComponent({ defaultLang }: LanguageSelec
|
||||
className={`
|
||||
inline-flex items-center px-3 py-2 text-sm font-medium rounded-md
|
||||
transition-colors duration-200 hover:bg-gray-100
|
||||
${language.code === currentLang
|
||||
? 'text-blue-600 bg-blue-50'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}
|
||||
${language.code === currentLang ? 'text-blue-600 bg-blue-50' : 'text-gray-600 hover:text-gray-900'}
|
||||
`}
|
||||
aria-current={language.code === currentLang ? 'page' : undefined}
|
||||
>
|
||||
<Icon
|
||||
name={`circle-flags:${language.flag}`}
|
||||
className="w-5 h-5 mr-2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Icon name={`circle-flags:${language.flag}`} className="w-5 h-5 mr-2" aria-hidden="true" />
|
||||
<span>{language.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -4,6 +4,7 @@ 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"
|
||||
aria-label={SITE?.name}
|
||||
>
|
||||
{SITE?.name}
|
||||
{SITE?.name}
|
||||
</span>
|
||||
|
@@ -163,7 +163,7 @@ import { UI } from 'astrowind:config';
|
||||
// Handle smooth scrolling for anchor links across all pages
|
||||
function setupSmoothScrolling() {
|
||||
// Handle links that start with # (pure anchor links)
|
||||
document.querySelectorAll('a[href^="#"]:not([href="#"])').forEach(anchor => {
|
||||
document.querySelectorAll('a[href^="#"]:not([href="#"])').forEach((anchor) => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -173,23 +173,23 @@ import { UI } from 'astrowind:config';
|
||||
if (targetElement) {
|
||||
window.scrollTo({
|
||||
top: targetElement.offsetTop - 50, // Offset for header
|
||||
behavior: 'smooth'
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle links that contain # but don't start with it (page + anchor)
|
||||
document.querySelectorAll('a[href*="#"]:not([href^="#"])').forEach(anchor => {
|
||||
document.querySelectorAll('a[href*="#"]:not([href^="#"])').forEach((anchor) => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
const href = this.getAttribute('href');
|
||||
const isHashLink = this.getAttribute('data-hash-link') === 'true';
|
||||
|
||||
|
||||
// Check if this is a link to the current page
|
||||
// First, extract the path part (before the hash)
|
||||
const hrefPath = href.split('#')[0];
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
|
||||
// Consider it's the current page if:
|
||||
// 1. The path matches exactly
|
||||
// 2. The href is just a hash (like '/#services')
|
||||
@@ -198,23 +198,23 @@ import { UI } from 'astrowind:config';
|
||||
currentPath === hrefPath ||
|
||||
hrefPath === '' ||
|
||||
(hrefPath === '/' && (currentPath === '/' || currentPath.endsWith('/index.html')));
|
||||
|
||||
|
||||
// For hash links, we want to update the URL and scroll to the element
|
||||
if (isHashLink || isCurrentPage) {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
const hashIndex = href.indexOf('#');
|
||||
if (hashIndex !== -1) {
|
||||
const targetId = href.substring(hashIndex + 1);
|
||||
const targetElement = document.getElementById(targetId);
|
||||
|
||||
|
||||
if (targetElement) {
|
||||
// Update the URL with the hash fragment
|
||||
history.pushState(null, null, href);
|
||||
|
||||
|
||||
window.scrollTo({
|
||||
top: targetElement.offsetTop - 50, // Offset for header
|
||||
behavior: 'smooth'
|
||||
behavior: 'smooth',
|
||||
});
|
||||
} else {
|
||||
// If the target element doesn't exist on the current page, navigate to the page
|
||||
@@ -245,50 +245,48 @@ import { UI } from 'astrowind:config';
|
||||
function updateUrlsWithLanguage() {
|
||||
const currentLang = getStoredLanguage();
|
||||
const supportedLanguages = ['en', 'nl', 'de', 'fr'];
|
||||
|
||||
|
||||
// Update all internal links to include the language prefix
|
||||
document.querySelectorAll('a[href^="/"]:not([href^="//"])').forEach(link => {
|
||||
document.querySelectorAll('a[href^="/"]:not([href^="//"])').forEach((link) => {
|
||||
const href = link.getAttribute('href');
|
||||
if (!href) return;
|
||||
|
||||
|
||||
// Skip hash-only links (e.g., "#services")
|
||||
if (href.startsWith('#')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Extract hash fragment if present
|
||||
let hashFragment = '';
|
||||
let pathWithoutHash = href;
|
||||
|
||||
|
||||
if (href.includes('#')) {
|
||||
const parts = href.split('#');
|
||||
pathWithoutHash = parts[0];
|
||||
hashFragment = '#' + parts[1];
|
||||
}
|
||||
|
||||
|
||||
// Parse the URL path (without hash) to check for existing language code
|
||||
const pathSegments = pathWithoutHash.split('/').filter(Boolean);
|
||||
const hasLanguagePrefix = pathSegments.length > 0 && supportedLanguages.includes(pathSegments[0]);
|
||||
|
||||
|
||||
// If it already has a language prefix but it's different from the current language,
|
||||
// update it to the current language
|
||||
if (hasLanguagePrefix && pathSegments[0] !== currentLang) {
|
||||
// Replace the existing language prefix with the current one
|
||||
pathSegments[0] = currentLang;
|
||||
const newPath = '/' + pathSegments.join('/');
|
||||
|
||||
|
||||
// Set the new href with the hash fragment (if any)
|
||||
link.setAttribute('href', newPath + hashFragment);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// If it doesn't have a language prefix, add the current language
|
||||
if (!hasLanguagePrefix) {
|
||||
// Create the new path with the language prefix
|
||||
const newPath = pathWithoutHash === '/' ?
|
||||
`/${currentLang}` :
|
||||
`/${currentLang}${pathWithoutHash}`;
|
||||
|
||||
const newPath = pathWithoutHash === '/' ? `/${currentLang}` : `/${currentLang}${pathWithoutHash}`;
|
||||
|
||||
// Set the new href with the hash fragment (if any)
|
||||
link.setAttribute('href', newPath + hashFragment);
|
||||
}
|
||||
@@ -300,11 +298,11 @@ import { UI } from 'astrowind:config';
|
||||
// Store the selected language in localStorage
|
||||
if (event.detail && event.detail.langCode) {
|
||||
storeLanguagePreference(event.detail.langCode);
|
||||
|
||||
|
||||
// Always update all internal links with the new language
|
||||
// regardless of whether we're doing a full page reload
|
||||
updateUrlsWithLanguage();
|
||||
|
||||
|
||||
// If this event includes a path but doesn't indicate it will reload,
|
||||
// force a full page reload to ensure all content is properly translated
|
||||
if (event.detail.path && !event.detail.willReload) {
|
||||
@@ -312,13 +310,13 @@ import { UI } from 'astrowind:config';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Process links when the page loads
|
||||
updateUrlsWithLanguage();
|
||||
|
||||
|
||||
// Process links after client-side navigation
|
||||
document.addEventListener('astro:page-load', updateUrlsWithLanguage);
|
||||
|
||||
|
||||
// Also update links when the DOM content is loaded
|
||||
document.addEventListener('DOMContentLoaded', updateUrlsWithLanguage);
|
||||
|
||||
@@ -327,25 +325,25 @@ import { UI } from 'astrowind:config';
|
||||
if (window.location.hash) {
|
||||
const targetId = window.location.hash.substring(1);
|
||||
const targetElement = document.getElementById(targetId);
|
||||
|
||||
|
||||
if (targetElement) {
|
||||
// Use setTimeout to ensure the page has fully loaded
|
||||
setTimeout(() => {
|
||||
window.scrollTo({
|
||||
top: targetElement.offsetTop - 50, // Offset for header
|
||||
behavior: 'smooth'
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
scrollToHashOnLoad();
|
||||
|
||||
// Make language functions available globally
|
||||
window.languageUtils = {
|
||||
getStoredLanguage,
|
||||
storeLanguagePreference
|
||||
storeLanguagePreference,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -471,36 +469,36 @@ import { UI } from 'astrowind:config';
|
||||
if (!contactForm) return;
|
||||
|
||||
// Form validation and submission
|
||||
contactForm.addEventListener('submit', async function(e) {
|
||||
contactForm.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
// Reset previous error messages
|
||||
resetFormErrors();
|
||||
|
||||
|
||||
// Client-side validation
|
||||
if (!validateForm(contactForm)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Show loading state
|
||||
const submitButton = contactForm.querySelector('button[type="submit"]');
|
||||
const originalButtonText = submitButton.innerHTML;
|
||||
submitButton.disabled = true;
|
||||
submitButton.innerHTML = 'Sending...';
|
||||
|
||||
|
||||
try {
|
||||
const formData = new FormData(contactForm);
|
||||
|
||||
|
||||
// Add timestamp to help prevent duplicate submissions
|
||||
formData.append('timestamp', Date.now().toString());
|
||||
|
||||
|
||||
// Submit the form to Netlify
|
||||
const response = await fetch('/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(formData).toString()
|
||||
body: new URLSearchParams(formData).toString(),
|
||||
});
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
// Show success message
|
||||
document.getElementById('form-success').classList.remove('hidden');
|
||||
@@ -516,14 +514,14 @@ import { UI } from 'astrowind:config';
|
||||
submitButton.innerHTML = originalButtonText;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Add input validation on blur
|
||||
contactForm.querySelectorAll('input, textarea').forEach(input => {
|
||||
input.addEventListener('blur', function() {
|
||||
contactForm.querySelectorAll('input, textarea').forEach((input) => {
|
||||
input.addEventListener('blur', function () {
|
||||
validateInput(this);
|
||||
});
|
||||
|
||||
input.addEventListener('input', function() {
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
// Remove error styling when user starts typing
|
||||
this.classList.remove('border-red-500');
|
||||
const feedbackElement = this.closest('div').querySelector('.invalid-feedback');
|
||||
@@ -536,29 +534,29 @@ import { UI } from 'astrowind:config';
|
||||
|
||||
function validateForm(form) {
|
||||
let isValid = true;
|
||||
|
||||
|
||||
// Validate all inputs
|
||||
form.querySelectorAll('input, textarea').forEach(input => {
|
||||
form.querySelectorAll('input, textarea').forEach((input) => {
|
||||
if (!validateInput(input)) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function validateInput(input) {
|
||||
if (!input.required) return true;
|
||||
|
||||
|
||||
let isValid = true;
|
||||
const feedbackElement = input.closest('div').querySelector('.invalid-feedback');
|
||||
|
||||
|
||||
// Reset previous error
|
||||
if (feedbackElement) {
|
||||
feedbackElement.classList.add('hidden');
|
||||
}
|
||||
input.classList.remove('border-red-500');
|
||||
|
||||
|
||||
// Checkbox validation (special case for disclaimer)
|
||||
if (input.type === 'checkbox') {
|
||||
if (input.required && !input.checked) {
|
||||
@@ -582,7 +580,7 @@ import { UI } from 'astrowind:config';
|
||||
}
|
||||
return isValid;
|
||||
}
|
||||
|
||||
|
||||
// Check if empty
|
||||
if (input.required && !input.value.trim()) {
|
||||
isValid = false;
|
||||
@@ -592,7 +590,7 @@ import { UI } from 'astrowind:config';
|
||||
}
|
||||
input.classList.add('border-red-500');
|
||||
}
|
||||
|
||||
|
||||
// Email validation
|
||||
if (input.type === 'email' && input.value.trim()) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
@@ -605,7 +603,7 @@ import { UI } from 'astrowind:config';
|
||||
input.classList.add('border-red-500');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Textarea minimum length
|
||||
if (input.tagName === 'TEXTAREA' && input.value.trim().length < 10) {
|
||||
isValid = false;
|
||||
@@ -615,26 +613,26 @@ import { UI } from 'astrowind:config';
|
||||
}
|
||||
input.classList.add('border-red-500');
|
||||
}
|
||||
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function resetFormErrors() {
|
||||
// Hide all error messages
|
||||
document.querySelectorAll('.invalid-feedback').forEach(el => {
|
||||
document.querySelectorAll('.invalid-feedback').forEach((el) => {
|
||||
el.classList.add('hidden');
|
||||
});
|
||||
|
||||
|
||||
// Remove error styling
|
||||
document.querySelectorAll('input, textarea').forEach(input => {
|
||||
document.querySelectorAll('input, textarea').forEach((input) => {
|
||||
input.classList.remove('border-red-500');
|
||||
});
|
||||
|
||||
|
||||
// Remove checkbox container error styling
|
||||
document.querySelectorAll('.flex.items-start').forEach(container => {
|
||||
document.querySelectorAll('.flex.items-start').forEach((container) => {
|
||||
container.classList.remove('checkbox-error');
|
||||
});
|
||||
|
||||
|
||||
// Hide form-level messages
|
||||
document.getElementById('form-success')?.classList.add('hidden');
|
||||
document.getElementById('form-error')?.classList.add('hidden');
|
||||
|
@@ -2,4 +2,4 @@
|
||||
const { data } = Astro.props;
|
||||
---
|
||||
|
||||
<script type="application/ld+json" set:html={JSON.stringify(data)} />
|
||||
<script type="application/ld+json" set:html={JSON.stringify(data)} />
|
||||
|
@@ -10,25 +10,33 @@ const { items } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="accordion">
|
||||
{items.map((item, index) => (
|
||||
<div class="accordion-item">
|
||||
<button class="accordion-header" data-accordion-target={`#accordion-body-${index}`}>
|
||||
{item.title}
|
||||
<svg class="w-4 h-4 ml-2 shrink-0" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
|
||||
</button>
|
||||
<div id={`accordion-body-${index}`} class="accordion-body" aria-labelledby={`accordion-header-${index}`}>
|
||||
<div class="p-5 border border-b-0 dark:border-gray-700 dark:bg-gray-900">
|
||||
<p class="text-gray-500 dark:text-gray-400">{item.description}</p>
|
||||
{
|
||||
items.map((item, index) => (
|
||||
<div class="accordion-item">
|
||||
<button class="accordion-header" data-accordion-target={`#accordion-body-${index}`}>
|
||||
{item.title}
|
||||
<svg class="w-4 h-4 ml-2 shrink-0" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id={`accordion-body-${index}`} class="accordion-body" aria-labelledby={`accordion-header-${index}`}>
|
||||
<div class="p-5 border border-b-0 dark:border-gray-700 dark:bg-gray-900">
|
||||
<p class="text-gray-500 dark:text-gray-400">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const accordionHeaders = document.querySelectorAll('.accordion-header');
|
||||
|
||||
accordionHeaders.forEach(header => {
|
||||
accordionHeaders.forEach((header) => {
|
||||
header.addEventListener('click', () => {
|
||||
const target = header.getAttribute('data-accordion-target');
|
||||
const body = document.querySelector(target);
|
||||
@@ -37,7 +45,7 @@ const { items } = Astro.props;
|
||||
const expanded = (body as HTMLElement).classList.contains('expanded');
|
||||
|
||||
// Close all accordion items
|
||||
document.querySelectorAll('.accordion-body.expanded').forEach(item => {
|
||||
document.querySelectorAll('.accordion-body.expanded').forEach((item) => {
|
||||
if (item && 'style' in item) {
|
||||
(item as any).style.display = 'none';
|
||||
item.classList.remove('expanded');
|
||||
@@ -57,7 +65,7 @@ const { items } = Astro.props;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.accordion-header {
|
||||
.accordion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
@@ -69,17 +77,17 @@ const { items } = Astro.props;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-header:hover {
|
||||
.accordion-header:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-body {
|
||||
.accordion-body {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-body.expanded {
|
||||
.accordion-body.expanded {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,12 +1,8 @@
|
||||
---
|
||||
---
|
||||
|
||||
<button
|
||||
id="back-to-top"
|
||||
class="back-to-top"
|
||||
aria-label="Back to top"
|
||||
title="Return to Top"
|
||||
>
|
||||
---
|
||||
|
||||
<button id="back-to-top" class="back-to-top" aria-label="Back to top" title="Return to Top">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
@@ -81,7 +77,7 @@
|
||||
// Function to initialize the back to top button
|
||||
function initBackToTop() {
|
||||
const backToTopButton = document.getElementById('back-to-top');
|
||||
|
||||
|
||||
if (!backToTopButton) return;
|
||||
|
||||
// Show button when scrolling down
|
||||
@@ -98,7 +94,7 @@
|
||||
e.preventDefault();
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -116,4 +112,4 @@
|
||||
// Re-initialize on view transitions (for Astro's View Transitions)
|
||||
document.addEventListener('astro:page-load', initBackToTop);
|
||||
document.addEventListener('astro:after-swap', initBackToTop);
|
||||
</script>
|
||||
</script>
|
||||
|
@@ -70,7 +70,7 @@ const iconNames: string[] = [
|
||||
'tabler:brand-github',
|
||||
'tabler:device-desktop',
|
||||
'tabler:brand-azure',
|
||||
|
||||
|
||||
// Additional tech-related icons
|
||||
'tabler:cpu',
|
||||
'tabler:device-mobile',
|
||||
@@ -79,7 +79,7 @@ const iconNames: string[] = [
|
||||
'tabler:shield',
|
||||
'tabler:lock',
|
||||
'tabler:key',
|
||||
|
||||
|
||||
// Tangentially related icons for visual diversity
|
||||
'tabler:bulb',
|
||||
'tabler:compass',
|
||||
@@ -101,17 +101,13 @@ const getRandomRotation = (): string => {
|
||||
// Function to get a random size
|
||||
const getRandomSize = (isDarkMode: boolean = false): string => {
|
||||
// Slightly larger size range for dark mode for better visibility
|
||||
return isDarkMode
|
||||
? `${getRandomInRange(160, 200)}px`
|
||||
: `${getRandomInRange(140, 180)}px`;
|
||||
return isDarkMode ? `${getRandomInRange(160, 200)}px` : `${getRandomInRange(140, 180)}px`;
|
||||
};
|
||||
|
||||
// Function to get a random opacity
|
||||
const getRandomOpacity = (isDarkMode: boolean = false): string => {
|
||||
// Higher opacity range for dark mode for better visibility
|
||||
return isDarkMode
|
||||
? getRandomInRange(0.45, 0.55).toFixed(2)
|
||||
: getRandomInRange(0.32, 0.38).toFixed(2);
|
||||
return isDarkMode ? getRandomInRange(0.45, 0.55).toFixed(2) : getRandomInRange(0.32, 0.38).toFixed(2);
|
||||
};
|
||||
|
||||
// Create a spacious layout with well-separated icons
|
||||
@@ -119,14 +115,14 @@ const createSpacedIcons = (): BaseIconObject[] => {
|
||||
const icons: BaseIconObject[] = [];
|
||||
const rows = 6; // Reduced from 8 to 6 for fewer potential positions
|
||||
const cols = 6; // Reduced from 8 to 6 for fewer potential positions
|
||||
|
||||
|
||||
// Define larger margins to keep icons away from edges (in percentage)
|
||||
const marginX = 10; // 10% margin from left and right edges (increased from 5%)
|
||||
const marginY = 10; // 10% margin from top and bottom edges (increased from 5%)
|
||||
|
||||
|
||||
// Minimum distance between icons (in percentage points)
|
||||
const minDistance = 20; // Ensure at least 20% distance between any two icons
|
||||
|
||||
|
||||
// Create a base grid of positions
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
@@ -134,46 +130,43 @@ const createSpacedIcons = (): BaseIconObject[] => {
|
||||
if (Math.random() < 0.75) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Base position in the grid with margins applied
|
||||
const baseX = marginX + ((col / cols) * (100 - 2 * marginX));
|
||||
const baseY = marginY + ((row / rows) * (100 - 2 * marginY));
|
||||
|
||||
const baseX = marginX + (col / cols) * (100 - 2 * marginX);
|
||||
const baseY = marginY + (row / rows) * (100 - 2 * marginY);
|
||||
|
||||
// Add limited randomness to the position (±5%) to maintain spacing
|
||||
const randomOffsetX = getRandomInRange(-5, 5);
|
||||
const randomOffsetY = getRandomInRange(-5, 5);
|
||||
|
||||
|
||||
// Ensure positions stay within margins
|
||||
const x = Math.max(marginX, Math.min(100 - marginX, baseX + randomOffsetX));
|
||||
const y = Math.max(marginY, Math.min(100 - marginY, baseY + randomOffsetY));
|
||||
|
||||
|
||||
// Check if this position is too close to any existing icon
|
||||
let tooClose = false;
|
||||
for (const existingIcon of icons) {
|
||||
// Extract numeric values from percentage strings
|
||||
const existingX = parseFloat(existingIcon.x);
|
||||
const existingY = parseFloat(existingIcon.y);
|
||||
|
||||
|
||||
// Calculate distance between points
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(x - existingX, 2) +
|
||||
Math.pow(y - existingY, 2)
|
||||
);
|
||||
|
||||
const distance = Math.sqrt(Math.pow(x - existingX, 2) + Math.pow(y - existingY, 2));
|
||||
|
||||
// If too close to an existing icon, skip this position
|
||||
if (distance < minDistance) {
|
||||
tooClose = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (tooClose) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Randomly select an icon from the expanded set
|
||||
const iconIndex = Math.floor(Math.random() * iconNames.length);
|
||||
|
||||
|
||||
icons.push({
|
||||
icon: iconNames[iconIndex],
|
||||
x: `${x}%`,
|
||||
@@ -184,14 +177,14 @@ const createSpacedIcons = (): BaseIconObject[] => {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return icons;
|
||||
};
|
||||
|
||||
const icons: BaseIconObject[] = createSpacedIcons();
|
||||
|
||||
// Assign random colors to each icon
|
||||
const iconsWithColors: IconWithColors[] = icons.map(icon => ({
|
||||
const iconsWithColors: IconWithColors[] = icons.map((icon) => ({
|
||||
icon: icon.icon,
|
||||
x: icon.x,
|
||||
y: icon.y,
|
||||
@@ -202,85 +195,89 @@ const iconsWithColors: IconWithColors[] = icons.map(icon => ({
|
||||
darkColor: getRandomColor(darkModeColors),
|
||||
}));
|
||||
---
|
||||
|
||||
<div class:list={['absolute inset-0', { 'backdrop-blur-sm bg-white/5 dark:bg-gray-900/10': isDark }]}>
|
||||
<slot />
|
||||
|
||||
{showIcons && (
|
||||
/* Decorative background icons with random placement */
|
||||
<div id="background-icons" class="absolute inset-0 overflow-hidden pointer-events-none z-[-5]">
|
||||
{iconsWithColors.map(({ icon, x, y, size, opacity, rotate, lightColor, darkColor }) => (
|
||||
<div
|
||||
class={`absolute ${lightColor} ${darkColor} background-icon`}
|
||||
style={`left: ${x}; top: ${y}; opacity: ${opacity}; transform: rotate(${rotate});`}
|
||||
>
|
||||
<Icon name={icon} style={`width: ${size}; height: ${size};`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{
|
||||
showIcons && (
|
||||
/* Decorative background icons with random placement */
|
||||
<div id="background-icons" class="absolute inset-0 overflow-hidden pointer-events-none z-[-5]">
|
||||
{iconsWithColors.map(({ icon, x, y, size, opacity, rotate, lightColor, darkColor }) => (
|
||||
<div
|
||||
class={`absolute ${lightColor} ${darkColor} background-icon`}
|
||||
style={`left: ${x}; top: ${y}; opacity: ${opacity}; transform: rotate(${rotate});`}
|
||||
>
|
||||
<Icon name={icon} style={`width: ${size}; height: ${size};`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{showIcons && (
|
||||
<script define:vars={{ disableParallax }}>
|
||||
// Parallax scrolling effect for background icons
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Get all parallax icons
|
||||
const parallaxIcons = document.querySelectorAll('.parallax-icon');
|
||||
|
||||
// Skip parallax on mobile devices for better performance or if parallax is disabled
|
||||
const isMobile = window.matchMedia('(max-width: 768px)').matches;
|
||||
if (isMobile || disableParallax) return;
|
||||
|
||||
// Variables to track scroll position
|
||||
let lastScrollY = window.scrollY;
|
||||
let ticking = false;
|
||||
|
||||
// Function to update icon positions based on scroll
|
||||
const updateParallax = () => {
|
||||
parallaxIcons.forEach((icon) => {
|
||||
const depth = parseFloat(icon.getAttribute('data-depth') || '0.5');
|
||||
|
||||
// Calculate parallax offset based on scroll position and depth
|
||||
// Lower depth value means the icon moves slower (appears further away)
|
||||
const yOffset = (lastScrollY * depth * 0.15);
|
||||
|
||||
// Get the original rotation
|
||||
const transformValue = icon.style.transform;
|
||||
const rotateMatch = transformValue.match(/rotate\([^)]+\)/);
|
||||
const rotateValue = rotateMatch ? rotateMatch[0] : 'rotate(0deg)';
|
||||
|
||||
// Apply transform with the original rotation plus the parallax offset
|
||||
icon.style.transform = `${rotateValue} translate3d(0, ${yOffset}px, 0)`;
|
||||
<script define:vars={{ disableParallax }}>
|
||||
// Parallax scrolling effect for background icons
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Get all parallax icons
|
||||
const parallaxIcons = document.querySelectorAll('.parallax-icon');
|
||||
|
||||
// Skip parallax on mobile devices for better performance or if parallax is disabled
|
||||
const isMobile = window.matchMedia('(max-width: 768px)').matches;
|
||||
if (isMobile || disableParallax) return;
|
||||
|
||||
// Variables to track scroll position
|
||||
let lastScrollY = window.scrollY;
|
||||
let ticking = false;
|
||||
|
||||
// Function to update icon positions based on scroll
|
||||
const updateParallax = () => {
|
||||
parallaxIcons.forEach((icon) => {
|
||||
const depth = parseFloat(icon.getAttribute('data-depth') || '0.5');
|
||||
|
||||
// Calculate parallax offset based on scroll position and depth
|
||||
// Lower depth value means the icon moves slower (appears further away)
|
||||
const yOffset = lastScrollY * depth * 0.15;
|
||||
|
||||
// Get the original rotation
|
||||
const transformValue = icon.style.transform;
|
||||
const rotateMatch = transformValue.match(/rotate\([^)]+\)/);
|
||||
const rotateValue = rotateMatch ? rotateMatch[0] : 'rotate(0deg)';
|
||||
|
||||
// Apply transform with the original rotation plus the parallax offset
|
||||
icon.style.transform = `${rotateValue} translate3d(0, ${yOffset}px, 0)`;
|
||||
});
|
||||
|
||||
ticking = false;
|
||||
};
|
||||
|
||||
// Throttle scroll events for better performance
|
||||
const onScroll = () => {
|
||||
lastScrollY = window.scrollY;
|
||||
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
updateParallax();
|
||||
ticking = false;
|
||||
});
|
||||
|
||||
ticking = false;
|
||||
};
|
||||
|
||||
// Throttle scroll events for better performance
|
||||
const onScroll = () => {
|
||||
lastScrollY = window.scrollY;
|
||||
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
updateParallax();
|
||||
ticking = false;
|
||||
});
|
||||
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Add scroll event listener
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
// Update on resize (debounced)
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Add scroll event listener
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
// Update on resize (debounced)
|
||||
let resizeTimer;
|
||||
window.addEventListener(
|
||||
'resize',
|
||||
() => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
// Check if device is now mobile and disable parallax if needed
|
||||
const isMobileNow = window.matchMedia('(max-width: 768px)').matches;
|
||||
|
||||
|
||||
if (isMobileNow) {
|
||||
// Reset positions on mobile
|
||||
parallaxIcons.forEach((icon) => {
|
||||
@@ -294,10 +291,11 @@ const iconsWithColors: IconWithColors[] = icons.map(icon => ({
|
||||
updateParallax();
|
||||
}
|
||||
}, 200);
|
||||
}, { passive: true });
|
||||
|
||||
// Initial update
|
||||
updateParallax();
|
||||
});
|
||||
</script>
|
||||
)}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
// Initial update
|
||||
updateParallax();
|
||||
});
|
||||
</script>
|
||||
|
@@ -22,7 +22,7 @@ const {
|
||||
|
||||
{
|
||||
items && items.length && (
|
||||
<div class={twMerge("grid grid-cols-1 md:grid-cols-2 gap-4", containerClass)}>
|
||||
<div class={twMerge('grid grid-cols-1 md:grid-cols-2 gap-4', containerClass)}>
|
||||
{items.map(({ title, description, icon, classes: itemClasses = {} }) => (
|
||||
<div
|
||||
class={twMerge(
|
||||
@@ -44,7 +44,11 @@ const {
|
||||
{description && (
|
||||
<div class="text-muted mt-2 overflow-hidden">
|
||||
<div
|
||||
class={twMerge('text-sm max-h-[6rem] hover:max-h-[300px] transition-all duration-500 ease-description', descriptionClass, itemClasses?.description)}
|
||||
class={twMerge(
|
||||
'text-sm max-h-[6rem] hover:max-h-[300px] transition-all duration-500 ease-description',
|
||||
descriptionClass,
|
||||
itemClasses?.description
|
||||
)}
|
||||
set:html={description}
|
||||
/>
|
||||
</div>
|
||||
@@ -61,9 +65,9 @@ const {
|
||||
.ease-description {
|
||||
transition-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
|
||||
/* Fade effect for the gradient overlay */
|
||||
.hover\:opacity-0:hover {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
@@ -15,20 +15,27 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
}
|
||||
</style>
|
||||
|
||||
<form id="contact-form" name="contact" method="POST" class="needs-validation" data-netlify="true" data-netlify-honeypot="bot-field" novalidate>
|
||||
|
||||
<form
|
||||
id="contact-form"
|
||||
name="contact"
|
||||
method="POST"
|
||||
class="needs-validation"
|
||||
data-netlify="true"
|
||||
data-netlify-honeypot="bot-field"
|
||||
novalidate
|
||||
>
|
||||
<!-- Form status messages -->
|
||||
<div id="form-success" class="hidden mb-6 p-4 bg-green-100 border border-green-200 text-green-700 rounded-lg">
|
||||
Your message has been sent successfully. We will get back to you soon!
|
||||
</div>
|
||||
|
||||
|
||||
<div id="form-error" class="hidden mb-6 p-4 bg-red-100 border border-red-200 text-red-700 rounded-lg">
|
||||
There was an error sending your message. Please check all fields and try again.
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Netlify form name -->
|
||||
<input type="hidden" name="form-name" value="contact" />
|
||||
|
||||
|
||||
<!-- Honeypot field to prevent spam -->
|
||||
<p class="hidden">
|
||||
<label>Don't fill this out if you're human: <input name="bot-field" /></label>
|
||||
@@ -41,7 +48,8 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
<div class="mb-6">
|
||||
{label && (
|
||||
<label for={name} class="block text-sm font-medium">
|
||||
{label}{required && <span class="text-red-600">*</span>}
|
||||
{label}
|
||||
{required && <span class="text-red-600">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
@@ -53,7 +61,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
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"
|
||||
required={required}
|
||||
/>
|
||||
<div class="invalid-feedback hidden text-red-600 text-sm mt-1"></div>
|
||||
<div class="invalid-feedback hidden text-red-600 text-sm mt-1" />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
@@ -63,7 +71,8 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
textarea && (
|
||||
<div class="mb-6">
|
||||
<label for="textarea" class="block text-sm font-medium">
|
||||
{textarea.label}<span class="text-red-600">*</span>
|
||||
{textarea.label}
|
||||
<span class="text-red-600">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="textarea"
|
||||
@@ -73,7 +82,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
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"
|
||||
required
|
||||
/>
|
||||
<div class="invalid-feedback hidden text-red-600 text-sm mt-1"></div>
|
||||
<div class="invalid-feedback hidden text-red-600 text-sm mt-1" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -92,9 +101,10 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<label for="disclaimer" class="cursor-pointer select-none text-sm text-gray-600 dark:text-gray-400">
|
||||
{disclaimer.label}<span class="text-red-600">*</span>
|
||||
{disclaimer.label}
|
||||
<span class="text-red-600">*</span>
|
||||
</label>
|
||||
<div class="invalid-feedback hidden text-red-600 text-sm mt-1"></div>
|
||||
<div class="invalid-feedback hidden text-red-600 text-sm mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -118,3 +128,57 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
)
|
||||
}
|
||||
</form>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('contact-form') as HTMLFormElement;
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
try {
|
||||
const response = await fetch('/.netlify/functions/contact', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log(result.message); // Log success message
|
||||
const successElement = document.getElementById('form-success');
|
||||
if (successElement) {
|
||||
successElement.classList.remove('hidden');
|
||||
}
|
||||
const errorElement = document.getElementById('form-error');
|
||||
if (errorElement) {
|
||||
errorElement.classList.add('hidden');
|
||||
}
|
||||
form.reset(); // Clear the form
|
||||
} else {
|
||||
console.error('Error:', response.status);
|
||||
const errorElement = document.getElementById('form-error');
|
||||
if (errorElement) {
|
||||
errorElement.classList.remove('hidden');
|
||||
}
|
||||
const successElement = document.getElementById('form-success');
|
||||
if (successElement) {
|
||||
successElement.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
const errorElement = document.getElementById('form-error');
|
||||
if (errorElement) {
|
||||
errorElement.classList.remove('hidden');
|
||||
}
|
||||
const successElement = document.getElementById('form-success');
|
||||
if (successElement) {
|
||||
successElement.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
@@ -70,7 +70,7 @@ const iconNames: string[] = [
|
||||
'tabler:brand-github',
|
||||
'tabler:device-desktop',
|
||||
'tabler:brand-azure',
|
||||
|
||||
|
||||
// Additional tech-related icons
|
||||
'tabler:cpu',
|
||||
'tabler:device-mobile',
|
||||
@@ -79,7 +79,7 @@ const iconNames: string[] = [
|
||||
'tabler:shield',
|
||||
'tabler:lock',
|
||||
'tabler:key',
|
||||
|
||||
|
||||
// Tangentially related icons for visual diversity
|
||||
'tabler:bulb',
|
||||
'tabler:compass',
|
||||
@@ -101,17 +101,13 @@ const getRandomRotation = (): string => {
|
||||
// Function to get a random size
|
||||
const getRandomSize = (isDarkMode: boolean = false): string => {
|
||||
// Slightly larger size range for dark mode for better visibility
|
||||
return isDarkMode
|
||||
? `${getRandomInRange(160, 200)}px`
|
||||
: `${getRandomInRange(140, 180)}px`;
|
||||
return isDarkMode ? `${getRandomInRange(160, 200)}px` : `${getRandomInRange(140, 180)}px`;
|
||||
};
|
||||
|
||||
// Function to get a random opacity
|
||||
const getRandomOpacity = (isDarkMode: boolean = false): string => {
|
||||
// Higher opacity range for dark mode for better visibility
|
||||
return isDarkMode
|
||||
? getRandomInRange(0.45, 0.55).toFixed(2)
|
||||
: getRandomInRange(0.32, 0.38).toFixed(2);
|
||||
return isDarkMode ? getRandomInRange(0.45, 0.55).toFixed(2) : getRandomInRange(0.32, 0.38).toFixed(2);
|
||||
};
|
||||
|
||||
// Create a spacious layout with well-separated icons
|
||||
@@ -119,14 +115,14 @@ const createSpacedIcons = (): BaseIconObject[] => {
|
||||
const icons: BaseIconObject[] = [];
|
||||
const rows = 10; // Increased from 6 to 10 for more coverage across the entire page
|
||||
const cols = 6;
|
||||
|
||||
|
||||
// Define larger margins to keep icons away from edges (in percentage)
|
||||
const marginX = 10;
|
||||
const marginY = 5; // Reduced from 10 to 5 to allow icons to span more of the page height
|
||||
|
||||
|
||||
// Minimum distance between icons (in percentage points)
|
||||
const minDistance = 15; // Reduced from 20 to 15 to allow more icons
|
||||
|
||||
|
||||
// Create a base grid of positions
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
@@ -134,62 +130,59 @@ const createSpacedIcons = (): BaseIconObject[] => {
|
||||
// For small screens: 88% chance to skip (showing ~1/3 of the icons)
|
||||
// For larger screens: 65% chance to skip (original density)
|
||||
const skipProbability = 0.65;
|
||||
|
||||
|
||||
// We'll add a data attribute to each icon to control visibility based on screen size
|
||||
let visibilityClass = '';
|
||||
|
||||
|
||||
// Generate a random number to determine if we skip this position
|
||||
const randomValue = Math.random();
|
||||
|
||||
|
||||
// Skip for all screen sizes if below the base threshold
|
||||
if (randomValue < skipProbability) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// For values between 0.65 and 0.88, create the icon but mark it to be hidden on small screens
|
||||
if (randomValue < 0.88) {
|
||||
visibilityClass = 'hidden sm:block'; // Hidden on small screens, visible on sm and up
|
||||
}
|
||||
|
||||
|
||||
// Base position in the grid with margins applied
|
||||
const baseX = marginX + ((col / cols) * (100 - 2 * marginX));
|
||||
const baseY = marginY + ((row / rows) * (100 - 2 * marginY));
|
||||
|
||||
const baseX = marginX + (col / cols) * (100 - 2 * marginX);
|
||||
const baseY = marginY + (row / rows) * (100 - 2 * marginY);
|
||||
|
||||
// Add limited randomness to the position (±5%) to maintain spacing
|
||||
const randomOffsetX = getRandomInRange(-5, 5);
|
||||
const randomOffsetY = getRandomInRange(-5, 5);
|
||||
|
||||
|
||||
// Ensure positions stay within margins
|
||||
const x = Math.max(marginX, Math.min(100 - marginX, baseX + randomOffsetX));
|
||||
const y = Math.max(marginY, Math.min(100 - marginY, baseY + randomOffsetY));
|
||||
|
||||
|
||||
// Check if this position is too close to any existing icon
|
||||
let tooClose = false;
|
||||
for (const existingIcon of icons) {
|
||||
// Extract numeric values from percentage strings
|
||||
const existingX = parseFloat(existingIcon.x);
|
||||
const existingY = parseFloat(existingIcon.y);
|
||||
|
||||
|
||||
// Calculate distance between points
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(x - existingX, 2) +
|
||||
Math.pow(y - existingY, 2)
|
||||
);
|
||||
|
||||
const distance = Math.sqrt(Math.pow(x - existingX, 2) + Math.pow(y - existingY, 2));
|
||||
|
||||
// If too close to an existing icon, skip this position
|
||||
if (distance < minDistance) {
|
||||
tooClose = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (tooClose) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Randomly select an icon from the expanded set
|
||||
const iconIndex = Math.floor(Math.random() * iconNames.length);
|
||||
|
||||
|
||||
icons.push({
|
||||
icon: iconNames[iconIndex],
|
||||
x: `${x}%`,
|
||||
@@ -201,15 +194,14 @@ const createSpacedIcons = (): BaseIconObject[] => {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return icons;
|
||||
};
|
||||
|
||||
|
||||
const icons: BaseIconObject[] = createSpacedIcons();
|
||||
|
||||
// Assign random colors to each icon
|
||||
const iconsWithColors: IconWithColors[] = icons.map(icon => ({
|
||||
const iconsWithColors: IconWithColors[] = icons.map((icon) => ({
|
||||
icon: icon.icon,
|
||||
x: icon.x,
|
||||
y: icon.y,
|
||||
@@ -224,18 +216,20 @@ const iconsWithColors: IconWithColors[] = icons.map(icon => ({
|
||||
|
||||
<div class="fixed inset-0 overflow-hidden pointer-events-none z-[-5]" aria-hidden="true">
|
||||
<div class:list={['absolute inset-0', { 'backdrop-blur-sm bg-white/5 dark:bg-gray-900/10': isDark }]}></div>
|
||||
|
||||
|
||||
{/* Decorative background icons with random placement */}
|
||||
<div id="background-icons" class="absolute inset-0 overflow-hidden">
|
||||
{iconsWithColors.map(({ icon, x, y, size, opacity, rotate, lightColor, darkColor, visibilityClass }) => (
|
||||
<div
|
||||
class={`absolute ${lightColor} ${darkColor} ${visibilityClass}`}
|
||||
style={`left: ${x}; top: ${y}; opacity: ${opacity}; transform: rotate(${rotate});`}
|
||||
>
|
||||
<Icon name={icon} style={`width: ${size}; height: ${size};`} />
|
||||
</div>
|
||||
))}
|
||||
{
|
||||
iconsWithColors.map(({ icon, x, y, size, opacity, rotate, lightColor, darkColor, visibilityClass }) => (
|
||||
<div
|
||||
class={`absolute ${lightColor} ${darkColor} ${visibilityClass}`}
|
||||
style={`left: ${x}; top: ${y}; opacity: ${opacity}; transform: rotate(${rotate});`}
|
||||
>
|
||||
<Icon name={icon} style={`width: ${size}; height: ${size};`} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parallax effect removed while maintaining random icon placement -->
|
||||
<!-- Parallax effect removed while maintaining random icon placement -->
|
||||
|
@@ -20,16 +20,24 @@ const {
|
||||
(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 content-backdrop p-2 inline-block rounded-md" set:html={tagline} />
|
||||
<p
|
||||
class="text-base text-secondary dark:text-blue-200 font-bold tracking-wide uppercase content-backdrop p-2 inline-block rounded-md"
|
||||
set:html={tagline}
|
||||
/>
|
||||
)}
|
||||
{title && (
|
||||
<h2
|
||||
class={twMerge('font-bold leading-tighter tracking-tighter font-heading text-heading text-3xl content-backdrop p-2 block rounded-md', titleClass)}
|
||||
class={twMerge(
|
||||
'font-bold leading-tighter tracking-tighter font-heading text-heading text-3xl content-backdrop p-2 block rounded-md',
|
||||
titleClass
|
||||
)}
|
||||
set:html={title}
|
||||
/>
|
||||
)}
|
||||
|
||||
{subtitle && <p class={twMerge('mt-2 text-muted content-backdrop p-2 rounded-md', subtitleClass)} set:html={subtitle} />}
|
||||
{subtitle && (
|
||||
<p class={twMerge('mt-2 text-muted content-backdrop p-2 rounded-md', subtitleClass)} set:html={subtitle} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@@ -2,25 +2,44 @@
|
||||
// ImageModal.astro - A reusable modal component for displaying enlarged images
|
||||
---
|
||||
|
||||
<div id="image-modal" class="fixed inset-0 z-50 flex items-center justify-center opacity-0 pointer-events-none transition-opacity duration-300 ease-in-out">
|
||||
<div
|
||||
id="image-modal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center opacity-0 pointer-events-none transition-opacity duration-300 ease-in-out"
|
||||
>
|
||||
<!-- Backdrop overlay -->
|
||||
<div id="modal-backdrop" class="absolute inset-0 bg-black bg-opacity-75 backdrop-blur-sm transition-opacity duration-300"></div>
|
||||
|
||||
<div
|
||||
id="modal-backdrop"
|
||||
class="absolute inset-0 bg-black bg-opacity-75 backdrop-blur-sm transition-opacity duration-300"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Modal container with animation -->
|
||||
<div id="modal-container" class="relative max-w-4xl mx-auto p-4 transform scale-95 transition-all duration-300 ease-in-out flex flex-col items-center">
|
||||
<div
|
||||
id="modal-container"
|
||||
class="relative max-w-4xl mx-auto p-4 transform scale-95 transition-all duration-300 ease-in-out flex flex-col items-center"
|
||||
>
|
||||
<!-- Close button -->
|
||||
<button
|
||||
id="modal-close"
|
||||
<button
|
||||
id="modal-close"
|
||||
class="absolute top-2 right-2 z-10 bg-white dark:bg-gray-800 rounded-full p-2 shadow-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-200"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-600 dark:text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-gray-600 dark:text-gray-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
|
||||
<!-- Image container -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl overflow-hidden" style="min-height: 300px; max-height: 75vh;">
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl overflow-hidden"
|
||||
style="min-height: 300px; max-height: 75vh;"
|
||||
>
|
||||
<div class="flex items-center justify-center h-full w-full">
|
||||
<img
|
||||
id="modal-image"
|
||||
@@ -31,7 +50,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Caption -->
|
||||
<div id="modal-caption" class="mt-2 text-center text-white text-lg font-medium"></div>
|
||||
</div>
|
||||
@@ -53,77 +72,77 @@
|
||||
const modalImage = document.getElementById('modal-image') as HTMLImageElement;
|
||||
const modalCaption = document.getElementById('modal-caption');
|
||||
const closeButton = document.getElementById('modal-close');
|
||||
|
||||
|
||||
// Function to open the modal with a specific image
|
||||
function openModal(imgSrc, imgAlt) {
|
||||
if (!modal || !modalImage || !modalCaption) return;
|
||||
|
||||
|
||||
// Create a temporary image to get the natural dimensions
|
||||
const tempImg = new Image();
|
||||
tempImg.onload = function() {
|
||||
tempImg.onload = function () {
|
||||
// Calculate the certification section height (75% of viewport height as defined in the container)
|
||||
const certSectionHeight = window.innerHeight * 0.75;
|
||||
const maxHeight = certSectionHeight * 0.75; // 75% of the certification section height
|
||||
|
||||
|
||||
// If the natural image height is smaller than the max height, use the natural height
|
||||
if (tempImg.height < maxHeight) {
|
||||
modalImage.style.setProperty('--cert-max-height', `${tempImg.height}px`);
|
||||
} else {
|
||||
modalImage.style.setProperty('--cert-max-height', `${maxHeight}px`);
|
||||
}
|
||||
|
||||
|
||||
// Set the image source and alt text
|
||||
modalImage.src = imgSrc;
|
||||
modalCaption.textContent = imgAlt;
|
||||
|
||||
|
||||
// Make the modal visible
|
||||
modal.classList.remove('opacity-0', 'pointer-events-none');
|
||||
modal.classList.add('opacity-100', 'pointer-events-auto');
|
||||
|
||||
|
||||
// Animate the container
|
||||
if (modalContainer) {
|
||||
modalContainer.classList.remove('scale-95');
|
||||
modalContainer.classList.add('scale-100');
|
||||
}
|
||||
|
||||
|
||||
// Set focus to the close button for accessibility
|
||||
if (closeButton) {
|
||||
setTimeout(() => closeButton.focus(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Start loading the image
|
||||
tempImg.src = imgSrc;
|
||||
|
||||
|
||||
// Prevent scrolling on the body
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
|
||||
// Function to close the modal
|
||||
function closeModal() {
|
||||
if (!modal || !modalContainer) return;
|
||||
|
||||
|
||||
// Hide the modal with animation
|
||||
modal.classList.remove('opacity-100', 'pointer-events-auto');
|
||||
modal.classList.add('opacity-0', 'pointer-events-none');
|
||||
|
||||
|
||||
// Animate the container
|
||||
modalContainer.classList.remove('scale-100');
|
||||
modalContainer.classList.add('scale-95');
|
||||
|
||||
|
||||
// Re-enable scrolling
|
||||
document.body.style.overflow = '';
|
||||
|
||||
|
||||
// Clear the image source after animation completes
|
||||
setTimeout(() => {
|
||||
if (modalImage) modalImage.src = '';
|
||||
if (modalCaption) modalCaption.textContent = '';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
|
||||
// Register the openImageModal function globally so it can be called from anywhere
|
||||
window.openImageModal = openModal;
|
||||
|
||||
|
||||
// Close modal when clicking the close button
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', (e) => {
|
||||
@@ -131,7 +150,7 @@
|
||||
closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Close modal when clicking outside the image container
|
||||
if (modal) {
|
||||
modal.addEventListener('click', (e) => {
|
||||
@@ -141,14 +160,14 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Close modal when pressing Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Handle window resize to recalculate image dimensions
|
||||
let resizeTimeout;
|
||||
window.addEventListener('resize', () => {
|
||||
@@ -158,7 +177,7 @@
|
||||
// Get current image source and recalculate dimensions
|
||||
const currentSrc = modalImage.src;
|
||||
const currentAlt = modalCaption?.textContent || '';
|
||||
|
||||
|
||||
// Close and reopen modal to trigger recalculation
|
||||
closeModal();
|
||||
setTimeout(() => openModal(currentSrc, currentAlt), 350);
|
||||
@@ -166,4 +185,4 @@
|
||||
}, 200); // Debounce resize events
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
@@ -19,40 +19,56 @@ const {
|
||||
{
|
||||
items && (
|
||||
<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 '
|
||||
: ''
|
||||
} grid-flow-row auto-rows-fr text-center sm:text-left`,
|
||||
containerClass
|
||||
)}
|
||||
>
|
||||
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 '
|
||||
: ''
|
||||
} grid-flow-row auto-rows-fr text-center sm:text-left`,
|
||||
containerClass
|
||||
)}
|
||||
>
|
||||
{items.map(({ title, description, icon, callToAction, classes: itemClasses = {} }) => (
|
||||
<div class="intersect-once motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade h-full">
|
||||
<div class={twMerge('flex flex-col sm:flex-row max-w-md mx-auto sm:mx-0 h-full min-h-[220px] backdrop-blur-sm bg-white/15 dark:bg-transparent p-3 rounded-md border border-gray-200 dark:border-gray-700 hover:bg-white/30 hover:backdrop-blur-md dark:hover:backdrop-blur-md transition-all duration-300 ease-in-out', panelClass, itemClasses?.panel)}>
|
||||
<div class="flex justify-center sm:justify-start flex-shrink-0 mb-2 sm:mb-0">
|
||||
{(icon || defaultIcon) && (
|
||||
<Icon
|
||||
name={icon || defaultIcon}
|
||||
class={twMerge('w-7 h-7 sm:mr-2 rtl:sm:mr-0 rtl:sm:ml-2', defaultIconClass, itemClasses?.icon)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div class="mt-0.5 flex flex-col overflow-hidden flex-grow items-center sm:items-start">
|
||||
{title && <h3 class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)}>{title}</h3>}
|
||||
{description && (
|
||||
<p
|
||||
class={twMerge(`${title ? 'mt-3' : ''} text-muted text-base overflow-y-auto`, descriptionClass, itemClasses?.description)}
|
||||
set:html={description}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex flex-col sm:flex-row max-w-md mx-auto sm:mx-0 h-full min-h-[220px] backdrop-blur-sm bg-white/15 dark:bg-transparent p-3 rounded-md border border-gray-200 dark:border-gray-700 hover:bg-white/30 hover:backdrop-blur-md dark:hover:backdrop-blur-md transition-all duration-300 ease-in-out',
|
||||
panelClass,
|
||||
itemClasses?.panel
|
||||
)}
|
||||
>
|
||||
<div class="flex justify-center sm:justify-start flex-shrink-0 mb-2 sm:mb-0">
|
||||
{(icon || defaultIcon) && (
|
||||
<Icon
|
||||
name={icon || defaultIcon}
|
||||
class={twMerge('w-7 h-7 sm:mr-2 rtl:sm:mr-0 rtl:sm:ml-2', defaultIconClass, itemClasses?.icon)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div class="mt-0.5 flex flex-col overflow-hidden flex-grow items-center sm:items-start">
|
||||
{title && <h3 class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)}>{title}</h3>}
|
||||
{description && (
|
||||
<p
|
||||
class={twMerge(
|
||||
`${title ? 'mt-3' : ''} text-muted text-base overflow-y-auto`,
|
||||
descriptionClass,
|
||||
itemClasses?.description
|
||||
)}
|
||||
set:html={description}
|
||||
/>
|
||||
)}
|
||||
{callToAction && (
|
||||
<div class={twMerge(`${title || description ? 'mt-3' : ''} mt-auto`, actionClass, itemClasses?.actionClass)}>
|
||||
<div
|
||||
class={twMerge(
|
||||
`${title || description ? 'mt-3' : ''} mt-auto`,
|
||||
actionClass,
|
||||
itemClasses?.actionClass
|
||||
)}
|
||||
>
|
||||
<Button variant="link" {...callToAction} />
|
||||
</div>
|
||||
)}
|
||||
|
@@ -45,7 +45,10 @@ const {
|
||||
)}
|
||||
<div class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)}>{title}</div>
|
||||
{description && (
|
||||
<p class={twMerge('text-muted text-base mt-2', descriptionClass, itemClasses?.description)} set:html={description} />
|
||||
<p
|
||||
class={twMerge('text-muted text-base mt-2', descriptionClass, itemClasses?.description)}
|
||||
set:html={description}
|
||||
/>
|
||||
)}
|
||||
{callToAction && (
|
||||
<div class="mt-2">
|
||||
|
@@ -26,18 +26,21 @@ const {
|
||||
|
||||
{
|
||||
items && items.length && (
|
||||
<div class={twMerge("relative mx-auto max-w-5xl", containerClass)}>
|
||||
<div class={twMerge('relative mx-auto max-w-5xl', containerClass)}>
|
||||
{/* Main timeline line */}
|
||||
<div class="absolute left-4 md:left-1/2 top-0 h-full w-1 transform -translate-x-1/2 z-0 transition-all duration-300 ease-in-out shadow-sm bg-blue-700/15 dark:bg-blue-700/30" class:list={[timelineClass]}></div>
|
||||
<div
|
||||
class="absolute left-4 md:left-1/2 top-0 h-full w-1 transform -translate-x-1/2 z-0 transition-all duration-300 ease-in-out shadow-sm bg-blue-700/15 dark:bg-gray-800"
|
||||
class:list={[timelineClass]}
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
{items.map((item, index) => {
|
||||
const { title, description, icon, classes: itemClasses = {} } = item;
|
||||
const isEven = index % 2 === 0;
|
||||
|
||||
|
||||
// Use the year property if available, otherwise try to extract from date
|
||||
let year = item.year;
|
||||
|
||||
|
||||
// If year is not provided, try to extract from date in the description
|
||||
if (!year && description) {
|
||||
// Look for a date pattern like MM-YYYY
|
||||
@@ -46,24 +49,32 @@ const {
|
||||
year = dateMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div class={`relative ${compact ? 'mb-6' : 'mb-12'}`}>
|
||||
{/* Year marker (if available) */}
|
||||
{year && (
|
||||
<div class={twMerge("absolute left-4 md:left-1/2 transform -translate-x-1/2 -top-4 font-bold text-sm z-10", yearClass)}>
|
||||
<div
|
||||
class={twMerge(
|
||||
'absolute left-4 md:left-1/2 transform -translate-x-1/2 -top-4 font-bold text-sm z-10',
|
||||
yearClass
|
||||
)}
|
||||
>
|
||||
<span class="relative">
|
||||
<span class="absolute inset-0 bg-white/20 backdrop-blur-sm rounded-md -z-10"></span>
|
||||
<span class="absolute inset-0 bg-white/20 backdrop-blur-sm rounded-md -z-10" />
|
||||
{year}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Timeline dot */}
|
||||
<div class={`absolute left-4 md:left-1/2 transform -translate-x-1/2 top-5 ${compact ? 'w-3 h-3' : 'w-4 h-4'} rounded-full z-10 shadow-md transition-all duration-300 ease-in-out`} class:list={[timelineDotClass]}></div>
|
||||
|
||||
<div
|
||||
class={`absolute left-4 md:left-1/2 transform -translate-x-1/2 top-5 ${compact ? 'w-3 h-3' : 'w-4 h-4'} rounded-full z-10 shadow-md transition-all duration-300 ease-in-out`}
|
||||
class:list={[timelineDotClass]}
|
||||
/>
|
||||
|
||||
{/* Content card */}
|
||||
<div
|
||||
<div
|
||||
class={twMerge(
|
||||
'relative ml-10 md:ml-0 md:w-[45%]',
|
||||
isEven ? 'md:mr-auto md:pr-6' : 'md:ml-auto md:pl-6',
|
||||
@@ -81,29 +92,51 @@ const {
|
||||
{(icon || defaultIcon) && (
|
||||
<Icon
|
||||
name={icon || defaultIcon}
|
||||
class={twMerge(`${compact ? 'w-6 h-6 p-1' : 'w-7 h-7 p-1.5'} rounded-full border-2 mr-2 flex-shrink-0`, defaultIconClass, itemClasses?.icon)}
|
||||
class={twMerge(
|
||||
`${compact ? 'w-6 h-6 p-1' : 'w-7 h-7 p-1.5'} rounded-full border-2 mr-2 flex-shrink-0`,
|
||||
defaultIconClass,
|
||||
itemClasses?.icon
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{title && (
|
||||
<p
|
||||
class={twMerge(
|
||||
`${compact ? 'text-base' : 'text-lg'} font-bold`,
|
||||
titleClass,
|
||||
itemClasses?.title
|
||||
)}
|
||||
set:html={title}
|
||||
/>
|
||||
)}
|
||||
{title && <p class={twMerge(`${compact ? 'text-base' : 'text-lg'} font-bold`, titleClass, itemClasses?.title)} set:html={title} />}
|
||||
</div>
|
||||
{description && (
|
||||
<div class="max-h-0 group-hover:max-h-[500px] overflow-hidden transition-all duration-500 ease-in-out" data-details>
|
||||
<div
|
||||
class="max-h-0 group-hover:max-h-[500px] overflow-hidden transition-all duration-500 ease-in-out"
|
||||
data-details
|
||||
>
|
||||
<div class="flex items-center justify-center mb-1 opacity-70 group-hover:opacity-0 transition-opacity duration-200 h-4">
|
||||
<div class="w-6 h-1 bg-gray-300 dark:bg-gray-700 rounded-full"></div>
|
||||
<div class="w-6 h-1 bg-gray-300 dark:bg-gray-700 rounded-full" />
|
||||
</div>
|
||||
<div
|
||||
class={twMerge(`text-muted ${compact ? 'text-sm' : 'text-sm'} opacity-0 group-hover:opacity-100 transition-all duration-500 ease-in-out`, descriptionClass, itemClasses?.description)}
|
||||
class={twMerge(
|
||||
`text-muted ${compact ? 'text-sm' : 'text-sm'} opacity-0 group-hover:opacity-100 transition-all duration-500 ease-in-out`,
|
||||
descriptionClass,
|
||||
itemClasses?.description
|
||||
)}
|
||||
set:html={description}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Connector line to timeline (visible only on desktop) */}
|
||||
<div class={twMerge(
|
||||
'absolute top-5 hidden md:block h-0.5 w-10 z-0',
|
||||
isEven ? 'right-0 bg-gradient-to-r' : 'left-0 bg-gradient-to-l',
|
||||
'from-transparent to-blue-700 dark:to-blue-700'
|
||||
)}></div>
|
||||
<div
|
||||
class={twMerge(
|
||||
'absolute top-5 hidden md:block h-0.5 w-10 z-0',
|
||||
isEven ? 'right-0 bg-gradient-to-r' : 'left-0 bg-gradient-to-l',
|
||||
'from-transparent to-blue-700 dark:to-blue-700'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -125,37 +158,42 @@ const {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Card container styles */
|
||||
.card-container {
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94), box-shadow 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transition:
|
||||
transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
box-shadow 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
will-change: transform, box-shadow;
|
||||
}
|
||||
|
||||
|
||||
/* Hover effect for details */
|
||||
[data-details] {
|
||||
transform-origin: top;
|
||||
transition: max-height 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transition:
|
||||
max-height 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
will-change: max-height, opacity;
|
||||
}
|
||||
|
||||
|
||||
/* Add a subtle indicator that more content is available */
|
||||
.group:not(:hover) [data-details] {
|
||||
max-height: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.group:hover [data-details] {
|
||||
max-height: 550px; /* Large enough to fit content but still allows animation */
|
||||
}
|
||||
|
||||
|
||||
@keyframes fadeIn {
|
||||
to { opacity: 1; }
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.timeline-item {
|
||||
@@ -163,4 +201,4 @@ const {
|
||||
margin-left: 2.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
@@ -100,28 +100,22 @@ const {
|
||||
|
||||
{/* Responsive styles for small screens */}
|
||||
<style>
|
||||
.ease-staggered {
|
||||
transition-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.hover\:opacity-0:hover {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#timeline-item-0,
|
||||
#timeline-item-1,
|
||||
#timeline-item-2,
|
||||
#timeline-item-3,
|
||||
#timeline-item-4 {
|
||||
[id^="timeline-item-"] {
|
||||
margin-top: 0 !important;
|
||||
margin-left: 2rem !important;
|
||||
margin-right: 0 !important;
|
||||
width: calc(100% - 2rem) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom easing function for staggered timeline */
|
||||
.ease-staggered {
|
||||
transition-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
/* Fade effect for the gradient overlay */
|
||||
.hover\:opacity-0:hover {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
)
|
||||
|
@@ -16,13 +16,13 @@ const WrapperTag = as;
|
||||
---
|
||||
|
||||
<WrapperTag class="relative not-prose scroll-mt-[72px]" {...id ? { id } : {}}>
|
||||
{!disableBackground && (
|
||||
<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>
|
||||
)}
|
||||
{
|
||||
!disableBackground && (
|
||||
<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(
|
||||
|
@@ -52,7 +52,9 @@ const posts = APP_BLOG.isEnabled ? await findLatestPosts({ count }) : [];
|
||||
</div>
|
||||
)}
|
||||
|
||||
{information && <p class="text-muted dark:text-slate-400 lg:text-sm lg:max-w-md text-sm" set:html={information} />}
|
||||
{information && (
|
||||
<p class="text-muted dark:text-slate-400 lg:text-sm lg:max-w-md text-sm" set:html={information} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Grid posts={posts} />
|
||||
|
@@ -19,10 +19,10 @@ const {
|
||||
---
|
||||
|
||||
<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
|
||||
<Headline
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
tagline={tagline}
|
||||
<Headline
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
tagline={tagline}
|
||||
classes={{
|
||||
container: 'max-w-3xl',
|
||||
title: 'text-3xl lg:text-4xl',
|
||||
@@ -30,17 +30,24 @@ const {
|
||||
/>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{items.map(({ title, description, icon }) => (
|
||||
<div class="group backdrop-blur-sm bg-white/15 dark:bg-transparent p-3 rounded-lg shadow-sm hover:shadow-md transition-all duration-300 ease-in-out hover:bg-white/30 hover:backdrop-blur-md dark:hover:backdrop-blur-md border border-gray-200 dark:border-gray-700 hover:translate-y-[-2px]">
|
||||
<div class="flex items-center mb-2">
|
||||
<Icon name={icon || defaultIcon} class="w-5 h-5 mr-2 text-primary dark:text-blue-300 transition-transform duration-300 group-hover:scale-110 dark:shadow-blue-500/40 dark:shadow-sm" />
|
||||
<h3 class="text-base font-semibold">{title}</h3>
|
||||
{
|
||||
items.map(({ title, description, icon }) => (
|
||||
<div class="group backdrop-blur-sm bg-white/15 dark:bg-transparent p-3 rounded-lg shadow-sm hover:shadow-md transition-all duration-300 ease-in-out hover:bg-white/30 hover:backdrop-blur-md dark:hover:backdrop-blur-md border border-gray-200 dark:border-gray-700 hover:translate-y-[-2px]">
|
||||
<div class="flex items-center mb-2">
|
||||
<Icon
|
||||
name={icon || defaultIcon}
|
||||
class="w-5 h-5 mr-2 text-primary dark:text-blue-300 transition-transform duration-300 group-hover:scale-110 dark:shadow-blue-500/40 dark:shadow-sm"
|
||||
/>
|
||||
<h3 class="text-base font-semibold">{title}</h3>
|
||||
</div>
|
||||
<div class="text-muted overflow-hidden">
|
||||
<p class="text-base max-h-[12rem] hover:max-h-[300px] transition-all duration-500 ease-skills">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted overflow-hidden">
|
||||
<p class="text-base max-h-[12rem] hover:max-h-[300px] transition-all duration-500 ease-skills">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</WidgetWrapper>
|
||||
|
||||
@@ -49,9 +56,9 @@ const {
|
||||
.ease-skills {
|
||||
transition-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
|
||||
/* Fade effect for the gradient overlay */
|
||||
.hover\:opacity-0:hover {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
@@ -31,4 +31,4 @@ const {
|
||||
/>
|
||||
<CompactTimeline items={items} classes={classes?.items as Record<string, never>} />
|
||||
</div>
|
||||
</WidgetWrapper>
|
||||
</WidgetWrapper>
|
||||
|
@@ -44,7 +44,11 @@ const {
|
||||
<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 content-backdrop p-3 rounded-md" set:html={content} />}
|
||||
{
|
||||
content && (
|
||||
<div class="mb-12 text-lg dark:text-slate-400 content-backdrop p-3 rounded-md" set:html={content} />
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
callToAction && (
|
||||
|
@@ -27,25 +27,27 @@ export interface Props {
|
||||
import { supportedLanguages } from '~/i18n/translations';
|
||||
|
||||
// Define the type for supported languages
|
||||
type SupportedLanguage = typeof supportedLanguages[number];
|
||||
type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
// Get current language from URL
|
||||
const currentPath = `/${Astro.url.pathname.replace(/^\/+|\/+$/g, '')}`;
|
||||
const pathSegments = currentPath.split('/').filter(Boolean);
|
||||
|
||||
// Check for language in URL path
|
||||
let currentLang = pathSegments[0] && supportedLanguages.includes(pathSegments[0] as SupportedLanguage)
|
||||
? pathSegments[0] as SupportedLanguage
|
||||
: null;
|
||||
let currentLang =
|
||||
pathSegments[0] && supportedLanguages.includes(pathSegments[0] as SupportedLanguage)
|
||||
? (pathSegments[0] as SupportedLanguage)
|
||||
: null;
|
||||
|
||||
// If no language in URL, check cookies
|
||||
if (!currentLang) {
|
||||
const cookies = Astro.request.headers.get('cookie') || '';
|
||||
const cookieLanguage = cookies.split(';')
|
||||
.map(cookie => cookie.trim())
|
||||
.find(cookie => cookie.startsWith('preferredLanguage='))
|
||||
const cookieLanguage = cookies
|
||||
.split(';')
|
||||
.map((cookie) => cookie.trim())
|
||||
.find((cookie) => cookie.startsWith('preferredLanguage='))
|
||||
?.split('=')[1];
|
||||
|
||||
|
||||
if (cookieLanguage && supportedLanguages.includes(cookieLanguage as SupportedLanguage)) {
|
||||
currentLang = cookieLanguage as SupportedLanguage;
|
||||
} else {
|
||||
@@ -60,7 +62,7 @@ const footerData = getFooterData(currentLang);
|
||||
const {
|
||||
secondaryLinks = footerData.secondaryLinks,
|
||||
socialLinks = footerData.socialLinks,
|
||||
theme = 'light'
|
||||
theme = 'light',
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
@@ -69,10 +71,8 @@ const {
|
||||
<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"
|
||||
>
|
||||
|
||||
<!-- ✅ Combined Footer Section -->
|
||||
<div class="flex flex-col md:flex-row md:justify-between py-6 md:py-8 space-y-6 md:space-y-0">
|
||||
|
||||
<!-- Left Section: Company Name and Business Details -->
|
||||
<div class="flex flex-col items-start space-y-2">
|
||||
<!-- Site Title -->
|
||||
@@ -86,39 +86,28 @@ const {
|
||||
</a>
|
||||
<!-- Business Information (Dutch Law Requirements) -->
|
||||
<div class="text-sm text-white-500 space-y-1">
|
||||
<p>KVK: 87654321 | BTW: NL123456789B01</p>
|
||||
<p>info@365devnet.eu</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Section: Social Icons and Terms/Privacy Links -->
|
||||
<div class="flex flex-col items-start md:items-end space-y-4">
|
||||
<!-- Social Icons -->
|
||||
{
|
||||
socialLinks?.length && (
|
||||
<ul class="flex space-x-4">
|
||||
{socialLinks.map(({ ariaLabel, href, icon }) => (
|
||||
<li>
|
||||
<a
|
||||
class="text-muted dark:text-white-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 p-2 inline-flex items-center"
|
||||
aria-label={ariaLabel}
|
||||
href={href}
|
||||
>
|
||||
{icon && <Icon name={icon} class="w-5 h-5" />}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
<div class="text-sm text-white-500 space-y-1">
|
||||
<p>KVK: 87654321 | BTW: NL123456789B01</p>
|
||||
</div>
|
||||
|
||||
<!-- Terms & Privacy Policy Links -->
|
||||
<div class="flex items-center space-x-4 text-sm text-white-500">
|
||||
{secondaryLinks.map(({ text, href }) => (
|
||||
<a class="hover:text-gray-700 hover:underline dark:hover:text-gray-200 transition duration-150 ease-in-out" href={href}>
|
||||
{text}
|
||||
</a>
|
||||
))}
|
||||
{
|
||||
secondaryLinks.map(({ text, href }) => (
|
||||
<a
|
||||
class="hover:text-gray-700 hover:underline dark:hover:text-gray-200 transition duration-150 ease-in-out"
|
||||
href={href}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,4 +119,4 @@ const {
|
||||
border-radius: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
@@ -7,7 +7,7 @@ import ToggleMenu from '~/components/common/ToggleMenu.astro';
|
||||
import LanguageDropdown from '~/components/LanguageDropdown.astro';
|
||||
|
||||
import { getHomePermalink } from '~/utils/permalinks';
|
||||
import { trimSlash, getAsset } from '~/utils/permalinks';
|
||||
import { trimSlash } from '~/utils/permalinks';
|
||||
import { getHeaderData } from '~/navigation';
|
||||
|
||||
interface Link {
|
||||
@@ -40,21 +40,23 @@ const currentPath = `/${trimSlash(new URL(Astro.url).pathname)}`;
|
||||
const pathSegments = currentPath.split('/').filter(Boolean);
|
||||
|
||||
// Define the type for supported languages
|
||||
type SupportedLanguage = typeof supportedLanguages[number];
|
||||
type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
// Check for language in URL path
|
||||
let currentLang = pathSegments[0] && supportedLanguages.includes(pathSegments[0] as SupportedLanguage)
|
||||
? pathSegments[0] as SupportedLanguage
|
||||
: null;
|
||||
let currentLang =
|
||||
pathSegments[0] && supportedLanguages.includes(pathSegments[0] as SupportedLanguage)
|
||||
? (pathSegments[0] as SupportedLanguage)
|
||||
: null;
|
||||
|
||||
// If no language in URL, check cookies
|
||||
if (!currentLang) {
|
||||
const cookies = Astro.request.headers.get('cookie') || '';
|
||||
const cookieLanguage = cookies.split(';')
|
||||
.map(cookie => cookie.trim())
|
||||
.find(cookie => cookie.startsWith('preferredLanguage='))
|
||||
const cookieLanguage = cookies
|
||||
.split(';')
|
||||
.map((cookie) => cookie.trim())
|
||||
.find((cookie) => cookie.startsWith('preferredLanguage='))
|
||||
?.split('=')[1];
|
||||
|
||||
|
||||
if (cookieLanguage && supportedLanguages.includes(cookieLanguage as SupportedLanguage)) {
|
||||
currentLang = cookieLanguage as SupportedLanguage;
|
||||
} else {
|
||||
@@ -65,7 +67,6 @@ if (!currentLang) {
|
||||
|
||||
// Get translated header data - ensure we're using the current language
|
||||
const headerData = getHeaderData(currentLang);
|
||||
console.log(`Header initialized with language: ${currentLang}`);
|
||||
|
||||
const {
|
||||
id = 'header',
|
||||
@@ -74,7 +75,7 @@ const {
|
||||
isDark = false,
|
||||
isFullWidth = false,
|
||||
showToggleTheme = false,
|
||||
showRssFeed = false,
|
||||
|
||||
position = 'center',
|
||||
} = Astro.props;
|
||||
---
|
||||
@@ -93,7 +94,7 @@ const {
|
||||
'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',
|
||||
},
|
||||
@@ -112,7 +113,8 @@ const {
|
||||
<!-- Improved mobile navigation accessibility -->
|
||||
<style>
|
||||
@media (max-width: 767px) {
|
||||
nav ul li a, nav ul li button {
|
||||
nav ul li a,
|
||||
nav ul li button {
|
||||
padding: 0.75rem 1rem; /* Larger touch targets */
|
||||
min-height: 44px; /* Minimum touch target size */
|
||||
display: flex;
|
||||
@@ -189,4 +191,4 @@ const {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
|
@@ -26,13 +26,13 @@ const {
|
||||
---
|
||||
|
||||
<section class="relative md:-mt-[76px] not-prose" {...id ? { id } : {}}>
|
||||
{!disableBackground && (
|
||||
<div class="absolute inset-0 pointer-events-none" aria-hidden="true">
|
||||
<slot name="bg">
|
||||
{bg ? <Fragment set:html={bg} /> : <Background isDark={isDark} disableParallax={true} />}
|
||||
</slot>
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
!disableBackground && (
|
||||
<div class="absolute inset-0 pointer-events-none" aria-hidden="true">
|
||||
<slot name="bg">{bg ? <Fragment set:html={bg} /> : <Background isDark={isDark} disableParallax={true} />}</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">
|
||||
|
@@ -26,7 +26,12 @@ const {
|
||||
{
|
||||
testimonials &&
|
||||
testimonials.map(({ title, linkUrl, name, issueDate, description, image }) => (
|
||||
<a href={linkUrl} target="_blank" rel="noopener noreferrer" class="flex flex-col justify-start items-center p-2 md:p-4 rounded-md shadow-md backdrop-blur-sm bg-white/15 dark:bg-transparent border border-gray-200 dark:border-slate-600 hover:bg-white/30 hover:backdrop-blur-md dark:hover:backdrop-blur-md transition-all duration-300 ease-in-out hover:shadow-lg hover:translate-y-[-2px] w-[425px] mx-auto sm:mx-0">
|
||||
<a
|
||||
href={linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex flex-col justify-start items-center p-2 md:p-4 rounded-md shadow-md backdrop-blur-sm bg-white/15 dark:bg-transparent border border-gray-200 dark:border-slate-600 hover:bg-white/30 hover:backdrop-blur-md dark:hover:backdrop-blur-md transition-all duration-300 ease-in-out hover:shadow-lg hover:translate-y-[-2px] w-[425px] mx-auto sm:mx-0"
|
||||
>
|
||||
{title && <h2 class="text-lg font-medium leading-6 pb-4 text-center">{title}</h2>}
|
||||
{image && (
|
||||
<div class="h-[160px] w-[160px] border-slate-200 dark:border-slate-600 mx-auto">
|
||||
@@ -42,10 +47,10 @@ const {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Horizontal line directly under the image */}
|
||||
<hr class="border-slate-200 dark:border-slate-600 my-4" />
|
||||
|
||||
|
||||
{/* Text content takes up the remaining space */}
|
||||
<div class="flex flex-col flex-grow items-center">
|
||||
{name && <p class="text-lg font-semibold text-center">{name}</p>}
|
||||
@@ -56,8 +61,7 @@ const {
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{
|
||||
callToAction && (
|
||||
<div class="flex justify-center mx-auto w-fit mt-8 md:mt-12 font-medium">
|
||||
@@ -68,12 +72,12 @@ const {
|
||||
</WidgetWrapper>
|
||||
|
||||
<style>
|
||||
@media (max-width: 640px) {
|
||||
.grid {
|
||||
justify-content: center;
|
||||
@media (max-width: 640px) {
|
||||
.grid {
|
||||
justify-content: center;
|
||||
}
|
||||
.grid > a {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
.grid > a {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@@ -32,7 +32,7 @@ const {
|
||||
} = Astro.props as Props;
|
||||
|
||||
// Transform the work experience items to the format expected by ModernTimeline
|
||||
const timelineItems = items.map(item => {
|
||||
const timelineItems = items.map((item) => {
|
||||
// Extract year from date if available
|
||||
let year: string | undefined = undefined;
|
||||
if (item.date) {
|
||||
@@ -43,7 +43,7 @@ const timelineItems = items.map(item => {
|
||||
year = dateMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
title: `<span class="${compact ? 'text-lg' : 'text-xl'}">${item.title}</span>${item.company ? `<span class="block ${compact ? 'text-sm' : 'text-sm'} font-normal text-gray-600 dark:text-gray-400">${item.company}</span>` : ''}`,
|
||||
description: `<div class="transform-origin-top transition-transform duration-300">${item.description || ''}${item.description && (item.date || item.location) ? '<div class="mt-2"></div>' : ''}${item.date ? `<span class="block text-sm font-semibold text-gray-500 dark:text-gray-400">${item.date}</span>` : ''}${item.location ? `<span class="block text-sm text-gray-500 dark:text-gray-400">${item.location}</span>` : ''}</div>`,
|
||||
@@ -56,15 +56,21 @@ const timelineItems = items.map(item => {
|
||||
|
||||
<WidgetWrapper id={id} isDark={isDark} bg={bg} classes={classes}>
|
||||
<div class={`flex flex-col gap-${compact ? '8' : '12'} md:gap-${compact ? '12' : '16'}`}>
|
||||
{title && (
|
||||
<div class="flex flex-col gap-4 text-center">
|
||||
{tagline && (
|
||||
<p class="text-sm font-semibold uppercase tracking-wide text-primary dark:text-blue-200">{tagline}</p>
|
||||
)}
|
||||
{title && <h2 class={`${compact ? 'text-2xl md:text-3xl' : 'text-3xl md:text-4xl'} font-bold font-heading`}>{title}</h2>}
|
||||
{subtitle && <p class={`${compact ? 'text-lg' : 'text-xl'} text-muted dark:text-slate-400`}>{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
title && (
|
||||
<div class="flex flex-col gap-4 text-center">
|
||||
{tagline && (
|
||||
<p class="text-sm font-semibold uppercase tracking-wide text-primary dark:text-blue-200">{tagline}</p>
|
||||
)}
|
||||
{title && (
|
||||
<h2 class={`${compact ? 'text-2xl md:text-3xl' : 'text-3xl md:text-4xl'} font-bold font-heading`}>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{subtitle && <p class={`${compact ? 'text-lg' : 'text-xl'} text-muted dark:text-slate-400`}>{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<ModernTimeline
|
||||
items={timelineItems}
|
||||
compact={compact}
|
||||
@@ -79,4 +85,4 @@ const timelineItems = items.map(item => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</WidgetWrapper>
|
||||
</WidgetWrapper>
|
||||
|
Reference in New Issue
Block a user