Init Repo
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<header class="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
||||
<!-- Sidebar Toggle -->
|
||||
<SidebarTrigger class="-ml-1" />
|
||||
<Separator orientation="vertical" class="mr-2 h-4" />
|
||||
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<Breadcrumb class="flex-1">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem v-for="(crumb, index) in breadcrumbs" :key="index">
|
||||
<BreadcrumbLink v-if="crumb.href" :href="crumb.href">
|
||||
{{ crumb.label }}
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbPage v-else :class="{ 'font-semibold': crumb.isActive }">
|
||||
{{ crumb.label }}
|
||||
</BreadcrumbPage>
|
||||
<BreadcrumbSeparator v-if="index < breadcrumbs.length - 1" />
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<!-- Header Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Theme Toggle -->
|
||||
<ThemeToggle />
|
||||
|
||||
<!-- Notifications -->
|
||||
<NotificationCenter />
|
||||
|
||||
<!-- User Menu -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" class="relative h-10 w-10 rounded-full">
|
||||
<Avatar class="h-10 w-10">
|
||||
<AvatarImage
|
||||
v-if="user?.avatar_url"
|
||||
:src="getAvatarUrl(user.avatar_url)"
|
||||
/>
|
||||
<AvatarImage
|
||||
v-else
|
||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${user?.first_name} ${user?.last_name}`"
|
||||
/>
|
||||
<AvatarFallback>{{ userInitials }}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-56">
|
||||
<DropdownMenuLabel>
|
||||
<div class="flex flex-col space-y-1">
|
||||
<p class="text-sm font-medium leading-none">{{ user?.first_name }} {{ user?.last_name }}</p>
|
||||
<p class="text-xs leading-none text-muted-foreground">{{ user?.email }}</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem as-child>
|
||||
<router-link to="/profile" class="flex items-center">
|
||||
<User class="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
</router-link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as-child>
|
||||
<router-link to="/settings" class="flex items-center">
|
||||
<Settings class="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</router-link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="handleLogout" class="text-destructive">
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb'
|
||||
import { User, Settings, LogOut } from 'lucide-vue-next'
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { BreadcrumbService, type BreadcrumbItem as BreadcrumbData } from '@/services/breadcrumb'
|
||||
import ThemeToggle from '@/components/ui/theme/ThemeToggle.vue'
|
||||
import NotificationCenter from './NotificationCenter.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
|
||||
const userInitials = computed(() => {
|
||||
if (!user.value) return '?'
|
||||
return `${user.value.first_name.charAt(0)}${user.value.last_name.charAt(0)}`.toUpperCase()
|
||||
})
|
||||
|
||||
const getAvatarUrl = (url: string | null | undefined) => {
|
||||
if (!url) return ''
|
||||
// If it's already a full URL, return it
|
||||
if (url.startsWith('http')) return url
|
||||
// Use direct static file serving
|
||||
const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
||||
return `/${cleanUrl}`
|
||||
}
|
||||
|
||||
// Generate breadcrumbs based on current route with enhanced context
|
||||
const breadcrumbs = ref<BreadcrumbData[]>([])
|
||||
|
||||
const updateBreadcrumbs = async () => {
|
||||
try {
|
||||
breadcrumbs.value = await BreadcrumbService.generateBreadcrumbs(route)
|
||||
} catch (error) {
|
||||
console.error('Failed to generate breadcrumbs:', error)
|
||||
// Fallback to simple breadcrumbs
|
||||
const pathSegments = route.path.split('/').filter(Boolean)
|
||||
const crumbs = [{ label: 'Home', href: '/' }]
|
||||
|
||||
let currentPath = ''
|
||||
pathSegments.forEach((segment, index) => {
|
||||
currentPath += `/${segment}`
|
||||
const isLast = index === pathSegments.length - 1
|
||||
|
||||
const label = segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ')
|
||||
|
||||
crumbs.push({
|
||||
label,
|
||||
href: isLast ? undefined : currentPath,
|
||||
isActive: isLast
|
||||
})
|
||||
})
|
||||
|
||||
breadcrumbs.value = crumbs
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for route changes to update breadcrumbs
|
||||
watch(route, updateBreadcrumbs, { immediate: true })
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<SidebarProvider>
|
||||
<div class="flex h-screen w-full">
|
||||
<!-- Main Sidebar -->
|
||||
<AppSidebar />
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<SidebarInset class="flex-1 flex flex-col">
|
||||
<!-- Header -->
|
||||
<AppHeader />
|
||||
|
||||
<!-- Content Area -->
|
||||
<main class="flex-1 flex overflow-hidden">
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
<!-- Detail Panel (conditionally shown) -->
|
||||
<div
|
||||
v-if="showDetailPanel"
|
||||
class="w-80 border-l bg-background overflow-auto"
|
||||
>
|
||||
<slot name="detail-panel">
|
||||
<!-- Default detail panel content -->
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold mb-4">Details</h3>
|
||||
<p class="text-muted-foreground">Select an item to view details</p>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
|
||||
// Show detail panel based on route or store state
|
||||
const route = useRoute()
|
||||
const showDetailPanel = computed(() => {
|
||||
// Show detail panel for certain routes or when an item is selected
|
||||
return route.meta?.showDetailPanel || false
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<Sidebar variant="inset" v-bind="props">
|
||||
<SidebarHeader>
|
||||
<ProjectSwitcher v-if="userRole !== 'developer'" />
|
||||
|
||||
<!-- Developer header (no project switching) -->
|
||||
<SidebarMenu v-else>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" as-child>
|
||||
<router-link to="/" class="flex items-center gap-2">
|
||||
<div class="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Clapperboard class="size-4" />
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span class="truncate font-semibold">VFX Studio</span>
|
||||
<span class="truncate text-xs">Developer Tools</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<!-- Main Navigation -->
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in navigationItems" :key="item.title">
|
||||
<SidebarMenuButton as-child :tooltip="isCollapsed ? item.title : undefined">
|
||||
<router-link :to="item.url" class="flex items-center gap-2">
|
||||
<component :is="item.icon" class="size-4" />
|
||||
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span>
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
|
||||
<!-- Projects Section -->
|
||||
<!-- <SidebarGroup v-if="userRole !== 'developer'">
|
||||
<SidebarGroupLabel>Projects</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="project in recentProjects" :key="project.id">
|
||||
<SidebarMenuButton as-child :tooltip="isCollapsed ? project.name : undefined">
|
||||
<router-link :to="`/projects/${project.id}`" class="flex items-center gap-2">
|
||||
<Folder class="size-4" />
|
||||
<span class="truncate group-data-[collapsible=icon]:hidden">{{ project.name }}</span>
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup> -->
|
||||
|
||||
<!-- Admin Tools (only for admin users) -->
|
||||
<SidebarGroup v-if="authStore.isAdmin">
|
||||
<SidebarGroupLabel>Administration</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in adminItems" :key="item.title">
|
||||
<SidebarMenuButton as-child :tooltip="isCollapsed ? item.title : undefined">
|
||||
<router-link :to="item.url" class="flex items-center gap-2">
|
||||
<component :is="item.icon" class="size-4" />
|
||||
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span>
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
|
||||
<!-- Developer Tools (only for developer role) -->
|
||||
<SidebarGroup v-if="userRole === 'developer'">
|
||||
<SidebarGroupLabel>Developer Tools</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in developerItems" :key="item.title">
|
||||
<SidebarMenuButton as-child :tooltip="isCollapsed ? item.title : undefined">
|
||||
<router-link :to="item.url" class="flex items-center gap-2">
|
||||
<component :is="item.icon" class="size-4" />
|
||||
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span>
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<UserMenu />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
SidebarProps
|
||||
} from '@/components/ui/sidebar'
|
||||
import {
|
||||
Clapperboard,
|
||||
Home,
|
||||
CheckSquare,
|
||||
FolderOpen,
|
||||
Users,
|
||||
Settings,
|
||||
Folder,
|
||||
Key,
|
||||
Database,
|
||||
BarChart3,
|
||||
FileText,
|
||||
RotateCcw,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ProjectSwitcher from './ProjectSwitcher.vue'
|
||||
import UserMenu from './UserMenu.vue'
|
||||
const authStore = useAuthStore()
|
||||
const { state } = useSidebar()
|
||||
const user = computed(() => authStore.user)
|
||||
const userRole = computed(() => authStore.user?.role || 'artist')
|
||||
|
||||
// Check if sidebar is collapsed
|
||||
const isCollapsed = computed(() => state.value === 'collapsed')
|
||||
|
||||
const props = withDefaults(defineProps<SidebarProps>(), {
|
||||
collapsible: "icon",
|
||||
})
|
||||
// Navigation items based on user role
|
||||
const navigationItems = computed(() => {
|
||||
const baseItems = [
|
||||
{ title: 'Dashboard', url: '/', icon: Home },
|
||||
{ title: 'My Tasks', url: '/tasks', icon: CheckSquare },
|
||||
]
|
||||
|
||||
if (userRole.value === 'coordinator' || authStore.isAdmin) {
|
||||
baseItems.push(
|
||||
{ title: 'Projects', url: '/projects', icon: FolderOpen },
|
||||
{ title: 'Team', url: '/users', icon: Users }
|
||||
)
|
||||
}
|
||||
|
||||
if (userRole.value === 'director' || authStore.isAdmin) {
|
||||
baseItems.push(
|
||||
{ title: 'Reviews', url: '/reviews', icon: CheckSquare }
|
||||
)
|
||||
}
|
||||
|
||||
if (authStore.isAdmin) {
|
||||
baseItems.push(
|
||||
{ title: 'Settings', url: '/settings', icon: Settings }
|
||||
)
|
||||
}
|
||||
|
||||
return baseItems
|
||||
})
|
||||
|
||||
// Admin-specific navigation items
|
||||
const adminItems = computed(() => [
|
||||
{ title: 'Recovery Management', url: '/admin/deleted-items', icon: RotateCcw },
|
||||
])
|
||||
|
||||
// Developer-specific navigation items
|
||||
const developerItems = computed(() => [
|
||||
{ title: 'API Keys', url: '/developer/api-keys', icon: Key },
|
||||
{ title: 'All Projects', url: '/developer/projects', icon: Database },
|
||||
{ title: 'All Tasks', url: '/developer/tasks', icon: CheckSquare },
|
||||
{ title: 'Usage Analytics', url: '/developer/analytics', icon: BarChart3 },
|
||||
{ title: 'Documentation', url: '/developer/docs', icon: FileText }
|
||||
])
|
||||
|
||||
// Mock recent projects - this would come from a store in real implementation
|
||||
const recentProjects = computed(() => [
|
||||
{ id: 1, name: 'Project Alpha' },
|
||||
{ id: 2, name: 'Project Beta' },
|
||||
{ id: 3, name: 'Project Gamma' }
|
||||
])
|
||||
</script>
|
||||
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<Popover v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="relative">
|
||||
<Bell class="h-5 w-5" />
|
||||
<span v-if="unreadCount > 0" class="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-red-500 text-white text-xs flex items-center justify-center">
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-96 p-0" align="end">
|
||||
<div class="flex items-center justify-between p-4 border-b">
|
||||
<h3 class="font-semibold">Notifications</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="unreadCount > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="handleMarkAllRead"
|
||||
>
|
||||
Mark all read
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="handleRefresh"
|
||||
:disabled="loading"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea class="h-[400px]">
|
||||
<div v-if="loading && notifications.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
Loading notifications...
|
||||
</div>
|
||||
|
||||
<div v-else-if="notifications.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
<Bell class="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No notifications</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y">
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="p-4 hover:bg-accent cursor-pointer transition-colors"
|
||||
:class="{ 'bg-accent/50': !notification.read }"
|
||||
@click="handleNotificationClick(notification)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<component
|
||||
:is="getNotificationIcon(notification.type)"
|
||||
class="h-5 w-5"
|
||||
:class="getNotificationColor(notification.priority)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="font-medium text-sm" :class="{ 'font-semibold': !notification.read }">
|
||||
{{ notification.title }}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 flex-shrink-0"
|
||||
@click.stop="handleDelete(notification.id)"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{{ notification.message }}
|
||||
</p>
|
||||
|
||||
<p class="text-xs text-muted-foreground mt-2">
|
||||
{{ formatTime(notification.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div class="p-2 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
@click="handleViewAll"
|
||||
>
|
||||
View all notifications
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Bell,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
Clock,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
X
|
||||
} from 'lucide-vue-next'
|
||||
import type { Notification } from '@/types/notification'
|
||||
import { NotificationType, NotificationPriority } from '@/types/notification'
|
||||
|
||||
const router = useRouter()
|
||||
const notificationsStore = useNotificationsStore()
|
||||
const { toast } = useToast()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const notifications = computed(() => notificationsStore.notifications)
|
||||
const unreadCount = computed(() => notificationsStore.unreadCount)
|
||||
|
||||
let stopPolling: (() => void) | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
await loadNotifications()
|
||||
await notificationsStore.fetchStats()
|
||||
|
||||
// Start polling for new notifications every 30 seconds
|
||||
stopPolling = notificationsStore.startPolling(30000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (stopPolling) {
|
||||
stopPolling()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadNotifications() {
|
||||
loading.value = true
|
||||
try {
|
||||
await notificationsStore.fetchNotifications()
|
||||
} catch (error) {
|
||||
console.error('Failed to load notifications:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
await loadNotifications()
|
||||
await notificationsStore.fetchStats()
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
try {
|
||||
await notificationsStore.markAllAsRead()
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'All notifications marked as read'
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to mark notifications as read'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNotificationClick(notification: Notification) {
|
||||
// Mark as read
|
||||
if (!notification.read) {
|
||||
await notificationsStore.markAsRead([notification.id])
|
||||
}
|
||||
|
||||
// Navigate to relevant page
|
||||
if (notification.task_id) {
|
||||
router.push(`/tasks?taskId=${notification.task_id}`)
|
||||
} else if (notification.project_id) {
|
||||
router.push(`/projects/${notification.project_id}`)
|
||||
}
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
async function handleDelete(notificationId: number) {
|
||||
try {
|
||||
await notificationsStore.deleteNotification(notificationId)
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to delete notification'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewAll() {
|
||||
router.push('/notifications')
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function getNotificationIcon(type: NotificationType) {
|
||||
switch (type) {
|
||||
case NotificationType.TASK_ASSIGNED:
|
||||
return FileText
|
||||
case NotificationType.TASK_STATUS_CHANGED:
|
||||
return CheckCircle
|
||||
case NotificationType.SUBMISSION_REVIEWED:
|
||||
return CheckCircle
|
||||
case NotificationType.WORK_SUBMITTED:
|
||||
return FileText
|
||||
case NotificationType.DEADLINE_APPROACHING:
|
||||
return Clock
|
||||
case NotificationType.PROJECT_UPDATE:
|
||||
return AlertCircle
|
||||
case NotificationType.COMMENT_ADDED:
|
||||
return MessageSquare
|
||||
default:
|
||||
return Bell
|
||||
}
|
||||
}
|
||||
|
||||
function getNotificationColor(priority: NotificationPriority) {
|
||||
switch (priority) {
|
||||
case NotificationPriority.URGENT:
|
||||
return 'text-red-500'
|
||||
case NotificationPriority.HIGH:
|
||||
return 'text-orange-500'
|
||||
case NotificationPriority.NORMAL:
|
||||
return 'text-blue-500'
|
||||
case NotificationPriority.LOW:
|
||||
return 'text-gray-500'
|
||||
default:
|
||||
return 'text-blue-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()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
</div>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-destructive text-destructive-foreground"
|
||||
>
|
||||
<AlertCircle class="size-4" />
|
||||
</div>
|
||||
<!-- Normal State -->
|
||||
<div
|
||||
v-else
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
>
|
||||
<component :is="activeProject.icon" class="size-4" />
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">
|
||||
{{ isLoading ? 'Loading...' : error ? 'Error' : activeProject.name }}
|
||||
</span>
|
||||
<span class="truncate text-xs">
|
||||
{{ isLoading ? 'Fetching projects' : error ? 'Failed to load' : activeProject.status }}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
align="start"
|
||||
:side="isMobile ? 'bottom' : 'right'"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuLabel class="text-xs text-muted-foreground">
|
||||
Projects
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<!-- Loading State in Dropdown -->
|
||||
<DropdownMenuItem v-if="isLoading" class="gap-2 p-2" disabled>
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
<span class="text-muted-foreground">Loading projects...</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Error State in Dropdown -->
|
||||
<template v-else-if="error">
|
||||
<DropdownMenuItem class="gap-2 p-2" disabled>
|
||||
<AlertCircle class="size-4 text-destructive" />
|
||||
<span class="text-destructive text-sm">{{ error }}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem class="gap-2 p-2" @click="refreshProjects">
|
||||
<RefreshCw class="size-4" />
|
||||
<span>Retry</span>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
||||
<!-- Projects List -->
|
||||
<template v-else>
|
||||
<DropdownMenuItem
|
||||
v-for="(project, index) in projects"
|
||||
:key="project.id"
|
||||
class="gap-2 p-2"
|
||||
@click="setActiveProject(project)"
|
||||
>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-sm border"
|
||||
>
|
||||
<component :is="project.icon" class="size-4 shrink-0" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{{ project.name }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
project.status
|
||||
}}</span>
|
||||
</div>
|
||||
<DropdownMenuShortcut>⌘{{ index + 1 }}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem class="gap-2 p-2" @click="refreshProjects">
|
||||
<RefreshCw class="size-4" />
|
||||
<div class="font-medium text-muted-foreground">Refresh projects</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="canCreateProjects"
|
||||
class="gap-2 p-2"
|
||||
@click="handleCreateProject"
|
||||
>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-md border bg-background"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
</div>
|
||||
<div class="font-medium text-muted-foreground">Create project</div>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ChevronsUpDown, Plus, AlertCircle, RefreshCw } from "lucide-vue-next";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useProjectsStore } from "@/stores/projects";
|
||||
|
||||
const router = useRouter();
|
||||
const { isMobile } = useSidebar();
|
||||
const authStore = useAuthStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
// Get projects and active project from store
|
||||
const projects = computed(() => projectsStore.availableProjects);
|
||||
const activeProject = computed(() => projectsStore.currentProject);
|
||||
const isLoading = computed(() => projectsStore.isLoading);
|
||||
const error = computed(() => projectsStore.error);
|
||||
|
||||
// Check if user can create projects
|
||||
const canCreateProjects = computed(() => {
|
||||
const user = authStore.user;
|
||||
return user?.is_admin || user?.role === "coordinator";
|
||||
});
|
||||
|
||||
const setActiveProject = (project: any) => {
|
||||
projectsStore.setActiveProject(project);
|
||||
|
||||
// Navigate to project-specific view
|
||||
if (project.id === 0) {
|
||||
// "All Projects" view
|
||||
router.push("/projects");
|
||||
} else {
|
||||
// Specific project view - navigate to overview tab
|
||||
router.push(`/projects/${project.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProject = () => {
|
||||
if (canCreateProjects.value) {
|
||||
router.push("/projects/new");
|
||||
}
|
||||
};
|
||||
|
||||
const refreshProjects = async () => {
|
||||
try {
|
||||
await projectsStore.fetchProjects();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh projects:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Set active project based on current route
|
||||
const updateActiveProjectFromRoute = () => {
|
||||
const currentPath = router.currentRoute.value.path;
|
||||
|
||||
if (currentPath.startsWith("/projects/")) {
|
||||
const pathSegments = currentPath.split("/");
|
||||
if (pathSegments[2] === "new") {
|
||||
// Creating new project, keep current active project
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = parseInt(pathSegments[2]);
|
||||
if (!isNaN(projectId)) {
|
||||
const project = projectsStore.getProjectById(projectId);
|
||||
if (project) {
|
||||
projectsStore.setActiveProject(project);
|
||||
}
|
||||
}
|
||||
} else if (currentPath === "/projects") {
|
||||
projectsStore.setActiveProject(projectsStore.allProjectsView);
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for route changes
|
||||
watch(
|
||||
() => router.currentRoute.value.path,
|
||||
() => {
|
||||
updateActiveProjectFromRoute();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Watch for authentication changes to fetch projects
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
(isAuthenticated) => {
|
||||
if (isAuthenticated && projectsStore.projects.length === 0 && !projectsStore.isLoading) {
|
||||
refreshProjects();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Ensure projects are loaded on component mount
|
||||
onMounted(() => {
|
||||
if (authStore.isAuthenticated && projectsStore.projects.length === 0 && !projectsStore.isLoading) {
|
||||
refreshProjects();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="userAvatar" :alt="userDisplayName" />
|
||||
<AvatarFallback class="rounded-lg">
|
||||
{{ userInitials }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ userDisplayName }}</span>
|
||||
<span class="truncate text-xs capitalize">{{ user?.role }}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
:side="isMobile ? 'bottom' : 'right'"
|
||||
align="end"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuLabel class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="userAvatar" :alt="userDisplayName" />
|
||||
<AvatarFallback class="rounded-lg">
|
||||
{{ userInitials }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ userDisplayName }}</span>
|
||||
<span class="truncate text-xs">{{ user?.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<!-- Role-specific features -->
|
||||
<DropdownMenuGroup v-if="showRoleFeatures">
|
||||
<DropdownMenuItem v-if="user?.role === 'developer'" @click="navigateTo('/developer/api-keys')">
|
||||
<Key class="size-4" />
|
||||
API Keys
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="isAdminOrCoordinator" @click="navigateTo('/users')">
|
||||
<Users class="size-4" />
|
||||
Team Management
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="user?.is_admin" @click="navigateTo('/settings')">
|
||||
<Settings class="size-4" />
|
||||
System Settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator v-if="showRoleFeatures" />
|
||||
|
||||
<!-- User account options -->
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem @click="navigateTo('/profile')">
|
||||
<User class="size-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="navigateTo('/settings/preferences')">
|
||||
<Palette class="size-4" />
|
||||
Preferences
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="toggleNotifications">
|
||||
<Bell class="size-4" />
|
||||
Notifications
|
||||
<span class="ml-auto text-xs text-muted-foreground">
|
||||
{{ notificationsEnabled ? 'On' : 'Off' }}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<!-- Help and support -->
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem @click="navigateTo('/help')">
|
||||
<HelpCircle class="size-4" />
|
||||
Help & Support
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="showKeyboardShortcuts">
|
||||
<Keyboard class="size-4" />
|
||||
Keyboard Shortcuts
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<!-- Logout -->
|
||||
<DropdownMenuItem @click="handleLogout" class="text-destructive focus:text-destructive">
|
||||
<LogOut class="size-4" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
ChevronsUpDown,
|
||||
LogOut,
|
||||
User,
|
||||
Settings,
|
||||
Bell,
|
||||
Key,
|
||||
Users,
|
||||
Palette,
|
||||
HelpCircle,
|
||||
Keyboard,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@/components/ui/avatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
const { isMobile } = useSidebar()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// User data
|
||||
const user = computed(() => authStore.user)
|
||||
|
||||
// User display information
|
||||
const userDisplayName = computed(() => {
|
||||
if (!user.value) return 'Guest'
|
||||
return `${user.value.first_name} ${user.value.last_name}`
|
||||
})
|
||||
|
||||
const userInitials = computed(() => {
|
||||
if (!user.value) return 'G'
|
||||
const firstInitial = user.value.first_name?.charAt(0) || ''
|
||||
const lastInitial = user.value.last_name?.charAt(0) || ''
|
||||
return (firstInitial + lastInitial).toUpperCase()
|
||||
})
|
||||
|
||||
const userAvatar = computed(() => {
|
||||
// Use uploaded avatar if available
|
||||
if (user.value?.avatar_url) {
|
||||
// Use direct static file serving
|
||||
const cleanUrl = user.value.avatar_url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
||||
return `/${cleanUrl}`
|
||||
}
|
||||
// Return empty string to show fallback initials
|
||||
return ''
|
||||
})
|
||||
|
||||
// Role-based features
|
||||
const isAdminOrCoordinator = computed(() => {
|
||||
return user.value?.is_admin || user.value?.role === 'coordinator'
|
||||
})
|
||||
|
||||
const showRoleFeatures = computed(() => {
|
||||
return user.value?.role === 'developer' || isAdminOrCoordinator.value || user.value?.is_admin
|
||||
})
|
||||
|
||||
// Notifications state (this would typically come from a notifications store)
|
||||
const notificationsEnabled = ref(true)
|
||||
|
||||
// Actions
|
||||
const navigateTo = (path: string) => {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleNotifications = () => {
|
||||
notificationsEnabled.value = !notificationsEnabled.value
|
||||
// In a real app, this would update user preferences
|
||||
console.log('Notifications toggled:', notificationsEnabled.value)
|
||||
}
|
||||
|
||||
const showKeyboardShortcuts = () => {
|
||||
// In a real app, this would open a modal with keyboard shortcuts
|
||||
console.log('Keyboard shortcuts modal would open here')
|
||||
// For now, just show an alert with some common shortcuts
|
||||
alert(`Keyboard Shortcuts:
|
||||
|
||||
⌘/Ctrl + B - Toggle sidebar
|
||||
⌘/Ctrl + K - Quick search
|
||||
⌘/Ctrl + , - Open preferences
|
||||
⌘/Ctrl + / - Show help
|
||||
|
||||
More shortcuts available in the help documentation.`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
Reference in New Issue
Block a user