Main page overhaul
This commit is contained in:
@@ -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 {
|
||||
|
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -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')} />
|
||||
|
5
src/components/common/StructuredData.astro
Normal file
5
src/components/common/StructuredData.astro
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
const { data } = Astro.props;
|
||||
---
|
||||
|
||||
<script type="application/ld+json" set:html={JSON.stringify(data)} />
|
69
src/components/ui/CompactTimeline.astro
Normal file
69
src/components/ui/CompactTimeline.astro
Normal 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>
|
169
src/components/ui/ImageModal.astro
Normal file
169
src/components/ui/ImageModal.astro
Normal 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>
|
164
src/components/ui/ModernTimeline.astro
Normal file
164
src/components/ui/ModernTimeline.astro
Normal 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>
|
128
src/components/ui/StaggeredTimeline.astro
Normal file
128
src/components/ui/StaggeredTimeline.astro
Normal 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>
|
||||
)
|
||||
}
|
105
src/components/widgets/CompactCertifications.astro
Normal file
105
src/components/widgets/CompactCertifications.astro
Normal 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>
|
57
src/components/widgets/CompactSkills.astro
Normal file
57
src/components/widgets/CompactSkills.astro
Normal 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>
|
34
src/components/widgets/CompactSteps.astro
Normal file
34
src/components/widgets/CompactSteps.astro
Normal 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>
|
@@ -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}
|
||||
|
@@ -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"
|
||||
|
82
src/components/widgets/WorkExperience.astro
Normal file
82
src/components/widgets/WorkExperience.astro
Normal 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>
|
Reference in New Issue
Block a user