Files
LinkDesk/frontend/src/components/activity/ActivityFeed.vue
T

282 lines
8.4 KiB
Vue

<template>
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle>{{ title }}</CardTitle>
<div class="flex items-center gap-2">
<Select v-if="showFilters" v-model="selectedDays">
<SelectTrigger class="w-32">
<SelectValue placeholder="All time" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All time</SelectItem>
<SelectItem value="1">Last 24h</SelectItem>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
@click="handleRefresh"
:disabled="loading"
>
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<ScrollArea :class="scrollHeight">
<div v-if="loading && activities.length === 0" class="text-center py-8 text-muted-foreground">
Loading activities...
</div>
<div v-else-if="activities.length === 0" class="text-center py-8 text-muted-foreground">
<Activity class="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No activity yet</p>
</div>
<div v-else class="space-y-4">
<div
v-for="activity in activities"
:key="activity.id"
class="flex gap-3 pb-4 border-b last:border-0"
>
<div class="flex-shrink-0 mt-1">
<Avatar class="h-8 w-8">
<AvatarImage
v-if="activity.user.avatar_url"
:src="getAvatarUrl(activity.user.avatar_url, activity.user.first_name, activity.user.last_name)"
/>
<AvatarImage
v-else
:src="getInitialsAvatarUrl(activity.user.first_name, activity.user.last_name)"
/>
<AvatarFallback>
{{ getInitials(activity.user) }}
</AvatarFallback>
</Avatar>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start gap-2">
<component
:is="getActivityIcon(activity.type)"
class="h-4 w-4 mt-0.5 flex-shrink-0"
:class="getActivityColor(activity.type)"
/>
<div class="flex-1">
<p class="text-sm">{{ activity.description }}</p>
<p class="text-xs text-muted-foreground mt-1">
{{ formatTime(activity.created_at) }}
</p>
</div>
</div>
<!-- Action buttons for navigating to related items -->
<div v-if="activity.task_id || activity.project_id" class="mt-2 flex gap-2">
<Button
v-if="activity.task_id"
variant="outline"
size="sm"
@click="navigateToTask(activity.task_id)"
>
View Task
</Button>
<Button
v-if="activity.project_id && !activity.task_id"
variant="outline"
size="sm"
@click="navigateToProject(activity.project_id)"
>
View Project
</Button>
</div>
</div>
</div>
</div>
<div v-if="hasMore" class="text-center pt-4">
<Button
variant="outline"
@click="loadMore"
:disabled="loading"
>
Load More
</Button>
</div>
</ScrollArea>
</CardContent>
</Card>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Activity as ActivityIcon,
FileText,
CheckCircle,
UserPlus,
MessageSquare,
Image,
Film,
FolderPlus,
RefreshCw
} from 'lucide-vue-next'
import type { Activity, ActivityType, UserInfo } from '@/types/activity'
import * as activityService from '@/services/activity'
interface Props {
title?: string
projectId?: number
taskId?: number
userId?: number
showFilters?: boolean
scrollHeight?: string
}
const props = withDefaults(defineProps<Props>(), {
title: 'Activity Feed',
showFilters: true,
scrollHeight: 'h-[500px]'
})
const router = useRouter()
const activities = ref<Activity[]>([])
const loading = ref(false)
const selectedDays = ref<string>('all')
const hasMore = ref(false)
const currentSkip = ref(0)
const limit = 20
onMounted(() => {
loadActivities()
})
watch(selectedDays, () => {
currentSkip.value = 0
loadActivities()
})
async function loadActivities() {
loading.value = true
try {
const days = selectedDays.value === 'all' ? undefined : parseInt(selectedDays.value)
let result: Activity[] = []
if (props.taskId) {
result = await activityService.getTaskActivities(props.taskId, currentSkip.value, limit)
} else if (props.projectId) {
result = await activityService.getProjectActivities(props.projectId, currentSkip.value, limit, undefined, days)
} else if (props.userId) {
result = await activityService.getUserActivities(props.userId, currentSkip.value, limit, days)
} else {
result = await activityService.getRecentActivities(currentSkip.value, limit)
}
if (currentSkip.value === 0) {
activities.value = result
} else {
activities.value.push(...result)
}
hasMore.value = result.length === limit
} catch (error) {
console.error('Failed to load activities:', error)
} finally {
loading.value = false
}
}
async function loadMore() {
currentSkip.value += limit
await loadActivities()
}
async function handleRefresh() {
currentSkip.value = 0
await loadActivities()
}
function getInitials(user: UserInfo): string {
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase()
}
function getActivityIcon(type: ActivityType) {
const iconMap: Record<string, any> = {
task_created: FileText,
task_updated: FileText,
task_assigned: UserPlus,
task_status_changed: CheckCircle,
submission_created: FileText,
submission_reviewed: CheckCircle,
comment_added: MessageSquare,
asset_created: Image,
asset_updated: Image,
shot_created: Film,
shot_updated: Film,
project_created: FolderPlus,
project_updated: FolderPlus,
user_joined_project: UserPlus
}
return iconMap[type] || ActivityIcon
}
function getActivityColor(type: ActivityType): string {
const colorMap: Record<string, string> = {
task_created: 'text-blue-500',
task_updated: 'text-blue-500',
task_assigned: 'text-purple-500',
task_status_changed: 'text-green-500',
submission_created: 'text-orange-500',
submission_reviewed: 'text-green-500',
comment_added: 'text-gray-500',
asset_created: 'text-cyan-500',
asset_updated: 'text-cyan-500',
shot_created: 'text-indigo-500',
shot_updated: 'text-indigo-500',
project_created: 'text-emerald-500',
project_updated: 'text-emerald-500',
user_joined_project: 'text-purple-500'
}
return colorMap[type] || 'text-gray-500'
}
function formatTime(timestamp: string): string {
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}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
function navigateToTask(taskId: number) {
router.push(`/tasks?taskId=${taskId}`)
}
function navigateToProject(projectId: number) {
router.push(`/projects/${projectId}`)
}
import { useAvatarUrl } from '@/composables/useAvatarUrl'
const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl()
</script>