From 98aaa86ec4a1860b844bb5bc87e809f6e7624e59 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Mon, 25 May 2026 02:06:11 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=BD=D0=B0=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D1=82?= =?UTF-8?q?=D0=B5=D1=80=20=D0=BD=D0=B0=20=D0=B2=D0=B5=D1=81=D1=8C=20=D1=84?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.vue | 8 +- frontend/src/api/client.ts | 2 +- frontend/src/api/mappers.ts | 39 +- frontend/src/assets/main.css | 439 ++++++++++++------ .../components/admin/CreateLectureModal.vue | 4 +- .../src/components/layout/AppBottomNav.vue | 43 +- frontend/src/components/layout/AppSidebar.vue | 28 +- frontend/src/components/layout/AppTopbar.vue | 95 +++- .../src/components/ui/AchievementBadge.vue | 43 +- frontend/src/components/ui/AppIcon.vue | 1 - frontend/src/components/ui/CoinChip.vue | 20 +- frontend/src/components/ui/DataTable.vue | 30 +- frontend/src/components/ui/EmptyState.vue | 19 +- .../components/ui/EnrollmentLimitModal.vue | 4 +- frontend/src/components/ui/GlassCard.vue | 19 +- frontend/src/components/ui/LectureCard.vue | 4 +- frontend/src/components/ui/LoadingSpinner.vue | 29 +- frontend/src/components/ui/ModalDialog.vue | 88 ++-- frontend/src/components/ui/ProgressBar.vue | 23 +- frontend/src/components/ui/SearchInput.vue | 8 +- frontend/src/components/ui/StatsWidget.vue | 25 +- frontend/src/components/ui/StatusBadge.vue | 48 +- .../src/components/ui/ToastNotification.vue | 55 ++- frontend/src/icons/index.ts | 2 +- frontend/src/router/index.ts | 109 ++++- frontend/src/stores/auth.ts | 3 +- frontend/src/stores/lectures.ts | 39 +- frontend/src/stores/user.ts | 28 +- .../src/views/admin/AdminLecturesView.vue | 8 +- frontend/src/views/admin/AdminReviewsView.vue | 4 +- frontend/src/views/admin/AdminUsersView.vue | 87 +++- frontend/src/views/auth/AuthCallbackView.vue | 26 +- frontend/src/views/auth/LoginView.vue | 32 +- frontend/src/views/student/CatalogView.vue | 220 +++++++-- frontend/src/views/student/DashboardView.vue | 175 +++++-- .../src/views/student/LectureDetailView.vue | 111 ++++- frontend/src/views/student/MyLecturesView.vue | 117 ++++- .../src/views/student/NotificationsView.vue | 69 ++- frontend/src/views/student/ProfileView.vue | 124 ++++- frontend/src/views/student/ReviewFormView.vue | 111 ++++- .../views/teacher/TeacherAnalyticsView.vue | 128 +++-- .../views/teacher/TeacherDashboardView.vue | 108 ++++- .../src/views/teacher/TeacherLecturesView.vue | 29 +- 43 files changed, 1947 insertions(+), 657 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 85ce08e..8c43063 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -12,7 +12,11 @@ const route = useRoute() const isAuthPage = computed(() => Boolean(route.meta.public)) -interface Toast { id: number; message: string; type: 'success' | 'error' | 'info' } +interface Toast { + id: number + message: string + type: 'success' | 'error' | 'info' +} const toasts = ref([]) let toastId = 0 @@ -21,7 +25,7 @@ function addToast(msg: string, type: Toast['type'] = 'success') { } function removeToast(id: number) { - toasts.value = toasts.value.filter(t => t.id !== id) + toasts.value = toasts.value.filter((t) => t.id !== id) } // expose globally via provide diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c6d16ae..db72f1a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -70,7 +70,7 @@ export async function apiRequest( ? String((body as { message: unknown }).message) : typeof body === 'object' && body && 'detail' in body ? String((body as { detail: unknown }).detail) - : `API request failed with status ${response.status}` + : `API request failed with status ${response.status}` throw new ApiError(message, response.status, body) } diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts index 334c997..97a093b 100644 --- a/frontend/src/api/mappers.ts +++ b/frontend/src/api/mappers.ts @@ -1,4 +1,12 @@ -import type { Achievement, CoinTransaction, Lecture, Notification, Review, User, UserRole } from '@/types' +import type { + Achievement, + CoinTransaction, + Lecture, + Notification, + Review, + User, + UserRole, +} from '@/types' import type { AchievementDto, CoinTransactionDto, @@ -30,7 +38,10 @@ function getDefaultActiveRole(roles: UserRole[]): UserRole { return 'student' } -export function mapApiUser(user: UserAuthDto | UserDto | CurrentUserDto, stats?: UserStatsDto): User { +export function mapApiUser( + user: UserAuthDto | UserDto | CurrentUserDto, + stats?: UserStatsDto, +): User { const roles = mapApiRoles(user.roles) return { id: user.id, @@ -38,7 +49,7 @@ export function mapApiUser(user: UserAuthDto | UserDto | CurrentUserDto, stats?: email: user.email || '', roles, activeRole: getDefaultActiveRole(roles), - avatar: 'avatarUrl' in user ? user.avatarUrl ?? undefined : undefined, + avatar: 'avatarUrl' in user ? (user.avatarUrl ?? undefined) : undefined, institute: 'ЮФУ', department: '', year: 0, @@ -50,7 +61,9 @@ export function mapApiUser(user: UserAuthDto | UserDto | CurrentUserDto, stats?: nextLevelXp: stats?.nextLevelXp, lecturesAttended: stats?.attendedLectures ?? 0, hoursLearned: stats ? Math.round(stats.attendedLectures * 1.5 * 10) / 10 : 0, - achievements: stats ? Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)) : [], + achievements: stats + ? Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)) + : [], activeEnrollments: stats?.activeEnrollments, enrollmentSlotLimit: stats?.enrollmentSlotLimit, enrollmentSlotRules: stats?.enrollmentSlotRules, @@ -61,11 +74,13 @@ export function mapApiLecture(lecture: LectureDto): Lecture { const startsAt = new Date(lecture.startsAt) const endsAt = new Date(lecture.endsAt) const durationMs = endsAt.getTime() - startsAt.getTime() - const duration = Number.isFinite(durationMs) && durationMs > 0 ? Math.round(durationMs / 60000) : 90 + const duration = + Number.isFinite(durationMs) && durationMs > 0 ? Math.round(durationMs / 60000) : 90 const totalSeats = lecture.maxEnrollments || 0 const enrolled = lecture.enrollmentsCount || 0 const freeSeats = Math.max(totalSeats - enrolled, 0) - const locationName = lecture.locationName || (lecture.format === 'Online' ? 'Онлайн' : 'Аудитория уточняется') + const locationName = + lecture.locationName || (lecture.format === 'Online' ? 'Онлайн' : 'Аудитория уточняется') return { id: String(lecture.id), @@ -96,9 +111,17 @@ export function mapApiLecture(lecture: LectureDto): Lecture { export function mapApiReview(review: ReviewDto): Review { const sentiment = - review.sentiment === 'Positive' ? 'positive' : review.sentiment === 'Negative' ? 'negative' : 'neutral' + review.sentiment === 'Positive' + ? 'positive' + : review.sentiment === 'Negative' + ? 'negative' + : 'neutral' const status = - review.llmStatus === 'Rejected' ? 'rejected' : review.llmStatus === 'Analyzed' ? 'done' : 'pending' + review.llmStatus === 'Rejected' + ? 'rejected' + : review.llmStatus === 'Analyzed' + ? 'done' + : 'pending' return { id: String(review.id), diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 7bcf706..cce9735 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -1,147 +1,184 @@ - :root { - --color-white: #FFFFFF; + --color-white: #ffffff; --color-black: #000000; - --color-primary: #22C55E; - --color-primary-dark: #16A34A; - --color-primary-light: #86EFAC; - --color-primary-bright: #4ADE80; - --color-primary-border: #15803D; + --color-primary: #22c55e; + --color-primary-dark: #16a34a; + --color-primary-light: #86efac; + --color-primary-bright: #4ade80; + --color-primary-border: #15803d; - --color-aqua: #06B6D4; - --color-aqua-dark: #0E7490; - --color-aqua-light: #67E8F9; + --color-aqua: #06b6d4; + --color-aqua-dark: #0e7490; + --color-aqua-light: #67e8f9; - --color-sky: #7DD3FC; - --color-sky-light: #BAE6FD; + --color-sky: #7dd3fc; + --color-sky-light: #bae6fd; --color-white-glass: rgba(255, 255, 255, 0.75); --color-surface: rgba(255, 255, 255, 0.85); --color-border-glass: rgba(255, 255, 255, 0.8); - --color-text: #1E293B; - --color-text-secondary: #64748B; + --color-text: #1e293b; + --color-text-secondary: #64748b; - --color-bg-start: #E0F2FE; - --color-bg-mid: #DCFCE7; + --color-bg-start: #e0f2fe; + --color-bg-mid: #dcfce7; - --color-error: #EF4444; - --color-error-dark: #DC2626; - --color-error-border: #B91C1C; + --color-error: #ef4444; + --color-error-dark: #dc2626; + --color-error-border: #b91c1c; - --color-success: #22C55E; + --color-success: #22c55e; --color-success-text: #166534; - --color-warning: #F59E0B; - --color-warning-text: #92400E; - --color-warning-border: #FDE68A; + --color-warning: #f59e0b; + --color-warning-text: #92400e; + --color-warning-border: #fde68a; - --color-brown-dark: #78350F; - --color-danger-text: #991B1B; - --color-danger-light: #FCA5A5; - --color-danger-pale: #FECACA; - --color-info-text: #1E40AF; - --color-info-border: #93C5FD; + --color-brown-dark: #78350f; + --color-danger-text: #991b1b; + --color-danger-light: #fca5a5; + --color-danger-pale: #fecaca; + --color-info-text: #1e40af; + --color-info-border: #93c5fd; - --color-orange: #FB923C; - --color-orange-deep: #EA580C; - --color-orange-dark: #C2410C; - --color-yellow: #FCD34D; + --color-orange: #fb923c; + --color-orange-deep: #ea580c; + --color-orange-dark: #c2410c; + --color-yellow: #fcd34d; - --color-purple: #A78BFA; - --color-purple-light: #C4B5FD; - --color-purple-dark: #6D28D9; + --color-purple: #a78bfa; + --color-purple-light: #c4b5fd; + --color-purple-dark: #6d28d9; - --color-gray-400: #9CA3AF; - --color-star: #FBBF24; + --color-gray-400: #9ca3af; + --color-star: #fbbf24; - --color-white-a10: rgba(255,255,255,0.1); - --color-white-a30: rgba(255,255,255,0.3); - --color-white-a40: rgba(255,255,255,0.4); - --color-white-a50: rgba(255,255,255,0.5); - --color-white-a60: rgba(255,255,255,0.6); - --color-white-a70: rgba(255,255,255,0.7); - --color-white-a72: rgba(255,255,255,0.72); - --color-white-a80: rgba(255,255,255,0.8); - --color-white-a82: rgba(255,255,255,0.82); - --color-white-a86: rgba(255,255,255,0.86); - --color-white-a90: rgba(255,255,255,0.9); - --color-white-a96: rgba(255,255,255,0.96); + --color-white-a10: rgba(255, 255, 255, 0.1); + --color-white-a30: rgba(255, 255, 255, 0.3); + --color-white-a40: rgba(255, 255, 255, 0.4); + --color-white-a50: rgba(255, 255, 255, 0.5); + --color-white-a60: rgba(255, 255, 255, 0.6); + --color-white-a70: rgba(255, 255, 255, 0.7); + --color-white-a72: rgba(255, 255, 255, 0.72); + --color-white-a80: rgba(255, 255, 255, 0.8); + --color-white-a82: rgba(255, 255, 255, 0.82); + --color-white-a86: rgba(255, 255, 255, 0.86); + --color-white-a90: rgba(255, 255, 255, 0.9); + --color-white-a96: rgba(255, 255, 255, 0.96); - --color-black-a04: rgba(0,0,0,0.04); - --color-black-a05: rgba(0,0,0,0.05); - --color-black-a06: rgba(0,0,0,0.06); - --color-black-a08: rgba(0,0,0,0.08); - --color-black-a12: rgba(0,0,0,0.12); - --color-black-a15: rgba(0,0,0,0.15); - --color-black-a20: rgba(0,0,0,0.2); - --color-black-a35: rgba(0,0,0,0.35); + --color-black-a04: rgba(0, 0, 0, 0.04); + --color-black-a05: rgba(0, 0, 0, 0.05); + --color-black-a06: rgba(0, 0, 0, 0.06); + --color-black-a08: rgba(0, 0, 0, 0.08); + --color-black-a12: rgba(0, 0, 0, 0.12); + --color-black-a15: rgba(0, 0, 0, 0.15); + --color-black-a20: rgba(0, 0, 0, 0.2); + --color-black-a35: rgba(0, 0, 0, 0.35); - --color-slate-900-a08: rgba(15,23,42,0.08); - --color-slate-900-a14: rgba(15,23,42,0.14); - --color-slate-500-a10: rgba(100,116,139,0.1); - --color-slate-500-a20: rgba(100,116,139,0.2); + --color-slate-900-a08: rgba(15, 23, 42, 0.08); + --color-slate-900-a14: rgba(15, 23, 42, 0.14); + --color-slate-500-a10: rgba(100, 116, 139, 0.1); + --color-slate-500-a20: rgba(100, 116, 139, 0.2); - --color-primary-a05: rgba(34,197,94,0.05); - --color-primary-a08: rgba(34,197,94,0.08); - --color-primary-a10: rgba(34,197,94,0.1); - --color-primary-a12: rgba(34,197,94,0.12); - --color-primary-a15: rgba(34,197,94,0.15); - --color-primary-a18: rgba(34,197,94,0.18); - --color-primary-a20: rgba(34,197,94,0.2); - --color-primary-a25: rgba(34,197,94,0.25); - --color-primary-a30: rgba(34,197,94,0.3); - --color-primary-a40: rgba(34,197,94,0.4); - --color-primary-a45: rgba(34,197,94,0.45); - --color-primary-a50: rgba(34,197,94,0.5); - --color-primary-light-a12: rgba(134,239,172,0.12); + --color-primary-a05: rgba(34, 197, 94, 0.05); + --color-primary-a08: rgba(34, 197, 94, 0.08); + --color-primary-a10: rgba(34, 197, 94, 0.1); + --color-primary-a12: rgba(34, 197, 94, 0.12); + --color-primary-a15: rgba(34, 197, 94, 0.15); + --color-primary-a18: rgba(34, 197, 94, 0.18); + --color-primary-a20: rgba(34, 197, 94, 0.2); + --color-primary-a25: rgba(34, 197, 94, 0.25); + --color-primary-a30: rgba(34, 197, 94, 0.3); + --color-primary-a40: rgba(34, 197, 94, 0.4); + --color-primary-a45: rgba(34, 197, 94, 0.45); + --color-primary-a50: rgba(34, 197, 94, 0.5); + --color-primary-light-a12: rgba(134, 239, 172, 0.12); - --color-error-a10: rgba(239,68,68,0.1); - --color-error-a12: rgba(239,68,68,0.12); - --color-error-a20: rgba(239,68,68,0.2); - --color-error-a24: rgba(239,68,68,0.24); - --color-error-a30: rgba(239,68,68,0.3); - --color-error-a40: rgba(239,68,68,0.4); + --color-error-a10: rgba(239, 68, 68, 0.1); + --color-error-a12: rgba(239, 68, 68, 0.12); + --color-error-a20: rgba(239, 68, 68, 0.2); + --color-error-a24: rgba(239, 68, 68, 0.24); + --color-error-a30: rgba(239, 68, 68, 0.3); + --color-error-a40: rgba(239, 68, 68, 0.4); - --color-aqua-a15: rgba(6,182,212,0.15); - --color-aqua-a25: rgba(6,182,212,0.25); - --color-aqua-a30: rgba(6,182,212,0.3); - --color-aqua-a40: rgba(6,182,212,0.4); - --color-orange-a15: rgba(251,146,60,0.15); - --color-orange-a30: rgba(251,146,60,0.3); - --color-purple-a12: rgba(139,92,246,0.12); - --color-purple-a20: rgba(139,92,246,0.2); - --color-star-a15: rgba(251,191,36,0.15); - --color-star-a20: rgba(251,191,36,0.2); - --color-star-a30: rgba(251,191,36,0.3); - --color-warning-a15: rgba(245,158,11,0.15); - --color-warning-a25: rgba(245,158,11,0.25); - --color-warning-a40: rgba(245,158,11,0.4); + --color-aqua-a15: rgba(6, 182, 212, 0.15); + --color-aqua-a25: rgba(6, 182, 212, 0.25); + --color-aqua-a30: rgba(6, 182, 212, 0.3); + --color-aqua-a40: rgba(6, 182, 212, 0.4); + --color-orange-a15: rgba(251, 146, 60, 0.15); + --color-orange-a30: rgba(251, 146, 60, 0.3); + --color-purple-a12: rgba(139, 92, 246, 0.12); + --color-purple-a20: rgba(139, 92, 246, 0.2); + --color-star-a15: rgba(251, 191, 36, 0.15); + --color-star-a20: rgba(251, 191, 36, 0.2); + --color-star-a30: rgba(251, 191, 36, 0.3); + --color-warning-a15: rgba(245, 158, 11, 0.15); + --color-warning-a25: rgba(245, 158, 11, 0.25); + --color-warning-a40: rgba(245, 158, 11, 0.4); - --color-success-bg-a90: rgba(220,252,231,0.9); - --color-success-bg-a95: rgba(220,252,231,0.95); - --color-info-bg-a90: rgba(224,242,254,0.9); - --color-info-bg-a95: rgba(224,242,254,0.95); - --color-danger-bg-a68: rgba(254,242,242,0.68); - --color-danger-bg-a90: rgba(254,226,226,0.9); - --color-danger-bg-a95: rgba(254,226,226,0.95); - --color-warning-bg-a90: rgba(254,243,199,0.9); + --color-success-bg-a90: rgba(220, 252, 231, 0.9); + --color-success-bg-a95: rgba(220, 252, 231, 0.95); + --color-info-bg-a90: rgba(224, 242, 254, 0.9); + --color-info-bg-a95: rgba(224, 242, 254, 0.95); + --color-danger-bg-a68: rgba(254, 242, 242, 0.68); + --color-danger-bg-a90: rgba(254, 226, 226, 0.9); + --color-danger-bg-a95: rgba(254, 226, 226, 0.95); + --color-warning-bg-a90: rgba(254, 243, 199, 0.9); - --gradient-bg: linear-gradient(135deg, var(--color-bg-start) 0%, var(--color-bg-mid) 50%, var(--color-bg-start) 100%); - --gradient-brand: linear-gradient(135deg, var(--color-primary) 0%, var(--color-aqua) 60%, var(--color-sky) 100%); - --gradient-progress-success: linear-gradient(90deg, var(--color-primary), var(--color-primary-light)); + --gradient-bg: linear-gradient( + 135deg, + var(--color-bg-start) 0%, + var(--color-bg-mid) 50%, + var(--color-bg-start) 100% + ); + --gradient-brand: linear-gradient( + 135deg, + var(--color-primary) 0%, + var(--color-aqua) 60%, + var(--color-sky) 100% + ); + --gradient-progress-success: linear-gradient( + 90deg, + var(--color-primary), + var(--color-primary-light) + ); --gradient-progress-neutral: linear-gradient(90deg, var(--color-sky), var(--color-sky-light)); - --gradient-progress-danger: linear-gradient(90deg, var(--color-danger-light), var(--color-danger-pale)); - --gradient-bar-success-vertical: linear-gradient(180deg, var(--color-primary), var(--color-primary-light)); - --gradient-bar-neutral-vertical: linear-gradient(180deg, var(--color-sky), var(--color-sky-light)); - --gradient-nav-active: linear-gradient(135deg, var(--color-primary-a18), var(--color-primary-light-a12)); + --gradient-progress-danger: linear-gradient( + 90deg, + var(--color-danger-light), + var(--color-danger-pale) + ); + --gradient-bar-success-vertical: linear-gradient( + 180deg, + var(--color-primary), + var(--color-primary-light) + ); + --gradient-bar-neutral-vertical: linear-gradient( + 180deg, + var(--color-sky), + var(--color-sky-light) + ); + --gradient-nav-active: linear-gradient( + 135deg, + var(--color-primary-a18), + var(--color-primary-light-a12) + ); --gradient-stats-green: linear-gradient(90deg, var(--color-primary), var(--color-primary-light)); --gradient-stats-aqua: linear-gradient(90deg, var(--color-aqua), var(--color-aqua-light)); --gradient-stats-orange: linear-gradient(90deg, var(--color-orange), var(--color-yellow)); --gradient-stats-purple: linear-gradient(90deg, var(--color-purple), var(--color-purple-light)); - --gradient-coin-chip: linear-gradient(135deg, var(--color-primary-a25) 0%, var(--color-aqua-a25) 100%); - --gradient-coin-chip-hover: linear-gradient(135deg, var(--color-primary-a40) 0%, var(--color-aqua-a40) 100%); + --gradient-coin-chip: linear-gradient( + 135deg, + var(--color-primary-a25) 0%, + var(--color-aqua-a25) 100% + ); + --gradient-coin-chip-hover: linear-gradient( + 135deg, + var(--color-primary-a40) 0%, + var(--color-aqua-a40) 100% + ); --color-coin-chip-border: var(--color-primary-a45); --color-coin-chip-text: var(--color-primary-border); --color-coin-chip-label: var(--color-primary-border); @@ -155,15 +192,19 @@ --topbar-height: 60px; } -*, *::before, *::after { +*, +*::before, +*::after { box-sizing: border-box; margin: 0; padding: 0; } -html, body { +html, +body { height: 100%; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 14px; color: var(--color-text); -webkit-font-smoothing: antialiased; @@ -175,7 +216,6 @@ body { min-height: 100vh; } - a { color: var(--color-primary-dark); text-decoration: none; @@ -188,16 +228,29 @@ button { outline: none; } -input, textarea, select { +input, +textarea, +select { font-family: inherit; outline: none; } /* Scrollbar */ -::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: var(--color-black-a04); border-radius: 3px; } -::-webkit-scrollbar-thumb { background: var(--color-primary-a30); border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: var(--color-primary-a50); } +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: var(--color-black-a04); + border-radius: 3px; +} +::-webkit-scrollbar-thumb { + background: var(--color-primary-a30); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--color-primary-a50); +} /* Glass panel utility */ .glass-panel { @@ -239,7 +292,9 @@ input, textarea, select { box-shadow: 0 8px 18px var(--color-primary-a15); } .btn-primary:focus-visible { - box-shadow: 0 0 0 3px var(--color-primary-a20), 0 2px 8px var(--color-primary-a08); + box-shadow: + 0 0 0 3px var(--color-primary-a20), + 0 2px 8px var(--color-primary-a08); } .btn-primary:active { background: var(--color-primary-a30); @@ -312,7 +367,9 @@ input, textarea, select { box-shadow: 0 8px 18px var(--color-error-a12); } .btn-danger:focus-visible { - box-shadow: 0 0 0 3px var(--color-error-a20), 0 2px 8px var(--color-error-a10); + box-shadow: + 0 0 0 3px var(--color-error-a20), + 0 2px 8px var(--color-error-a10); } .btn-danger:active { background: var(--color-danger-pale); @@ -380,12 +437,35 @@ input, textarea, select { font-weight: 600; white-space: nowrap; } -.badge-green { color: var(--color-primary-border); border: 1.3px solid var(--color-primary-a30); } -.badge-blue { background: var(--color-aqua-a15); color: var(--color-aqua-dark); border: 1px solid var(--color-aqua-a30); } -.badge-orange { background: var(--color-orange-a15); color: var(--color-orange-dark); border: 1px solid var(--color-orange-a30); } -.badge-gray { background: var(--color-slate-500-a10); color: var(--color-text-secondary); border: 1px solid var(--color-slate-500-a20); } -.badge-red { background: var(--color-error-a12); color: var(--color-error-border); border: 1px solid var(--color-error-a20); } -.badge-purple { background: var(--color-purple-a12); color: var(--color-purple-dark); border: 1px solid var(--color-purple-a20); } +.badge-green { + color: var(--color-primary-border); + border: 1.3px solid var(--color-primary-a30); +} +.badge-blue { + background: var(--color-aqua-a15); + color: var(--color-aqua-dark); + border: 1px solid var(--color-aqua-a30); +} +.badge-orange { + background: var(--color-orange-a15); + color: var(--color-orange-dark); + border: 1px solid var(--color-orange-a30); +} +.badge-gray { + background: var(--color-slate-500-a10); + color: var(--color-text-secondary); + border: 1px solid var(--color-slate-500-a20); +} +.badge-red { + background: var(--color-error-a12); + color: var(--color-error-border); + border: 1px solid var(--color-error-a20); +} +.badge-purple { + background: var(--color-purple-a12); + color: var(--color-purple-dark); + border: 1px solid var(--color-purple-a20); +} /* Tag chip */ .tag-chip { @@ -430,45 +510,101 @@ input, textarea, select { } /* Grid helpers */ -.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; } -.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } -.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; } +.grid-2 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} +.grid-3 { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} +.grid-4 { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} @media (max-width: 768px) { - .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; } - .page-content { padding: 16px; } + .grid-2, + .grid-3, + .grid-4 { + grid-template-columns: 1fr; + } + .page-content { + padding: 16px; + } } /* Flex helpers */ -.flex { display: flex; } -.flex-col { display: flex; flex-direction: column; } -.items-center { align-items: center; } -.justify-between { justify-content: space-between; } -.gap-2 { gap: 8px; } -.gap-3 { gap: 12px; } -.gap-4 { gap: 16px; } +.flex { + display: flex; +} +.flex-col { + display: flex; + flex-direction: column; +} +.items-center { + align-items: center; +} +.justify-between { + justify-content: space-between; +} +.gap-2 { + gap: 8px; +} +.gap-3 { + gap: 12px; +} +.gap-4 { + gap: 16px; +} /* Text helpers */ -.text-sm { font-size: 12px; } -.text-secondary { color: var(--color-text-secondary); } -.font-bold { font-weight: 700; } -.font-semibold { font-weight: 600; } +.text-sm { + font-size: 12px; +} +.text-secondary { + color: var(--color-text-secondary); +} +.font-bold { + font-weight: 700; +} +.font-semibold { + font-weight: 600; +} /* Stars rating */ -.stars { color: var(--color-star); font-size: 14px; letter-spacing: 1px; } +.stars { + color: var(--color-star); + font-size: 14px; + letter-spacing: 1px; +} /* Animations */ @keyframes fadeIn { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.fade-in { + animation: fadeIn 0.3s ease; } -.fade-in { animation: fadeIn 0.3s ease; } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } .spinner { - width: 20px; height: 20px; + width: 20px; + height: 20px; border: 2px solid var(--color-white-a30); border-top-color: var(--color-white); border-radius: 50%; @@ -480,7 +616,6 @@ input, textarea, select { border-top-color: var(--color-primary); } - #app { min-height: 100vh; } diff --git a/frontend/src/components/admin/CreateLectureModal.vue b/frontend/src/components/admin/CreateLectureModal.vue index 76651cf..6cf222b 100644 --- a/frontend/src/components/admin/CreateLectureModal.vue +++ b/frontend/src/components/admin/CreateLectureModal.vue @@ -50,7 +50,9 @@ const filteredLocations = computed(() => { const search = locationSearch.value.trim().toLowerCase() if (!search) return props.locations - return props.locations.filter((location) => formatLocation(location).toLowerCase().includes(search)) + return props.locations.filter((location) => + formatLocation(location).toLowerCase().includes(search), + ) }) const isOpen = computed({ diff --git a/frontend/src/components/layout/AppBottomNav.vue b/frontend/src/components/layout/AppBottomNav.vue index 052c342..b5e8da4 100644 --- a/frontend/src/components/layout/AppBottomNav.vue +++ b/frontend/src/components/layout/AppBottomNav.vue @@ -9,17 +9,19 @@ const route = useRoute() const navItems = computed(() => { const role = auth.user?.activeRole ?? 'student' - if (role === 'teacher') return [ - { label: 'Дашборд', icon: 'chart-bar', to: '/teacher' }, - { label: 'Лекции', icon: 'book-2', to: '/teacher/lectures' }, - { label: 'Аналитика', icon: 'chart-line', to: '/teacher/analytics' }, - ] - if (role === 'admin') return [ - { label: 'Дашборд', icon: 'shield', to: '/admin' }, - { label: 'Юзеры', icon: 'users', to: '/admin/users' }, - { label: 'Лекции', icon: 'books', to: '/admin/lectures' }, - { label: 'Отзывы', icon: 'message-circle', to: '/admin/reviews' }, - ] + if (role === 'teacher') + return [ + { label: 'Дашборд', icon: 'chart-bar', to: '/teacher' }, + { label: 'Лекции', icon: 'book-2', to: '/teacher/lectures' }, + { label: 'Аналитика', icon: 'chart-line', to: '/teacher/analytics' }, + ] + if (role === 'admin') + return [ + { label: 'Дашборд', icon: 'shield', to: '/admin' }, + { label: 'Юзеры', icon: 'users', to: '/admin/users' }, + { label: 'Лекции', icon: 'books', to: '/admin/lectures' }, + { label: 'Отзывы', icon: 'message-circle', to: '/admin/reviews' }, + ] return [ { label: 'Главная', icon: 'home', to: '/' }, { label: 'Лекции', icon: 'books', to: '/catalog' }, @@ -76,9 +78,20 @@ function isActive(to: string) { padding: 4px 0; transition: color 0.2s; } -.bottom-nav-item.active { color: var(--color-primary-dark); } -.bottom-nav-icon { color: currentColor; } -.bottom-nav-label { font-size: 10px; font-weight: 600; } +.bottom-nav-item.active { + color: var(--color-primary-dark); +} +.bottom-nav-icon { + color: currentColor; +} +.bottom-nav-label { + font-size: 10px; + font-weight: 600; +} -@media (max-width: 768px) { .bottom-nav { display: flex; } } +@media (max-width: 768px) { + .bottom-nav { + display: flex; + } +} diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 0599500..5138f64 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -7,7 +7,12 @@ import AppIcon from '@/components/ui/AppIcon.vue' const auth = useAuthStore() const route = useRoute() -interface NavItem { label: string; icon: string; to: string; roles: string[] } +interface NavItem { + label: string + icon: string + to: string + roles: string[] +} const navItems: NavItem[] = [ { label: 'Главная', icon: 'home', to: '/', roles: ['student'] }, @@ -25,7 +30,7 @@ const navItems: NavItem[] = [ ] const visible = computed(() => - navItems.filter(n => auth.user && n.roles.includes(auth.user.activeRole)) + navItems.filter((n) => auth.user && n.roles.includes(auth.user.activeRole)), ) function isActive(to: string) { @@ -40,10 +45,10 @@ function isActive(to: string) { + :to="item.to" + class="nav-item" + :class="{ active: isActive(item.to) }" + > {{ item.label }} @@ -101,7 +106,10 @@ function isActive(to: string) { font-weight: 700; box-shadow: 0 2px 8px var(--color-primary-a12); } -.nav-icon { flex-shrink: 0; color: currentColor; } +.nav-icon { + flex-shrink: 0; + color: currentColor; +} .sidebar-footer { padding: 10px 18px 8px; display: flex; @@ -113,5 +121,9 @@ function isActive(to: string) { font-weight: 600; } -@media (max-width: 768px) { .sidebar { display: none; } } +@media (max-width: 768px) { + .sidebar { + display: none; + } +} diff --git a/frontend/src/components/layout/AppTopbar.vue b/frontend/src/components/layout/AppTopbar.vue index 045fee5..46656eb 100644 --- a/frontend/src/components/layout/AppTopbar.vue +++ b/frontend/src/components/layout/AppTopbar.vue @@ -41,8 +41,8 @@ const roleButtons = computed(() => { if (!auth.user) return [] return auth.user.roles - .filter(role => role !== auth.user?.activeRole) - .map(role => ({ + .filter((role) => role !== auth.user?.activeRole) + .map((role) => ({ role, label: roleLabels[role], to: roleTargets[role], @@ -137,8 +137,7 @@ onBeforeUnmount(() => { UniVerse -
-
+
{ {{ enrollmentSlotText }} - -
@@ -694,7 +694,9 @@ onMounted(() => { border-radius: var(--radius-sm); padding: 12px; background: var(--color-white-a82); - box-shadow: 0 8px 22px var(--color-black-a04), inset 0 1px 0 var(--color-white-a90); + box-shadow: + 0 8px 22px var(--color-black-a04), + inset 0 1px 0 var(--color-white-a90); } .sync-advanced-fields .glass-input { background: var(--color-white-a96); diff --git a/frontend/src/views/admin/AdminReviewsView.vue b/frontend/src/views/admin/AdminReviewsView.vue index 69bec55..aef782a 100644 --- a/frontend/src/views/admin/AdminReviewsView.vue +++ b/frontend/src/views/admin/AdminReviewsView.vue @@ -359,7 +359,9 @@ onMounted(() => { > - + @@ -155,13 +168,53 @@ onMounted(fetchUsers) diff --git a/frontend/src/views/auth/AuthCallbackView.vue b/frontend/src/views/auth/AuthCallbackView.vue index 2b72b4d..7d81d35 100644 --- a/frontend/src/views/auth/AuthCallbackView.vue +++ b/frontend/src/views/auth/AuthCallbackView.vue @@ -11,7 +11,8 @@ const message = ref('Завершаем вход через Microsoft...') onMounted(async () => { const code = typeof route.query.code === 'string' ? route.query.code : '' const state = typeof route.query.state === 'string' ? route.query.state : null - const error = typeof route.query.error_description === 'string' ? route.query.error_description : '' + const error = + typeof route.query.error_description === 'string' ? route.query.error_description : '' const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, '')) const accessToken = hashParams.get('access_token') @@ -28,7 +29,10 @@ onMounted(async () => { else await router.replace('/') } catch (err) { message.value = err instanceof Error ? err.message : 'Не удалось завершить авторизацию.' - window.setTimeout(() => router.replace({ path: '/login', query: { error: message.value } }), 1600) + window.setTimeout( + () => router.replace({ path: '/login', query: { error: message.value } }), + 1600, + ) } }) @@ -54,14 +58,22 @@ onMounted(async () => { } .callback-card { width: min(420px, 100%); - background: rgba(255,255,255,0.86); + background: rgba(255, 255, 255, 0.86); border: 1px solid var(--color-border-glass); border-radius: var(--radius-lg); padding: 32px; text-align: center; - box-shadow: 0 24px 70px rgba(0,0,0,0.12); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.12); +} +.spinner { + margin: 0 auto 16px; +} +h1 { + font-size: 24px; + margin: 0 0 8px; +} +p { + color: var(--color-text-secondary); + margin: 0; } -.spinner { margin: 0 auto 16px; } -h1 { font-size: 24px; margin: 0 0 8px; } -p { color: var(--color-text-secondary); margin: 0; } diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index aedc713..b427cc9 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -17,22 +17,19 @@ const featureCards = [ icon: 'search' as const, tone: 'blue', title: 'Поиск лекций', - description: - 'Найдите самые интересные курсы и открытые лекции от ведущих преподавателей ЮФУ.', + description: 'Найдите самые интересные курсы и открытые лекции от ведущих преподавателей ЮФУ.', }, { icon: 'coin' as const, tone: 'green', title: 'Зарабатывайте монеты', - description: - 'Оставляйте конструктивные отзывы после занятий и получайте вознаграждение.', + description: 'Оставляйте конструктивные отзывы после занятий и получайте вознаграждение.', }, { icon: 'trophy' as const, tone: 'amber', title: 'Достижения и награды', - description: - 'Отслеживайте свой рост и открывайте уникальные достижения за активность.', + description: 'Отслеживайте свой рост и открывайте уникальные достижения за активность.', }, ] @@ -85,11 +82,13 @@ async function loginViaYufu() { - -