Files
365devnet/src/components/ui/ModernTimeline.astro
2025-02-26 02:35:19 +01:00

164 lines
6.1 KiB
Plaintext

---
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-sm 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-sm' : '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>