monacousa-portal/components/dashboard/ActivityTimeline.vue

285 lines
6.0 KiB
Vue

<template>
<div class="activity-timeline">
<div
v-for="(item, index) in activities"
:key="item.id"
v-motion
:initial="{ opacity: 0, x: -20 }"
:visibleOnce="{
opacity: 1,
x: 0,
transition: {
delay: index * 100,
duration: 500,
type: 'spring',
stiffness: 200
}
}"
class="timeline-item"
:class="{ 'timeline-item--last': index === activities.length - 1 }"
>
<!-- Timeline Marker -->
<div
class="timeline-marker"
:class="[
`timeline-marker--${item.type}`,
{ 'timeline-marker--pulse': item.isNew }
]"
>
<v-icon
:color="getIconColor(item.type)"
size="16"
>
{{ item.icon }}
</v-icon>
</div>
<!-- Timeline Content -->
<div class="timeline-content">
<div class="timeline-header">
<h4 class="timeline-title">{{ item.title }}</h4>
<span class="timeline-time">{{ formatTime(item.timestamp) }}</span>
</div>
<p class="timeline-description">{{ item.description }}</p>
<!-- Optional metadata -->
<div v-if="item.metadata" class="timeline-metadata">
<v-chip
v-for="(meta, key) in item.metadata"
:key="key"
size="x-small"
variant="tonal"
:color="getMetaColor(key)"
class="mr-1"
>
{{ meta }}
</v-chip>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface TimelineActivity {
id: string | number;
type: 'event' | 'profile';
title: string;
description: string;
timestamp: string | Date;
icon: string;
isNew?: boolean;
metadata?: Record<string, any>;
}
interface Props {
activities: TimelineActivity[];
maxItems?: number;
}
const props = withDefaults(defineProps<Props>(), {
maxItems: 10
});
// Compute visible activities
const visibleActivities = computed(() => {
return props.activities.slice(0, props.maxItems);
});
// Get icon color based on activity type
const getIconColor = (type: string) => {
const colors: Record<string, string> = {
event: 'error',
profile: 'info'
};
return colors[type] || 'grey';
};
// Get metadata chip color
const getMetaColor = (key: string) => {
const colors: Record<string, string> = {
status: 'success',
category: 'primary',
amount: 'warning',
level: 'info'
};
return colors[key] || 'grey';
};
// Format timestamp
const formatTime = (timestamp: string | Date) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
});
};
</script>
<style scoped lang="scss">
.activity-timeline {
position: relative;
padding-left: 2rem;
// Vertical line
&::before {
content: '';
position: absolute;
left: 0.75rem;
top: 0.5rem;
bottom: 1rem;
width: 2px;
background: linear-gradient(
to bottom,
rgba(220, 38, 38, 0.3),
rgba(220, 38, 38, 0.1),
transparent
);
}
}
.timeline-item {
position: relative;
padding-bottom: 1.5rem;
&--last {
padding-bottom: 0;
}
}
.timeline-marker {
position: absolute;
left: -1.25rem;
top: 0.125rem;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: white;
border: 2px solid;
z-index: 1;
transition: all 0.3s ease;
&--event {
border-color: rgb(220, 38, 38);
background: linear-gradient(135deg, rgba(220, 38, 38, 0.1), rgba(220, 38, 38, 0.05));
}
&--profile {
border-color: rgb(59, 130, 246);
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(59, 130, 246, 0.05));
}
&--pulse {
&::after {
content: '';
position: absolute;
inset: -6px;
border-radius: 50%;
border: 2px solid currentColor;
opacity: 0;
animation: pulse-ring 2s infinite;
}
}
}
@keyframes pulse-ring {
0% {
transform: scale(0.8);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.3;
}
100% {
transform: scale(1.4);
opacity: 0;
}
}
.timeline-content {
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.8),
rgba(255, 255, 255, 0.6)
);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 0.75rem;
padding: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
&:hover {
transform: translateX(4px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
}
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.timeline-title {
font-size: 0.875rem;
font-weight: 600;
color: rgb(31, 41, 55);
margin: 0;
}
.timeline-time {
font-size: 0.75rem;
color: rgb(156, 163, 175);
white-space: nowrap;
}
.timeline-description {
font-size: 0.8125rem;
color: rgb(107, 114, 128);
margin: 0 0 0.5rem 0;
line-height: 1.5;
}
.timeline-metadata {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
}
@media (max-width: 640px) {
.activity-timeline {
padding-left: 1.5rem;
}
.timeline-marker {
left: -1rem;
width: 1.25rem;
height: 1.25rem;
}
.timeline-content {
padding: 0.75rem;
}
}
</style>