major update in looks and feel
This commit is contained in:
85
src/components/ui/Accordion.astro
Normal file
85
src/components/ui/Accordion.astro
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
interface Props {
|
||||
items: {
|
||||
title: string;
|
||||
description: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const accordionHeaders = document.querySelectorAll('.accordion-header');
|
||||
|
||||
accordionHeaders.forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const target = header.getAttribute('data-accordion-target');
|
||||
const body = document.querySelector(target);
|
||||
|
||||
if (body) {
|
||||
const expanded = (body as HTMLElement).classList.contains('expanded');
|
||||
|
||||
// Close all accordion items
|
||||
document.querySelectorAll('.accordion-body.expanded').forEach(item => {
|
||||
if (item && 'style' in item) {
|
||||
(item as any).style.display = 'none';
|
||||
item.classList.remove('expanded');
|
||||
}
|
||||
});
|
||||
|
||||
// Open the clicked accordion item if it was closed
|
||||
if (!expanded) {
|
||||
body.classList.add('expanded');
|
||||
if (body && 'style' in body) {
|
||||
(body as HTMLElement).style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.accordion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.accordion-header:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.accordion-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accordion-body.expanded {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
@@ -1,11 +1,302 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components';
|
||||
|
||||
export interface Props {
|
||||
isDark?: boolean;
|
||||
showIcons?: boolean;
|
||||
disableParallax?: boolean;
|
||||
}
|
||||
|
||||
const { isDark = false } = Astro.props;
|
||||
---
|
||||
const { isDark = false, showIcons = true, disableParallax = false } = Astro.props;
|
||||
|
||||
<div class:list={['absolute inset-0', { 'bg-dark dark:bg-transparent': isDark }]}>
|
||||
// Define color palettes for light and dark modes with higher contrast
|
||||
const lightModeColors = [
|
||||
'text-blue-600/40',
|
||||
'text-indigo-600/40',
|
||||
'text-purple-600/40',
|
||||
'text-cyan-600/40',
|
||||
'text-teal-600/40',
|
||||
'text-emerald-600/40',
|
||||
'text-sky-600/40',
|
||||
'text-violet-600/40',
|
||||
];
|
||||
|
||||
const darkModeColors = [
|
||||
'dark:text-blue-400/45',
|
||||
'dark:text-indigo-400/45',
|
||||
'dark:text-purple-400/45',
|
||||
'dark:text-cyan-400/45',
|
||||
'dark:text-teal-400/45',
|
||||
'dark:text-emerald-400/45',
|
||||
'dark:text-sky-400/45',
|
||||
'dark:text-violet-400/45',
|
||||
];
|
||||
|
||||
// Define interfaces for our icon objects
|
||||
interface BaseIconObject {
|
||||
icon: string;
|
||||
x: string;
|
||||
y: string;
|
||||
size: string;
|
||||
opacity: string;
|
||||
rotate: string;
|
||||
}
|
||||
|
||||
interface IconWithColors extends BaseIconObject {
|
||||
lightColor: string;
|
||||
darkColor: string;
|
||||
}
|
||||
|
||||
// Function to get a random color from the palette
|
||||
const getRandomColor = (palette: string[]): string => {
|
||||
const randomIndex = Math.floor(Math.random() * palette.length);
|
||||
return palette[randomIndex];
|
||||
};
|
||||
|
||||
// List of icons to be used in the background
|
||||
// Expanded to include a wider variety of symbols for more visual diversity
|
||||
const iconNames: string[] = [
|
||||
// Core IT/Tech icons
|
||||
'tabler:settings-automation',
|
||||
'tabler:brand-office',
|
||||
'tabler:api',
|
||||
'tabler:server',
|
||||
'tabler:message-chatbot',
|
||||
'tabler:share',
|
||||
'tabler:code',
|
||||
'tabler:cloud',
|
||||
'tabler:device-laptop',
|
||||
'tabler:chart-line',
|
||||
'tabler:database',
|
||||
'tabler:brand-github',
|
||||
'tabler:device-desktop',
|
||||
'tabler:brand-azure',
|
||||
|
||||
// Additional tech-related icons
|
||||
'tabler:cpu',
|
||||
'tabler:device-mobile',
|
||||
'tabler:wifi',
|
||||
'tabler:network',
|
||||
'tabler:shield',
|
||||
'tabler:lock',
|
||||
'tabler:key',
|
||||
'tabler:rocket',
|
||||
|
||||
// Tangentially related icons for visual diversity
|
||||
'tabler:bulb',
|
||||
'tabler:compass',
|
||||
'tabler:binary',
|
||||
'tabler:infinity',
|
||||
'tabler:brain',
|
||||
];
|
||||
|
||||
// Function to get a random value within a range
|
||||
const getRandomInRange = (min: number, max: number): number => {
|
||||
return Math.random() * (max - min) + min;
|
||||
};
|
||||
|
||||
// Function to get a random rotation with increased range
|
||||
const getRandomRotation = (): string => {
|
||||
return `${getRandomInRange(-30, 30)}deg`;
|
||||
};
|
||||
|
||||
// Function to get a random size (restored to original dimensions)
|
||||
const getRandomSize = (): string => {
|
||||
return `${getRandomInRange(140, 180)}px`;
|
||||
};
|
||||
|
||||
// Function to get a random opacity
|
||||
const getRandomOpacity = (): string => {
|
||||
return getRandomInRange(0.32, 0.38).toFixed(2);
|
||||
};
|
||||
|
||||
// Create a spacious layout with well-separated icons
|
||||
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++) {
|
||||
// Randomly skip more positions (75% chance) for fewer icons overall
|
||||
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));
|
||||
|
||||
// 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)
|
||||
);
|
||||
|
||||
// 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}%`,
|
||||
y: `${y}%`,
|
||||
size: getRandomSize(),
|
||||
opacity: getRandomOpacity(),
|
||||
rotate: getRandomRotation(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return icons;
|
||||
};
|
||||
|
||||
const icons: BaseIconObject[] = createSpacedIcons();
|
||||
|
||||
// Assign random colors to each icon
|
||||
const iconsWithColors: IconWithColors[] = icons.map(icon => ({
|
||||
icon: icon.icon,
|
||||
x: icon.x,
|
||||
y: icon.y,
|
||||
size: icon.size,
|
||||
opacity: icon.opacity,
|
||||
rotate: icon.rotate,
|
||||
lightColor: getRandomColor(lightModeColors),
|
||||
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 parallax effect */
|
||||
<div id="parallax-background" class="absolute inset-0 overflow-hidden pointer-events-none z-[-5]">
|
||||
{iconsWithColors.map(({ icon, x, y, size, opacity, rotate, lightColor, darkColor }, index) => (
|
||||
<div
|
||||
class={`absolute ${lightColor} ${darkColor} parallax-icon`}
|
||||
style={`left: ${x}; top: ${y}; opacity: ${opacity}; transform: rotate(${rotate}); will-change: transform; transition: transform 0.1s ease-out;`}
|
||||
data-depth={`${0.5 + (index % 3) * 0.2}`}
|
||||
data-initial-x={x}
|
||||
data-initial-y={y}
|
||||
>
|
||||
<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)`;
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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) => {
|
||||
const transformValue = icon.style.transform;
|
||||
const rotateMatch = transformValue.match(/rotate\([^)]+\)/);
|
||||
const rotateValue = rotateMatch ? rotateMatch[0] : 'rotate(0deg)';
|
||||
icon.style.transform = rotateValue;
|
||||
});
|
||||
} else {
|
||||
// Update parallax on desktop
|
||||
updateParallax();
|
||||
}
|
||||
}, 200);
|
||||
}, { passive: true });
|
||||
|
||||
// Initial update
|
||||
updateParallax();
|
||||
});
|
||||
</script>
|
||||
)}
|
||||
|
@@ -16,7 +16,7 @@ const {
|
||||
panel: panelClass = '',
|
||||
title: titleClass = '',
|
||||
description: descriptionClass = '',
|
||||
icon: defaultIconClass = 'text-primary dark:text-slate-200 border-primary dark:border-blue-700',
|
||||
icon: defaultIconClass = 'text-primary dark:text-blue-300 border-primary dark:border-blue-500 dark:shadow-blue-500/40 dark:shadow-sm',
|
||||
} = classes;
|
||||
---
|
||||
|
||||
@@ -26,7 +26,7 @@ const {
|
||||
{items.map(({ title, description, icon, classes: itemClasses = {} }) => (
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-all duration-300 ease-in-out hover:bg-gray-50 dark:hover:bg-gray-800',
|
||||
'flex intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-all duration-300 ease-in-out backdrop-blur-sm bg-white/15 dark:bg-transparent hover:bg-white/30 hover:backdrop-blur-md dark:hover:backdrop-blur-md hover:translate-y-[-2px]',
|
||||
panelClass,
|
||||
itemClasses?.panel
|
||||
)}
|
||||
|
@@ -15,7 +15,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
}
|
||||
</style>
|
||||
|
||||
<form id="contact-form" name="contact" netlify 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">
|
||||
|
305
src/components/ui/GlobalBackground.astro
Normal file
305
src/components/ui/GlobalBackground.astro
Normal file
@@ -0,0 +1,305 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components';
|
||||
|
||||
export interface Props {
|
||||
isDark?: boolean;
|
||||
}
|
||||
|
||||
const { isDark = false } = Astro.props;
|
||||
|
||||
// Define color palettes for light and dark modes with higher contrast
|
||||
const lightModeColors = [
|
||||
'text-blue-600/40',
|
||||
'text-indigo-600/40',
|
||||
'text-purple-600/40',
|
||||
'text-cyan-600/40',
|
||||
'text-teal-600/40',
|
||||
'text-emerald-600/40',
|
||||
'text-sky-600/40',
|
||||
'text-violet-600/40',
|
||||
];
|
||||
|
||||
const darkModeColors = [
|
||||
'dark:text-blue-400/45',
|
||||
'dark:text-indigo-400/45',
|
||||
'dark:text-purple-400/45',
|
||||
'dark:text-cyan-400/45',
|
||||
'dark:text-teal-400/45',
|
||||
'dark:text-emerald-400/45',
|
||||
'dark:text-sky-400/45',
|
||||
'dark:text-violet-400/45',
|
||||
];
|
||||
|
||||
// Define interfaces for our icon objects
|
||||
interface BaseIconObject {
|
||||
icon: string;
|
||||
x: string;
|
||||
y: string;
|
||||
size: string;
|
||||
opacity: string;
|
||||
rotate: string;
|
||||
}
|
||||
|
||||
interface IconWithColors extends BaseIconObject {
|
||||
lightColor: string;
|
||||
darkColor: string;
|
||||
}
|
||||
|
||||
// Function to get a random color from the palette
|
||||
const getRandomColor = (palette: string[]): string => {
|
||||
const randomIndex = Math.floor(Math.random() * palette.length);
|
||||
return palette[randomIndex];
|
||||
};
|
||||
|
||||
// List of icons to be used in the background
|
||||
// Expanded to include a wider variety of symbols for more visual diversity
|
||||
const iconNames: string[] = [
|
||||
// Core IT/Tech icons
|
||||
'tabler:settings-automation',
|
||||
'tabler:brand-office',
|
||||
'tabler:api',
|
||||
'tabler:server',
|
||||
'tabler:message-chatbot',
|
||||
'tabler:share',
|
||||
'tabler:code',
|
||||
'tabler:cloud',
|
||||
'tabler:device-laptop',
|
||||
'tabler:chart-line',
|
||||
'tabler:database',
|
||||
'tabler:brand-github',
|
||||
'tabler:device-desktop',
|
||||
'tabler:brand-azure',
|
||||
|
||||
// Additional tech-related icons
|
||||
'tabler:cpu',
|
||||
'tabler:device-mobile',
|
||||
'tabler:wifi',
|
||||
'tabler:network',
|
||||
'tabler:shield',
|
||||
'tabler:lock',
|
||||
'tabler:key',
|
||||
'tabler:rocket',
|
||||
'tabler:satellite-off',
|
||||
|
||||
// Tangentially related icons for visual diversity
|
||||
'tabler:bulb',
|
||||
'tabler:puzzle',
|
||||
'tabler:compass',
|
||||
'tabler:chart-dots',
|
||||
'tabler:math',
|
||||
'tabler:atom',
|
||||
'tabler:binary',
|
||||
'tabler:circuit-resistor',
|
||||
'tabler:infinity',
|
||||
'tabler:planet',
|
||||
'tabler:brain',
|
||||
'tabler:cube',
|
||||
];
|
||||
|
||||
// Function to get a random value within a range
|
||||
const getRandomInRange = (min: number, max: number): number => {
|
||||
return Math.random() * (max - min) + min;
|
||||
};
|
||||
|
||||
// Function to get a random rotation with increased range
|
||||
const getRandomRotation = (): string => {
|
||||
return `${getRandomInRange(-30, 30)}deg`;
|
||||
};
|
||||
|
||||
// Function to get a random size
|
||||
const getRandomSize = (): string => {
|
||||
return `${getRandomInRange(140, 180)}px`;
|
||||
};
|
||||
|
||||
// Function to get a random opacity
|
||||
const getRandomOpacity = (): string => {
|
||||
return getRandomInRange(0.32, 0.38).toFixed(2);
|
||||
};
|
||||
|
||||
// Create a spacious layout with well-separated icons
|
||||
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++) {
|
||||
// Randomly skip positions (65% chance) for fewer icons overall
|
||||
if (Math.random() < 0.65) {
|
||||
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));
|
||||
|
||||
// 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)
|
||||
);
|
||||
|
||||
// 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}%`,
|
||||
y: `${y}%`,
|
||||
size: getRandomSize(),
|
||||
opacity: getRandomOpacity(),
|
||||
rotate: getRandomRotation(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return icons;
|
||||
};
|
||||
|
||||
const icons: BaseIconObject[] = createSpacedIcons();
|
||||
|
||||
// Assign random colors to each icon
|
||||
const iconsWithColors: IconWithColors[] = icons.map(icon => ({
|
||||
icon: icon.icon,
|
||||
x: icon.x,
|
||||
y: icon.y,
|
||||
size: icon.size,
|
||||
opacity: icon.opacity,
|
||||
rotate: icon.rotate,
|
||||
lightColor: getRandomColor(lightModeColors),
|
||||
darkColor: getRandomColor(darkModeColors),
|
||||
}));
|
||||
---
|
||||
|
||||
<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 parallax effect */}
|
||||
<div id="parallax-background" class="absolute inset-0 overflow-hidden">
|
||||
{iconsWithColors.map(({ icon, x, y, size, opacity, rotate, lightColor, darkColor }, index) => (
|
||||
<div
|
||||
class={`absolute ${lightColor} ${darkColor} parallax-icon`}
|
||||
style={`left: ${x}; top: ${y}; opacity: ${opacity}; transform: rotate(${rotate}); will-change: transform; transition: transform 0.1s ease-out;`}
|
||||
data-depth={`${0.5 + (index % 3) * 0.2}`}
|
||||
data-initial-x={x}
|
||||
data-initial-y={y}
|
||||
>
|
||||
<Icon name={icon} style={`width: ${size}; height: ${size};`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Parallax scrolling effect for background icons
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Get all parallax icons
|
||||
const parallaxIcons = document.querySelectorAll<HTMLElement>('.parallax-icon');
|
||||
|
||||
// Skip parallax on mobile devices for better performance
|
||||
const isMobile = window.matchMedia('(max-width: 768px)').matches;
|
||||
if (isMobile) 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 = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Add scroll event listener
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
// Update on resize (debounced)
|
||||
let resizeTimer: number;
|
||||
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) => {
|
||||
const transformValue = icon.style.transform;
|
||||
const rotateMatch = transformValue.match(/rotate\([^)]+\)/);
|
||||
const rotateValue = rotateMatch ? rotateMatch[0] : 'rotate(0deg)';
|
||||
icon.style.transform = rotateValue;
|
||||
});
|
||||
} else {
|
||||
// Update parallax on desktop
|
||||
updateParallax();
|
||||
}
|
||||
}, 200) as unknown as number;
|
||||
}, { passive: true });
|
||||
|
||||
// Initial update
|
||||
updateParallax();
|
||||
});
|
||||
</script>
|
@@ -20,16 +20,16 @@ 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" 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', 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-4 text-muted', subtitleClass)} set:html={subtitle} />}
|
||||
{subtitle && <p class={twMerge('mt-2 text-muted content-backdrop p-2 rounded-md', subtitleClass)} set:html={subtitle} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@@ -11,7 +11,7 @@ const {
|
||||
panel: panelClass = '',
|
||||
title: titleClass = '',
|
||||
description: descriptionClass = '',
|
||||
icon: defaultIconClass = 'text-primary',
|
||||
icon: defaultIconClass = 'text-primary dark:text-blue-300 dark:shadow-blue-500/40 dark:shadow-sm',
|
||||
action: actionClass = '',
|
||||
} = classes;
|
||||
---
|
||||
@@ -34,7 +34,7 @@ const {
|
||||
>
|
||||
{items.map(({ title, description, icon, callToAction, classes: itemClasses = {} }) => (
|
||||
<div class="intersect-once motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade">
|
||||
<div class={twMerge('flex flex-row max-w-md', panelClass, itemClasses?.panel)}>
|
||||
<div class={twMerge('flex flex-row max-w-md 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">
|
||||
{(icon || defaultIcon) && (
|
||||
<Icon
|
||||
|
@@ -17,7 +17,7 @@ const {
|
||||
panel: panelClass = '',
|
||||
title: titleClass = '',
|
||||
description: descriptionClass = '',
|
||||
icon: defaultIconClass = 'text-primary dark:text-slate-200 border-primary dark:border-blue-700',
|
||||
icon: defaultIconClass = 'text-primary dark:text-blue-300 border-primary dark:border-blue-500 dark:shadow-blue-500/40 dark:shadow-sm',
|
||||
timeline: timelineClass = 'bg-primary/30 dark:bg-blue-700/30',
|
||||
timelineDot: timelineDotClass = 'bg-primary dark:bg-blue-700',
|
||||
year: yearClass = 'text-primary dark:text-blue-300',
|
||||
@@ -28,7 +28,7 @@ const {
|
||||
items && items.length && (
|
||||
<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" 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-primary/70 dark:bg-blue-700/30" class:list={[timelineClass]}></div>
|
||||
|
||||
<div class="relative">
|
||||
{items.map((item, index) => {
|
||||
@@ -69,7 +69,7 @@ const {
|
||||
>
|
||||
<div
|
||||
class={twMerge(
|
||||
`flex flex-col border border-gray-200 dark:border-gray-700 rounded-lg ${compact ? 'p-3' : 'p-4'} shadow-sm hover:shadow-md transition-all duration-300 ease-in-out bg-white dark:bg-gray-900 hover:translate-y-[-3px] group card-container`,
|
||||
`flex flex-col border border-gray-200 dark:border-gray-700 rounded-lg ${compact ? 'p-3' : 'p-4'} shadow-sm hover:shadow-md transition-all duration-300 ease-in-out backdrop-blur-sm bg-white/15 dark:bg-transparent hover:bg-white/30 hover:backdrop-blur-md dark:hover:backdrop-blur-md hover:translate-y-[-3px] group card-container`,
|
||||
panelClass,
|
||||
itemClasses?.panel
|
||||
)}
|
||||
@@ -95,12 +95,11 @@ const {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connector line to timeline (visible only on desktop) */}
|
||||
<div class={twMerge(
|
||||
'absolute top-5 hidden md:block h-0.5 w-6 z-0',
|
||||
isEven ? 'right-0 bg-gradient-to-r' : 'left-0 bg-gradient-to-l',
|
||||
'from-transparent to-primary/70 dark:to-blue-700/70'
|
||||
'from-transparent to-primary/50 dark:to-blue-700'
|
||||
)}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -16,7 +16,7 @@ const {
|
||||
panel: panelClass = '',
|
||||
title: titleClass = '',
|
||||
description: descriptionClass = '',
|
||||
icon: defaultIconClass = 'text-primary dark:text-slate-200 border-primary dark:border-blue-700',
|
||||
icon: defaultIconClass = 'text-primary dark:text-blue-300 border-primary dark:border-blue-500 dark:shadow-blue-500/40 dark:shadow-sm',
|
||||
arrow: arrowClass = 'text-primary dark:text-slate-200',
|
||||
} = classes;
|
||||
---
|
||||
@@ -68,7 +68,7 @@ const {
|
||||
{/* Timeline item */}
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade border border-gray-200 dark:border-gray-700 rounded-lg p-5 hover:shadow-md transition-all duration-300 ease-in-out hover:bg-gray-50 dark:hover:bg-gray-800 ml-8 md:ml-0 shadow-sm relative',
|
||||
'flex intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade border border-gray-200 dark:border-gray-700 rounded-lg p-5 hover:shadow-md transition-all duration-300 ease-in-out backdrop-blur-sm bg-white/15 dark:bg-transparent hover:bg-white/30 hover:backdrop-blur-md dark:hover:backdrop-blur-md ml-8 md:ml-0 shadow-sm relative hover:translate-y-[-2px]',
|
||||
panelClass,
|
||||
itemClasses?.panel
|
||||
)}
|
||||
|
@@ -16,7 +16,7 @@ const {
|
||||
panel: panelClass = '',
|
||||
title: titleClass = '',
|
||||
description: descriptionClass = '',
|
||||
icon: defaultIconClass = 'text-primary dark:text-slate-200 border-primary dark:border-blue-700',
|
||||
icon: defaultIconClass = 'text-primary dark:text-blue-300 border-primary dark:border-blue-500 dark:shadow-blue-500/40 dark:shadow-sm',
|
||||
} = classes;
|
||||
---
|
||||
|
||||
@@ -42,7 +42,7 @@ const {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{index !== items.length - 1 && <div class="w-px h-full bg-black/10 dark:bg-slate-400/50" />}
|
||||
{index !== items.length - 1 && <div class="w-px h-full bg-primary/30 dark:bg-slate-400/50" />}
|
||||
</div>
|
||||
<div class={`pt-1 ${index !== items.length - 1 ? 'pb-8' : ''}`}>
|
||||
{title && <p class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)} set:html={title} />}
|
||||
|
@@ -7,19 +7,22 @@ import Background from './Background.astro';
|
||||
export interface Props extends Widget {
|
||||
containerClass?: string;
|
||||
['as']?: HTMLTag;
|
||||
disableBackground?: boolean;
|
||||
}
|
||||
|
||||
const { id, isDark = false, containerClass = '', bg, as = 'section' } = Astro.props;
|
||||
const { id, isDark = false, containerClass = '', bg, as = 'section', disableBackground = true } = Astro.props;
|
||||
|
||||
const WrapperTag = as;
|
||||
---
|
||||
|
||||
<WrapperTag class="relative not-prose scroll-mt-[72px]" {...id ? { id } : {}}>
|
||||
<div class="absolute inset-0 pointer-events-none -z-[1]" aria-hidden="true">
|
||||
<slot name="bg">
|
||||
{bg ? <Fragment set:html={bg} /> : <Background isDark={isDark} />}
|
||||
</slot>
|
||||
</div>
|
||||
{!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(
|
||||
|
Reference in New Issue
Block a user