feat: первое подключение фронтенда
🚀 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 54s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 27s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s
🚀 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 54s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 27s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || '/api').replace(/\/$/, '')
|
||||
const API_PREFIX = '/v1'
|
||||
|
||||
let accessToken: string | null = null
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
export function setApiAccessToken(token: string | null) {
|
||||
accessToken = token
|
||||
}
|
||||
|
||||
export function getApiAccessToken() {
|
||||
return accessToken
|
||||
}
|
||||
|
||||
function makeUrl(path: string, query?: Record<string, unknown>) {
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
const url = new URL(`${API_BASE_URL}${API_PREFIX}${normalizedPath}`, window.location.origin)
|
||||
|
||||
Object.entries(query ?? {}).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return
|
||||
url.searchParams.set(key, String(value))
|
||||
})
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function buildApiUrl(path: string, query?: Record<string, unknown>) {
|
||||
return makeUrl(path, query)
|
||||
}
|
||||
|
||||
async function parseResponse(response: Response) {
|
||||
if (response.status === 204) return undefined
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (contentType.includes('application/json')) return response.json()
|
||||
|
||||
const text = await response.text()
|
||||
return text || undefined
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
options: RequestInit & { query?: Record<string, unknown> } = {},
|
||||
): Promise<T> {
|
||||
const headers = new Headers(options.headers)
|
||||
if (!headers.has('Accept')) headers.set('Accept', 'application/json')
|
||||
if (options.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
|
||||
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`)
|
||||
|
||||
const response = await fetch(makeUrl(path, options.query), {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
const body = await parseResponse(response)
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof body === 'object' && body && 'message' in body
|
||||
? String((body as { message: unknown }).message)
|
||||
: `API request failed with status ${response.status}`
|
||||
throw new ApiError(message, response.status, body)
|
||||
}
|
||||
|
||||
return body as T
|
||||
}
|
||||
|
||||
export function extractItems<T>(payload: T[] | { items?: T[] } | undefined): T[] {
|
||||
if (Array.isArray(payload)) return payload
|
||||
return payload?.items ?? []
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { apiRequest, extractItems } from './client'
|
||||
import type {
|
||||
AchievementDto,
|
||||
AuthResponse,
|
||||
CoinTransactionDto,
|
||||
LectureDto,
|
||||
LectureQuery,
|
||||
PagedResult,
|
||||
ReviewDto,
|
||||
UserAchievementDto,
|
||||
UserDto,
|
||||
UserStatsDto,
|
||||
} from './types'
|
||||
|
||||
export const authApi = {
|
||||
loginMicrosoft: (authorizationCode: string, redirectUri?: string) =>
|
||||
apiRequest<AuthResponse>('/auth/login/microsoft', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ authorizationCode, redirectUri }),
|
||||
}),
|
||||
refresh: () => apiRequest<AuthResponse>('/auth/refresh', { method: 'POST' }),
|
||||
logout: () => apiRequest<void>('/auth/logout', { method: 'POST' }),
|
||||
me: () => apiRequest<UserDto>('/auth/me'),
|
||||
}
|
||||
|
||||
export const lecturesApi = {
|
||||
async list(query: LectureQuery = {}) {
|
||||
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[]>('/lectures', {
|
||||
query: query as Record<string, unknown>,
|
||||
})
|
||||
return extractItems(payload)
|
||||
},
|
||||
get: (id: string | number) => apiRequest<LectureDto>(`/lectures/${id}`),
|
||||
enroll: (id: string | number) => apiRequest<void>(`/lectures/${id}/enroll`, { method: 'POST' }),
|
||||
unenroll: (id: string | number) => apiRequest<void>(`/lectures/${id}/enroll`, { method: 'DELETE' }),
|
||||
async reviews(id: string | number) {
|
||||
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>(`/lectures/${id}/reviews`)
|
||||
return extractItems(payload)
|
||||
},
|
||||
}
|
||||
|
||||
export const usersApi = {
|
||||
get: (id: string | number) => apiRequest<UserDto>(`/users/${id}`),
|
||||
stats: (id: string | number) => apiRequest<UserStatsDto>(`/users/${id}/stats`),
|
||||
async enrollments(id: string | number) {
|
||||
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[] | undefined>(`/users/${id}/enrollments`)
|
||||
return extractItems(payload)
|
||||
},
|
||||
async achievements(id: string | number) {
|
||||
const payload = await apiRequest<PagedResult<UserAchievementDto> | UserAchievementDto[] | AchievementDto[]>(
|
||||
`/users/${id}/achievements`,
|
||||
)
|
||||
if (Array.isArray(payload)) return payload
|
||||
return payload.items ?? []
|
||||
},
|
||||
async transactions(id: string | number) {
|
||||
const payload = await apiRequest<PagedResult<CoinTransactionDto> | CoinTransactionDto[]>(
|
||||
`/users/${id}/transactions`,
|
||||
)
|
||||
return extractItems(payload)
|
||||
},
|
||||
}
|
||||
|
||||
export const reviewsApi = {
|
||||
create: (lectureId: string | number, rating: 'Like' | 'Neutral' | 'Dislike', text: string) =>
|
||||
apiRequest<ReviewDto>('/reviews', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ lectureId: Number(lectureId), rating, text }),
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Achievement, CoinTransaction, Lecture, Review, User, UserRole } from '@/types'
|
||||
import type {
|
||||
AchievementDto,
|
||||
CoinTransactionDto,
|
||||
LectureDto,
|
||||
ReviewDto,
|
||||
UserAuthDto,
|
||||
UserDto,
|
||||
UserStatsDto,
|
||||
UserAchievementDto,
|
||||
} from './types'
|
||||
|
||||
export function mapApiRole(role: string | undefined): UserRole {
|
||||
if (role === 'Teacher') return 'teacher'
|
||||
if (role === 'Admin') return 'admin'
|
||||
return 'student'
|
||||
}
|
||||
|
||||
export function mapApiUser(user: UserAuthDto | UserDto, stats?: UserStatsDto): User {
|
||||
return {
|
||||
id: String(user.id),
|
||||
name: user.displayName || user.email || 'Пользователь UniVerse',
|
||||
email: user.email || '',
|
||||
role: mapApiRole(user.role),
|
||||
avatar: 'avatarUrl' in user ? user.avatarUrl ?? undefined : undefined,
|
||||
institute: 'ЮФУ',
|
||||
department: '',
|
||||
year: 0,
|
||||
direction: '',
|
||||
coins: stats?.coins ?? ('coins' in user ? user.coins : 0),
|
||||
level: stats?.level ?? ('level' in user ? user.level : 1),
|
||||
xp: stats?.xp ?? ('xp' in user ? user.xp : 0),
|
||||
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)) : [],
|
||||
}
|
||||
}
|
||||
|
||||
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 totalSeats = lecture.maxEnrollments || 0
|
||||
const enrolled = lecture.enrollmentsCount || 0
|
||||
const freeSeats = Math.max(totalSeats - enrolled, 0)
|
||||
const locationName = lecture.locationName || (lecture.format === 'Online' ? 'Онлайн' : 'Аудитория уточняется')
|
||||
|
||||
return {
|
||||
id: String(lecture.id),
|
||||
title: lecture.title || lecture.courseName || 'Лекция без названия',
|
||||
description: lecture.description || 'Описание появится позже.',
|
||||
teacher: lecture.teacherName || 'Преподаватель уточняется',
|
||||
teacherTitle: '',
|
||||
department: '',
|
||||
institute: lecture.courseName || 'ЮФУ',
|
||||
date: startsAt.toISOString().slice(0, 10),
|
||||
time: startsAt.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }),
|
||||
duration,
|
||||
building: lecture.format === 'Online' ? 'Онлайн' : locationName,
|
||||
room: lecture.format === 'Online' ? undefined : locationName,
|
||||
format: lecture.format === 'Online' ? 'online' : 'offline',
|
||||
totalSeats,
|
||||
freeSeats,
|
||||
registrationClosed: !lecture.isOpen,
|
||||
tags: lecture.courseName ? [`#${lecture.courseName}`] : [],
|
||||
rating: 0,
|
||||
reviewCount: 0,
|
||||
status: startsAt.getTime() > Date.now() ? 'upcoming' : 'completed',
|
||||
registered: lecture.isEnrolled,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapApiReview(review: ReviewDto): Review {
|
||||
const sentiment = review.sentiment === 'Negative' ? 'negative' : review.sentiment === 'Neutral' ? 'neutral' : 'positive'
|
||||
const status =
|
||||
review.llmStatus === 'Rejected' ? 'rejected' : review.llmStatus === 'Analyzed' ? 'done' : 'pending'
|
||||
|
||||
return {
|
||||
id: String(review.id),
|
||||
lectureId: String(review.lectureId),
|
||||
userId: String(review.userId),
|
||||
userName: review.userName || 'Анонимный отзыв',
|
||||
text: review.text || '',
|
||||
sentiment,
|
||||
createdAt: review.createdAt,
|
||||
status,
|
||||
quality: review.qualityScore ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapApiAchievement(input: AchievementDto | UserAchievementDto): Achievement {
|
||||
const dto = 'achievement' in input ? input.achievement : input
|
||||
const awardedAt = 'achievement' in input ? input.awardedAt : undefined
|
||||
|
||||
return {
|
||||
id: String(dto.id),
|
||||
title: dto.name || 'Достижение',
|
||||
description: dto.description || dto.condition || '',
|
||||
icon: dto.iconUrl || '⭐',
|
||||
unlocked: Boolean(awardedAt),
|
||||
unlockedAt: awardedAt,
|
||||
coins: dto.coinReward,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapApiCoinTransaction(transaction: CoinTransactionDto): CoinTransaction {
|
||||
return {
|
||||
id: String(transaction.id),
|
||||
date: transaction.createdAt.slice(0, 10),
|
||||
description: transaction.description || transaction.type,
|
||||
amount: transaction.amount,
|
||||
type: transaction.amount >= 0 ? 'earned' : 'spent',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
export type ApiUserRole = 'Student' | 'Teacher' | 'Admin'
|
||||
export type ApiLectureFormat = 'Online' | 'Offline'
|
||||
export type ApiReviewRating = 'Like' | 'Neutral' | 'Dislike'
|
||||
export type ApiReviewLlmStatus = 'Pending' | 'Analyzed' | 'Rejected'
|
||||
export type ApiReviewSentiment = 'Positive' | 'Neutral' | 'Negative'
|
||||
export type ApiCoinTransactionType =
|
||||
| 'ReviewReward'
|
||||
| 'AchievementReward'
|
||||
| 'AttendanceReward'
|
||||
| 'AdminAdjustment'
|
||||
|
||||
export interface PagedResult<T> {
|
||||
items: T[]
|
||||
totalCount: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
accessToken: string
|
||||
expiresAt: string
|
||||
user: UserAuthDto
|
||||
}
|
||||
|
||||
export interface LoginMicrosoftRequest {
|
||||
authorizationCode: string
|
||||
redirectUri?: string
|
||||
}
|
||||
|
||||
export interface UserAuthDto {
|
||||
id: number
|
||||
email: string
|
||||
displayName?: string | null
|
||||
role: ApiUserRole
|
||||
}
|
||||
|
||||
export interface UserDto extends UserAuthDto {
|
||||
avatarUrl?: string | null
|
||||
isActive: boolean
|
||||
xp: number
|
||||
coins: number
|
||||
level: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface UserStatsDto {
|
||||
totalLectures: number
|
||||
attendedLectures: number
|
||||
totalReviews: number
|
||||
xp: number
|
||||
coins: number
|
||||
level: number
|
||||
achievementsCount: number
|
||||
}
|
||||
|
||||
export interface LectureDto {
|
||||
id: number
|
||||
courseId: number
|
||||
courseName?: string | null
|
||||
teacherId?: number | null
|
||||
teacherName?: string | null
|
||||
locationId?: number | null
|
||||
locationName?: string | null
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
format: ApiLectureFormat
|
||||
startsAt: string
|
||||
endsAt: string
|
||||
isOpen: boolean
|
||||
maxEnrollments: number
|
||||
enrollmentsCount: number
|
||||
onlineUrl?: string | null
|
||||
createdAt: string
|
||||
isEnrolled?: boolean
|
||||
}
|
||||
|
||||
export interface ReviewDto {
|
||||
id: number
|
||||
lectureId: number
|
||||
lectureTitle?: string | null
|
||||
userId: number
|
||||
userName?: string | null
|
||||
rating: ApiReviewRating
|
||||
text?: string | null
|
||||
llmStatus: ApiReviewLlmStatus
|
||||
sentiment: ApiReviewSentiment
|
||||
qualityScore?: number | null
|
||||
isInformative?: boolean | null
|
||||
llmTags?: string[] | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AchievementDto {
|
||||
id: number
|
||||
name?: string | null
|
||||
description?: string | null
|
||||
iconUrl?: string | null
|
||||
xpReward: number
|
||||
coinReward: number
|
||||
condition?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface UserAchievementDto {
|
||||
id: number
|
||||
achievement: AchievementDto
|
||||
awardedAt: string
|
||||
}
|
||||
|
||||
export interface CoinTransactionDto {
|
||||
id: number
|
||||
amount: number
|
||||
type: ApiCoinTransactionType
|
||||
reviewId?: number | null
|
||||
achievementId?: number | null
|
||||
description?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface LectureQuery {
|
||||
DateFrom?: string
|
||||
DateTo?: string
|
||||
CourseId?: number
|
||||
TeacherId?: number
|
||||
Format?: ApiLectureFormat
|
||||
IsOpen?: boolean
|
||||
TagId?: number
|
||||
Search?: string
|
||||
Page?: number
|
||||
PageSize?: number
|
||||
}
|
||||
Reference in New Issue
Block a user