Main page overhaul

This commit is contained in:
becarta
2025-02-26 01:53:49 +01:00
parent 1510206b1f
commit 4b0cdaf83c
22 changed files with 1046 additions and 103 deletions

View File

@@ -20,6 +20,12 @@ import '@fontsource-variable/inter';
---
<style is:inline>
/* Optimize font loading with font-display:swap */
@font-face {
font-family: 'Inter Variable';
font-display: swap;
}
:root {
--aw-font-sans: 'Inter Variable';
--aw-font-serif: 'Inter Variable';
@@ -31,7 +37,7 @@ import '@fontsource-variable/inter';
--aw-color-text-heading: rgb(0 0 0);
--aw-color-text-default: rgb(16 16 16);
--aw-color-text-muted: rgb(16 16 16 / 66%);
--aw-color-text-muted: rgb(16 16 16 / 40%);
--aw-color-bg-page: rgb(255 255 255);
--aw-color-bg-page-dark: rgb(3 6 32);
@@ -52,7 +58,7 @@ import '@fontsource-variable/inter';
--aw-color-text-heading: rgb(247, 248, 248);
--aw-color-text-default: rgb(229 236 246);
--aw-color-text-muted: rgb(229 236 246 / 66%);
--aw-color-text-muted: rgb(229 236 246 / 85%);
--aw-color-bg-page: rgb(3 6 32);
::selection {

View File

@@ -23,12 +23,13 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
<div class="relative inline-block text-left">
<div>
<button
type="button"
class="inline-flex justify-center w-full rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-indigo-500 dark:focus:ring-indigo-400 transition-colors duration-200"
id="menu-button"
aria-expanded="false"
<button
type="button"
class="inline-flex justify-center w-full rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus-visible:ring-4 transition-colors duration-200"
id="menu-button"
aria-expanded="false"
aria-haspopup="true"
aria-label={`Select language. Current language: ${currentLanguage.name}`}
>
<Icon name={`circle-flags:${currentLanguage.flag}`} class="inline-block w-5 h-5 mr-2" />
<span id="selected-language">{currentLanguage.name}</span>
@@ -55,9 +56,10 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
<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"
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 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"
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}
@@ -188,6 +190,11 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
closeMenu();
} else {
openMenu();
// Focus the first menu item for better keyboard navigation
const firstMenuItem = menu.querySelector('button[role="menuitem"]');
if (firstMenuItem) {
(firstMenuItem as HTMLElement).focus();
}
}
});
@@ -236,6 +243,22 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
closeMenu();
button.focus();
}
// Enhanced keyboard navigation with arrow keys
if (isOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
const menuItems = Array.from(menu.querySelectorAll('button[role="menuitem"]'));
const currentIndex = menuItems.findIndex(item => item === document.activeElement);
let newIndex;
if (e.key === 'ArrowDown') {
newIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0;
} else {
newIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1;
}
(menuItems[newIndex] as HTMLElement).focus();
}
});
}

View File

@@ -5,4 +5,9 @@ import { getAsset } from '~/utils/permalinks';
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Resource hints to improve performance -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preload" href="/fonts/inter-variable.woff2" as="font" type="font/woff2" crossorigin />
<link rel="sitemap" href={getAsset('/sitemap-index.xml')} />

View File

@@ -0,0 +1,5 @@
---
const { data } = Astro.props;
---
<script type="application/ld+json" set:html={JSON.stringify(data)} />

View File

@@ -0,0 +1,69 @@
---
import { Icon } from 'astro-icon/components';
import { twMerge } from 'tailwind-merge';
import type { Item } from '~/types';
export interface Props {
items?: Array<Item>;
defaultIcon?: string;
classes?: Record<string, string>;
}
const { items = [], classes = {}, defaultIcon } = Astro.props as Props;
const {
container: containerClass = '',
panel: panelClass = '',
title: titleClass = '',
description: descriptionClass = '',
icon: defaultIconClass = 'text-primary dark:text-slate-200 border-primary dark:border-blue-700',
} = classes;
---
{
items && items.length && (
<div class={twMerge("grid grid-cols-1 md:grid-cols-2 gap-4", containerClass)}>
{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',
panelClass,
itemClasses?.panel
)}
>
<div class="flex-shrink-0 mr-3 rtl:mr-0 rtl:ml-3">
{(icon || defaultIcon) && (
<Icon
name={icon || defaultIcon}
class={twMerge('w-8 h-8 p-1.5 rounded-full border-2', defaultIconClass, itemClasses?.icon)}
/>
)}
</div>
<div>
{title && <p class={twMerge('text-lg font-bold', titleClass, itemClasses?.title)} set:html={title} />}
{description && (
<div class="text-muted mt-2 overflow-hidden">
<div
class={twMerge('text-sm max-h-[4.5rem] hover:max-h-[300px] transition-all duration-500 ease-description', descriptionClass, itemClasses?.description)}
set:html={description}
/>
</div>
)}
</div>
</div>
))}
</div>
)
}
<style>
/* Custom easing function for description expansion */
.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>

View File

@@ -0,0 +1,169 @@
---
// 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">
<!-- Backdrop overlay -->
<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">
<!-- Close button -->
<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>
</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="flex items-center justify-center h-full w-full">
<img
id="modal-image"
src=""
alt="Enlarged certificate"
class="w-auto object-contain"
style="max-height: var(--cert-max-height, 75%); vertical-align: middle;"
/>
</div>
</div>
<!-- Caption -->
<div id="modal-caption" class="mt-2 text-center text-white text-lg font-medium"></div>
</div>
</div>
<script>
// Declare the global function type
declare global {
interface Window {
openImageModal: (imgSrc: string, imgAlt: string) => void;
}
}
// Initialize modal functionality when the DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('image-modal');
const modalBackdrop = document.getElementById('modal-backdrop');
const modalContainer = document.getElementById('modal-container');
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() {
// 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) => {
e.preventDefault();
closeModal();
});
}
// Close modal when clicking outside the image container
if (modal) {
modal.addEventListener('click', (e) => {
// Check if the click was on the modal backdrop or the modal itself (not on its children)
if (e.target === modal || e.target === modalBackdrop) {
closeModal();
}
});
}
// 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', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
if (modalImage && modalImage.src && modal && modal.classList.contains('opacity-100')) {
// 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);
}
}, 200); // Debounce resize events
});
});
</script>

View File

@@ -0,0 +1,164 @@
---
import { Icon } from 'astro-icon/components';
import { twMerge } from 'tailwind-merge';
import type { Item } from '~/types';
export interface Props {
items?: Array<Item & { year?: string }>;
defaultIcon?: string;
classes?: Record<string, string>;
compact?: boolean;
}
const { items = [], classes = {}, defaultIcon, compact = false } = Astro.props as Props;
const {
container: containerClass = '',
panel: panelClass = '',
title: titleClass = '',
description: descriptionClass = '',
icon: defaultIconClass = 'text-primary dark:text-slate-200 border-primary dark:border-blue-700',
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',
} = classes;
---
{
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="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
const dateMatch = description.match(/\d{2}-(\d{4})/);
if (dateMatch) {
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-xs z-10", yearClass)}>
{year}
</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>
{/* Content card */}
<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',
'transition-all duration-300 ease-in-out'
)}
>
<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`,
panelClass,
itemClasses?.panel
)}
>
<div class="flex items-center mb-2">
{(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)}
/>
)}
{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="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>
<div
class={twMerge(`text-muted ${compact ? 'text-xs' : '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-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'
)}></div>
</div>
</div>
);
})}
</div>
</div>
)
}
<style>
/* Add some animation classes */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
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);
will-change: transform, box-shadow;
}
/* Hover effect for details */
[data-details] {
transform-origin: top;
transition: max-height 0.5s 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: 500px; /* Large enough to fit content but still allows animation */
}
@keyframes fadeIn {
to { opacity: 1; }
}
/* Responsive adjustments */
@media (max-width: 768px) {
.timeline-item {
width: calc(100% - 2.5rem);
margin-left: 2.5rem;
}
}
</style>

View File

@@ -0,0 +1,128 @@
---
import { Icon } from 'astro-icon/components';
import { twMerge } from 'tailwind-merge';
import type { Item } from '~/types';
export interface Props {
items?: Array<Item>;
defaultIcon?: string;
classes?: Record<string, string>;
}
const { items = [], classes = {}, defaultIcon } = Astro.props as Props;
const {
container: containerClass = '',
panel: panelClass = '',
title: titleClass = '',
description: descriptionClass = '',
icon: defaultIconClass = 'text-primary dark:text-slate-200 border-primary dark:border-blue-700',
arrow: arrowClass = 'text-primary dark:text-slate-200',
} = classes;
---
{
items && items.length && (
<div class={twMerge("relative mx-auto max-w-5xl pt-8", containerClass)}>
{/* Mobile timeline line */}
<div class="absolute left-4 top-8 h-full w-1 bg-primary/30 dark:bg-blue-700/30 md:hidden"></div>
<div class="relative min-h-screen">
{items.map((item, index) => {
const { title, description, icon, classes: itemClasses = {} } = item;
const isEven = index % 2 === 0;
const isFirst = index === 0;
// Calculate vertical offset based on position with consistent spacing
const offsetValue = index * 8;
return (
<div
class={`relative ${isEven ? 'ml-0 mr-auto' : 'ml-auto mr-0'} w-full md:w-[45%] mb-12`}
style={!isFirst ? `margin-top: ${offsetValue}rem;` : ''}
id={`timeline-item-${index}`}
>
{/* Arrow connecting to previous item (except for first item) */}
{!isFirst && (
<div class="absolute hidden md:block">
{isEven ? (
<div class={twMerge("absolute -left-16 -top-16 w-32 h-32", arrowClass)}>
<svg class="w-full h-full" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 20 C 40 20, 60 80, 80 80" stroke="currentColor" stroke-width="2.5" fill="none" />
<path d="M80 80 L 70 72" stroke="currentColor" stroke-width="2.5" />
<path d="M80 80 L 72 90" stroke="currentColor" stroke-width="2.5" />
</svg>
</div>
) : (
<div class={twMerge("absolute -right-16 -top-16 w-32 h-32", arrowClass)}>
<svg class="w-full h-full" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M80 20 C 60 20, 40 80, 20 80" stroke="currentColor" stroke-width="2.5" fill="none" />
<path d="M20 80 L 30 72" stroke="currentColor" stroke-width="2.5" />
<path d="M20 80 L 28 90" stroke="currentColor" stroke-width="2.5" />
</svg>
</div>
)}
</div>
)}
{/* 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',
panelClass,
itemClasses?.panel
)}
>
<div class="flex-shrink-0 mr-4 rtl:mr-0 rtl:ml-4">
{(icon || defaultIcon) && (
<Icon
name={icon || defaultIcon}
class={twMerge('w-10 h-10 p-2 rounded-full border-2 shadow-sm', defaultIconClass, itemClasses?.icon)}
/>
)}
</div>
<div>
{title && <p class={twMerge('text-lg font-bold', titleClass, itemClasses?.title)} set:html={title} />}
{description && (
<div class="text-muted mt-2 overflow-hidden">
<div
class={twMerge('text-sm max-h-[2.5rem] md:max-h-none hover:max-h-[300px] transition-all duration-500 ease-staggered', descriptionClass, itemClasses?.description)}
set:html={description}
/>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Responsive styles for small screens */}
<style>
@media (max-width: 768px) {
#timeline-item-0,
#timeline-item-1,
#timeline-item-2,
#timeline-item-3,
#timeline-item-4 {
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

@@ -0,0 +1,105 @@
---
import Headline from '~/components/ui/Headline.astro';
import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
import Button from '~/components/ui/Button.astro';
import ImageModal from '~/components/ui/ImageModal.astro';
import type { Testimonials as Props } from '~/types';
import DefaultImage from '~/assets/images/default.png';
// Function to get the correct image path for a testimonial
const getImagePath = (image: unknown) => {
if (typeof image === 'object' && image !== null && 'src' in image && typeof (image as { src: unknown }).src === 'string') {
// If the image has a src property, use it
return String((image as { src: string }).src);
}
// Otherwise, return the default image path
return DefaultImage.src;
};
// Function to get the alt text for an image
const getImageAlt = (image: unknown, fallback: string = "Certification badge") => {
if (typeof image === 'object' && image !== null && 'alt' in image && typeof (image as { alt: unknown }).alt === 'string') {
return String((image as { alt: string }).alt);
}
return fallback;
};
const {
title = '',
subtitle = '',
tagline = '',
testimonials = [],
callToAction,
id,
isDark = false,
classes = {},
bg = await Astro.slots.render('bg'),
} = Astro.props;
---
<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
<Headline title={title} subtitle={subtitle} tagline={tagline} classes={{
container: 'max-w-3xl',
title: 'text-3xl lg:text-4xl',
}} />
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 mt-6">
{
testimonials &&
testimonials.map(({ linkUrl, name, issueDate, description, image }) => (
<div class="flex flex-col p-3 rounded-md shadow-md dark:shadow-none dark:border dark:border-slate-600 hover:shadow-lg transition-all duration-300 ease-in-out hover:bg-gray-50 dark:hover:bg-gray-800">
<div class="flex items-center mb-3">
<div
class="h-12 w-12 mr-3 flex-shrink-0 bg-gray-100 dark:bg-gray-800 rounded-md flex items-center justify-center overflow-hidden cursor-pointer"
onclick={`window.openImageModal('${getImagePath(image)}', ${JSON.stringify(name || "Certification badge")})`}
>
<img
src={getImagePath(image)}
alt={getImageAlt(image, name || "Certification badge")}
class="h-10 w-10 object-contain transition-transform duration-300 hover:scale-110"
/>
</div>
<a href={linkUrl} target="_blank" rel="noopener noreferrer" class="flex-1">
<div>
{name && <p class="text-sm font-semibold line-clamp-2">{name}</p>}
{issueDate && <p class="text-xs text-muted">{issueDate}</p>}
</div>
</a>
</div>
<a href={linkUrl} target="_blank" rel="noopener noreferrer" class="block">
<div class="text-xs text-muted overflow-hidden">
<div class="max-h-[8rem] hover:max-h-[300px] transition-all duration-500 ease-cert">
{description}
</div>
</div>
</a>
</div>
))
}
</div>
{/* Include the image modal component */}
<ImageModal />
{
callToAction && (
<div class="flex justify-center mx-auto w-fit mt-8 font-medium">
<Button {...callToAction} />
</div>
)
}
</WidgetWrapper>
<style>
/* Custom easing function for certification description expansion */
.ease-cert {
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>

View File

@@ -0,0 +1,57 @@
---
import Headline from '~/components/ui/Headline.astro';
import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
import { Icon } from 'astro-icon/components';
import type { Features as Props } from '~/types';
const {
title = await Astro.slots.render('title'),
subtitle = await Astro.slots.render('subtitle'),
tagline = await Astro.slots.render('tagline'),
items = [],
defaultIcon = 'tabler:point-filled',
id,
isDark = false,
classes = {},
bg = await Astro.slots.render('bg'),
} = Astro.props;
---
<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
<Headline
title={title}
subtitle={subtitle}
tagline={tagline}
classes={{
container: 'max-w-3xl',
title: 'text-3xl lg:text-4xl',
}}
/>
<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 bg-white dark:bg-slate-800 p-3 rounded-lg shadow-sm hover:shadow-md transition-all duration-300 ease-in-out hover:bg-gray-50 dark:hover:bg-gray-800 border border-gray-200 dark:border-gray-700">
<div class="flex items-center mb-2">
<Icon name={icon || defaultIcon} class="w-5 h-5 mr-2 text-primary transition-transform duration-300 group-hover:scale-110" />
<h3 class="text-base font-semibold">{title}</h3>
</div>
<div class="text-muted overflow-hidden">
<p class="text-xs max-h-[8rem] hover:max-h-[300px] transition-all duration-500 ease-skills">{description}</p>
</div>
</div>
))}
</div>
</WidgetWrapper>
<style>
/* Custom easing function for skills expansion */
.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>

View File

@@ -0,0 +1,34 @@
---
import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
import CompactTimeline from '~/components/ui/CompactTimeline.astro';
import Headline from '~/components/ui/Headline.astro';
import type { Steps as Props } from '~/types';
const {
title = await Astro.slots.render('title'),
subtitle = await Astro.slots.render('subtitle'),
tagline = await Astro.slots.render('tagline'),
items = [],
id,
isDark = false,
classes = {},
bg = await Astro.slots.render('bg'),
} = Astro.props;
---
<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl ${classes?.container ?? ''}`} bg={bg}>
<div class="flex flex-col gap-4">
<Headline
title={title}
subtitle={subtitle}
tagline={tagline}
classes={{
container: 'text-left rtl:text-right max-w-3xl',
title: 'text-3xl lg:text-4xl',
...((classes?.headline as object) ?? {}),
}}
/>
<CompactTimeline items={items} classes={classes?.items as Record<string, never>} />
</div>
</WidgetWrapper>

View File

@@ -16,14 +16,14 @@ interface Links {
}
export interface Props {
links: Array<Links>;
links?: Array<Links>;
secondaryLinks: Array<Link>;
socialLinks: Array<Link>;
footNote?: string;
theme?: string;
}
const { socialLinks = [], secondaryLinks = [], links = [], footNote = '', theme = 'light' } = Astro.props;
const { socialLinks = [], theme = 'light' } = Astro.props;
---
<footer class:list={[{ dark: theme === 'dark' }, 'relative border-t border-gray-200 dark:border-slate-800 not-prose']}>
@@ -52,8 +52,8 @@ const { socialLinks = [], secondaryLinks = [], links = [], footNote = '', theme
{
socialLinks?.length && (
<ul class="flex space-x-4">
{socialLinks.map(({ ariaLabel, href, icon }, index) => (
<li key={index}>
{socialLinks.map(({ ariaLabel, href, icon }) => (
<li>
<a
class="text-muted dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg p-2 inline-flex items-center"
aria-label={ariaLabel}

View File

@@ -75,6 +75,17 @@ const currentPath = `/${trimSlash(new URL(Astro.url).pathname)}`;
<div class="flex items-center md:hidden">
<ToggleMenu />
</div>
<!-- Improved mobile navigation accessibility -->
<style>
@media (max-width: 767px) {
nav ul li a, nav ul li button {
padding: 0.75rem 1rem; /* Larger touch targets */
min-height: 44px; /* Minimum touch target size */
display: flex;
align-items: center;
}
}
</style>
</div>
<nav
class="items-center w-full md:w-auto hidden md:flex md:mx-5 text-default overflow-y-auto overflow-x-hidden md:overflow-y-visible md:overflow-x-auto md:justify-self-center"

View File

@@ -0,0 +1,82 @@
---
import ModernTimeline from '~/components/ui/ModernTimeline.astro';
import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
import type { Widget } from '~/types';
export interface Props extends Widget {
title?: string;
subtitle?: string;
tagline?: string;
compact?: boolean;
items?: Array<{
title: string;
description?: string;
company?: string;
date?: string;
location?: string;
icon?: string;
classes?: Record<string, string>;
}>;
}
const {
title = 'Work Experience',
subtitle = 'My professional journey',
tagline = '',
compact = false,
items = [],
id,
isDark = false,
classes = {},
bg = '',
} = Astro.props as Props;
// Transform the work experience items to the format expected by ModernTimeline
const timelineItems = items.map(item => {
// Extract year from date if available
let year: string | undefined = undefined;
if (item.date) {
// The date format in translations is like "04-2018 - 09-2018" or "02-2025 - Present"
// We want to extract the first year (start year)
const dateMatch = item.date.match(/\d{2}-(\d{4})/);
if (dateMatch) {
year = dateMatch[1];
}
}
return {
title: `<span class="${compact ? 'text-lg' : 'text-xl'}">${item.title}</span>${item.company ? `<span class="block ${compact ? 'text-xs' : '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-xs font-semibold text-gray-500 dark:text-gray-400">${item.date}</span>` : ''}${item.location ? `<span class="block text-xs text-gray-500 dark:text-gray-400">${item.location}</span>` : ''}</div>`,
icon: item.icon || 'tabler:briefcase',
classes: item.classes,
year,
};
});
---
<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>
)}
<ModernTimeline
items={timelineItems}
compact={compact}
classes={{
container: 'max-w-4xl mx-auto px-4',
title: 'text-xl font-bold',
description: 'text-muted mt-2',
icon: 'text-primary dark:text-slate-200 border-primary dark:border-blue-700',
timeline: 'bg-primary/30 dark:bg-blue-700/30',
timelineDot: 'bg-primary dark:bg-blue-700',
year: 'text-primary dark:text-blue-300',
}}
/>
</div>
</WidgetWrapper>