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

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