Updated site completely
Some checks failed
GitHub Actions / build (18) (push) Has been cancelled
GitHub Actions / build (20) (push) Has been cancelled
GitHub Actions / build (22) (push) Has been cancelled
GitHub Actions / check (push) Has been cancelled

This commit is contained in:
becarta
2025-03-29 22:32:31 +01:00
parent a9adf1bb4f
commit 890d7b8670
56 changed files with 1807 additions and 1299 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 -->

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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(