Main page overhaul
This commit is contained in:
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>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user