Init Repo

This commit is contained in:
2026-02-28 03:22:04 +08:00
commit de59b57ee7
883 changed files with 156857 additions and 0 deletions
@@ -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>
+223
View File
@@ -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>