303 lines
6.5 KiB
Vue
303 lines
6.5 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' | 'payment' | 'achievement' | 'profile' | 'system';
|
|
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',
|
|
payment: 'success',
|
|
achievement: 'warning',
|
|
profile: 'info',
|
|
system: 'grey'
|
|
};
|
|
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));
|
|
}
|
|
|
|
&--payment {
|
|
border-color: rgb(34, 197, 94);
|
|
background: linear-gradient(135deg, rgba(34, 197, 94, 0.1), rgba(34, 197, 94, 0.05));
|
|
}
|
|
|
|
&--achievement {
|
|
border-color: rgb(245, 158, 11);
|
|
background: linear-gradient(135deg, rgba(245, 158, 11, 0.1), rgba(245, 158, 11, 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));
|
|
}
|
|
|
|
&--system {
|
|
border-color: rgb(156, 163, 175);
|
|
background: linear-gradient(135deg, rgba(156, 163, 175, 0.1), rgba(156, 163, 175, 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> |