Files
UniVerse/frontend/src/components/layout/AppTopbar.vue
T
serega404 c4ed23a3d9
🚀 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
refactor: настроил линтер против сиротского css
2026-05-25 02:15:34 +03:00

527 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>