🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 8s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m22s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 26s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 4s
Backend CI / build-and-test (pull_request) Successful in 54s
Frontend CI / build-and-check (pull_request) Failing after 5m4s
527 lines
14 KiB
Vue
527 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { useUserStore } from '@/stores/user'
|
||
import CoinChip from '@/components/ui/CoinChip.vue'
|
||
import AppIcon from '@/components/ui/AppIcon.vue'
|
||
import ModalDialog from '@/components/ui/ModalDialog.vue'
|
||
import { formatUserName } from '@/utils/formatUserName'
|
||
import type { UserRole } from '@/types'
|
||
|
||
const auth = useAuthStore()
|
||
const userStore = useUserStore()
|
||
const router = useRouter()
|
||
const isProfileMenuOpen = ref(false)
|
||
const isCoinDialogOpen = ref(false)
|
||
const isLevelDialogOpen = ref(false)
|
||
const isSlotsDialogOpen = ref(false)
|
||
const profileMenuRef = ref<HTMLElement | null>(null)
|
||
|
||
const unreadCount = computed(() => userStore.unreadCount())
|
||
const showStudentChips = computed(() => auth.user?.activeRole === 'student')
|
||
const enrollmentSlotText = computed(() => {
|
||
const user = auth.user
|
||
if (!user || user.activeRole !== 'student') return ''
|
||
if (typeof user.enrollmentSlotLimit !== 'number') return ''
|
||
return `${user.activeEnrollments ?? 0}/${user.enrollmentSlotLimit}`
|
||
})
|
||
const roleLabels: Record<UserRole, string> = {
|
||
student: 'Студент',
|
||
teacher: 'Преподаватель',
|
||
admin: 'Администратор',
|
||
}
|
||
const roleTargets: Record<UserRole, string> = {
|
||
student: '/',
|
||
teacher: '/teacher',
|
||
admin: '/admin',
|
||
}
|
||
|
||
const roleButtons = computed(() => {
|
||
if (!auth.user) return []
|
||
|
||
return auth.user.roles
|
||
.filter((role) => role !== auth.user?.activeRole)
|
||
.map((role) => ({
|
||
role,
|
||
label: roleLabels[role],
|
||
to: roleTargets[role],
|
||
}))
|
||
})
|
||
|
||
function closeProfileMenu() {
|
||
isProfileMenuOpen.value = false
|
||
}
|
||
|
||
function closeCoinDialog() {
|
||
isCoinDialogOpen.value = false
|
||
}
|
||
|
||
function closeLevelDialog() {
|
||
isLevelDialogOpen.value = false
|
||
}
|
||
|
||
function closeSlotsDialog() {
|
||
isSlotsDialogOpen.value = false
|
||
}
|
||
|
||
function toggleProfileMenu() {
|
||
closeCoinDialog()
|
||
closeLevelDialog()
|
||
closeSlotsDialog()
|
||
isProfileMenuOpen.value = !isProfileMenuOpen.value
|
||
}
|
||
|
||
function openCoinDialog() {
|
||
closeProfileMenu()
|
||
isCoinDialogOpen.value = true
|
||
}
|
||
|
||
function openLevelDialog() {
|
||
closeProfileMenu()
|
||
closeCoinDialog()
|
||
closeSlotsDialog()
|
||
isLevelDialogOpen.value = true
|
||
}
|
||
|
||
function openSlotsDialog() {
|
||
closeProfileMenu()
|
||
closeCoinDialog()
|
||
closeLevelDialog()
|
||
isSlotsDialogOpen.value = true
|
||
}
|
||
|
||
function openMyLecturesFromSlotsDialog() {
|
||
closeSlotsDialog()
|
||
router.push('/my-lectures')
|
||
}
|
||
|
||
function openProfile() {
|
||
closeProfileMenu()
|
||
router.push('/profile')
|
||
}
|
||
|
||
function switchToRole(role: UserRole, to: string) {
|
||
if (auth.setActiveRole(role)) {
|
||
closeProfileMenu()
|
||
router.push(to)
|
||
}
|
||
}
|
||
|
||
async function logout() {
|
||
closeProfileMenu()
|
||
await auth.logout()
|
||
router.push('/login')
|
||
}
|
||
|
||
function handleDocumentPointerDown(event: PointerEvent) {
|
||
const target = event.target
|
||
if (!(target instanceof Node)) return
|
||
|
||
if (!profileMenuRef.value?.contains(target)) closeProfileMenu()
|
||
}
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
||
if (auth.user?.roles.includes('student')) void userStore.fetchStats().catch(() => undefined)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<header class="topbar">
|
||
<div class="topbar-brand">
|
||
<span class="brand-name">UniVerse</span>
|
||
</div>
|
||
|
||
<div class="topbar-center"></div>
|
||
|
||
<div class="topbar-right">
|
||
<CoinChip
|
||
v-if="auth.user && showStudentChips"
|
||
:amount="auth.user.coins"
|
||
aria-haspopup="dialog"
|
||
@click="openCoinDialog"
|
||
/>
|
||
|
||
<button
|
||
v-if="auth.user && showStudentChips"
|
||
class="level-chip"
|
||
type="button"
|
||
aria-haspopup="dialog"
|
||
@click="openLevelDialog"
|
||
>
|
||
<AppIcon class="level-icon" icon="star" :size="15" />
|
||
<span class="level-label">Ур.</span>
|
||
<span class="level-value">{{ auth.user.level }}</span>
|
||
</button>
|
||
|
||
<button
|
||
v-if="enrollmentSlotText"
|
||
class="slot-chip"
|
||
type="button"
|
||
aria-haspopup="dialog"
|
||
title="Занятые слоты записи / доступные слоты"
|
||
@click="openSlotsDialog"
|
||
>
|
||
<AppIcon class="slot-icon" icon="calendar" :size="15" />
|
||
<span class="slot-value">{{ enrollmentSlotText }}</span>
|
||
</button>
|
||
|
||
<button
|
||
class="notif-btn"
|
||
type="button"
|
||
aria-label="Уведомления"
|
||
@click="$router.push('/notifications')"
|
||
>
|
||
<AppIcon icon="bell" :size="18" />
|
||
<span class="notif-dot" v-if="auth.user && unreadCount > 0">
|
||
{{ unreadCount }}
|
||
</span>
|
||
</button>
|
||
|
||
<div ref="profileMenuRef" class="profile-menu">
|
||
<button
|
||
class="avatar"
|
||
type="button"
|
||
:aria-expanded="isProfileMenuOpen"
|
||
aria-haspopup="menu"
|
||
@click="toggleProfileMenu"
|
||
@keydown.esc.prevent="closeProfileMenu"
|
||
>
|
||
<AppIcon class="avatar-icon" icon="user" :size="18" />
|
||
<span class="avatar-name" v-if="auth.user">{{ formatUserName(auth.user.name) }}</span>
|
||
</button>
|
||
|
||
<div
|
||
v-if="isProfileMenuOpen"
|
||
class="profile-dropdown"
|
||
role="menu"
|
||
@keydown.esc.prevent="closeProfileMenu"
|
||
>
|
||
<button class="profile-menu-item" type="button" role="menuitem" @click="openProfile">
|
||
<AppIcon icon="user" :size="16" />
|
||
Профиль
|
||
</button>
|
||
|
||
<div class="profile-menu-section" v-if="roleButtons.length">
|
||
<span class="profile-menu-caption">Сменить роль</span>
|
||
<button
|
||
v-for="item in roleButtons"
|
||
:key="item.role"
|
||
class="profile-menu-item"
|
||
type="button"
|
||
role="menuitem"
|
||
@click="switchToRole(item.role, item.to)"
|
||
>
|
||
<AppIcon
|
||
:icon="
|
||
item.role === 'admin' ? 'shield' : item.role === 'teacher' ? 'chart-bar' : 'home'
|
||
"
|
||
:size="16"
|
||
/>
|
||
{{ item.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<button class="profile-menu-item danger" type="button" role="menuitem" @click="logout">
|
||
<AppIcon icon="logout" :size="16" />
|
||
Выйти
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<ModalDialog v-model="isCoinDialogOpen" title="Монеты UniVerse" icon="coin" size="sm">
|
||
<p>
|
||
В будущем монеты можно будет использовать во внутреннем магазине. Сейчас магазин еще не
|
||
запущен, поэтому монеты просто копятся на вашем балансе.
|
||
</p>
|
||
<template #footer>
|
||
<button class="btn-primary" type="button" @click="closeCoinDialog">ОК</button>
|
||
</template>
|
||
</ModalDialog>
|
||
|
||
<ModalDialog v-model="isLevelDialogOpen" title="Уровень студента" icon="star" size="sm">
|
||
<p>
|
||
Уровень показывает ваш прогресс в UniVerse. Он растет вместе с активностью: посещением
|
||
открытых лекций, полезными отзывами и достижениями.
|
||
</p>
|
||
<template #footer>
|
||
<button class="btn-primary" type="button" @click="closeLevelDialog">Понятно</button>
|
||
</template>
|
||
</ModalDialog>
|
||
|
||
<ModalDialog v-model="isSlotsDialogOpen" title="Слоты записи" icon="calendar" size="sm">
|
||
<p>
|
||
Слоты показывают, сколько открытых лекций уже занято в вашем текущем лимите. Когда лимит
|
||
заполнен, освободите слот отменой записи или повышайте уровень, чтобы получить больше
|
||
возможностей.
|
||
</p>
|
||
<template #footer>
|
||
<button class="btn-secondary" type="button" @click="openMyLecturesFromSlotsDialog">
|
||
Мои записи
|
||
</button>
|
||
<button class="btn-primary" type="button" @click="closeSlotsDialog">Понятно</button>
|
||
</template>
|
||
</ModalDialog>
|
||
</header>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.topbar {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: var(--topbar-height);
|
||
background: var(--color-white-a80);
|
||
backdrop-filter: blur(16px);
|
||
-webkit-backdrop-filter: blur(16px);
|
||
border-bottom: 1px solid var(--color-border-glass);
|
||
box-shadow: 0 2px 20px var(--color-black-a06);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 20px;
|
||
z-index: 100;
|
||
gap: 12px;
|
||
}
|
||
.topbar-brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
cursor: pointer;
|
||
}
|
||
.brand-name {
|
||
font-size: 20px;
|
||
font-weight: 800;
|
||
background: var(--gradient-brand);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
}
|
||
.topbar-center {
|
||
flex: 1;
|
||
max-width: 400px;
|
||
}
|
||
.topbar-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
.level-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
height: 28px;
|
||
padding: 0 11px;
|
||
border: 1px solid var(--color-primary-a20);
|
||
border-radius: 20px;
|
||
background: var(--color-primary-a10);
|
||
color: var(--color-primary-dark);
|
||
cursor: pointer;
|
||
font: inherit;
|
||
white-space: nowrap;
|
||
transition:
|
||
background-color 0.18s ease,
|
||
border-color 0.18s ease,
|
||
box-shadow 0.18s ease;
|
||
}
|
||
.level-icon {
|
||
color: var(--color-primary);
|
||
}
|
||
.level-label {
|
||
color: var(--color-text-secondary);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
.level-value {
|
||
color: var(--color-primary-dark);
|
||
font-size: 14px;
|
||
font-weight: 800;
|
||
}
|
||
.slot-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
height: 28px;
|
||
padding: 0 10px;
|
||
border: 1px solid var(--color-primary-a20);
|
||
border-radius: 20px;
|
||
background: var(--color-primary-a10);
|
||
color: var(--color-success-text);
|
||
cursor: pointer;
|
||
font: inherit;
|
||
white-space: nowrap;
|
||
transition:
|
||
background-color 0.18s ease,
|
||
border-color 0.18s ease,
|
||
box-shadow 0.18s ease;
|
||
}
|
||
.level-chip:hover,
|
||
.slot-chip:hover {
|
||
background: var(--color-primary-a18);
|
||
border-color: var(--color-primary-a40);
|
||
}
|
||
.level-chip:focus-visible,
|
||
.slot-chip:focus-visible {
|
||
outline: 2px solid var(--color-primary-a45);
|
||
outline-offset: 2px;
|
||
box-shadow: 0 0 0 3px var(--color-primary-a12);
|
||
}
|
||
.slot-icon {
|
||
color: var(--color-primary);
|
||
}
|
||
.slot-value {
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
}
|
||
.notif-btn {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
background: transparent;
|
||
border: 0;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
padding: 0;
|
||
border-radius: 50%;
|
||
transition: background 0.2s;
|
||
}
|
||
.notif-btn:hover {
|
||
background: var(--color-black-a05);
|
||
}
|
||
.notif-dot {
|
||
position: absolute;
|
||
top: -2px;
|
||
right: -2px;
|
||
background: var(--color-error);
|
||
color: var(--color-white);
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.profile-menu {
|
||
position: relative;
|
||
}
|
||
.avatar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
cursor: pointer;
|
||
padding: 5px 10px;
|
||
border-radius: 20px;
|
||
border: 1px solid var(--color-border-glass);
|
||
background: var(--color-white-a50);
|
||
color: var(--color-text);
|
||
font: inherit;
|
||
transition: all 0.2s;
|
||
}
|
||
.avatar:hover,
|
||
.avatar[aria-expanded='true'] {
|
||
border-color: var(--color-primary-a50);
|
||
background: var(--color-white-a80);
|
||
box-shadow: 0 0 0 3px var(--color-primary-a12);
|
||
}
|
||
.avatar:focus-visible,
|
||
.profile-menu-item:focus-visible {
|
||
outline: 2px solid var(--color-primary-a45);
|
||
outline-offset: 2px;
|
||
}
|
||
.avatar-icon {
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.avatar-name {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--color-text);
|
||
}
|
||
.profile-dropdown {
|
||
position: fixed;
|
||
top: calc(var(--topbar-height) + 10px);
|
||
right: 20px;
|
||
min-width: 220px;
|
||
padding: 8px;
|
||
border: 1px solid var(--color-border-glass);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--color-white-a96);
|
||
box-shadow: 0 18px 38px var(--color-slate-900-a14);
|
||
backdrop-filter: blur(18px);
|
||
-webkit-backdrop-filter: blur(18px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
z-index: 120;
|
||
}
|
||
.profile-menu-section {
|
||
border-top: 1px solid var(--color-slate-900-a08);
|
||
border-bottom: 1px solid var(--color-slate-900-a08);
|
||
margin: 4px 0;
|
||
padding: 6px 0;
|
||
}
|
||
.profile-menu-caption {
|
||
display: block;
|
||
padding: 3px 10px 5px;
|
||
color: var(--color-text-secondary);
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
}
|
||
.profile-menu-item {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
padding: 9px 10px;
|
||
border: 0;
|
||
border-radius: var(--radius-sm);
|
||
background: transparent;
|
||
color: var(--color-text);
|
||
cursor: pointer;
|
||
font: inherit;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
text-align: left;
|
||
transition:
|
||
background 0.2s,
|
||
color 0.2s;
|
||
}
|
||
.profile-menu-item:hover {
|
||
background: var(--color-primary-a10);
|
||
color: var(--color-primary-dark);
|
||
}
|
||
.profile-menu-item.danger {
|
||
color: var(--color-danger-text);
|
||
}
|
||
.profile-menu-item.danger:hover {
|
||
background: var(--color-error-a10);
|
||
color: var(--color-danger-text);
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.topbar-center {
|
||
display: none;
|
||
}
|
||
.brand-name {
|
||
display: none;
|
||
}
|
||
.avatar-name {
|
||
display: none;
|
||
}
|
||
}
|
||
</style>
|