diff --git a/package.json b/package.json index a843f40d5..a302369f7 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:skills:prod": "ng build skills --configuration=production", "build:skills:dev": "ng build skills --configuration=development", "build:prod": "npm run build:social:prod && npm run build:skills:prod", - "build:pr": "npm run build:social:dev && npm run build:skills:dev", + "build:pr": "npm run build:social:dev", "watch": "ng build --watch social_platform --configuration=development", "build:sprite-social": "svg-sprite -s --dest=projects/social_platform/src/assets/icons 'projects/social_platform/src/assets/icons/svg/**/*.svg'", "build:sprite-skills": "svg-sprite -s --dest=projects/skills/src/assets/icons 'projects/skills/src/assets/icons/svg/**/*.svg'", diff --git a/projects/core/src/lib/pipes/truncate-html.pipe.ts b/projects/core/src/lib/pipes/truncate-html.pipe.ts new file mode 100644 index 000000000..35bac7276 --- /dev/null +++ b/projects/core/src/lib/pipes/truncate-html.pipe.ts @@ -0,0 +1,77 @@ +/** @format */ + +import { Pipe, PipeTransform } from "@angular/core"; +import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; + +@Pipe({ name: "truncateHtml", standalone: true }) +export class TruncateHtmlPipe implements PipeTransform { + constructor(private sanitizer: DomSanitizer) {} + + transform(value: string, limit: number): SafeHtml { + if (!value) return ""; + + // Сначала убираем все HTML теги чтобы считать только текст + const plainText = value.replace(/<[^>]*>/g, ""); + + if (plainText.length <= limit) { + return this.sanitizer.bypassSecurityTrustHtml(value); + } + + // Обрезаем по тексту, сохраняя теги + let charCount = 0; + let result = ""; + let inTag = false; + let currentTag = ""; + const openTags: string[] = []; + const selfClosingTags = ["br", "hr", "img", "input", "meta", "link"]; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + + if (char === "<") { + inTag = true; + currentTag = ""; + } + + if (inTag) { + result += char; + currentTag += char; + + if (char === ">") { + // Закончился тег, анализируем его + const tagContent = currentTag.slice(1, -1).trim(); // убираем < и > + + if (tagContent.startsWith("/")) { + // Закрывающий тег + openTags.pop(); + } else if (!tagContent.startsWith("!")) { + // Открывающий тег (не комментарий) + const tagNameMatch = tagContent.match(/^(\w+)/i); + if (tagNameMatch) { + const tagName = tagNameMatch[1].toLowerCase(); + // Проверяем, что это не самозакрывающийся тег + if (!selfClosingTags.includes(tagName) && !tagContent.endsWith("/")) { + openTags.push(tagName); + } + } + } + + inTag = false; + currentTag = ""; + } + } else { + if (charCount >= limit) break; + result += char; + charCount++; + } + } + + // Закрываем все открытые теги + let closedTags = result; + for (let i = openTags.length - 1; i >= 0; i--) { + closedTags += ``; + } + + return this.sanitizer.bypassSecurityTrustHtml(closedTags + "..."); + } +} diff --git a/projects/skills/src/app/app.component.html b/projects/skills/src/app/app.component.html index a091c7e5d..37f22b118 100644 --- a/projects/skills/src/app/app.component.html +++ b/projects/skills/src/app/app.component.html @@ -1,12 +1,6 @@
- - @if(userData()){ - - } - -
diff --git a/projects/skills/src/app/app.component.scss b/projects/skills/src/app/app.component.scss index 056326355..5ca25a593 100644 --- a/projects/skills/src/app/app.component.scss +++ b/projects/skills/src/app/app.component.scss @@ -2,10 +2,10 @@ @use "styles/typography"; .app { + position: relative; display: flex; height: 100%; - padding: 20px; - background-color: var(--light-gray); + background-color: var(--white); &__wrapper { display: flex; @@ -22,6 +22,20 @@ flex-shrink: 0; width: 234px; height: 100vh; + + ::ng-deep { + .sidebar__logo { + margin-bottom: 17px; + } + } + } + + &--text { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 17px; + color: var(--dark-grey); } } @@ -116,12 +130,30 @@ } &__body { + display: flex; flex-grow: 1; - padding: 10px 10px 0; + justify-content: center; + padding: 0 200px; overflow-y: auto; - @include responsive.apply-desktop { - padding: 20px 20px 0; + @media (max-width: 1600px) { + padding: 0 150px; + } + + @media (max-width: 1400px) { + padding: 0 100px; + } + + @media (max-width: 1200px) { + padding: 0 50px; + } + + @media (max-width: 992px) { + padding: 0 20px; + } + + @media (max-width: 768px) { + padding: 0 15px; } } @@ -135,12 +167,23 @@ } &__inner { + display: flex; + width: 100%; height: 100%; - max-height: 100%; - @include responsive.apply-desktop { - max-width: responsive.$container-md; - margin: 0 auto; + &--wrapper { + display: grid; + grid-template-columns: 2fr 10fr; + width: 100%; + + @media (max-width: 992px) { + grid-template-columns: 1fr; + } + } + + &--content { + flex-grow: 1; + min-width: 0; } } } diff --git a/projects/skills/src/app/app.component.ts b/projects/skills/src/app/app.component.ts index 3c70b6778..ac9776b57 100644 --- a/projects/skills/src/app/app.component.ts +++ b/projects/skills/src/app/app.component.ts @@ -3,11 +3,11 @@ import { Component, inject, type OnInit, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; import { Router, RouterLink, RouterOutlet } from "@angular/router"; -import { IconComponent, SidebarComponent } from "@uilib"; +import { IconComponent, ProfileControlPanelComponent, SidebarComponent } from "@uilib"; import { SidebarProfileComponent } from "./shared/sidebar-profile/sidebar-profile.component"; -import { ProfileService } from "./profile/services/profile.service"; import type { UserData } from "../models/profile.model"; import { AuthService } from "@auth/services"; +import { SnackbarComponent } from "@ui/components/snackbar/snackbar.component"; /** * Корневой компонент приложения, который служит основным контейнером макета @@ -32,13 +32,14 @@ import { AuthService } from "@auth/services"; SidebarComponent, SidebarProfileComponent, IconComponent, + ProfileControlPanelComponent, + SnackbarComponent, ], templateUrl: "./app.component.html", styleUrl: "./app.component.scss", }) export class AppComponent implements OnInit { // Внедренные сервисы для управления профилем и аутентификацией - profileService = inject(ProfileService); authService = inject(AuthService); router = inject(Router); @@ -54,12 +55,9 @@ export class AppComponent implements OnInit { * Каждый элемент представляет основной раздел приложения */ navItems = [ - // { name: "Навыки", icon: "lib", link: "skills" }, // Временно отключено - { name: "Детский Форсайт", icon: "trackcar", link: "trackCar" }, - // { name: "Траектории", icon: "receipt", link: "subscription" }, - // { name: "Рейтинг", icon: "growth", link: "rating" }, - // { name: "Траектория бизнеса", icon: "trackbuss", link: "trackBuss" }, // Временно отключено - // { name: "Вебинары", icon: "webinars", link: "webinars" }, // Временно отключено + { name: "Мой профиль", icon: "person", link: "profile" }, + { name: "Рейтинг", icon: "growth", link: "rating" }, + { name: "Траектории", icon: "receipt", link: "trackCar" }, ]; // Реактивное состояние с использованием Angular signals @@ -71,23 +69,11 @@ export class AppComponent implements OnInit { * Получает данные пользователя и синхронизирует профиль при запуске * Перенаправляет на страницу входа при ошибке аутентификации */ - ngOnInit(): void { - // Получение текущих данных пользователя - this.profileService.getUserData().subscribe({ - next: data => this.userData.set(data as UserData), - error: () => { - // Перенаправление на основное приложение для входа при ошибке получения данных пользователя - location.href = "https://app.procollab.ru/auth/login"; - }, - }); + ngOnInit(): void {} - // Синхронизация данных профиля с бэкендом - this.profileService.syncProfile().subscribe({ + onLogout() { + this.authService.logout().subscribe({ next: () => { - // Синхронизация профиля успешна - никаких действий не требуется - }, - error: () => { - // Перенаправление на основное приложение для входа при ошибке синхронизации профиля location.href = "https://app.procollab.ru/auth/login"; }, }); diff --git a/projects/skills/src/app/app.config.ts b/projects/skills/src/app/app.config.ts index a44bef102..a981b9ffb 100644 --- a/projects/skills/src/app/app.config.ts +++ b/projects/skills/src/app/app.config.ts @@ -2,6 +2,7 @@ import { ApplicationConfig } from "@angular/core"; import { provideRouter } from "@angular/router"; +import { provideAnimations } from "@angular/platform-browser/animations"; import { routes } from "./app.routes"; import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http"; import { @@ -22,5 +23,6 @@ export const appConfig: ApplicationConfig = { { provide: SKILLS_API_URL, useValue: environment.skillsApiUrl }, { provide: PRODUCTION, useValue: environment.production }, provideHttpClient(withInterceptorsFromDi()), + provideAnimations(), ], }; diff --git a/projects/skills/src/app/app.routes.ts b/projects/skills/src/app/app.routes.ts index b0eb79ec1..edfe580ae 100644 --- a/projects/skills/src/app/app.routes.ts +++ b/projects/skills/src/app/app.routes.ts @@ -23,43 +23,11 @@ export const routes: Routes = [ { path: "", pathMatch: "full", - redirectTo: "profile", // Маршрут по умолчанию перенаправляет на профиль пользователя + redirectTo: "trackCar", // Маршрут по умолчанию перенаправляет на профиль пользователя }, - { - path: "profile", - loadChildren: () => import("./profile/profile.routes").then(c => c.PROFILE_ROUTES), - }, - { - path: "skills", - loadChildren: () => import("./skills/skills.routes").then(c => c.SKILLS_ROUTES), - }, - // { - // path: "rating", - // loadChildren: () => import("./rating/rating.routes").then(c => c.RATING_ROUTES), - // }, - { - path: "task", - loadChildren: () => import("./task/task.routes").then(c => c.TASK_ROUTES), - }, - // { - // path: "trackBuss", - // loadChildren: () => - // import("./trajectories/track-bussiness/track-bussiness.routes").then( - // c => c.TRACK_BUSSINESS_ROUTES - // ), - // }, { path: "trackCar", loadChildren: () => import("./trajectories/track-career/track-career.routes").then(c => c.TRACK_CAREER_ROUTES), }, - { - path: "subscription", - loadChildren: () => - import("./subscription/subscription.routes").then(c => c.SUBSCRIPTION_ROUTES), - }, - // { - // path: "webinars", - // loadChildren: () => import("./webinars/webinars.routes").then(c => c.WEBINARS_ROUTES), - // }, ]; diff --git a/projects/skills/src/app/profile/home/profile-home.component.html b/projects/skills/src/app/profile/home/profile-home.component.html deleted file mode 100644 index 8ca391a53..000000000 --- a/projects/skills/src/app/profile/home/profile-home.component.html +++ /dev/null @@ -1,18 +0,0 @@ - - -
- - @if (profileData()) { - - @if(type === 'months') { - - } @else { - - } - - - } -
diff --git a/projects/skills/src/app/profile/home/profile-home.component.scss b/projects/skills/src/app/profile/home/profile-home.component.scss deleted file mode 100644 index 2c3af8f99..000000000 --- a/projects/skills/src/app/profile/home/profile-home.component.scss +++ /dev/null @@ -1,43 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; - -.profile { - display: flex; - flex-direction: column; - - @include responsive.apply-desktop { - position: relative; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 18px; - } - - &__overlay { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 10; - align-content: center; - align-items: center; - justify-content: center; - text-align: center; - background-color: var(--accent-light); - opacity: 0.7; - - &__text { - @include typography.heading-1; - - color: #a59fb9; - } - - &__image { - margin: 0 auto; - } - } - - &__month { - grid-column: span 2; - } -} diff --git a/projects/skills/src/app/profile/home/profile-home.component.ts b/projects/skills/src/app/profile/home/profile-home.component.ts deleted file mode 100644 index f8be4426c..000000000 --- a/projects/skills/src/app/profile/home/profile-home.component.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** @format */ - -import { Component, inject, type OnDestroy, type OnInit } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { MonthBlockComponent } from "../shared/month-block/month-block.component"; -import { SkillsBlockComponent } from "../shared/skills-block/skills-block.component"; -import { ProgressBlockComponent } from "../shared/progress-block/progress-block.component"; -import { ActivatedRoute } from "@angular/router"; -import { toSignal } from "@angular/core/rxjs-interop"; -import { map, type Subscription } from "rxjs"; -import { mockMonthsList } from "projects/core/src/consts/lists/mock-months-list.const"; -import { ProfileService } from "../services/profile.service"; -import { TrajectoryBlockComponent } from "../shared/trajectory-block/trajectory-block.component"; - -/** - * Компонент главной страницы профиля пользователя - * - * Отображает основную информацию профиля в зависимости от статуса подписки: - * - Для пользователей без подписки: календарь месячной активности - * - Для подписчиков: блок траекторий обучения - * - * Также включает блоки навыков и общего прогресса пользователя. - * - * Компонент автоматически определяет тип отображения на основе - * статуса подписки пользователя. - */ -@Component({ - selector: "app-skills", - standalone: true, - imports: [ - CommonModule, - MonthBlockComponent, - SkillsBlockComponent, - ProgressBlockComponent, - TrajectoryBlockComponent, - ], - templateUrl: "./profile-home.component.html", - styleUrl: "./profile-home.component.scss", -}) -export class ProfileHomeComponent implements OnInit, OnDestroy { - // Моковые данные для календаря месяцев (временное решение) - readonly mockMonts = mockMonthsList; - - // Внедрение зависимостей - private readonly route = inject(ActivatedRoute); - private readonly profileService = inject(ProfileService); - - /** - * Данные профиля пользователя из резолвера - * Содержат информацию о пользователе, навыках и прогрессе - */ - profileData = toSignal(this.route.data.pipe(map(r => r["data"]))); - - /** - * Тип отображения основного блока: - * - 'months' - календарь месячной активности (для бесплатных пользователей) - * - 'trajectory' - блок траекторий (для подписчиков) - */ - type: "months" | "trajectory" = "months"; - - // Массив подписок для управления жизненным циклом - subscription$: Subscription[] = []; - - /** - * Инициализация компонента - * - * Определяет тип отображения на основе статуса подписки пользователя. - * Подписчики видят траектории обучения, остальные - календарь активности. - */ - ngOnInit(): void { - const isSubscribedSub$ = this.profileService.getSubscriptionData().subscribe(r => { - this.type = r.isSubscribed ? "trajectory" : "months"; - }); - - isSubscribedSub$ && this.subscription$.push(isSubscribedSub$); - } - - /** - * Очистка ресурсов при уничтожении компонента - */ - ngOnDestroy(): void { - this.subscription$.forEach($ => $.unsubscribe()); - } -} diff --git a/projects/skills/src/app/profile/home/profile-home.routes.ts b/projects/skills/src/app/profile/home/profile-home.routes.ts deleted file mode 100644 index 153d4bd36..000000000 --- a/projects/skills/src/app/profile/home/profile-home.routes.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { ProfileHomeComponent } from "./profile-home.component"; - -/** - * Конфигурация маршрутов для домашней страницы профиля - * - * Определяет маршрут для главной страницы профиля пользователя. - * Используется как дочерний маршрут в основной конфигурации профиля. - * - * @returns {Routes} Массив конфигураций маршрутов домашней страницы - */ -export const PROFILE_HOME_ROUTES: Routes = [ - { - path: "", - component: ProfileHomeComponent, - }, -]; diff --git a/projects/skills/src/app/profile/profile.component.html b/projects/skills/src/app/profile/profile.component.html deleted file mode 100644 index dfd1c35e3..000000000 --- a/projects/skills/src/app/profile/profile.component.html +++ /dev/null @@ -1,26 +0,0 @@ - - -
-
- -
- - - -
diff --git a/projects/skills/src/app/profile/profile.component.scss b/projects/skills/src/app/profile/profile.component.scss deleted file mode 100644 index f7912b7ef..000000000 --- a/projects/skills/src/app/profile/profile.component.scss +++ /dev/null @@ -1,10 +0,0 @@ -.profile { - &__bar { - margin-bottom: 18px; - } - - &__info { - display: block; - margin-bottom: 18px; - } -} diff --git a/projects/skills/src/app/profile/profile.component.ts b/projects/skills/src/app/profile/profile.component.ts deleted file mode 100644 index 1cb0df6c6..000000000 --- a/projects/skills/src/app/profile/profile.component.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** @format */ - -import { Component, inject } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ActivatedRoute, RouterOutlet } from "@angular/router"; -import { InfoBlockComponent } from "./shared/info-block/info-block.component"; -import { toSignal } from "@angular/core/rxjs-interop"; -import { map } from "rxjs"; -import { BarComponent } from "@ui/components"; -import { BarNewComponent } from "@ui/components/bar-new/bar.component"; - -/** - * Основной компонент профиля пользователя - * - * Служит контейнером для всех разделов профиля и предоставляет - * общие данные профиля для дочерних компонентов через router-outlet. - * - * Компонент загружает данные профиля через резолвер и делает их - * доступными для всех дочерних маршрутов профиля. - */ -@Component({ - selector: "app-profile", - standalone: true, - imports: [CommonModule, RouterOutlet, InfoBlockComponent, BarComponent, BarNewComponent], - templateUrl: "./profile.component.html", - styleUrl: "./profile.component.scss", -}) -export class ProfileComponent { - // Внедрение сервиса для работы с маршрутами - route = inject(ActivatedRoute); - - /** - * Данные профиля пользователя - * - * Получаются из резолвера маршрута и содержат: - * - Основную информацию о пользователе - * - Список навыков с прогрессом - * - Историю месячной активности - */ - profileData = toSignal(this.route.data.pipe(map(r => r["data"]))); -} diff --git a/projects/skills/src/app/profile/profile.resolver.ts b/projects/skills/src/app/profile/profile.resolver.ts deleted file mode 100644 index 449f53d71..000000000 --- a/projects/skills/src/app/profile/profile.resolver.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -import { ResolveFn } from "@angular/router"; -import { inject } from "@angular/core"; -import { Profile } from "../../models/profile.model"; -import { ProfileService } from "./services/profile.service"; - -/** - * Резолвер для загрузки данных профиля пользователя - * - * Выполняется перед активацией маршрута профиля и предоставляет - * полную информацию о пользователе, включая навыки и прогресс. - * - * Это гарантирует, что данные профиля будут доступны - * во всех дочерних компонентах профиля сразу при загрузке. - * - * @returns Observable - полные данные профиля пользователя - */ -export const profileResolver: ResolveFn = () => { - const profileService = inject(ProfileService); - return profileService.getProfile(); -}; diff --git a/projects/skills/src/app/profile/profile.routes.ts b/projects/skills/src/app/profile/profile.routes.ts deleted file mode 100644 index 108cb160c..000000000 --- a/projects/skills/src/app/profile/profile.routes.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { ProfileComponent } from "./profile.component"; -import { ProfileSkillsRatingComponent } from "./skills-rating/skills-rating.component"; -import { profileResolver } from "./profile.resolver"; -import { ProfileStudentsComponent } from "./students/students.component"; -import { studentsResolver } from "./students/students.resolver"; - -/** - * Конфигурация маршрутов для модуля профиля пользователя - * - * Определяет структуру навигации в разделе профиля: - * - Главная страница профиля с дочерними маршрутами - * - Страница навыков пользователя - * - Страница студентов (для наставников) - * - * @returns {Routes} Массив конфигураций маршрутов - */ -export const PROFILE_ROUTES: Routes = [ - { - path: "", - component: ProfileComponent, - resolve: { data: profileResolver }, - children: [ - { - path: "", - loadChildren: () => import("./home/profile-home.routes").then(m => m.PROFILE_HOME_ROUTES), - }, - { path: "skills", component: ProfileSkillsRatingComponent }, - { - path: "students", - component: ProfileStudentsComponent, - resolve: { - students: studentsResolver, - }, - }, - ], - }, -]; diff --git a/projects/skills/src/app/profile/services/profile.service.ts b/projects/skills/src/app/profile/services/profile.service.ts deleted file mode 100644 index 8d8fefe94..000000000 --- a/projects/skills/src/app/profile/services/profile.service.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** @format */ - -import { inject, Injectable } from "@angular/core"; -import { SkillsApiService, SubscriptionData } from "@corelib"; -import { Profile, UserData } from "../../../models/profile.model"; - -/** - * Служба профилей - * - * Управляет всеми операциями, связанными с профилями пользователей, включая: - * - Получение и управление данными пользователей - * - Синхронизация профилей с бэкэндом - * - Управление подписками - * - Выбор и обновление навыков - * - * Эта служба выступает в качестве основного интерфейса между фронтендом - * и бэкэндом для всех функций, связанных с профилями. - */ -@Injectable({ - providedIn: "root", -}) -export class ProfileService { - private readonly PROGRESS_URL = "/progress"; - private readonly SUBSCRIPTION_URL = "/subscription"; - - private apiService = inject(SkillsApiService); - - /** - * Получает информацию о профиле текущего пользователя. - * - * @returns Observable Полные данные профиля, включая прогресс и достижения. - */ - getProfile() { - return this.apiService.get(`${this.PROGRESS_URL}/profile/`); - } - - /** - * Fetches basic user data and account information - * - * @returns Observable Essential user information for display and navigation - */ - getUserData() { - return this.apiService.get(`${this.PROGRESS_URL}/user-data/`); - } - - /** - * Получает текущий статус подписки и подробную информацию о ней. - * - * @returns Observable Информация о подписке, включая тип тарифного плана и статус. - */ - getSubscriptionData() { - return this.apiService.get(`${this.PROGRESS_URL}/subscription-data/`); - } - - /** - * Обновляет настройки автоматического продления подписки пользователя. - * - * @param allowed — следует ли включить автоматическое продление. - * @returns Observable Ответ, подтверждающий обновление. - */ - updateSubscriptionDate(allowed: boolean) { - return this.apiService.patch(`${this.PROGRESS_URL}/update-auto-renewal/`, { - is_autopay_allowed: allowed, - }); - } - - /** - * Отменяет текущую подписку и обрабатывает возврат средств, если применимо. - * - * @returns Observable Ответ, подтверждающий отмену. - */ - cancelSubscription() { - return this.apiService.post(`${this.SUBSCRIPTION_URL}/refund`, {}); - } - - /** - * Добавляет новые навыки в профиль обучения пользователя. - * - * @param skills — массив идентификаторов навыков, которые необходимо добавить в профиль пользователя. - * @returns Observable Ответ, подтверждающий добавление навыков. - */ - addSkill(skills: number[]) { - return this.apiService.patch(`${this.PROGRESS_URL}/add-skills/`, skills); - } - - /** - * Синхронизирует данные локального профиля с бэкэндом. - * - * Этот метод гарантирует, что профиль пользователя будет обновлен - * с учетом последней информации из основного приложения. - * Должен вызываться при инициализации приложения и после значительных изменений. - * - * @returns Observable Ответ, подтверждающий синхронизацию. - */ - syncProfile() { - return this.apiService.post(`${this.PROGRESS_URL}/sync-profile/`, {}); - } -} diff --git a/projects/skills/src/app/profile/shared/info-block/info-block.component.html b/projects/skills/src/app/profile/shared/info-block/info-block.component.html deleted file mode 100644 index 0cef2c8a4..000000000 --- a/projects/skills/src/app/profile/shared/info-block/info-block.component.html +++ /dev/null @@ -1,67 +0,0 @@ - - -
- -
-
-

{{ userData.firstName }} {{ userData.lastName }}

- @if (isMentor) { -

Наставник skills

- } -
-
-
- {{ userData.age }} {{ userData.age | pluralize: ["год", "года", "лет"] }} • - {{ userData.specialization }} • -
-
- - {{ userData.geoPosition }} -
-
-
- - - -
- {{ userData.points }} {{ userData.points | pluralize: ["балл", "балла", "баллов"] }} -
- - вернуться на procollab.ru - -
- - -
- - -
- -
-

Подписка оформлена

-

Погрузись в мир знаний прямо сейчас

- Начать - wave -
-
diff --git a/projects/skills/src/app/profile/shared/info-block/info-block.component.scss b/projects/skills/src/app/profile/shared/info-block/info-block.component.scss deleted file mode 100644 index 393ae2cda..000000000 --- a/projects/skills/src/app/profile/shared/info-block/info-block.component.scss +++ /dev/null @@ -1,226 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.info { - display: grid; - grid-template-columns: 72px 1fr; - gap: 18px 32px; - - @include responsive.apply-desktop { - grid-template-columns: 223px 1fr 1fr 0.8fr; - } - - @include responsive.apply-desktop { - gap: 15px; - align-items: flex-start; - padding: 20px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - } - - &__avatar { - grid-row: span 1; - - @include responsive.apply-desktop { - grid-row: span 2; - } - } - - &__top { - display: flex; - gap: 10px; - align-items: center; - } - - &__mentor { - display: flex; - align-items: center; - width: 100%; - height: 23px; - padding: 0 15px; - color: var(--green); - border: 1px solid var(--green); - border-radius: 20px; - - @include responsive.apply-desktop { - width: inherit; - padding: 0 20px; - } - } - - &__left { - @include responsive.apply-desktop { - grid-column: 2/span 2; - } - } - - &__keys { - display: flex; - flex-wrap: wrap; - gap: 5px; - color: var(--dark-grey); - - @include responsive.apply-desktop { - // gap: 20px; - } - } - - &__title { - color: var(--black); - - @include typography.heading-4; - - @include responsive.apply-desktop { - @include typography.heading-2; - } - } - - &__geo { - display: flex; - align-items: center; - order: -1; - - @include responsive.apply-desktop { - order: unset; - - i { - display: none; - } - } - - i { - display: block; - width: 16px; - height: 16px; - } - } - - &__return { - grid-row: 4; - grid-column: span 3; - - @include responsive.apply-desktop { - grid-row: 1; - grid-column: 4/span 1; - } - } - - &__achievements { - display: none; - grid-row: 3; - grid-column: span 3; - - @include responsive.apply-desktop { - display: flex; - flex-wrap: wrap; - grid-row-start: 2; - grid-column: 2/span 2; - grid-gap: 18px; - } - - &-image { - cursor: pointer; - background-color: #e8e8e8; - border-radius: 18px; - } - } - - &__score { - grid-row: 2; - grid-column: span 3; - color: var(--accent) !important; - - @include responsive.apply-desktop { - grid-row-start: 2; - grid-column: 4; - } - } -} - -.box { - display: flex; - align-items: center; - justify-content: center; - height: 50px; - color: var(--dark-grey); - background-color: var(--white); - border: 2px solid; - border-color: var(--gradient); - border-radius: 15px; - - @include responsive.apply-desktop { - height: 90px; - background-color: transparent; - } -} - -.cancel { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - max-height: calc(100vh - 40px); - padding: 20px 0 50px; - overflow-y: auto; - - @include responsive.apply-desktop { - padding: 20px 0 60px; - } - - &__cross { - position: absolute; - top: 0; - right: 0; - width: 32px; - height: 32px; - cursor: pointer; - - @include responsive.apply-desktop { - top: 8px; - right: 8px; - } - } - - &__check { - color: var(--green); - } - - &__complete { - display: flex; - align-items: center; - justify-content: center; - width: 50px; - height: 50px; - color: var(--green); - border: 2px solid var(--green); - border-radius: 100%; - } - - &__title { - margin-top: 10px; - margin-bottom: 5px; - color: var(--black); - text-align: center; - inline-size: 280px; - - @include typography.heading-3; - - @include responsive.apply-desktop { - inline-size: 500px; - } - } - - &__text { - margin-bottom: 24px; - color: var(--grey-for-text); - } - - &__wave { - position: absolute; - bottom: -10%; - left: 0; - z-index: 0; - width: 100%; - } -} diff --git a/projects/skills/src/app/profile/shared/info-block/info-block.component.ts b/projects/skills/src/app/profile/shared/info-block/info-block.component.ts deleted file mode 100644 index 5aedefb31..000000000 --- a/projects/skills/src/app/profile/shared/info-block/info-block.component.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** @format */ - -import { Component, HostListener, inject, Input, OnDestroy, OnInit, signal } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ButtonComponent } from "@ui/components"; -import { AvatarComponent, IconComponent } from "@uilib"; -import { Router } from "@angular/router"; -import { Profile, UserData } from "../../../../models/profile.model"; -import { PluralizePipe } from "@corelib"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { ProfileService } from "../../services/profile.service"; -import { Subscription } from "rxjs"; - -/** - * Компонент информационного блока пользователя - * - * Отображает основную информацию о пользователе: - * - Аватар, имя, возраст, специализацию - * - Статус наставника (если применимо) - * - Количество баллов - * - Модальное окно уведомления о подписке - * - * @component InfoBlockComponent - * @selector app-info-block - * - * @input userData - Данные пользователя для отображения - * - * @property avatarSize - Размер аватара (адаптивный) - * @property openSuscriptionBought - Флаг модального окна подписки - * @property isMentor - Флаг статуса наставника - */ -@Component({ - selector: "app-info-block", - standalone: true, - imports: [ - CommonModule, - ButtonComponent, - IconComponent, - PluralizePipe, - AvatarComponent, - ModalComponent, - ], - templateUrl: "./info-block.component.html", - styleUrl: "./info-block.component.scss", -}) -export class InfoBlockComponent implements OnInit, OnDestroy { - @Input({ required: true }) userData!: Profile["userData"]; - - router = inject(Router); - profileService = inject(ProfileService); - subscriptions: Subscription[] = []; - achievementsList = Array; - - avatarSize = signal(window.innerWidth > 1200 ? 165 : 90); - openSuscriptionBought = false; - isMentor?: boolean; - - ngOnInit(): void { - const isMentorSub = this.profileService.getUserData().subscribe((res: UserData) => { - this.isMentor = res.isMentor; - }); - - const hasShownModal = localStorage.getItem("hasShownSubscriptionModal"); - - if (!hasShownModal) { - const profileSub = this.profileService.getSubscriptionData().subscribe({ - next: r => { - this.openSuscriptionBought = r.lastSubscriptionType === null; - if (this.openSuscriptionBought) { - localStorage.setItem("hasShownSubscriptionModal", "true"); - } - }, - }); - this.subscriptions.push(profileSub); - } - - this.subscriptions.push(isMentorSub); - } - - ngOnDestroy() { - this.subscriptions.forEach(s => s.unsubscribe()); - } - - /** - * Обрабатывает изменение размера окна для адаптивного размера аватара - */ - @HostListener("window:resize", ["$event"]) - onResize(event: any) { - this.avatarSize.set(event.target.innerWidth > 1200 ? 165 : 90); - } -} diff --git a/projects/skills/src/app/profile/shared/month-block/month-block.component.html b/projects/skills/src/app/profile/shared/month-block/month-block.component.html deleted file mode 100644 index 1392c5ab4..000000000 --- a/projects/skills/src/app/profile/shared/month-block/month-block.component.html +++ /dev/null @@ -1,15 +0,0 @@ - - -@if (months.length) { -
- @if(hasNext){ -

Далее

- } @for (m of months; track $index) { -
-
- {{ m.month }} -
-
- } -
-} diff --git a/projects/skills/src/app/profile/shared/month-block/month-block.component.scss b/projects/skills/src/app/profile/shared/month-block/month-block.component.scss deleted file mode 100644 index 372a9d2dc..000000000 --- a/projects/skills/src/app/profile/shared/month-block/month-block.component.scss +++ /dev/null @@ -1,118 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; - -.month { - position: relative; - width: 100%; - height: 130px; - padding: 50px 50px 50px 18px; - overflow: hidden; - white-space: nowrap; - background-color: var(--white); - border-radius: 15px; - - @include responsive.apply-desktop { - height: 100px; - } - - &__item { - opacity: 0.3; - - &--done { - opacity: 1; - } - } - - &__next { - position: absolute; - top: 65%; - right: 40%; - color: var(--grey-for-text); - text-decoration: underline; - - @include typography.bold-body-14; - - @include responsive.apply-desktop { - top: 40%; - right: 15px; - } - } - - &__block { - display: flex; - align-items: center; - padding-right: 50px; - } -} - -.item { - position: relative; - z-index: 10; - display: inline-block; - width: 100%; - max-width: 70px; - height: 30px; - margin: 0; - margin-right: 0.8%; - line-height: 45px; - text-align: center; - transition: all 0.8s; - - @include responsive.apply-desktop { - max-width: 180px; - height: 60px; - } - - &::before, - &::after { - position: absolute; - content: ""; - transition: all 0.8s; - } - - &::before { - position: absolute; - top: -50%; - left: 20%; - z-index: -100; - width: 100%; - height: 50%; - content: ""; - background: var(--accent); - opacity: 1; - transition: all 0.8s; - transform: skew(60deg); - } - - &::after { - position: absolute; - top: 0%; - left: 20%; - z-index: -100; - width: 100%; - height: 50%; - content: ""; - background: var(--accent); - opacity: 1; - transition: all 0.8s; - transform: skew(60deg); - transform: skew(-60deg); - } - - &__name { - position: absolute; - top: -18%; - left: 42%; - display: flex; - gap: 5px; - color: var(--white); - - @include typography.body-12; - - @include responsive.apply-desktop { - left: 55%; - - @include typography.bold-body-14; - } - } -} diff --git a/projects/skills/src/app/profile/shared/month-block/month-block.component.spec.ts b/projects/skills/src/app/profile/shared/month-block/month-block.component.spec.ts deleted file mode 100644 index b38fd05dd..000000000 --- a/projects/skills/src/app/profile/shared/month-block/month-block.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { MonthBlockComponent } from "./month-block.component"; - -describe("MonthBlockComponent", () => { - let component: MonthBlockComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MonthBlockComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(MonthBlockComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/profile/shared/month-block/month-block.component.ts b/projects/skills/src/app/profile/shared/month-block/month-block.component.ts deleted file mode 100644 index 204d191d6..000000000 --- a/projects/skills/src/app/profile/shared/month-block/month-block.component.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** @format */ - -import { Component, Input } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { Profile } from "../../../../models/profile.model"; - -/** - * Компонент для отображения блока месяцев с прогрессом - * - * Отображает список месяцев в виде стилизованных элементов, - * показывает выполненные месяцы и индикатор "Далее" - * - * @component MonthBlockComponent - * @selector app-month-block - * - * @input months - Массив месяцев из профиля пользователя - * @input hasNext - Флаг отображения индикатора "Далее" (по умолчанию true) - */ -@Component({ - selector: "app-month-block", - standalone: true, - imports: [CommonModule], - templateUrl: "./month-block.component.html", - styleUrl: "./month-block.component.scss", -}) -export class MonthBlockComponent { - @Input({ required: true }) months!: Profile["months"]; - @Input() hasNext = true; -} diff --git a/projects/skills/src/app/profile/shared/personal-rating-card/personal-rating-card.component.html b/projects/skills/src/app/profile/shared/personal-rating-card/personal-rating-card.component.html deleted file mode 100644 index 927fe6d50..000000000 --- a/projects/skills/src/app/profile/shared/personal-rating-card/personal-rating-card.component.html +++ /dev/null @@ -1,28 +0,0 @@ - - -
-
- skill-image -
-

- {{ personalRatingCardData.skillName }} -

-

- {{ personalRatingCardData.skillLevel }} - {{ personalRatingCardData.skillLevel | pluralize: ["уровень", "уровня", "уровней"] }} -

-
-
- -
diff --git a/projects/skills/src/app/profile/shared/personal-rating-card/personal-rating-card.component.scss b/projects/skills/src/app/profile/shared/personal-rating-card/personal-rating-card.component.scss deleted file mode 100644 index 8ffe36b80..000000000 --- a/projects/skills/src/app/profile/shared/personal-rating-card/personal-rating-card.component.scss +++ /dev/null @@ -1,51 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; - -.rating-card { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px; - border: 1px solid var(--grey-button); - border-radius: 15px; - - @include responsive.apply-desktop { - padding: 10px 15px; - } - - &__left { - display: flex; - gap: 15px; - align-items: center; - } - - &__name { - color: var(--black); - - @include typography.heading-4; - - @include responsive.apply-desktop { - @include typography.body-bold-18; - } - } - - &__level { - color: var(--dark-grey); - - @include typography.body-12; - - @include responsive.apply-desktop { - @include typography.body-14; - } - } - - &__right { - color: var(--grey-for-text); - - @include typography.body-12; - - @include responsive.apply-desktop { - @include typography.body-18; - } - } -} diff --git a/projects/skills/src/app/profile/shared/personal-rating-card/personal-rating-card.component.spec.ts b/projects/skills/src/app/profile/shared/personal-rating-card/personal-rating-card.component.spec.ts deleted file mode 100644 index 191a2f312..000000000 --- a/projects/skills/src/app/profile/shared/personal-rating-card/personal-rating-card.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { PersonalRatingCardComponent } from "./personal-rating-card.component"; - -describe("PersonalRatingCardComponent", () => { - let component: PersonalRatingCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PersonalRatingCardComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PersonalRatingCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/profile/shared/personal-rating-card/personal-rating-card.component.ts b/projects/skills/src/app/profile/shared/personal-rating-card/personal-rating-card.component.ts deleted file mode 100644 index b2a00ee71..000000000 --- a/projects/skills/src/app/profile/shared/personal-rating-card/personal-rating-card.component.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** @format */ - -import { Component, Input } from "@angular/core"; -import { CommonModule, NgOptimizedImage } from "@angular/common"; -import { CircleProgressBarComponent } from "../../../shared/circle-progress-bar/circle-progress-bar.component"; -import { Skill } from "projects/skills/src/models/profile.model"; -import { PluralizePipe } from "@corelib"; - -/** - * Компонент карточки навыка с отображением рейтинга и прогресса - * - * Показывает информацию о навыке пользователя: - * - Название и изображение навыка - * - Текущий уровень навыка - * - Круговой индикатор прогресса - * - * @component PersonalRatingCardComponent - * @selector app-personal-rating-card - * - * @input personalRatingCardData - Данные навыка с прогрессом для отображения - */ -@Component({ - selector: "app-personal-rating-card", - standalone: true, - imports: [CommonModule, CircleProgressBarComponent, PluralizePipe, NgOptimizedImage], - templateUrl: "./personal-rating-card.component.html", - styleUrl: "./personal-rating-card.component.scss", -}) -export class PersonalRatingCardComponent { - @Input() personalRatingCardData!: Skill; -} diff --git a/projects/skills/src/app/profile/shared/personal-skill-card/personal-skill-card.component.html b/projects/skills/src/app/profile/shared/personal-skill-card/personal-skill-card.component.html deleted file mode 100644 index b772b28b3..000000000 --- a/projects/skills/src/app/profile/shared/personal-skill-card/personal-skill-card.component.html +++ /dev/null @@ -1,31 +0,0 @@ - - -
-
- skill-image -
-

- {{ personalSkillCard.name }} -

- -

- {{ personalSkillCard.quantityOfLevels }} - {{ personalSkillCard.quantityOfLevels | pluralize: ["уровень", "уровня", "уровней"] }} -

-
-
-
- -
-
diff --git a/projects/skills/src/app/profile/shared/personal-skill-card/personal-skill-card.component.scss b/projects/skills/src/app/profile/shared/personal-skill-card/personal-skill-card.component.scss deleted file mode 100644 index c8e18b301..000000000 --- a/projects/skills/src/app/profile/shared/personal-skill-card/personal-skill-card.component.scss +++ /dev/null @@ -1,49 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; - -.rating-card { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px; - border: 1px solid var(--grey-button); - border-radius: 15px; - - &--unpicked { - opacity: 0.6; - } - - &__left { - display: flex; - gap: 15px; - align-items: center; - padding-right: 30px; - } - - &__name { - color: var(--black); - text-decoration: underline; - - @include typography.heading-4; - - @include responsive.apply-desktop { - @include typography.body-bold-18; - } - } - - &__level { - color: var(--dark-grey); - - @include typography.body-14; - } - - &__right { - color: var(--grey-for-text); - - @include typography.body-12; - - @include responsive.apply-desktop { - @include typography.body-18; - } - } -} diff --git a/projects/skills/src/app/profile/shared/personal-skill-card/personal-skill-card.component.spec.ts b/projects/skills/src/app/profile/shared/personal-skill-card/personal-skill-card.component.spec.ts deleted file mode 100644 index 1055d8509..000000000 --- a/projects/skills/src/app/profile/shared/personal-skill-card/personal-skill-card.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { PersonalSkillCardComponent } from "./personal-skill-card.component"; - -describe("PersonalSkillCardComponent", () => { - let component: PersonalSkillCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PersonalSkillCardComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PersonalSkillCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/profile/shared/personal-skill-card/personal-skill-card.component.ts b/projects/skills/src/app/profile/shared/personal-skill-card/personal-skill-card.component.ts deleted file mode 100644 index b529ea724..000000000 --- a/projects/skills/src/app/profile/shared/personal-skill-card/personal-skill-card.component.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** @format */ - -import { - Component, - computed, - EventEmitter, - inject, - Input, - Output, - WritableSignal, -} from "@angular/core"; -import { CommonModule, NgOptimizedImage } from "@angular/common"; -import { CheckboxComponent } from "../../../../../../social_platform/src/app/ui/components/checkbox/checkbox.component"; -import { Skill } from "projects/skills/src/models/skill.model"; -import { PluralizePipe } from "@corelib"; -import { SkillService } from "../../../skills/services/skill.service"; -import { ActivatedRoute } from "@angular/router"; -import { Skill as ProfileSkill } from "projects/skills/src/models/profile.model"; - -/** - * Компонент карточки навыка для выбора в личном кабинете - * - * Отображает информацию о навыке с возможностью выбора/отмены выбора. - * Ограничивает выбор до 5 навыков максимум. - * Показывает состояние навыка (выполнен/не выполнен). - * - * @component PersonalSkillCardComponent - * @selector app-personal-skill-card - * - * @input personalSkillCard - Данные навыка для отображения - * @input profileIdSkills - Сигнал с массивом ID выбранных навыков - * @input isRetryPicked - Сигнал флага повторного выбора навыка - * - * @output selectedCountChange - Событие изменения количества выбранных навыков - */ -@Component({ - selector: "app-personal-skill-card", - standalone: true, - imports: [CommonModule, CheckboxComponent, CheckboxComponent, PluralizePipe, NgOptimizedImage], - templateUrl: "./personal-skill-card.component.html", - styleUrl: "./personal-skill-card.component.scss", -}) -export class PersonalSkillCardComponent { - @Input() personalSkillCard!: Skill; - @Input() profileIdSkills!: WritableSignal; - @Input() isRetryPicked!: WritableSignal; - - @Output() selectedCountChange: EventEmitter = new EventEmitter(); - - route = inject(ActivatedRoute); - skillService = inject(SkillService); - - /** Вычисляемое свойство - выбран ли текущий навык */ - isChecked = computed(() => this.profileIdSkills().includes(this.personalSkillCard.id)); - /** Вычисляемое свойство - количество выбранных навыков */ - selectedCount = computed(() => this.profileIdSkills().length); - - ngOnInit(): void {} - - /** - * Переключает состояние выбора навыка - * Проверяет ограничения на количество выбранных навыков (максимум 5) - * Устанавливает флаг повторного выбора если навык уже был выполнен - */ - toggleCheck(): void { - const currentId = this.personalSkillCard.id; - - if (!this.isChecked()) { - if (this.selectedCount() < 5) { - this.profileIdSkills.update(ids => [...ids, currentId]); - - this.selectedCountChange.emit(this.selectedCount()); - - if (this.personalSkillCard.isDone === true) { - this.isRetryPicked.set(true); - } - } - } else { - this.profileIdSkills.update(ids => ids.filter(id => id !== currentId)); - - this.selectedCountChange.emit(this.selectedCount()); - } - } -} diff --git a/projects/skills/src/app/profile/shared/progress-block/progress-block.component.html b/projects/skills/src/app/profile/shared/progress-block/progress-block.component.html deleted file mode 100644 index e2029a3a9..000000000 --- a/projects/skills/src/app/profile/shared/progress-block/progress-block.component.html +++ /dev/null @@ -1,66 +0,0 @@ - - -
-
- @if (skillsList().length) { @for (skill of skillsList().slice(0, 5); track $index) { - - - - - } } @else { - - - - - } -
- -
-

Прогресс

- - {{ tooltipText }} - @for (skill of skillsList().slice(0, 5); track $index) { -
    -
  • -
    -

    {{ skill.skillName }}

    -
  • -
- } -
-
diff --git a/projects/skills/src/app/profile/shared/progress-block/progress-block.component.scss b/projects/skills/src/app/profile/shared/progress-block/progress-block.component.scss deleted file mode 100644 index 6769a6be0..000000000 --- a/projects/skills/src/app/profile/shared/progress-block/progress-block.component.scss +++ /dev/null @@ -1,158 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; -@use "sass:list"; -@use "sass:math"; - -.progress { - position: relative; - display: flex; - flex-direction: column; - align-items: center; - justify-content: space-between; - min-height: 365px; - padding: 20px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - - @include responsive.apply-desktop { - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-evenly; - } - - &__info { - margin-top: 100px; - - @include responsive.apply-desktop { - margin-top: 0; - } - } - - &__chart { - position: relative; - width: 100%; - } - - &__hint { - position: absolute; - top: 0; - right: 0; - z-index: 10; - display: inline-block; - width: 24px; - height: 24px; - cursor: pointer; - - &:hover { - opacity: 1; - } - - @include responsive.apply-desktop { - top: 8px; - right: 8px; - opacity: 0.5; - } - } - - &__tooltip { - position: absolute; - bottom: 95%; - left: 66%; - z-index: 100; - display: none; - width: 200px; - padding: 12px; - color: var(--dark-grey); - background-color: var(--white); - border: 1px solid var(--dark-grey); - border-radius: 15px 15px 0; - opacity: 0; - transition: opacity 0.3s ease-in-out; - - &--visible { - display: block; - opacity: 1; - } - } -} - -.info { - &__list { - margin-top: 10px; - } - - &__item { - display: flex; - gap: 10px; - align-items: center; - cursor: pointer; - } - - &__bullet { - width: 15px; - height: 15px; - background-color: var(--accent); - border-radius: 50%; - } -} - -.chart { - position: relative; - width: 300px; - height: 300px; - - &__circle { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } -} - -.circle { - $items: ( - (275, 6), - (235, 6.5), - (198, 7), - (155, 9), - (107, 12) - ); - - @for $i from 1 through 5 { - &:nth-child(#{$i}) { - width: #{list.nth(list.nth($items, $i), 1)}px; - - circle { - stroke-width: #{list.nth(list.nth($items, $i), 2)}px; - - // ⬇️ Keep it for some time - - // @if $i == 1 { - // stroke-width: $base-stroke-width; - // } @else if $i == 2 { - // stroke-width: $base-stroke-width * (list.nth($items-v2, $i - 1) / list.nth($items-v2, $i)); - // } @else { - // stroke-width: $base-stroke-width * - // (list.nth($items-v2, $i - 2) / - // list.nth($items-v2, $i - 1)) * (list.nth($items-v2, $i - 1) / list.nth($items-v2, $i)); - // } - } - } - } - - &__track, - &__filled { - fill: none; - } - - &__track { - stroke: var(--accent-light); - } - - &__filled { - stroke: var(--accent); - transition: all 0.2s; - } -} diff --git a/projects/skills/src/app/profile/shared/progress-block/progress-block.component.spec.ts b/projects/skills/src/app/profile/shared/progress-block/progress-block.component.spec.ts deleted file mode 100644 index efb67c2e4..000000000 --- a/projects/skills/src/app/profile/shared/progress-block/progress-block.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProgressBlockComponent } from "./progress-block.component"; - -describe("ProgressBlockComponent", () => { - let component: ProgressBlockComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ProgressBlockComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ProgressBlockComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/profile/shared/progress-block/progress-block.component.ts b/projects/skills/src/app/profile/shared/progress-block/progress-block.component.ts deleted file mode 100644 index 13be49288..000000000 --- a/projects/skills/src/app/profile/shared/progress-block/progress-block.component.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** @format */ - -import { Component, inject, OnInit, signal } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { CircleProgressBarComponent } from "../../../shared/circle-progress-bar/circle-progress-bar.component"; -import { ActivatedRoute } from "@angular/router"; -import { Skill } from "projects/skills/src/models/profile.model"; -import { IconComponent } from "@uilib"; - -/** - * Компонент блока прогресса навыков - * - * Отображает топ-5 навыков пользователя в виде концентрических кругов - * с индикаторами прогресса. Поддерживает интерактивность при наведении. - * - * @component ProgressBlockComponent - * @selector app-progress-block - * - * @property radius - Радиус кругов прогресса (70px) - * @property skillsList - Список навыков пользователя - * @property hoveredIndex - Индекс навыка при наведении (-1 если нет) - * @property tooltipText - Текст подсказки - * @property isHintVisible - Флаг отображения подсказки - */ -@Component({ - selector: "app-progress-block", - standalone: true, - imports: [CommonModule, CircleProgressBarComponent, IconComponent], - templateUrl: "./progress-block.component.html", - styleUrl: "./progress-block.component.scss", -}) -export class ProgressBlockComponent implements OnInit { - route = inject(ActivatedRoute); - radius = 70; - skillsList = signal([]); - hoveredIndex = -1; - - tooltipText = "В блоке «Прогресс» отображаются ваши топ-5 навыков, которые вы проходите"; - isHintVisible = false; - - /** - * Показывает подсказку - */ - showTooltip() { - this.isHintVisible = true; - } - - /** - * Скрывает подсказку - */ - hideTooltip() { - this.isHintVisible = false; - } - - /** - * Вычисляет смещение обводки для индикатора прогресса - * @param skillProgress - Прогресс навыка в процентах (0-100) - * @returns Значение stroke-dashoffset для SVG - */ - calculateStrokeDashOffset(skillProgress: number): number { - const circumference = 2 * Math.PI * this.radius; - return circumference - (skillProgress / 100) * circumference; - } - - /** - * Вычисляет массив штрихов для окружности - * @returns Значение stroke-dasharray для SVG - */ - calculateStrokeDashArray(): number { - return 2 * Math.PI * this.radius; - } - - ngOnInit(): void { - this.route.data.subscribe(r => { - this.skillsList.set( - r["data"].skills.sort((a: any, b: any) => b.skillProgress - a.skillProgress) - ); - }); - } - - /** - * Вычисляет прозрачность элемента при наведении - * @param index - Индекс элемента - * @returns Значение прозрачности (0.3 или 1) - */ - getOpacity(index: number) { - if (this.hoveredIndex === -1) { - return 1; - } - return this.hoveredIndex === index ? 1 : 0.3; - } -} diff --git a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.html b/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.html deleted file mode 100644 index 47c1393df..000000000 --- a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.html +++ /dev/null @@ -1,101 +0,0 @@ - - -@if (open && !nonConfirmerModalOpen()) { - -
- -

Выберите 5 навыков

-

- Пожалуйста, выберите 5 навыков из приведённого ниже списка, которые будут доступны вам в - следующем месяце. -

-
- @for (skill of skillsList(); track skill.id) { - - } - -
-
- -

- {{ currentPage }} / - {{ totalPages() }} -

- -
- - Выбрать -
-
-
-
-} - - -
-
- -

Этот навык вы уже выбирали в прошлом месяце.

-
-

- Если хотите выбрать другой навык, то закройте это окно и выберите другой навык. -

-
-
- -@if (open && nonConfirmerModalOpen()) { - -
-

У вас нет активной подписки

-
- more image -

- Чтобы получить доступ ко всем возможностям платформы, оформите подписку прямо сейчас! -

- -
- Назад - - Купить -
-
-
-
-} diff --git a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.scss b/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.scss deleted file mode 100644 index 1d15cdaf9..000000000 --- a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.scss +++ /dev/null @@ -1,233 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.modal { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 10; - display: flex; - align-items: center; - justify-content: center; - - &__overlay { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - background-color: var(--black); - opacity: 0.4; - } - - &__body { - position: relative; - min-width: 345px; - padding: 24px 16px; - background-color: var(--light-gray); - border-radius: 15px; - - @include responsive.apply-desktop { - min-width: 830px; - padding: 24px 24px 62px; - } - } -} - -.plans { - position: relative; - max-height: calc(100vh - 40px); - padding: 10px 0; - overflow-y: auto; - - @include responsive.apply-desktop { - padding: 10px 60px; - } - - &__title { - margin-bottom: 5px; - text-align: center; - - @include typography.heading-4; - - @include responsive.apply-desktop { - margin-bottom: 3px; - - @include typography.heading-3; - } - } - - &__cross { - position: absolute; - top: 0; - right: 0; - width: 32px; - height: 32px; - cursor: pointer; - - @include responsive.apply-desktop { - top: 8px; - right: 8px; - } - } - - &__tariffs { - display: flex; - flex-direction: column; - gap: 20px; - margin-top: 20px; - } - - &_pagination { - display: flex; - flex-direction: column; - gap: 10px; - margin-top: 25px; - - @include responsive.apply-desktop { - display: flex; - align-items: center; - justify-content: space-between; - } - } - - &_pages { - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 25px; - - @include responsive.apply-desktop { - gap: 10px; - font-size: 20px; - font-weight: 600; - } - - &-start { - color: var(--dark-grey); - } - - &-icon { - width: 32px; - height: 32px; - cursor: pointer; - } - - &-prev { - color: var(--dark-grey); - } - - &-next { - transform: rotate(180deg); - } - } -} - -.cancel { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - max-height: calc(100vh - 40px); - padding: 40px 0; - overflow-y: auto; - - @include responsive.apply-desktop { - padding: 80px 0; - } - - &__cross { - position: absolute; - top: 0; - right: 0; - width: 32px; - height: 32px; - cursor: pointer; - - @include responsive.apply-desktop { - top: 8px; - right: 8px; - } - } - - &__title { - margin-bottom: 20px; - color: var(--black); - text-align: center; - inline-size: 280px; - - @include typography.heading-4; - - @include responsive.apply-desktop { - margin-bottom: 23px; - inline-size: 500px; - text-align: center; - - @include typography.heading-3; - } - } - - &__text { - width: 55%; - margin-bottom: 37px; - color: var(--grey-for-text); - text-align: center; - } - - &__buttons-group { - display: flex; - gap: 15px; - } - - &__confirm { - display: flex; - flex-direction: column; - align-items: center; - - @include responsive.apply-desktop { - align-items: center; - text-align: center; - } - } - - &__subtext { - width: 100%; - margin-bottom: 24px; - - @include responsive.apply-desktop { - width: 80%; - } - } -} - -.confirm { - z-index: 100; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 0; - text-align: center; - - @include responsive.apply-desktop { - padding: 20px 50px 24px; - } - - &__title { - margin-bottom: 20px; - } - - &__image { - width: 130px; - height: 125px; - margin-top: 24px; - margin-bottom: 18px; - - @include responsive.apply-desktop { - width: 100%; - height: 218px; - } - } -} diff --git a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.spec.ts b/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.spec.ts deleted file mode 100644 index 12000d1a4..000000000 --- a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { SkillChooserComponent } from "./skill-chooser.component"; - -describe("SkillChooserComponent", () => { - let component: SkillChooserComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SkillChooserComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(SkillChooserComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.ts b/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.ts deleted file mode 100644 index 9436d4308..000000000 --- a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** @format */ - -import { - Component, - inject, - Input, - signal, - EventEmitter, - Output, - OnInit, - computed, -} from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ButtonComponent } from "@ui/components"; -import { ActivatedRoute, RouterLink } from "@angular/router"; -import { IconComponent } from "@uilib"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { SkillService } from "../../../skills/services/skill.service"; -import { Skill } from "projects/skills/src/models/skill.model"; -import { PersonalSkillCardComponent } from "../personal-skill-card/personal-skill-card.component"; -import { ProfileService } from "../../services/profile.service"; -import { Skill as ProfileSkill } from "projects/skills/src/models/profile.model"; -import { HttpErrorResponse } from "@angular/common/http"; - -/** - * Компонент выбора навыков для месячной подписки - * - * Позволяет пользователю выбрать 5 навыков из доступного списка. - * Поддерживает пагинацию, валидацию выбора и обработку ошибок подписки. - * - * @component SkillChooserComponent - * @selector app-skill-chooser - * - * @input open - Флаг отображения модального окна выбора - * @output openChange - Событие изменения состояния модального окна - * - * @property limit - Количество навыков на странице (3) - * @property offset - Смещение для пагинации - * @property currentPage - Текущая страница - * @property totalPages - Общее количество страниц (вычисляемое) - * @property skillsList - Список доступных навыков - * @property profileIdSkills - Выбранные навыки пользователя - */ -@Component({ - selector: "app-skill-chooser", - standalone: true, - imports: [ - CommonModule, - ButtonComponent, - ModalComponent, - RouterLink, - IconComponent, - PersonalSkillCardComponent, - ], - templateUrl: "./skill-chooser.component.html", - styleUrl: "./skill-chooser.component.scss", -}) -export class SkillChooserComponent implements OnInit { - @Input() open!: boolean; - @Output() openChange: EventEmitter = new EventEmitter(); - - route = inject(ActivatedRoute); - skillService = inject(SkillService); - profileService = inject(ProfileService); - - limit = 3; - offset = 0; - currentPage = 1; - - totalPages = computed(() => Math.ceil(this.totalSkills() / this.limit)); - totalSkills = signal(0); - - skillsList = signal([]); - profileIdSkills = signal([]); - - isRetryPicked = signal(false); - nonConfirmerModalOpen = signal(false); - - selectedSkillsCount = signal(0); - - constructor() { - this.skillsList.set([]); - this.totalSkills.set(0); - } - - ngOnInit(): void { - this.loadSkills(); - - this.route.data.subscribe(r => { - this.profileIdSkills.set(r["data"].skills.map((skill: ProfileSkill) => skill.skillId)); - }); - } - - /** - * Загружает список навыков с сервера с учетом пагинации - * Обрабатывает ошибку 403 (нет подписки) - */ - private loadSkills(): void { - this.skillService.getAllMarked(this.limit, this.offset).subscribe({ - next: r => { - if (r.results && Array.isArray(r.results)) { - this.skillsList.set( - r.results.map(skill => ({ - ...skill, - isSelected: this.profileIdSkills().includes(skill.id), - })) - ); - this.totalSkills.set(r.count); - } - }, - error: err => { - if (err instanceof HttpErrorResponse) { - if (err.status === 403) { - this.nonConfirmerModalOpen.set(true); - } - } - }, - }); - } - - /** - * Обрабатывает изменение состояния модального окна - */ - onOpenChange(event: boolean) { - this.openChange.emit(event); - } - - /** - * Закрывает модальное окно и сохраняет выбранные навыки - */ - onCloseModal() { - this.openChange.emit(false); - this.profileService.addSkill(this.profileIdSkills()).subscribe(); - } - - /** - * Закрывает модальное окно подписки - */ - onSubscriptionModalClosed() { - this.nonConfirmerModalOpen.set(false); - this.open = false; - } - - /** - * Переходит к предыдущей странице навыков - */ - prevPage(): void { - if (this.currentPage > 1) { - this.currentPage -= 1; - this.offset = (this.currentPage - 1) * this.limit; - this.loadSkills(); - } - } - - /** - * Переходит к следующей странице навыков - */ - nextPage(): void { - if (this.currentPage < this.totalPages()) { - this.currentPage += 1; - this.offset = (this.currentPage - 1) * this.limit; - this.loadSkills(); - } - } - - /** - * Обрабатывает изменение количества выбранных навыков - */ - onSelectedCountChange(count: number): void { - this.selectedSkillsCount.set(count); - } -} diff --git a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.resolver.ts b/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.resolver.ts deleted file mode 100644 index 64687b169..000000000 --- a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.resolver.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { SkillService } from "../../../skills/services/skill.service"; - -/** - * Резолвер для предзагрузки данных навыков - * - * Загружает все навыки пользователя перед отображением компонента выбора. - * Используется в маршрутизации для обеспечения доступности данных. - * - * @returns {Observable} Поток данных с информацией о навыках пользователя - */ -export const skillChooserResolver = () => { - const skillsService = inject(SkillService); - return skillsService.getAll(); -}; diff --git a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.routes.ts b/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.routes.ts deleted file mode 100644 index 29128c154..000000000 --- a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.routes.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** @format */ - -import { SkillChooserComponent } from "./skill-chooser.component"; -import { skillChooserResolver } from "./skill-chooser.resolver"; - -export const SKILL_CHOOSER_ROUTES = [ - { - path: "", - component: SkillChooserComponent, - resolve: { - data: skillChooserResolver, - }, - }, -]; diff --git a/projects/skills/src/app/profile/shared/skills-block/skills-block.component.html b/projects/skills/src/app/profile/shared/skills-block/skills-block.component.html deleted file mode 100644 index 7a5d7bad8..000000000 --- a/projects/skills/src/app/profile/shared/skills-block/skills-block.component.html +++ /dev/null @@ -1,113 +0,0 @@ - - -
-
-

Навыки

- - {{ - tooltipText - }} -
- @for (skill of displayedSkills; track $index) { -
- -
- } @empty { - - -

начините проходить навыки, чтобы увидеть свой прогресс

- - } @if (displayedSkills.length) { -
-
- -

- {{ currentPage }}/{{ totalPages() }} -

- -
- Перейти -
- } -
-
- -
- - -
- - -

Выбор навыков

-

- Тебе предстоит выбрать 5 навыков, развитию которых будет посвящен месяц твоей подписки. Не - торопись с выбором. Отменить или поменять его будет нельзя! -

- Выбрать навыки месяца - wave -
-
- - -
-

У вас нет активной подписки

-
- more image -

- Чтобы получить доступ ко всем возможностям платформы, оформите подписку прямо сейчас! -

- -
- Назад - - Купить -
-
-
-
- - diff --git a/projects/skills/src/app/profile/shared/skills-block/skills-block.component.scss b/projects/skills/src/app/profile/shared/skills-block/skills-block.component.scss deleted file mode 100644 index da98c0598..000000000 --- a/projects/skills/src/app/profile/shared/skills-block/skills-block.component.scss +++ /dev/null @@ -1,254 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; - -.skills { - &__main { - position: relative; - min-height: 365px; - padding: 20px; - margin-bottom: 18px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - } - - &__title { - margin-bottom: 30px; - color: var(--black); - - @include typography.heading-4; - - @include responsive.apply-desktop { - margin-bottom: 12px; - - @include typography.heading-3; - } - } - - &__hint { - position: absolute; - top: 0; - right: 0; - z-index: 10; - display: inline-block; - width: 24px; - height: 24px; - cursor: pointer; - - &:hover { - opacity: 1; - } - - @include responsive.apply-desktop { - top: 8px; - right: 8px; - opacity: 0.5; - } - } - - &__tooltip { - position: absolute; - bottom: 98%; - left: 42%; - z-index: 100; - display: none; - width: 200px; - padding: 12px; - color: var(--dark-grey); - background-color: var(--white); - border: 1px solid var(--dark-grey); - border-radius: 15px 15px 0; - opacity: 0; - transition: opacity 0.3s ease-in-out; - - &--visible { - display: block; - opacity: 1; - } - - @include responsive.apply-desktop { - left: 96%; - border-radius: 15px 15px 15px 0; - } - } - - &__placeholder-text { - margin-bottom: 44px; - color: var(--dark-grey); - - @include typography.body-12; - - @include responsive.apply-desktop { - margin-bottom: 4px; - - @include typography.body-14; - } - } - - &__select { - display: block; - max-width: 315px; - } - - &_list { - display: flex; - flex-direction: column; - gap: 12px; - } - - &_pagination { - display: flex; - flex-direction: column; - gap: 10px; - margin-top: 25px; - - @include responsive.apply-desktop { - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - } - } - - &_pages { - display: flex; - align-items: center; - justify-content: center; - - @include responsive.apply-desktop { - gap: 10px; - font-size: 20px; - font-weight: 600; - } - - &-start { - color: var(--dark-grey); - } - - &-icon { - width: 32px; - height: 32px; - cursor: pointer; - } - - &-prev { - color: var(--dark-grey); - } - - &-next { - transform: rotate(180deg); - } - } -} - -.cancel { - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: center; - max-height: calc(100vh - 40px); - padding: 20px 0 50px; - overflow-y: auto; - - @include responsive.apply-desktop { - padding: 20px 0 60px; - } - - &__cross { - position: absolute; - top: 0; - right: 0; - width: 32px; - height: 32px; - cursor: pointer; - - @include responsive.apply-desktop { - top: 8px; - right: 8px; - } - } - - &__title { - margin-bottom: 20px; - color: var(--black); - inline-size: 280px; - - @include typography.heading-3; - - @include responsive.apply-desktop { - inline-size: 500px; - } - } - - &__text { - margin-bottom: 50px; - - @include responsive.apply-desktop { - margin-bottom: 30px; - } - } - - &__wave { - position: absolute; - bottom: -20%; - left: 0; - z-index: -10; - width: 100%; - opacity: 0.3; - } - - &__buttons-group { - display: flex; - gap: 15px; - } - - &__confirm { - display: flex; - flex-direction: column; - align-items: center; - - @include responsive.apply-desktop { - align-items: center; - text-align: center; - } - } - - &__subtext { - width: 100%; - margin-bottom: 24px; - - @include responsive.apply-desktop { - width: 80%; - } - } -} - -.confirm { - z-index: 100; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 0; - text-align: center; - - @include responsive.apply-desktop { - padding: 20px 50px 24px; - } - - &__title { - margin-bottom: 20px; - } - - &__image { - width: 130px; - height: 125px; - margin-top: 24px; - margin-bottom: 18px; - - @include responsive.apply-desktop { - width: 100%; - height: 218px; - } - } -} diff --git a/projects/skills/src/app/profile/shared/skills-block/skills-block.component.spec.ts b/projects/skills/src/app/profile/shared/skills-block/skills-block.component.spec.ts deleted file mode 100644 index e755da7c2..000000000 --- a/projects/skills/src/app/profile/shared/skills-block/skills-block.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { SkillsBlockComponent } from "./skills-block.component"; - -describe("SkillsBlockComponent", () => { - let component: SkillsBlockComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SkillsBlockComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(SkillsBlockComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/profile/shared/skills-block/skills-block.component.ts b/projects/skills/src/app/profile/shared/skills-block/skills-block.component.ts deleted file mode 100644 index 40c0644fc..000000000 --- a/projects/skills/src/app/profile/shared/skills-block/skills-block.component.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** @format */ - -import { Component, computed, inject, type OnInit, signal } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ButtonComponent } from "@ui/components"; -import { PersonalRatingCardComponent } from "../personal-rating-card/personal-rating-card.component"; -import { IconComponent } from "@uilib"; -import { Router, RouterLink } from "@angular/router"; -import { SkillChooserComponent } from "../skill-chooser/skill-chooser.component"; -import { ProfileService } from "../../services/profile.service"; -import type { Profile } from "projects/skills/src/models/profile.model"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { SkillService } from "../../../skills/services/skill.service"; - -/** - * Компонент блока навыков пользователя - * - * Отображает список выбранных навыков пользователя с пагинацией. - * Поддерживает навигацию к детальной странице навыка и выбор новых навыков. - * Обрабатывает ошибки доступа для пользователей без подписки. - * - * @component SkillsBlockComponent - * @selector app-skills-block - * - * @property openSkillChoose - Флаг модального окна выбора навыков - * @property openInstruction - Флаг модального окна инструкций - * @property skillsList - Полный список навыков пользователя - * @property displayedSkills - Навыки для отображения на текущей странице - * @property limit - Количество навыков на странице (2) - * @property currentPage - Текущая страница - * @property totalPages - Общее количество страниц (вычисляемое) - */ -@Component({ - selector: "app-skills-block", - standalone: true, - imports: [ - CommonModule, - ButtonComponent, - PersonalRatingCardComponent, - IconComponent, - RouterLink, - SkillChooserComponent, - ModalComponent, - ], - templateUrl: "./skills-block.component.html", - styleUrl: "./skills-block.component.scss", -}) -export class SkillsBlockComponent implements OnInit { - openSkillChoose = false; - openInstruction = false; - isHintVisible = false; - - profileService = inject(ProfileService); - skillService = inject(SkillService); - router = inject(Router); - - skillsList: Profile["skills"] = []; - displayedSkills: Profile["skills"] = []; - nonConfirmerModalOpen = signal(false); - - limit = 2; - offset = 0; - currentPage = 1; - totalPages = computed(() => Math.ceil(this.skillsList.length / this.limit)); - - tooltipText = "В данном блоке отображаются ваши навыки, которые вы выбрали в текущем месяце."; - - /** - * Показывает подсказку - */ - showTooltip() { - this.isHintVisible = true; - } - - /** - * Скрывает подсказку - */ - hideTooltip() { - this.isHintVisible = false; - } - - /** - * Обрабатывает изменение состояния модального окна выбора навыков - */ - onOpenSkillsChange(open: boolean) { - this.openSkillChoose = open; - } - - /** - * Обрабатывает изменение состояния модального окна инструкций - */ - onOpenInstructionChange(open: boolean) { - this.openSkillChoose = open; - } - - /** - * Переходит от инструкций к выбору навыков - */ - nextStepModal() { - this.openInstruction = false; - this.openSkillChoose = true; - } - - /** - * Переходит к предыдущей странице навыков - */ - prevPage(): void { - this.offset -= this.limit; - this.currentPage -= 1; - - if (this.offset < 0) { - this.offset = 0; - this.currentPage = 1; - } - - this.updateDisplayedSkills(); - } - - /** - * Переходит к следующей странице навыков - */ - nextPage(): void { - if (this.currentPage < this.totalPages()) { - this.currentPage += 1; - this.offset += this.limit; - this.updateDisplayedSkills(); - } - } - - /** - * Обновляет список отображаемых навыков на основе текущей страницы - */ - updateDisplayedSkills() { - this.displayedSkills = this.skillsList.slice(this.offset, this.offset + this.limit); - } - - /** - * Обрабатывает клик по навыку для перехода к детальной странице - * @param skillId - ID навыка - */ - onSkillClick(skillId: number) { - this.skillService.setSkillId(skillId); - this.router.navigate(["skills", skillId]).catch(err => { - if (err.status === 403) { - this.nonConfirmerModalOpen.set(true); - } - }); - } - - ngOnInit(): void { - this.profileService.getProfile().subscribe((r: Profile) => { - this.skillsList = r["skills"]; - this.updateDisplayedSkills(); - }); - } -} diff --git a/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.html b/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.html deleted file mode 100644 index c25637a1b..000000000 --- a/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.html +++ /dev/null @@ -1,25 +0,0 @@ - - -
- trajectory image - Начать -
- - -
- - -

У вас нет активной траектории!

-

Выберите нужную вам траекторию!

- Ок -
-
diff --git a/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.scss b/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.scss deleted file mode 100644 index 10ec062aa..000000000 --- a/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.scss +++ /dev/null @@ -1,97 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; - -.trajectory { - position: relative; - width: 100%; - height: 130px; - overflow: hidden; - white-space: nowrap; - cursor: pointer; - border-radius: 15px; - transition: ease 0.3s; - - // padding: 50px 50px 50px 18px; - // background-color: var(--white); - - &:hover { - box-shadow: 0 0 6px var(--gray); - } - - @include responsive.apply-desktop { - height: 100px; - } - - &__image { - height: 100%; - object-fit: cover; - - @include responsive.apply-desktop { - width: 100%; - height: auto; - object-fit: fill; - } - } - - ::ng-deep { - app-button { - position: absolute; - top: 17%; - right: 0%; - width: 30%; - - @include responsive.apply-desktop { - top: 30%; - right: 3%; - width: 20%; - } - } - } -} - -.cancel { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - max-height: calc(100vh - 40px); - padding: 20px 0 50px; - overflow-y: auto; - - @include responsive.apply-desktop { - padding: 20px 0 60px; - } - - &__cross { - position: absolute; - top: 0; - right: 0; - width: 32px; - height: 32px; - cursor: pointer; - - @include responsive.apply-desktop { - top: 8px; - right: 8px; - } - } - - &__title { - margin-top: 10px; - margin-bottom: 5px; - color: var(--black); - text-align: center; - inline-size: 280px; - - @include typography.heading-3; - - @include responsive.apply-desktop { - inline-size: 500px; - } - } - - &__text { - margin-bottom: 24px; - color: var(--grey-for-text); - } -} diff --git a/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.spec.ts b/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.spec.ts deleted file mode 100644 index 78343088f..000000000 --- a/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { TrajectoryBlockComponent } from "./trajectory-block.component"; - -describe("TrajectoryBlockComponent", () => { - let component: TrajectoryBlockComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TrajectoryBlockComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(TrajectoryBlockComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.ts b/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.ts deleted file mode 100644 index 202754b0f..000000000 --- a/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** @format */ - -import { Component, inject } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ButtonComponent } from "@ui/components"; -import { IconComponent } from "@uilib"; -import { Router } from "@angular/router"; -import { HttpErrorResponse } from "@angular/common/http"; -import { ModalComponent } from "@ui/components/modal/modal.component"; - -/** - * Компонент блока траектории обучения - * - * Отображает интерактивный блок для перехода к траектории обучения. - * Обрабатывает ошибки навигации и показывает модальные окна с уведомлениями. - * - * @component TrajectoryBlockComponent - * @selector app-trajectory-block - * - * @property isErrorTrajectoryModalOpen - Флаг отображения модального окна ошибки - */ -@Component({ - selector: "app-trajectory-block", - standalone: true, - imports: [CommonModule, ButtonComponent, IconComponent, ModalComponent], - templateUrl: "./trajectory-block.component.html", - styleUrl: "./trajectory-block.component.scss", -}) -export class TrajectoryBlockComponent { - private readonly router = inject(Router); - - isErrorTrajectoryModalOpen = false; - - /** - * Переключает состояние модального окна ошибки траектории - */ - onOpenErorTrajectoryModalChange = (): void => { - this.isErrorTrajectoryModalOpen = !this.isErrorTrajectoryModalOpen; - }; - - /** - * Выполняет навигацию к траектории обучения - * При ошибке 404 показывает модальное окно с уведомлением - */ - navigateToTrajectory = (): void => { - this.router.navigateByUrl("/trackCar/1").catch(err => { - if (err instanceof HttpErrorResponse) { - if (err.status === 404) { - this.isErrorTrajectoryModalOpen = true; - } - } - }); - }; -} diff --git a/projects/skills/src/app/profile/skills-rating/skills-rating.component.html b/projects/skills/src/app/profile/skills-rating/skills-rating.component.html deleted file mode 100644 index f143e4d4e..000000000 --- a/projects/skills/src/app/profile/skills-rating/skills-rating.component.html +++ /dev/null @@ -1,12 +0,0 @@ - - -
-

Навыки

-
- @for (skill of skillsList; track $index) { -
- -
- } -
-
diff --git a/projects/skills/src/app/profile/skills-rating/skills-rating.component.scss b/projects/skills/src/app/profile/skills-rating/skills-rating.component.scss deleted file mode 100644 index 9f4770753..000000000 --- a/projects/skills/src/app/profile/skills-rating/skills-rating.component.scss +++ /dev/null @@ -1,36 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.rating { - padding: 25px 15px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - - @include responsive.apply-desktop { - padding: 24px 18px; - } - - &__title { - margin-bottom: 24px; - color: var(--black); - - @include typography.heading-4; - - @include responsive.apply-desktop { - margin-bottom: 16px; - - @include typography.heading-3; - } - } - - &__list { - display: flex; - flex-direction: column; - gap: 10px; - - @include responsive.apply-desktop { - gap: 18px; - } - } -} diff --git a/projects/skills/src/app/profile/skills-rating/skills-rating.component.spec.ts b/projects/skills/src/app/profile/skills-rating/skills-rating.component.spec.ts deleted file mode 100644 index d428d0fa6..000000000 --- a/projects/skills/src/app/profile/skills-rating/skills-rating.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { SkillsRatingComponent } from "./skills-rating.component"; - -describe("SkillsRatingComponent", () => { - let component: SkillsRatingComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SkillsRatingComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(SkillsRatingComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/profile/skills-rating/skills-rating.component.ts b/projects/skills/src/app/profile/skills-rating/skills-rating.component.ts deleted file mode 100644 index 387e2dc64..000000000 --- a/projects/skills/src/app/profile/skills-rating/skills-rating.component.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** @format */ - -import { Component, inject, type OnInit } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { PersonalRatingCardComponent } from "../shared/personal-rating-card/personal-rating-card.component"; -import { Profile } from "projects/skills/src/models/profile.model"; -import { ProfileService } from "../services/profile.service"; -import { SkillService } from "../../skills/services/skill.service"; -import { RouterModule } from "@angular/router"; - -/** - * Компонент страницы рейтинга навыков пользователя - * - * Отображает полный список навыков пользователя с их рейтингами и прогрессом. - * Загружает данные профиля при инициализации и показывает карточки навыков. - * - * @component ProfileSkillsRatingComponent - * @selector app-skills-rating - * - * @property skillsList - Список навыков пользователя с рейтингами - */ -@Component({ - selector: "app-skills-rating", - standalone: true, - imports: [CommonModule, PersonalRatingCardComponent, RouterModule], - templateUrl: "./skills-rating.component.html", - styleUrl: "./skills-rating.component.scss", -}) -export class ProfileSkillsRatingComponent implements OnInit { - profileService = inject(ProfileService); - skillService = inject(SkillService); - - skillsList: Profile["skills"] = []; - - ngOnInit(): void { - this.profileService.getProfile().subscribe((r: Profile) => { - this.skillsList = r["skills"]; - }); - } -} diff --git a/projects/skills/src/app/profile/students/students.component.html b/projects/skills/src/app/profile/students/students.component.html deleted file mode 100644 index ec399b4dd..000000000 --- a/projects/skills/src/app/profile/students/students.component.html +++ /dev/null @@ -1,122 +0,0 @@ - -
- @if (students) { @for (student of students; track $index) { -
-
-
- -
-

- {{ student.student.firstName }} {{ student.student.lastName }} -

-

- {{ student.student.age }} - {{ student.student.age | pluralize: ["год", "года", "лет"] }} • - {{ student.student.specialization }} -

- - Чат с участником - -
-
- -
-

{{ student.trajectory.name }}

- trajectory image -
-
- -
- @if(expandedStudentId === student.student.id){ @if(showLoader()) { - - } @else { -
-
-
-
-
-

Стартовая встреча с наставником

-

Встреча прошла

-
- -
-
- -
-
-
-

Финальная встреча с наставником

-

Встреча прошла

-
- -
-
-
- -
-
-
-

- {{ student.remainingDays }} - {{ student.remainingDays | pluralize: ["день", "дня", "дней"] }} -

-

До конца траектории

-
- -
-

0

-

Количество баллов

-
-
- -
- Сохранить -
-
-
- } } - -
-

- {{ expandedStudentId === student.student.id ? "Скрыть" : "Посмотреть" }} статистику -

- @if (expandedStudentId === student.student.id) { - - } @else { - - } -
-
-
- } } -
diff --git a/projects/skills/src/app/profile/students/students.component.scss b/projects/skills/src/app/profile/students/students.component.scss deleted file mode 100644 index 688de9ece..000000000 --- a/projects/skills/src/app/profile/students/students.component.scss +++ /dev/null @@ -1,197 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.students { - position: relative; - z-index: 100; - display: flex; - flex-direction: column; - gap: 20px; - - &__container { - display: flex; - flex-direction: column; - gap: 20px; - align-items: flex-start; - justify-content: space-between; - padding: 24px 15px 48px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - - @include responsive.apply-desktop { - flex-direction: row; - align-items: center; - padding: 15px 22px; - } - } - - &__title { - @include typography.heading-4; - - @include responsive.apply-desktop { - @include typography.heading-2; - } - } - - &__loader { - display: flex; - align-items: center; - justify-content: center; - margin-top: 40px; - margin-right: 65px; - } - - &__info { - display: flex; - gap: 30px; - align-items: center; - } - - &__trajectory { - display: flex; - gap: 30px; - align-items: center; - } - - .trajectory__image { - width: 85px; - height: 80px; - - @include responsive.apply-desktop { - width: 90px; - height: 94px; - } - } - - &__content { - display: flex; - flex-direction: column; - gap: 5px; - } - - &__profile { - color: var(--dark-grey); - } - - &__stats { - position: relative; - top: auto; - left: 5%; - z-index: 10; - width: 90%; - height: 40px; - padding: 10px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - transition: height 0.3s ease; - - &--expanded { - height: 400px; - padding: 36px 24px; - } - - @include responsive.apply-desktop { - &--expanded { - height: 240px; - padding: 36px 24px; - } - - position: absolute; - top: 92%; - z-index: -10; - height: 50px; - padding: 10px; - } - } -} - -.stats { - &__container { - display: flex; - flex-direction: column; - gap: 10px; - align-items: flex-start; - - @include responsive.apply-desktop { - align-items: flex-end; - } - } - - &__expand { - position: absolute; - bottom: 5%; - left: 21%; - display: flex; - gap: 5px; - align-items: center; - cursor: pointer; - - &--expanded { - left: 31%; - } - - @include responsive.apply-desktop { - left: 41%; - } - } - - &__skills { - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - } - - &__checkbox { - display: flex; - flex-direction: row-reverse; - gap: 10px; - align-items: center; - - @include responsive.apply-desktop { - flex-direction: row; - } - } - - &__info { - text-align: start; - - @include responsive.apply-desktop { - text-align: end; - } - } - - &__bottom { - display: flex; - flex-direction: column; - gap: 20px; - align-items: center; - justify-content: space-between; - margin-top: 40px; - - @include responsive.apply-desktop { - flex-direction: row; - gap: 0; - } - } - - &__text-content { - display: flex; - gap: 10px; - align-items: center; - } - - &__point, - &__trajectories, - &__meeting { - color: var(--dark-grey); - } -} - -.icon { - &__unexpanded { - transform: rotate(180deg); - } -} diff --git a/projects/skills/src/app/profile/students/students.component.ts b/projects/skills/src/app/profile/students/students.component.ts deleted file mode 100644 index 587ea939e..000000000 --- a/projects/skills/src/app/profile/students/students.component.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { - Component, - HostListener, - inject, - type OnDestroy, - type OnInit, - signal, -} from "@angular/core"; -import { ActivatedRoute, RouterModule } from "@angular/router"; -import { PluralizePipe } from "@corelib"; -import { ButtonComponent, CheckboxComponent } from "@ui/components"; -import { AvatarComponent, IconComponent } from "@uilib"; -import { Student } from "projects/skills/src/models/trajectory.model"; -import { map, Subscription } from "rxjs"; -import { TrajectoriesService } from "../../trajectories/trajectories.service"; -import { FormBuilder, FormGroup, Validators } from "@angular/forms"; -import { LoaderComponent } from "@ui/components/loader/loader.component"; - -/** - * Компонент управления студентами для менторов - * - * Предоставляет интерфейс для менторов по управлению своими студентами: - * - Просмотр списка закрепленных студентов - * - Отметка о проведении начальных и финальных встреч - * - Адаптивное отображение для разных размеров экрана - * - Расширяемые карточки студентов с формой управления - * - * Компонент доступен только пользователям со статусом ментора - * и отображает студентов, назначенных конкретному ментору. - */ -@Component({ - selector: "app-students", - standalone: true, - imports: [ - CommonModule, - RouterModule, - PluralizePipe, - AvatarComponent, - ButtonComponent, - IconComponent, - LoaderComponent, - CheckboxComponent, - ], - templateUrl: "./students.component.html", - styleUrl: "./students.component.scss", -}) -export class ProfileStudentsComponent implements OnInit, OnDestroy { - constructor(private readonly fb: FormBuilder) { - // Инициализация формы для управления встречами - this.studentForm = this.fb.group({ - initialMeeting: [false, Validators.required], - finalMeeting: [false, Validators.required], - }); - } - - // Внедрение зависимостей - route = inject(ActivatedRoute); - trajectoryService = inject(TrajectoriesService); - - // URL заглушки для аватаров без изображения - placeholderUrl = - "https://uch-ibadan.org.ng/wp-content/uploads/2021/10/Profile_avatar_placeholder_large.png"; - - // Состояние UI - expanded = false; - avatarSize = signal(window.innerWidth > 1200 ? 94 : 48); - expandedStudentId: number | null = null; - showLoader = signal(true); - - // Данные студентов - students?: Student[]; - subscriptions: Subscription[] = []; - - // Форма для управления встречами - studentForm: FormGroup; - - /** - * Переключение развернутого состояния карточки студента - * - * @param studentId - идентификатор студента - * - * Если карточка уже развернута - сворачивает её, - * иначе разворачивает и загружает данные студента в форму - */ - toggleExpand(studentId: number) { - if (this.expandedStudentId === studentId) { - this.expandedStudentId = null; - } else { - this.expandedStudentId = studentId; - - // Имитация загрузки данных - setTimeout(() => { - this.showLoader.set(false); - }, 600); - - // Заполнение формы данными выбранного студента - const student = this.students?.find(s => s.student.id === studentId); - if (student) { - this.studentForm.patchValue({ - initialMeeting: student.initialMeeting, - finalMeeting: student.finalMeeting, - }); - } - } - } - - /** - * Обработчик изменения чекбоксов встреч - * - * @param key - тип встречи ('initialMeeting' или 'finalMeeting') - * @param value - новое значение чекбокса - */ - onSelect(key: string, value: boolean) { - if (key === "initialMeeting") { - this.studentForm.get("initialMeeting")?.setValue(value); - } else { - this.studentForm.get("finalMeeting")?.setValue(value); - } - } - - /** - * Инициализация компонента - * - * Загружает список студентов из резолвера маршрута - */ - ngOnInit(): void { - const students = this.route.data.pipe(map(r => r["students"])).subscribe((r: Student[]) => { - this.students = r; - }); - - this.subscriptions.push(students); - } - - /** - * Очистка ресурсов при уничтожении компонента - */ - ngOnDestroy(): void { - this.subscriptions.forEach(s => s.unsubscribe()); - } - - /** - * Сохранение изменений статуса встреч - * - * @param id - идентификатор встречи (meetingId) - * - * Отправляет обновленные данные о встречах на сервер - * и обновляет локальное состояние при успешном ответе - */ - onSave(id: number) { - if (this.studentForm.invalid) return; - - const { initialMeeting, finalMeeting } = this.studentForm.value; - this.trajectoryService.updateMeetings(id, initialMeeting, finalMeeting).subscribe(() => { - // Обновление локальных данных - const student = this.students?.find(s => s.meetingId === id); - if (student) { - student.initialMeeting = initialMeeting; - student.finalMeeting = finalMeeting; - } - this.expandedStudentId = null; - }); - } - - /** - * Обработчик изменения размера окна - * - * Адаптирует размер аватаров в зависимости от ширины экрана: - * - Большие экраны (>1200px): аватары 94px - * - Малые экраны (≤1200px): аватары 48px - * - * @param event - событие изменения размера окна - */ - @HostListener("window:resize", ["$event"]) - onResize(event: any) { - this.avatarSize.set(event.target.innerWidth > 1200 ? 94 : 48); - } -} diff --git a/projects/skills/src/app/profile/students/students.resolver.ts b/projects/skills/src/app/profile/students/students.resolver.ts deleted file mode 100644 index 4fd11e175..000000000 --- a/projects/skills/src/app/profile/students/students.resolver.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { TrajectoriesService } from "../../trajectories/trajectories.service"; - -/** - * Резолвер для загрузки списка студентов ментора - * - * Выполняется перед активацией маршрута студентов и предоставляет - * список всех студентов, закрепленных за текущим ментором. - * - * Доступен только пользователям со статусом ментора. - * - * @returns Observable - массив студентов с информацией о встречах - */ -export const studentsResolver = () => { - const trajectoryService = inject(TrajectoriesService); - return trajectoryService.getMentorStudents(); -}; diff --git a/projects/skills/src/app/rating/general/general.component.html b/projects/skills/src/app/rating/general/general.component.html deleted file mode 100644 index 29d5417d6..000000000 --- a/projects/skills/src/app/rating/general/general.component.html +++ /dev/null @@ -1,26 +0,0 @@ - - -
-
- @for (r of top3(); track $index) { - - } -
-
-
-
-

Рейтинг пользователей

-
- - - -
-
-
- @for (r of rest(); track $index) { - - } -
-
-
-
diff --git a/projects/skills/src/app/rating/general/general.component.scss b/projects/skills/src/app/rating/general/general.component.scss deleted file mode 100644 index d91aa62a2..000000000 --- a/projects/skills/src/app/rating/general/general.component.scss +++ /dev/null @@ -1,83 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.rating-page { - &__top3 { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 10px; - margin-bottom: 20px; - - & > :first-child { - grid-column: span 2; - } - - @for $i from 1 through 3 { - :nth-child(#{$i}) { - order: $i; - } - } - - @include responsive.apply-desktop { - grid-template-columns: repeat(3, 1fr); - gap: 24px; - - :first-child { - grid-column: span 1; - order: 3; - } - } - } - - &__rest { - padding: 15px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - - @include responsive.apply-desktop { - padding: 18px 24px; - } - } - - &__filter { - display: flex; - flex-direction: column; - align-items: flex-start; - - @include responsive.apply-desktop { - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - margin-bottom: 15px; - } - } - - &__form { - width: 40%; - - @include responsive.apply-desktop { - width: 20%; - } - } - - &__title { - margin-bottom: 20px; - text-align: center; - - @include typography.heading-4; - - @include responsive.apply-desktop { - text-align: left; - - @include typography.heading-3; - } - } - - &__basic { - display: flex; - flex-direction: column; - gap: 10px; - } -} diff --git a/projects/skills/src/app/rating/general/general.component.ts b/projects/skills/src/app/rating/general/general.component.ts deleted file mode 100644 index c0e50fe6e..000000000 --- a/projects/skills/src/app/rating/general/general.component.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** @format */ - -import { Component, inject, type OnInit, signal } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { TopRatingCardComponent } from "../shared/top-rating-card/top-rating-card.component"; -import { BasicRatingCardComponent } from "../shared/basic-rating-card/basic-rating-card.component"; -import { ActivatedRoute, Router } from "@angular/router"; -import { map, type Observable } from "rxjs"; -import type { GeneralRating } from "../../../models/rating.model"; -import { SelectComponent } from "@ui/components"; -import { IconComponent } from "@uilib"; -import { FormBuilder, type FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { RatingService } from "../services/rating.service"; -import { ratingFilters } from "projects/core/src/consts/filters/rating-filter.const"; - -/** - * Компонент общего рейтинга пользователей - * - * Отображает рейтинг всех пользователей системы с возможностью - * фильтрации по временным периодам (день, неделя, месяц, год). - * - * Функциональность: - * - Отображение топ-3 пользователей в специальном формате - * - Список остальных пользователей в стандартном формате - * - Фильтрация по временным периодам - * - Автоматическое обновление URL с параметрами фильтра - */ -@Component({ - selector: "app-general", - standalone: true, - imports: [ - CommonModule, - TopRatingCardComponent, - BasicRatingCardComponent, - SelectComponent, - IconComponent, - ReactiveFormsModule, - ], - templateUrl: "./general.component.html", - styleUrl: "./general.component.scss", -}) -export class RatingGeneralComponent implements OnInit { - constructor() { - // Инициализация формы с фильтром по умолчанию (последний месяц) - this.ratingForm = this.fb.group({ - filterParam: ["last_month"], - }); - } - - // Внедрение зависимостей - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly ratingService = inject(RatingService); - private readonly fb = inject(FormBuilder); - - // Поток данных рейтинга из резолвера - private readonly rating = this.route.data.pipe(map(r => r["data"])) as Observable< - GeneralRating[] - >; - - // Сигналы для разделения пользователей на топ-3 и остальных - top3 = signal([]); - rest = signal([]); - - // Форма для управления фильтрами - ratingForm: FormGroup; - - // Константы фильтров из конфигурации - readonly filterParams = ratingFilters; - - /** - * Инициализация компонента - * - * Загружает начальные данные рейтинга и настраивает - * отслеживание изменений формы фильтрации - */ - ngOnInit() { - this.loadInitialRatings(); - this.setupFormValueChanges(); - } - - /** - * Загрузка начальных данных рейтинга - * - * Получает данные из резолвера, разделяет их на топ-3 и остальных, - * и обновляет URL с текущим параметром фильтра - */ - loadInitialRatings() { - this.rating.subscribe(r => { - this.updateRatingSignals(r); - this.navigateWithFilterParam(this.ratingForm.get("filterParam")?.value); - }); - } - - /** - * Настройка отслеживания изменений формы - * - * При изменении фильтра автоматически обновляет URL - * и загружает новые данные рейтинга - */ - setupFormValueChanges() { - this.ratingForm.valueChanges.subscribe(value => { - this.navigateWithFilterParam(value.filterParam); - this.loadRatings(value.filterParam); - }); - } - - /** - * Обновление URL с параметром фильтра - * - * @param filterParam - выбранный временной период фильтрации - */ - navigateWithFilterParam(filterParam: string) { - this.router.navigate([], { - relativeTo: this.route, - queryParams: { filterParam }, - }); - } - - /** - * Загрузка данных рейтинга с указанным фильтром - * - * @param filterParam - временной период для фильтрации рейтинга - */ - loadRatings(filterParam: "last_month" | "last_year" | "last_day" | "last_week") { - this.ratingService.getGeneralRating(filterParam).subscribe(r => { - this.updateRatingSignals(r); - }); - } - - /** - * Обновление сигналов с данными рейтинга - * - * Разделяет массив пользователей на топ-3 (первые 3 места) - * и остальных участников рейтинга - * - * @param r - массив пользователей рейтинга - */ - updateRatingSignals(r: GeneralRating[]) { - this.top3.set(r.slice(0, 3)); - this.rest.set(r.slice(3)); - } -} diff --git a/projects/skills/src/app/rating/general/general.resolver.ts b/projects/skills/src/app/rating/general/general.resolver.ts deleted file mode 100644 index b4eda7834..000000000 --- a/projects/skills/src/app/rating/general/general.resolver.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** @format */ - -import type { ResolveFn } from "@angular/router"; -import { inject } from "@angular/core"; -import { RatingService } from "../services/rating.service"; -import type { GeneralRating } from "../../../models/rating.model"; - -/** - * Резолвер для загрузки данных общего рейтинга - * - * Выполняется перед активацией маршрута общего рейтинга - * и предоставляет начальные данные рейтинга с фильтром - * по умолчанию (последний месяц). - * - * Это обеспечивает мгновенное отображение данных - * при загрузке страницы рейтинга. - * - * @returns Observable - массив пользователей с рейтингом - */ -export const generalRatingResolver: ResolveFn = () => { - const ratingService = inject(RatingService); - return ratingService.getGeneralRating(); -}; diff --git a/projects/skills/src/app/rating/rating.routes.ts b/projects/skills/src/app/rating/rating.routes.ts deleted file mode 100644 index 26c4fcff4..000000000 --- a/projects/skills/src/app/rating/rating.routes.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { RatingGeneralComponent } from "./general/general.component"; -import { generalRatingResolver } from "./general/general.resolver"; - -/** - * Маршруты для модуля рейтинга - * - * Содержит: - * - Корневой маршрут ("") - отображает общий компонент рейтинга - * - Резолвер данных - предзагружает данные рейтинга перед отображением компонента - */ -export const RATING_ROUTES: Routes = [ - { - // Корневой маршрут модуля рейтинга - path: "", - component: RatingGeneralComponent, // Компонент для отображения общего рейтинга - resolve: { - data: generalRatingResolver, // Резолвер для предзагрузки данных рейтинга - }, - }, -]; diff --git a/projects/skills/src/app/rating/services/rating.service.spec.ts b/projects/skills/src/app/rating/services/rating.service.spec.ts deleted file mode 100644 index 9145c9356..000000000 --- a/projects/skills/src/app/rating/services/rating.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { RatingService } from "./rating.service"; - -describe("RatingService", () => { - let service: RatingService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(RatingService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/rating/services/rating.service.ts b/projects/skills/src/app/rating/services/rating.service.ts deleted file mode 100644 index 33ab96daa..000000000 --- a/projects/skills/src/app/rating/services/rating.service.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** @format */ - -import { inject, Injectable } from "@angular/core"; -import { SkillsApiService } from "@corelib"; -import { HttpParams } from "@angular/common/http"; -import { GeneralRating } from "../../../models/rating.model"; -import { map } from "rxjs"; -import { ApiPagination } from "../../../models/api-pagination.model"; - -/** - * Сервис для работы с рейтингами пользователей - * - * Предоставляет методы для получения различных типов рейтингов - * с поддержкой фильтрации по временным периодам. - * - * Взаимодействует с API для получения актуальных данных - * о достижениях и позициях пользователей в системе. - */ -@Injectable({ - providedIn: "root", -}) -export class RatingService { - private readonly PROGRESS_URL = "/progress"; - - private apiService = inject(SkillsApiService); - - /** - * Получение общего рейтинга пользователей - * - * Загружает рейтинг всех пользователей системы с возможностью - * фильтрации по различным временным периодам. - * - * @param ratingParam - временной период для расчета рейтинга: - * - 'last_day' - за последний день - * - 'last_week' - за последнюю неделю - * - 'last_month' - за последний месяц (по умолчанию) - * - 'last_year' - за последний год - * - * @returns Observable - массив пользователей с их рейтинговыми данными, - * отсортированный по убыванию количества баллов - */ - getGeneralRating( - ratingParam: "last_year" | "last_month" | "last_day" | "last_week" = "last_month" - ) { - return this.apiService - .get>( - `${this.PROGRESS_URL}/user-rating/`, - new HttpParams({ - fromObject: { - time_frame: ratingParam, - }, - }) - ) - .pipe(map(res => res.results)); - } -} diff --git a/projects/skills/src/app/rating/shared/basic-rating-card/basic-rating-card.component.html b/projects/skills/src/app/rating/shared/basic-rating-card/basic-rating-card.component.html deleted file mode 100644 index 788959d3f..000000000 --- a/projects/skills/src/app/rating/shared/basic-rating-card/basic-rating-card.component.html +++ /dev/null @@ -1,19 +0,0 @@ - - -
-
- -
-
{{ rating.userName }}
-

- {{ rating.specialization }} • {{ rating.age }} - {{ rating.age | pluralize: ["год", "года", "лет"] }} -

-
{{ rating.scoreCount }} очков
-
-
-
-
{{ rating.scoreCount }} очков
-
{{ ratingId }}
-
-
diff --git a/projects/skills/src/app/rating/shared/basic-rating-card/basic-rating-card.component.scss b/projects/skills/src/app/rating/shared/basic-rating-card/basic-rating-card.component.scss deleted file mode 100644 index d2ef116c2..000000000 --- a/projects/skills/src/app/rating/shared/basic-rating-card/basic-rating-card.component.scss +++ /dev/null @@ -1,68 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.rating-card { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 15px; - border: 1px solid var(--grey-button); - border-radius: 15px; - - @include responsive.apply-desktop { - padding: 20px; - } - - &__left { - display: flex; - gap: 16px; - align-items: center; - } - - &__name { - @include typography.body-12; - - @include responsive.apply-desktop { - @include typography.bold-body-16; - } - } - - &__info { - margin: 3px 0; - color: var(--accent); - } - - &__right { - display: flex; - gap: 40px; - align-items: center; - } - - &__score { - display: none; - color: var(--gray); - - @include responsive.apply-desktop { - display: block; - } - } - - &__score-mobile { - color: var(--gray); - - @include responsive.apply-desktop { - display: none; - } - } - - &__place { - display: flex; - align-items: center; - justify-content: center; - width: 38px; - height: 38px; - color: var(--gray); - border: 1px solid var(--gray); - border-radius: 50%; - } -} diff --git a/projects/skills/src/app/rating/shared/basic-rating-card/basic-rating-card.component.ts b/projects/skills/src/app/rating/shared/basic-rating-card/basic-rating-card.component.ts deleted file mode 100644 index e33050fda..000000000 --- a/projects/skills/src/app/rating/shared/basic-rating-card/basic-rating-card.component.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** @format */ - -import { Component, inject, Input } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { AvatarComponent } from "@uilib"; -import { GeneralRating } from "../../../../models/rating.model"; -import { PluralizePipe } from "@corelib"; -import { ActivatedRoute } from "@angular/router"; -import { map, Observable } from "rxjs"; - -/** - * Компонент базовой карточки рейтинга - * - * Описание: Отображает стандартную карточку участника рейтинга - * Использование: Для показа участников рейтинга вне топ-3 - * - * Входные параметры: - * @param rating - объект с данными рейтинга участника (обязательный) - * @param ratingId - идентификатор рейтинга - * - * Функциональность: - * - Получает данные рейтинга из маршрута через резолвер - * - Отображает информацию об участнике с аватаром - * - Использует pipe для плюрализации текста - * - * Зависимости: - * - AvatarComponent - для отображения аватара участника - * - PluralizePipe - для корректного склонения слов - * - GeneralRating - модель данных рейтинга - */ -@Component({ - selector: "app-basic-rating-card", - standalone: true, - imports: [CommonModule, AvatarComponent, PluralizePipe], - templateUrl: "./basic-rating-card.component.html", - styleUrl: "./basic-rating-card.component.scss", -}) -export class BasicRatingCardComponent { - /** - * Данные рейтинга участника - * Обязательное поле, содержит информацию об участнике и его показателях - */ - @Input({ required: true }) rating!: GeneralRating; - - /** - * Идентификатор рейтинга - * Используется для идентификации конкретного рейтинга - */ - @Input() ratingId!: number; - - /** - * Сервис для работы с активным маршрутом - * Инжектируется для получения данных из резолвера - */ - route = inject(ActivatedRoute); - - /** - * Observable с данными рейтинга - * Получает данные из резолвера маршрута и преобразует их в Observable - * Возвращает: поток данных с массивом рейтингов - */ - ratingData = this.route.data.pipe(map(r => r["data"])) as Observable; -} diff --git a/projects/skills/src/app/rating/shared/top-rating-card/top-rating-card.component.html b/projects/skills/src/app/rating/shared/top-rating-card/top-rating-card.component.html deleted file mode 100644 index 822b09e68..000000000 --- a/projects/skills/src/app/rating/shared/top-rating-card/top-rating-card.component.html +++ /dev/null @@ -1,13 +0,0 @@ - - -
-
- -
-

{{ rating.userName }}

-

{{ rating.scoreCount }} очков

-
{{ place }}
-
diff --git a/projects/skills/src/app/rating/shared/top-rating-card/top-rating-card.component.scss b/projects/skills/src/app/rating/shared/top-rating-card/top-rating-card.component.scss deleted file mode 100644 index e474a6aa8..000000000 --- a/projects/skills/src/app/rating/shared/top-rating-card/top-rating-card.component.scss +++ /dev/null @@ -1,88 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; - -.rating-card { - display: flex; - flex-direction: column; - align-items: center; - padding: 18px 0 22px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - - &--first { - background-image: url("/assets/images/rating/top-rating.png"); - background-repeat: no-repeat; - background-position: 0 50%; - background-size: cover; - - .rating-card__title { - color: var(--white); - } - - .rating-card__place { - color: var(--accent); - background-color: var(--white); - } - - .rating-card__score { - color: var(--gold); - } - } - - &--second { - border-color: var(--accent); - } - - &__avatar { - position: relative; - - &--first { - border: 4px solid var(--gold); - border-radius: 50%; - } - - img { - position: absolute; - top: -15px; - left: -25px; - z-index: 2; - width: 60px; - height: 60px; - } - } - - &__title { - margin: 24px 0 5px; - - @include typography.body-12; - - @include responsive.apply-desktop { - margin: 12px 0 5px; - - @include typography.body-bold-18; - } - } - - &__score { - margin-bottom: 5px; - color: var(--gray); - - @include typography.body-12; - - @include responsive.apply-desktop { - @include typography.body-18; - } - } - - &__place { - display: flex; - align-items: center; - justify-content: center; - width: 38px; - height: 38px; - color: var(--accent); - border: 1px solid var(--accent); - border-radius: 50%; - } -} diff --git a/projects/skills/src/app/rating/shared/top-rating-card/top-rating-card.component.spec.ts b/projects/skills/src/app/rating/shared/top-rating-card/top-rating-card.component.spec.ts deleted file mode 100644 index 5d28cf2f7..000000000 --- a/projects/skills/src/app/rating/shared/top-rating-card/top-rating-card.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { TopRatingCardComponent } from "./top-rating-card.component"; - -describe("TopRatingCardComponent", () => { - let component: TopRatingCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TopRatingCardComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(TopRatingCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/rating/shared/top-rating-card/top-rating-card.component.ts b/projects/skills/src/app/rating/shared/top-rating-card/top-rating-card.component.ts deleted file mode 100644 index 5325ff1b3..000000000 --- a/projects/skills/src/app/rating/shared/top-rating-card/top-rating-card.component.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** @format */ - -import { Component, Input } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { AvatarComponent } from "@uilib"; -import type { GeneralRating } from "../../../../models/rating.model"; - -/** - * Компонент карточки топ-рейтинга - * - * Описание: Отображает карточку участника с высоким рейтингом (топ-3) - * Использование: Для показа лидеров рейтинга с особым оформлением - * - * Входные параметры: - * @param place - позиция в рейтинге (по умолчанию 3) - * @param rating - объект с данными рейтинга участника (обязательный) - * - * Зависимости: - * - AvatarComponent - для отображения аватара участника - * - GeneralRating - модель данных рейтинга - */ -@Component({ - selector: "app-top-rating-card", - standalone: true, - imports: [CommonModule, AvatarComponent], - templateUrl: "./top-rating-card.component.html", - styleUrl: "./top-rating-card.component.scss", -}) -export class TopRatingCardComponent { - /** - * Позиция участника в рейтинге - * По умолчанию: 3 место - */ - @Input() place = 3; - - /** - * Данные рейтинга участника - * Обязательное поле, содержит информацию об участнике и его показателях - */ - @Input({ required: true }) rating!: GeneralRating; -} diff --git a/projects/skills/src/app/shared/circle-progress-bar/circle-progress-bar.component.html b/projects/skills/src/app/shared/circle-progress-bar/circle-progress-bar.component.html deleted file mode 100644 index 1568e4ae5..000000000 --- a/projects/skills/src/app/shared/circle-progress-bar/circle-progress-bar.component.html +++ /dev/null @@ -1,20 +0,0 @@ - - -
-
{{ progress }}%
-
- - - - -
-
diff --git a/projects/skills/src/app/shared/circle-progress-bar/circle-progress-bar.component.scss b/projects/skills/src/app/shared/circle-progress-bar/circle-progress-bar.component.scss deleted file mode 100644 index 8aadf4ddd..000000000 --- a/projects/skills/src/app/shared/circle-progress-bar/circle-progress-bar.component.scss +++ /dev/null @@ -1,34 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; - -.progress-bar { - position: relative; - - &__text { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - - @include typography.bold-body-14; - - @include responsive.apply-desktop { - @include typography.heading-4; - } - } -} - -.track, -.filled { - fill: none; - stroke-width: 17; -} - -.track { - stroke: var(--accent-light); -} - -.filled { - stroke: var(--accent); - transition: all 0.2s; -} diff --git a/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.html b/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.html index 37c6c2922..977e5ddc4 100644 --- a/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.html +++ b/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.html @@ -17,7 +17,7 @@
-
{{ user()?.firstName }} {{ user()?.lastName }}
+
{{ user()?.firstName }} {{ user()?.lastName }}
- diff --git a/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.ts b/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.ts index 21cb02ae6..9a88135b4 100644 --- a/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.ts +++ b/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.ts @@ -6,7 +6,6 @@ import { AvatarComponent, IconComponent } from "@uilib"; import { DayjsPipe } from "@corelib"; import { RouterLink } from "@angular/router"; import type { UserData } from "projects/skills/src/models/profile.model"; -import { ProfileService } from "../../profile/services/profile.service"; /** * Компонент профиля пользователя в боковой панели @@ -37,12 +36,6 @@ export class SidebarProfileComponent implements OnInit { */ user = signal(null); - /** - * Сервис для работы с профилем пользователя - * Инжектируется автоматически через DI контейнер Angular - */ - profileService = inject(ProfileService); - /** * Инициализация компонента * @@ -51,13 +44,5 @@ export class SidebarProfileComponent implements OnInit { * * @returns void */ - ngOnInit(): void { - this.profileService.getUserData().subscribe({ - next: data => this.user.set(data as UserData), - error: () => { - // Перенаправление на страницу авторизации при ошибке загрузки данных - location.href = "https://app.procollab.ru/auth/login"; - }, - }); - } + ngOnInit(): void {} } diff --git a/projects/skills/src/app/shared/task-card/task-card.component.ts b/projects/skills/src/app/shared/task-card/task-card.component.ts index c952630d2..22ffa9144 100644 --- a/projects/skills/src/app/shared/task-card/task-card.component.ts +++ b/projects/skills/src/app/shared/task-card/task-card.component.ts @@ -4,7 +4,10 @@ import { Component, Input } from "@angular/core"; import { CommonModule } from "@angular/common"; import { ButtonComponent } from "@ui/components"; import { IconComponent } from "@uilib"; -import type { Task, TasksResponse } from "../../../models/skill.model"; +import type { + Task, + TasksResponse, +} from "../../../../../social_platform/src/app/office/models/skill.model"; /** * Компонент карточки задачи diff --git a/projects/skills/src/app/skills/detail/detail.component.html b/projects/skills/src/app/skills/detail/detail.component.html deleted file mode 100644 index 4ea264fa3..000000000 --- a/projects/skills/src/app/skills/detail/detail.component.html +++ /dev/null @@ -1,46 +0,0 @@ - - -@if (data(); as routeData) { -
-
-

{{ routeData[1].name }}

- -

{{ routeData[1].description }}

-
{{ routeData[1].quantityOfLevels }} уровень
-
-
-

Ваш прогресс

- - @if (doneWeeks().length) { -
- Неделя {{ doneWeeks()[doneWeeks().length - 1].week }} - - {{ - doneWeeks()[doneWeeks().length - 1].doneOnTime - ? "пройдена вовремя" - : "Пройдена не вовремя" - }} - -
- } -
- @for (t of tasksData()?.tasks; track $index) { - - } -
-
-
-} diff --git a/projects/skills/src/app/skills/detail/detail.component.scss b/projects/skills/src/app/skills/detail/detail.component.scss deleted file mode 100644 index 3ad866f7e..000000000 --- a/projects/skills/src/app/skills/detail/detail.component.scss +++ /dev/null @@ -1,107 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; - -.skill { - display: grid; - grid-template-columns: 1fr; - gap: 40px; - height: 100%; - - @include responsive.apply-desktop { - grid-template-columns: 1fr 1fr; - gap: 10px; - } - - &__block { - padding: 20px 15px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - - @include responsive.apply-desktop { - padding: 30px; - } - } - - &__title { - margin-bottom: 30px; - color: var(--black); - text-align: center; - - @include typography.heading-2; - } - - &__week { - margin: 10px 0; - } -} - -.info { - display: flex; - flex-direction: column; - - &__img { - width: 240px; - height: 240px; - margin: 0 auto 30px; - border-radius: 50%; - object-fit: cover; - } - - &__text { - flex-shrink: 1; - overflow: auto; - white-space: pre-wrap; - - @include typography.body-14; - } - - &__level { - display: none; - padding: 24px 0; - margin-top: 45px; - text-align: center; - border: 1px solid var(--accent); - border-radius: 10px; - - @include typography.heading-4; - } -} - -.progress { - display: flex; - flex-direction: column; - height: 100%; - - &__bar { - display: block; - width: 180px; - height: 180px; - margin: 0 auto 45px; - - @include responsive.apply-desktop { - width: 240px; - height: 240px; - margin-bottom: 20px; - } - } - - &__list { - display: flex; - flex-direction: column; - gap: 20px; - height: 100%; - overflow-y: auto; - } -} - -.week-stat { - &__passed { - color: var(--green); - text-decoration: underline; - - &--passed { - color: var(--red); - } - } -} diff --git a/projects/skills/src/app/skills/detail/detail.component.ts b/projects/skills/src/app/skills/detail/detail.component.ts deleted file mode 100644 index bcb002da5..000000000 --- a/projects/skills/src/app/skills/detail/detail.component.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { - AfterViewInit, - Component, - computed, - ElementRef, - inject, - OnInit, - signal, -} from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { map } from "rxjs"; -import { CircleProgressBarComponent } from "../../shared/circle-progress-bar/circle-progress-bar.component"; -import { TaskCardComponent } from "../../shared/task-card/task-card.component"; -import { SkillDetailResolve } from "./detail.resolver"; - -/** - * Компонент детальной страницы навыка - * - * Отображает подробную информацию о навыке, прогресс пользователя, - * статистику по неделям и список задач - * - * Принимает данные через резолвер маршрута: - * - Информацию о задачах и статистике пользователя - * - Детальную информацию о навыке - * - * Функциональность: - * - Отображение прогресса в виде круговой диаграммы - * - Навигация к задачам по клику - * - Адаптивная высота блоков - * - Фильтрация выполненных недель - */ -@Component({ - selector: "app-detail", - standalone: true, - imports: [CommonModule, TaskCardComponent, CircleProgressBarComponent], - templateUrl: "./detail.component.html", - styleUrl: "./detail.component.scss", -}) -export class SkillDetailComponent implements OnInit, AfterViewInit { - protected readonly Array = Array; - - router = inject(Router); - route = inject(ActivatedRoute); - elementRef = inject(ElementRef); - - ngOnInit(): void { - this.route.data.pipe(map(r => r["data"])).subscribe(data => { - this.data.set(data); - this.tasksData.set(data[0]); - }); - } - - blockHeight = signal(0); - - data = signal(null); - tasksData = signal(null); - - doneWeeks = computed(() => { - const data = this.data(); - if (!data) return []; - - return data[0].statsOfWeeks.filter(s => s.isDone); - }); - - ngAfterViewInit() { - this.blockHeight.set(this.elementRef.nativeElement.getBoundingClientRect().height); - } -} diff --git a/projects/skills/src/app/skills/detail/detail.resolver.ts b/projects/skills/src/app/skills/detail/detail.resolver.ts deleted file mode 100644 index b7e2e9988..000000000 --- a/projects/skills/src/app/skills/detail/detail.resolver.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** @format */ - -import { ResolveFn } from "@angular/router"; -import { inject } from "@angular/core"; -import { SkillService } from "../services/skill.service"; -import { forkJoin } from "rxjs"; -import { TasksResponse, Skill } from "projects/skills/src/models/skill.model"; - -/** - * Резолвер для детальной страницы навыка - * - * Загружает данные перед отображением компонента: - * - Задачи и статистику пользователя по навыку - * - Детальную информацию о навыке - * - * @param route - Активный маршрут с параметром skillId - * @returns {Observable} Массив с данными задач и информацией о навыке - */ -export type SkillDetailResolve = [TasksResponse, Skill]; -export const skillDetailResolver: ResolveFn = route => { - const skillService = inject(SkillService); - - const skillId = route.params["skillId"]; - return forkJoin([skillService.getTasks(skillId), skillService.getDetail(skillId)]); -}; diff --git a/projects/skills/src/app/skills/detail/detail.routes.ts b/projects/skills/src/app/skills/detail/detail.routes.ts deleted file mode 100644 index 892ac13d1..000000000 --- a/projects/skills/src/app/skills/detail/detail.routes.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** @format */ - -import type { Routes } from "@angular/router"; -import { SkillDetailComponent } from "./detail.component"; -import { skillDetailResolver } from "./detail.resolver"; - -/** - * Маршруты для детальной страницы навыка - * - * Определяет единственный маршрут для отображения детальной информации о навыке - * с резолвером для предварительной загрузки данных - * - * @returns {Routes} Конфигурация маршрута для детальной страницы - */ -export const DETAIL_ROUTES: Routes = [ - { - path: "", - component: SkillDetailComponent, - resolve: { - data: skillDetailResolver, - }, - }, -]; diff --git a/projects/skills/src/app/skills/list/list.component.html b/projects/skills/src/app/skills/list/list.component.html deleted file mode 100644 index a47032003..000000000 --- a/projects/skills/src/app/skills/list/list.component.html +++ /dev/null @@ -1,66 +0,0 @@ - - -
- - - @if (skills()) { -
- @for (s of skills(); track $index) { - - } -
- } -
- - -
-

- {{ isFromTrajectoryModal() ? "Этот навык из траектории" : "У вас нет активной подписки" }} -

-
- more image -

- {{ - isFromTrajectoryModal() - ? "Чтобы получить доступ к этому навыку выберите нужную траекторию или завершите выбранную ранее траекторию!" - : "Чтобы получить доступ ко всем возможностям платформы, оформите подписку прямо сейчас!" - }} -

- -
- Назад - - {{ isFromTrajectoryModal() ? "К траекториям" : "Купить" }} -
-
-
-
diff --git a/projects/skills/src/app/skills/list/list.component.scss b/projects/skills/src/app/skills/list/list.component.scss deleted file mode 100644 index b0c070173..000000000 --- a/projects/skills/src/app/skills/list/list.component.scss +++ /dev/null @@ -1,123 +0,0 @@ -@use "styles/responsive"; - -.list { - &__search { - margin: 20px 0; - } - - &__items { - display: grid; - gap: 10px; - - @include responsive.apply-desktop { - grid-template-columns: repeat(3, 1fr); - gap: 18px; - } - } -} - -.search { - display: flex; - flex-direction: column; - gap: 10px; - - @include responsive.apply-desktop { - flex-direction: row; - gap: 18px; - } - - &__control { - position: relative; - flex-grow: 1; - } - - &__input { - width: 100%; - padding: 15px 15px 15px 60px; - border: 1px solid var(--grey-button); - border-radius: 8px; - } - - &__icon { - position: absolute; - top: 50%; - left: 8px; - transform: translateY(-50%); - } - - &__button { - min-width: 315px; - } -} - -.cancel { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 340px; - max-height: calc(100vh - 40px); - padding: 24px 20px 20px 24px; - overflow-y: auto; - - @include responsive.apply-desktop { - width: 800px; - padding: 52px 50px 24px 42px; - } - - &__subtext { - width: 100%; - margin-bottom: 24px; - - @include responsive.apply-desktop { - width: 60%; - } - } - - &__buttons-group { - display: flex; - gap: 15px; - } - - &__confirm { - display: flex; - flex-direction: column; - align-items: center; - - @include responsive.apply-desktop { - align-items: center; - text-align: center; - } - } -} - -.confirm { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 340px; - padding: 0; - text-align: center; - - @include responsive.apply-desktop { - width: 750px; - padding: 20px 50px 24px; - } - - &__title { - margin-bottom: 20px; - } - - &__image { - width: 130px; - height: 125px; - margin-top: 24px; - margin-bottom: 18px; - - @include responsive.apply-desktop { - width: 100%; - height: 218px; - } - } -} diff --git a/projects/skills/src/app/skills/list/list.component.ts b/projects/skills/src/app/skills/list/list.component.ts deleted file mode 100644 index 234c2c4f5..000000000 --- a/projects/skills/src/app/skills/list/list.component.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** @format */ - -import { Component, inject, OnDestroy, OnInit, signal } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { IconComponent } from "@uilib"; -import { ActivatedRoute, Router, RouterLink, RouterModule } from "@angular/router"; -import { BarComponent, ButtonComponent } from "@ui/components"; -import { SkillCardComponent } from "../shared/skill-card/skill-card.component"; -import { map, Subscription } from "rxjs"; -import { Skill } from "../../../models/skill.model"; -import { SkillService } from "../services/skill.service"; -import { ProfileService } from "../../profile/services/profile.service"; -import { SubscriptionData } from "@corelib"; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { ModalComponent } from "@ui/components/modal/modal.component"; - -/** - * Компонент списка навыков - * - * Отображает список всех доступных навыков с возможностью поиска - * и переходом к детальной странице - * - * Функциональность: - * - Загрузка и отображение списка навыков - * - Поиск навыков по названию - * - Проверка типа подписки пользователя - * - Обработка ограничений доступа к навыкам - * - Отображение модальных окон для пользователей без доступа - * - * Принимает данные через резолвер маршрута: - * - Список навыков с пагинацией - * - * Управляет состоянием: - * - Отфильтрованный список навыков - * - Состояние модальных окон - * - Форма поиска - */ -@Component({ - selector: "app-list", - standalone: true, - imports: [ - CommonModule, - IconComponent, - ButtonComponent, - ModalComponent, - SkillCardComponent, - BarComponent, - ReactiveFormsModule, - RouterLink, - RouterModule, - ], - templateUrl: "./list.component.html", - styleUrl: "./list.component.scss", -}) -export class SkillsListComponent implements OnInit, OnDestroy { - constructor(private readonly fb: FormBuilder) { - this.searchForm = this.fb.group({ - search: ["", [Validators.required]], - }); - } - - searchForm: FormGroup; - - router = inject(Router); - route = inject(ActivatedRoute); - subscriptions: Subscription[] = []; - - private readonly skillService = inject(SkillService); - private readonly profileService = inject(ProfileService); - - skills = signal([]); - originalSkills = signal([]); - subscriptionType = signal(null); - - nonConfirmerModalOpen = signal(false); - isFromTrajectoryModal = signal(false); - - ngOnInit(): void { - const profileSub = this.profileService.getSubscriptionData().subscribe(r => { - this.subscriptionType.set(r.lastSubscriptionType); - }); - - const skillsSub = this.route.data.pipe(map(r => r["data"])).subscribe(({ results }) => { - this.skills.set(results); - this.originalSkills.set(results); - }); - - this.subscriptions.push(profileSub); - this.subscriptions.push(skillsSub); - } - - ngOnDestroy(): void { - this.subscriptions.forEach(sub => sub.unsubscribe()); - } - - onSearchClick() { - const searchTerm = this.searchForm.get("search")?.value?.trim().toLowerCase(); - - if (!searchTerm) { - this.skills.set(this.originalSkills()); - return; - } - - const filteredSkills = this.originalSkills().filter(skill => - skill.name.toLowerCase().includes(searchTerm) - ); - - this.skills.set(filteredSkills); - } - - onSkillClick(skillId: number, isFromTrajectory: boolean) { - this.skillService.setSkillId(skillId); - this.router.navigate(["skills", skillId]).catch(err => { - if (err.status === 403) { - this.isFromTrajectoryModal.set(isFromTrajectory); - this.nonConfirmerModalOpen.set(true); - } - }); - } -} diff --git a/projects/skills/src/app/skills/list/list.resolver.spec.ts b/projects/skills/src/app/skills/list/list.resolver.spec.ts deleted file mode 100644 index 20a463a6f..000000000 --- a/projects/skills/src/app/skills/list/list.resolver.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; -import { ResolveFn } from "@angular/router"; - -import { skillsListResolver } from "./list.resolver"; - -describe("listResolver", () => { - const executeResolver: ResolveFn = (...resolverParameters) => - TestBed.runInInjectionContext(() => skillsListResolver(...resolverParameters)); - - beforeEach(() => { - TestBed.configureTestingModule({}); - }); - - it("should be created", () => { - expect(executeResolver).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/skills/list/list.resolver.ts b/projects/skills/src/app/skills/list/list.resolver.ts deleted file mode 100644 index a3e55e9c8..000000000 --- a/projects/skills/src/app/skills/list/list.resolver.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** @format */ - -import type { ResolveFn } from "@angular/router"; -import { inject } from "@angular/core"; -import { SkillService } from "../services/skill.service"; - -/** - * Резолвер для списка навыков - * - * Загружает список всех доступных навыков перед отображением компонента - * - * @returns {Observable} Данные со списком навыков - */ -export const skillsListResolver: ResolveFn = () => { - const skillService = inject(SkillService); - return skillService.getAll(); -}; diff --git a/projects/skills/src/app/skills/services/skill.service.spec.ts b/projects/skills/src/app/skills/services/skill.service.spec.ts deleted file mode 100644 index 262ff38f1..000000000 --- a/projects/skills/src/app/skills/services/skill.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { SkillService } from "./skill.service"; - -describe("SkillService", () => { - let service: SkillService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(SkillService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/skills/services/skill.service.ts b/projects/skills/src/app/skills/services/skill.service.ts index 611900ac9..9c2d23dd6 100644 --- a/projects/skills/src/app/skills/services/skill.service.ts +++ b/projects/skills/src/app/skills/services/skill.service.ts @@ -3,7 +3,10 @@ import { inject, Injectable } from "@angular/core"; import { SkillsApiService } from "@corelib"; import { ApiPagination } from "../../../models/api-pagination.model"; -import { Skill, TasksResponse } from "../../../models/skill.model"; +import { + Skill, + TasksResponse, +} from "../../../../../social_platform/src/app/office/models/skill.model"; import { HttpParams } from "@angular/common/http"; /** diff --git a/projects/skills/src/app/skills/shared/skill-card/skill-card.component.html b/projects/skills/src/app/skills/shared/skill-card/skill-card.component.html deleted file mode 100644 index c80d7e064..000000000 --- a/projects/skills/src/app/skills/shared/skill-card/skill-card.component.html +++ /dev/null @@ -1,36 +0,0 @@ - - -
-
-
-

- {{ skill.name }} -

-
{{ skill.whoCreated }}
-
- -
-

- {{ skill.description }} -

-
-
- {{ skill.quantityOfLevels }} - {{ skill.quantityOfLevels | pluralize: ["уровень", "уровня", "уровней"] }} -
- - @if (skill.requiresSubscription) { -
Доступно в подписке
- } @if (skill.overdue) { -
Просроченный навык
- } @if(skill.isDone) { -
Выполненный
- } -
-
diff --git a/projects/skills/src/app/skills/shared/skill-card/skill-card.component.scss b/projects/skills/src/app/skills/shared/skill-card/skill-card.component.scss deleted file mode 100644 index 3cbb98f1f..000000000 --- a/projects/skills/src/app/skills/shared/skill-card/skill-card.component.scss +++ /dev/null @@ -1,74 +0,0 @@ -@use "styles/responsive"; - -.skill-card { - display: flex; - flex-direction: column; - gap: 16px; - height: 220px; - padding: 20px; - margin-bottom: 20px; - cursor: pointer; - border: 1px solid var(--grey-button); - border-radius: 15px; - transition: all 0.3s; - - &:hover { - box-shadow: 0 0 6px var(--gray); - } - - @include responsive.apply-desktop { - margin-bottom: 0; - } - - &__top { - display: flex; - align-items: center; - justify-content: space-between; - } - - &__partner { - color: var(--black); - } - - &__text { - display: box; - height: 75px; - overflow: hidden; - color: var(--dark-grey); - text-overflow: ellipsis; - -webkit-line-clamp: 5; - -webkit-box-orient: vertical; - } - - &__level { - display: none; - color: var(--accent); - } - - &__bottom { - display: flex; - align-items: center; - justify-content: space-between; - } - - &__price, - &__lost, - &__complete { - padding: 5px; - color: var(--accent); - border: 1px solid var(--accent); - border-radius: 15px; - } - - &__lost { - padding: 5px 40px; - color: var(--red); - border: 1px solid var(--red); - } - - &__complete { - padding: 5px 40px; - color: var(--green); - border: 1px solid var(--green); - } -} diff --git a/projects/skills/src/app/skills/skills.routes.ts b/projects/skills/src/app/skills/skills.routes.ts deleted file mode 100644 index ea33dcf7d..000000000 --- a/projects/skills/src/app/skills/skills.routes.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { SkillsListComponent } from "./list/list.component"; -import { skillsListResolver } from "./list/list.resolver"; - -/** - * Конфигурация маршрутов для модуля навыков - * - * Определяет основные маршруты: - * - '' - список всех навыков с резолвером для загрузки данных - * - ':skillId' - детальная страница навыка с ленивой загрузкой дочерних маршрутов - * - * @returns {Routes} Массив конфигураций маршрутов для навыков - */ -export const SKILLS_ROUTES: Routes = [ - { path: "", component: SkillsListComponent, resolve: { data: skillsListResolver } }, - { - path: ":skillId", - loadChildren: () => import("./detail/detail.routes").then(m => m.DETAIL_ROUTES), - }, -]; diff --git a/projects/skills/src/app/subscription/service/subscription.service.ts b/projects/skills/src/app/subscription/service/subscription.service.ts deleted file mode 100644 index 8bd650c14..000000000 --- a/projects/skills/src/app/subscription/service/subscription.service.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** @format */ - -import { Injectable, inject } from "@angular/core"; -import { SkillsApiService, SubscriptionPlan } from "@corelib"; - -/** - * Сервис для работы с API подписок. Предоставляет методы для - * получения информации о доступных планах подписки. - */ -@Injectable({ - providedIn: "root", -}) -export class SubscriptionService { - private readonly SUBSCRIPTION_URL = "/subscription"; - - apiService = inject(SkillsApiService); - - /** - * Получение списка всех доступных планов подписки - * @returns Observable - массив планов подписки - * - * Ошибки обрабатываются на уровне компонентов - * Пример использования: this.subscriptionService.getSubscriptions(); - */ - getSubscriptions() { - return this.apiService.get(`${this.SUBSCRIPTION_URL}/`); - } -} diff --git a/projects/skills/src/app/subscription/subscription.component.html b/projects/skills/src/app/subscription/subscription.component.html deleted file mode 100644 index fd05d7d25..000000000 --- a/projects/skills/src/app/subscription/subscription.component.html +++ /dev/null @@ -1,126 +0,0 @@ - - - - -
- -
- -

Ты действительно хочешь отменить подписку?

- - Отменить - - stars -
-
- - - - -
- -

Включим автопродление подписки?

- Включить - wave -
-
- - -
- -

У вас уже есть активная подписка!

- wave -
-
- -
-
Виды подписок
- -
-
Включить автопродление подписки
- -
-
- -
-
- @for (subscription of subscriptions(); track subscription.id) { -
-
-
{{ subscription.name }}
-
₽ {{ subscription.price }}
-
- -
    - @for (feature of subscription.featuresList; track $index) { -
  • - - {{ feature }} -
  • - } -
- - - - Купить -
- } -
-
- - @if(subscriptionData()?.lastSubscriptionType !== null) { - - Отменить подписку - - } -
diff --git a/projects/skills/src/app/subscription/subscription.component.scss b/projects/skills/src/app/subscription/subscription.component.scss deleted file mode 100644 index 0deb8a78c..000000000 --- a/projects/skills/src/app/subscription/subscription.component.scss +++ /dev/null @@ -1,221 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.subscription { - display: flex; - flex-direction: column; - gap: 20px; - margin-bottom: 28px; - - &__types { - display: flex; - flex-direction: column; - width: 100%; - padding: 24px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - - @include responsive.apply-desktop { - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - } - } - - &__title { - @include typography.heading-3; - - @include responsive.apply-desktop { - margin-bottom: 3px; - - @include typography.heading-3; - } - } - - &__switch { - display: flex; - gap: 12px; - align-items: center; - justify-content: space-between; - margin-top: 15px; - - @include responsive.apply-desktop { - margin-top: 0; - } - } - - &__text { - @include typography.body-12; - - color: var(--grey-for-text); - } -} - -.plans { - &__title { - margin-bottom: 5px; - text-align: center; - - @include typography.heading-4; - - @include responsive.apply-desktop { - margin-bottom: 3px; - - @include typography.heading-3; - } - } - - &__tariffs { - display: grid; - grid-template-rows: 1fr; - grid-template-columns: 1fr; - grid-gap: 12px; - - @include responsive.apply-desktop { - grid-template-columns: repeat(2, 1fr); - margin-top: 16px; - } - } -} - -.tariff { - position: relative; - display: flex; - flex-direction: column; - gap: 12px; - width: 100%; - padding: 24px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - - >div:first-child { - flex-grow: 1; - } - - &--primary { - background-color: var(--accent); - - .tariff__title { - color: var(--gold-dark); - } - - .tariff__price, - .tariff__point, - .tariff__icon { - color: var(--white); - } - } - - &__title { - @include typography.heading-1; - } - - &__price { - margin-bottom: 24px; - color: var(--grey-for-text); - - @include typography.heading-1; - } - - &__points { - display: flex; - flex-direction: column; - gap: 16px; - margin-bottom: 24px; - - @include responsive.apply-desktop { - display: grid; - grid-template-columns: 1fr; - gap: 20px; - } - } - - &__point { - display: flex; - gap: 12px; - align-items: center; - color: var(--grey-for-text); - - @include typography.body-12; - } - - &__icon { - width: 24px; - height: 24px; - color: var(--accent); - } - - &__expiration-date { - display: flex; - align-items: flex-end; - justify-content: flex-end; - - @include typography.body-12; - } -} - -.cancel { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - max-height: calc(100vh - 40px); - padding: 80px 100px 100px; - overflow-y: auto; - - &__cross { - position: absolute; - top: 0; - right: 0; - width: 32px; - height: 32px; - cursor: pointer; - - @include responsive.apply-desktop { - top: 8px; - right: 8px; - } - } - - &__title { - margin-bottom: 20px; - color: var(--white); - text-align: center; - inline-size: 280px; - - @include typography.heading-4; - - @include responsive.apply-desktop { - margin-bottom: 40px; - inline-size: 500px; - text-align: center; - - @include typography.heading-3; - } - } - - app-button { - z-index: 100; - - @include typography.body-12; - } - - &__stars { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - } - - &__wave { - position: absolute; - bottom: 0; - left: 0; - z-index: 0; - width: 100%; - } -} diff --git a/projects/skills/src/app/subscription/subscription.component.spec.ts b/projects/skills/src/app/subscription/subscription.component.spec.ts deleted file mode 100644 index 1631a585e..000000000 --- a/projects/skills/src/app/subscription/subscription.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { SubscriptionComponent } from "./subscription.component"; - -describe("SubscriptionComponent", () => { - let component: SubscriptionComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SubscriptionComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(SubscriptionComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/subscription/subscription.component.ts b/projects/skills/src/app/subscription/subscription.component.ts deleted file mode 100644 index 2181c1fd3..000000000 --- a/projects/skills/src/app/subscription/subscription.component.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** @format */ - -import { Component, type OnDestroy, type OnInit, inject, signal } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { IconComponent } from "@uilib"; -import { ButtonComponent } from "@ui/components"; -import { SwitchComponent } from "@ui/components/switch/switch.component"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { ActivatedRoute } from "@angular/router"; -import { toSignal } from "@angular/core/rxjs-interop"; -import { map, type Subscription } from "rxjs"; -import { ProfileService } from "../profile/services/profile.service"; -import { type SubscriptionData, type SubscriptionPlan, SubscriptionPlansService } from "@corelib"; - -/** - * Компонент управления подписками - * - * Предоставляет интерфейс для: - * - Просмотра доступных планов подписки - * - Покупки новой подписки - * - Управления автопродлением - * - Отмены текущей подписки - * - * Компонент обрабатывает различные модальные окна для подтверждения действий - * и взаимодействует с API для выполнения операций с подписками - */ -@Component({ - selector: "app-subscription", - standalone: true, - imports: [CommonModule, IconComponent, ButtonComponent, SwitchComponent, ModalComponent], - templateUrl: "./subscription.component.html", - styleUrl: "./subscription.component.scss", -}) -export class SubscriptionComponent implements OnInit, OnDestroy { - // Сигналы для управления состоянием модальных окон - open = signal(false); - autoRenewModalOpen = signal(false); - isSubscribedModalOpen = signal(false); - - // Внедрение зависимостей - route = inject(ActivatedRoute); - profileService = inject(ProfileService); - subscriptionService = inject(SubscriptionPlansService); - - // Сигналы для данных подписки - subscriptions = signal([]); - subscriptionData = toSignal( - this.route.data.pipe(map(r => r["subscriptionData"])) - ); - - isSubscribed = toSignal( - this.route.data.pipe(map(r => r["subscriptionData"].isSubscribed)) - ); - - // Массив подписок для управления жизненным циклом - subscription: Subscription[] = []; - - /** - * Инициализация компонента - * - * Загружает данные о планах подписки из резолвера, - * сортирует их по цене и устанавливает в локальное состояние - */ - ngOnInit(): void { - const subsriptionPlanSub = this.route.data - .pipe(map(r => r["data"])) - .pipe( - map(subscription => { - if (Array.isArray(subscription)) { - return subscription; - } else return [subscription]; - }) - ) - .pipe(map(subs => subs.sort((a, b) => a.price - b.price))) - .subscribe(subscriptions => { - this.subscriptions.set(subscriptions); - }); - - this.subscription.push(subsriptionPlanSub); - } - - /** - * Очистка ресурсов при уничтожении компонента - */ - ngOnDestroy(): void { - this.subscription.forEach(sub => sub.unsubscribe()); - } - - /** - * Обработчик изменения состояния модальных окон - * - * @param event - новое состояние модального окна (открыто/закрыто) - */ - onOpenChange(event: boolean) { - if ((this.open() && !event) || (this.autoRenewModalOpen() && !event)) { - this.open.set(false); - this.autoRenewModalOpen.set(false); - } else { - this.open.set(event); - this.autoRenewModalOpen.set(event); - } - } - - /** - * Обработчик изменения настройки автопродления - * - * Если автопродление включено - отключает его, - * если выключено - открывает модальное окно подтверждения - */ - onCheckedChange() { - if (this.subscriptionData()?.isAutopayAllowed) { - this.profileService.updateSubscriptionDate(false).subscribe(() => { - const updatedData = this.subscriptionData()!; - updatedData.isAutopayAllowed = false; - }); - } else { - this.autoRenewModalOpen.set(true); - } - } - - /** - * Обработчик закрытия модального окна отмены подписки - * - * @param event - состояние модального окна - */ - onCancelModalClose(event: boolean) { - if (!event) this.open.set(false); - } - - /** - * Обработчик закрытия модального окна автопродления - * - * @param event - состояние модального окна - */ - onAutoRenewModalClose(event: boolean) { - if (!event) this.autoRenewModalOpen.set(false); - } - - /** - * Открытие модального окна отмены подписки - */ - openCancelModal() { - this.open.set(true); - } - - /** - * Подтверждение настройки автопродления - * - * @param event - новое значение настройки автопродления - */ - onConfirmAutoPlay(event: boolean) { - this.profileService.updateSubscriptionDate(event).subscribe(() => { - if (this.subscriptionData()) { - const updatedData = this.subscriptionData()!; - updatedData.isAutopayAllowed = event; - this.autoRenewModalOpen.set(false); - this.open.set(false); - } - }); - } - - /** - * Отмена текущей подписки - * - * Выполняет запрос на отмену подписки и перезагружает страницу - * для отображения обновленного состояния - */ - onCancelSubscription() { - this.profileService.cancelSubscription().subscribe({ - next: () => { - this.open.set(false); - location.reload(); - }, - error: () => { - this.open.set(false); - location.reload(); - }, - }); - } - - /** - * Обработчик покупки подписки - * - * @param planId - идентификатор выбранного плана подписки - * - * Если пользователь не подписан - инициирует процесс покупки, - * если уже подписан - показывает соответствующее уведомление - */ - onBuyClick(planId: SubscriptionPlan["id"]) { - if (!this.isSubscribed()) { - this.subscriptionService.buySubscription(planId).subscribe(status => { - location.href = status.confirmation.confirmationUrl; - }); - } else { - this.isSubscribedModalOpen.set(true); - } - } -} diff --git a/projects/skills/src/app/subscription/subscription.resolver.ts b/projects/skills/src/app/subscription/subscription.resolver.ts deleted file mode 100644 index f756ff190..000000000 --- a/projects/skills/src/app/subscription/subscription.resolver.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { SubscriptionService } from "./service/subscription.service"; -import { ProfileService } from "../profile/services/profile.service"; - -/** - * Резолвер для получения списка доступных планов подписки - * - * Выполняется перед загрузкой компонента подписки и предоставляет - * данные о всех доступных тарифных планах - * - * @returns Observable с массивом планов подписки - */ -export const subscriptionResolver = () => { - const subscriptionService = inject(SubscriptionService); - return subscriptionService.getSubscriptions(); -}; - -/** - * Резолвер для получения данных о текущей подписке пользователя - * - * Загружает информацию о статусе подписки, дате окончания, - * настройках автопродления и других параметрах - * - * @returns Observable с данными подписки пользователя - */ -export const subscriptionDataResolver = () => { - const profileService = inject(ProfileService); - return profileService.getSubscriptionData(); -}; diff --git a/projects/skills/src/app/subscription/subscription.routes.ts b/projects/skills/src/app/subscription/subscription.routes.ts deleted file mode 100644 index f2916d005..000000000 --- a/projects/skills/src/app/subscription/subscription.routes.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** @format */ - -import type { Routes } from "@angular/router"; -import { SubscriptionComponent } from "./subscription.component"; -import { subscriptionDataResolver, subscriptionResolver } from "./subscription.resolver"; - -export const SUBSCRIPTION_ROUTES: Routes = [ - { - path: "", - component: SubscriptionComponent, - resolve: { - data: subscriptionResolver, // Список всех доступных планов подписки - subscriptionData: subscriptionDataResolver, // Данные текущей подписки пользователя - }, - }, -]; diff --git a/projects/skills/src/app/task/complete/complete.component.html b/projects/skills/src/app/task/complete/complete.component.html deleted file mode 100644 index d949a97ea..000000000 --- a/projects/skills/src/app/task/complete/complete.component.html +++ /dev/null @@ -1,56 +0,0 @@ - - -@if (results | async; as res) { -
-

- Поздравляем!
- Сегодня ты вывел навык «{{ res.skillName }}» на новый уровень -

-
- -
-
-

ваш результат

-
-
-
- check -
-
-
отвечено верно
-
{{ res.quantityDoneCorrect }}/{{ res.quantityAll }}
-
-
-
-
- fire -
-
-
количество баллов
-
+{{ res.pointsGained }}
-
-
-
-
- prize -
-
-
задание
-
{{ res.level }}
-
-
-
-
- В меню навыков - @if (res.nextTaskId) { - Следующее задание - } -
-} diff --git a/projects/skills/src/app/task/complete/complete.component.scss b/projects/skills/src/app/task/complete/complete.component.scss deleted file mode 100644 index a11724bfd..000000000 --- a/projects/skills/src/app/task/complete/complete.component.scss +++ /dev/null @@ -1,123 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.complete { - display: grid; - - @include responsive.apply-desktop { - grid-template-columns: 1fr 1fr; - gap: 26px 18px; - } - - &__title { - grid-column: span 2; - - @include typography.heading-4; - - @include responsive.apply-desktop { - @include typography.heading-3; - } - } - - &__block { - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - } - - &__progress { - display: flex; - justify-content: center; - padding: 30px 0; - } - - &__progress-bar { - display: block; - width: 180px; - height: 180px; - - @include responsive.apply-desktop { - width: 275px; - height: 275px; - } - } -} - -.results { - padding: 26px 15px; - - @include responsive.apply-desktop { - padding: 26px; - } - - &__title { - margin-bottom: 28px; - color: var(--black); - - @include typography.heading-4; - - @include responsive.apply-desktop { - @include typography.heading-3; - } - } - - &__points { - display: flex; - flex-direction: column; - gap: 25px; - } - - &__icon { - display: flex; - align-items: center; - justify-content: center; - width: 76px; - } - - &__point { - position: relative; - display: flex; - align-items: center; - - &::before { - position: absolute; - top: 0; - bottom: 0; - left: 0; - width: 4px; - content: ""; - border-radius: 4px; - } - } - - &__correct { - &::before { - background-color: #92e3a9; - } - } - - &__level { - &::before { - background-color: #f6d067; - } - } - - &__amount { - &::before { - background-color: #ff5f39; - } - } - - &__label { - margin-bottom: 5px; - color: var(--black); - - @include typography.body-18; - } - - &__stat { - color: var(--black); - - @include typography.body-bold-18; - } -} diff --git a/projects/skills/src/app/task/complete/complete.resolver.ts b/projects/skills/src/app/task/complete/complete.resolver.ts deleted file mode 100644 index ecd08549f..000000000 --- a/projects/skills/src/app/task/complete/complete.resolver.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** @format */ - -import type { ResolveFn } from "@angular/router"; -import { TaskService } from "../services/task.service"; -import { inject } from "@angular/core"; -import type { TaskResults } from "../../../models/skill.model"; - -/** - * Резолвер для получения результатов выполнения задачи - * Загружает данные о результатах перед отображением компонента завершения - * - * @param route - объект маршрута с параметрами - * @param _state - состояние маршрутизатора (не используется) - * @returns Promise - промис с результатами выполнения задачи - */ -export const taskCompleteResolver: ResolveFn = (route, _state) => { - const taskService = inject(TaskService); - - // Получаем ID задачи из родительского маршрута и загружаем результаты - return taskService.fetchResults(route.parent?.params["taskId"]); -}; diff --git a/projects/skills/src/app/task/services/task.service.ts b/projects/skills/src/app/task/services/task.service.ts deleted file mode 100644 index ebb477fd9..000000000 --- a/projects/skills/src/app/task/services/task.service.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** @format */ - -import { inject, Injectable, signal } from "@angular/core"; -import { SkillsApiService } from "@corelib"; -import { TaskResults, TaskStep, TaskStepsResponse } from "../../../models/skill.model"; -import { Observable, tap } from "rxjs"; -import { StepType } from "../../../models/step.model"; - -/** - * Сервис заданий - * - * Управляет всеми операциями, связанными с заданиями, включая: - * - Навигацию по шагам заданий и управление состоянием - * - Получение и отправку данных шагов - * - Отслеживание прогресса в рамках заданий - * - Завершение заданий и обработку результатов - * - * Этот сервис поддерживает текущее состояние прогресса задания - * и предоставляет методы для взаимодействия с отдельными шагами заданий. - */ -@Injectable({ - providedIn: "root", -}) -export class TaskService { - private readonly TASK_URL = "/questions"; - private readonly SKILLS_URL = "/courses"; - - private apiService = inject(SkillsApiService); - - // Реактивное управление состоянием с использованием Angular signals - currentSteps = signal([]); - currentTaskDone = signal(false); - - /** - * Сопоставление типов шагов с соответствующими конечными точками API - * Используется для построения правильных маршрутов API для различных типов вопросов - */ - private readonly stepRouteMapping: Record = { - question_connect: "connect", - exclude_question: "exclude", - info_slide: "info-slide", - question_single_answer: "single-correct", - question_write: "write", - }; - - /** - * Получает конкретный шаг по его ID из массива текущих шагов - * - * @param stepId - Уникальный идентификатор шага - * @returns TaskStep | undefined - Данные шага или undefined, если не найден - */ - getStep(stepId: number): TaskStep | undefined { - return this.currentSteps().find(s => s.id === stepId); - } - - /** - * Находит следующий шаг в последовательности после данного ID шага - * - * @param stepId - ID текущего шага - * @returns TaskStep | undefined - Следующий шаг в последовательности или undefined, если это последний шаг - */ - getNextStep(stepId: number): TaskStep | undefined { - const step = this.getStep(stepId); - if (!step) return; - - return this.currentSteps().find(s => s.ordinalNumber === step.ordinalNumber + 1); - } - - /** - * Получает все шаги для данного задания и обновляет состояние текущих шагов - * - * @param taskId - Уникальный идентификатор задания - * @returns Observable - Полная информация о задании со всеми шагами - */ - fetchSteps(taskId: number) { - return this.apiService.get(`${this.SKILLS_URL}/${taskId}`).pipe( - tap(res => { - this.currentSteps.set(res.stepData); - }) - ); - } - - /** - * Получает подробные данные для конкретного шага задания - * - * @param taskStepId - Уникальный идентификатор шага задания - * @param taskStepType - Тип шага (определяет, какую конечную точку использовать) - * @returns Observable - Данные, специфичные для шага, основанные на типе шага - */ - fetchStep(taskStepId: TaskStep["id"], taskStepType: TaskStep["type"]): Observable { - const route = `${this.TASK_URL}/${this.stepRouteMapping[taskStepType]}/${taskStepId}`; - return this.apiService.get(route); - } - - /** - * Отправляет ответ для конкретного шага задания и проверяет ответ - * - * @param taskStepId - Уникальный идентификатор шага задания - * @param taskStepType - Тип шага (определяет логику проверки) - * @param body - Данные ответа (структура варьируется в зависимости от типа шага) - * @returns Observable - Успешный ответ или ошибка с обратной связью - */ - checkStep(taskStepId: TaskStep["id"], taskStepType: TaskStep["type"], body: any) { - const route = `${this.TASK_URL}/${this.stepRouteMapping[taskStepType]}/check/${taskStepId}`; - return this.apiService.post(route, body); - } - - /** - * Получает финальные результаты после завершения всех шагов в задании - * - * @param taskId - Уникальный идентификатор завершенного задания - * @returns Observable - Сводка производительности, заработанных очков и следующих шагов - */ - fetchResults(taskId: number) { - return this.apiService.get(`${this.SKILLS_URL}/task-result/${taskId}`); - } -} diff --git a/projects/skills/src/app/task/shared/exclude-task/exclude-task.component.html b/projects/skills/src/app/task/shared/exclude-task/exclude-task.component.html deleted file mode 100644 index 919dbd4ad..000000000 --- a/projects/skills/src/app/task/shared/exclude-task/exclude-task.component.html +++ /dev/null @@ -1,42 +0,0 @@ - - -
- @if (!sanitizedFileUrl) { -

{{ data.text }}

- } -
- @if (sanitizedFileUrl) { - - } - -
-
- -
- @if (sanitizedFileUrl) { -

{{ data.text }}

- } -
    - @for (op of data.answers; track op.id) { -
  • - {{ op.text }} -
  • - } -
- @if (hint.length) { -
- } -
-
diff --git a/projects/skills/src/app/task/shared/exclude-task/exclude-task.component.scss b/projects/skills/src/app/task/shared/exclude-task/exclude-task.component.scss deleted file mode 100644 index eedee9295..000000000 --- a/projects/skills/src/app/task/shared/exclude-task/exclude-task.component.scss +++ /dev/null @@ -1,92 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.exclude { - display: flex; - flex-direction: column; - padding: 22px 14px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - - @include responsive.apply-desktop { - &--hasVideo { - flex-direction: row; - } - - justify-content: space-evenly; - padding: 26px; - } - - &__title { - margin-bottom: 20px; - color: var(--black); - - @include typography.heading-4; - - @include responsive.apply-desktop { - margin-bottom: 26px; - - @include typography.heading-3; - } - } - - &__text { - width: 100%; - height: 100%; - - // max-height: 285px; - margin-bottom: 20px; - white-space: pre-line; - - &--hasVideo { - max-width: 435px; - } - - @include responsive.apply-desktop { - display: flex; - } - } - - &__list { - display: flex; - flex-wrap: wrap; - gap: 10px; - - @include responsive.apply-desktop { - gap: 18px; - } - } - - &__item { - padding: 10px; - cursor: pointer; - border: 1px solid var(--accent); - border-radius: 15px; - - @include typography.body-12; - - @include responsive.apply-desktop { - padding: 10px 14px; - - @include typography.body-14; - } - - &--active { - border-color: var(--black); - opacity: 0.5; - } - - &--success { - color: var(--green); - background-color: var(--light-green); - border-color: var(--green); - } - } - - :host ::ng-deep &__hint p { - margin-top: 20px; - - @include typography.body-14; - } -} diff --git a/projects/skills/src/app/task/shared/radio-select-task/radio-select-task.component.html b/projects/skills/src/app/task/shared/radio-select-task/radio-select-task.component.html deleted file mode 100644 index 4d6caf28e..000000000 --- a/projects/skills/src/app/task/shared/radio-select-task/radio-select-task.component.html +++ /dev/null @@ -1,37 +0,0 @@ - - -
- @if (sanitizedFileUrl) { -
- -
- } -
-

- {{ data.text }} -

-

-
- @for (op of data.answers; track op.id) { -
- {{ op.text }} -
- } -
- @if (hint.length) { -
- } -
-
diff --git a/projects/skills/src/app/task/shared/radio-select-task/radio-select-task.component.scss b/projects/skills/src/app/task/shared/radio-select-task/radio-select-task.component.scss deleted file mode 100644 index a1c288be8..000000000 --- a/projects/skills/src/app/task/shared/radio-select-task/radio-select-task.component.scss +++ /dev/null @@ -1,81 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; - -.radio { - padding: 26px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - - @include responsive.apply-desktop { - display: flex; - gap: 60px; - } - - &__description { - width: 100%; - max-width: 440px; - height: 100%; - max-height: 285px; - } - - &__body { - flex-grow: 1; - } - - &__img { - width: 385px; - height: 315px; - object-fit: cover; - border-radius: 15px; - } - - &__title { - margin-bottom: 15px; - - @include typography.body-bold-18; - - @include responsive.apply-desktop { - margin-bottom: 24px; - - @include typography.heading-4; - } - } - - &__text { - margin-bottom: 20px; - } - - &__list { - display: flex; - flex-direction: column; - gap: 10px; - - @include responsive.apply-desktop { - gap: 18px; - } - } - - &__option { - padding: 24px 20px; - cursor: pointer; - border: 1px solid var(--grey-button); - border-radius: 15px; - - &--active { - border-color: var(--accent); - } - - &--success { - color: var(--green); - background-color: var(--light-green); - border-color: var(--green); - } - } - - :host ::ng-deep &__hint p { - margin-top: 20px; - - @include typography.body-14; - } -} diff --git a/projects/skills/src/app/task/shared/radio-select-task/radio-select-task.component.ts b/projects/skills/src/app/task/shared/radio-select-task/radio-select-task.component.ts deleted file mode 100644 index 6bb223dcb..000000000 --- a/projects/skills/src/app/task/shared/radio-select-task/radio-select-task.component.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, inject, Input, Output, signal } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import type { SingleQuestion, SingleQuestionError } from "../../../../models/step.model"; -import { DomSanitizer, type SafeResourceUrl } from "@angular/platform-browser"; -import { ParseBreaksPipe, YtExtractService } from "@corelib"; - -/** - * Компонент задачи с выбором одного варианта (радио-кнопки) - * Отображает вопрос с несколькими вариантами ответа, из которых можно выбрать только один - * - * Входные параметры: - * @Input data - данные вопроса типа SingleQuestion - * @Input success - флаг успешного выполнения - * @Input hint - текст подсказки - * @Input error - объект ошибки для сброса состояния - * - * Выходные события: - * @Output update - событие обновления с ID выбранного ответа - * - * Функциональность: - * - Отображает вопрос и варианты ответов - * - Поддерживает встроенные видео и файлы - * - Позволяет выбрать только один вариант ответа - * - Извлекает YouTube ссылки из описания - */ -@Component({ - selector: "app-radio-select-task", - standalone: true, - imports: [CommonModule, ParseBreaksPipe], - templateUrl: "./radio-select-task.component.html", - styleUrl: "./radio-select-task.component.scss", -}) -export class RadioSelectTaskComponent { - @Input({ required: true }) data!: SingleQuestion; // Данные вопроса - @Input() success = false; // Флаг успешного выполнения - @Input() hint!: string; // Текст подсказки - - // Сеттер для обработки ошибок и сброса состояния - @Input() - set error(value: SingleQuestionError | null) { - this._error.set(value); - - if (value !== null) { - this.result.set({ answerId: null }); // Сбрасываем выбранный ответ при ошибке - } - } - - get error() { - return this._error(); - } - - @Output() update = new EventEmitter<{ answerId: number }>(); // Событие обновления выбора - - // Состояние компонента - result = signal<{ answerId: number | null }>({ answerId: null }); // Выбранный ответ - _error = signal(null); // Состояние ошибки - - sanitizer = inject(DomSanitizer); // Сервис для безопасной работы с HTML - ytExtractService = inject(YtExtractService); // Сервис для извлечения YouTube ссылок - - videoUrl?: SafeResourceUrl; // Безопасная ссылка на видео - description: any; // Обработанное описание - sanitizedFileUrl?: SafeResourceUrl; // Безопасная ссылка на файл - - ngOnInit(): void { - // Извлекаем YouTube ссылку из описания - const res = this.ytExtractService.transform(this.data.description); - - if (res.extractedLink) - this.videoUrl = this.sanitizer.bypassSecurityTrustResourceUrl(res.extractedLink); - - // Обрабатываем файлы, если они есть - if (this.data.files.length) { - this.sanitizedFileUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.data.files[0]); - } - - // Безопасно обрабатываем HTML в описании - this.description = this.sanitizer.bypassSecurityTrustHtml(this.data.description); - } - - /** - * Обработчик выбора варианта ответа - * @param id - ID выбранного ответа - */ - onSelect(id: number) { - this.result.set({ answerId: id }); - this.update.emit({ answerId: id }); - } -} diff --git a/projects/skills/src/app/task/shared/video-task/info-task.component.html b/projects/skills/src/app/task/shared/video-task/info-task.component.html deleted file mode 100644 index 925414710..000000000 --- a/projects/skills/src/app/task/shared/video-task/info-task.component.html +++ /dev/null @@ -1,30 +0,0 @@ - - -
-
-

{{ data.text }}

-

-
- - @if (sanitizedFileUrl) { -
-
- @if (contentType === 'mp4' || contentType === 'gif') { - - } @else if(data.files[0].length) { - - } -
-
- } -
diff --git a/projects/skills/src/app/task/shared/video-task/info-task.component.scss b/projects/skills/src/app/task/shared/video-task/info-task.component.scss deleted file mode 100644 index 4172cf0ef..000000000 --- a/projects/skills/src/app/task/shared/video-task/info-task.component.scss +++ /dev/null @@ -1,103 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.video { - display: flex; - flex-direction: column-reverse; - gap: 30px; - align-items: flex-start; - padding: 26px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - - @include responsive.apply-desktop { - flex-direction: row; - } - - &--hasVideo { - // flex-direction: column; - - @include responsive.apply-desktop { - align-items: center; - } - } - - &__wrapper { - display: flex; - justify-content: center; - width: 100%; - - &--hasVideo { - align-self: center; - justify-content: center; - - // border: 1px solid var(--grey-for-text); - border-radius: 15px; - } - - @include responsive.apply-desktop { - justify-content: flex-end; - width: 100%; - - &--hasVideo { - align-self: center; - justify-content: center; - - // border: 1px solid var(--grey-for-text); - border-radius: 15px; - } - } - } - - img { - width: 100%; - max-width: 440px; - height: 100%; - border-radius: 15px; - } - - &__frame { - width: 100%; - - &--hasVideo { - align-self: center; - width: 100%; - max-width: 670px; - height: 100%; - } - - @include responsive.apply-desktop { - width: 100%; - margin-left: 50px; - - &--hasVideo { - align-self: center; - width: 100%; - max-width: 670px; - height: 100%; - margin-left: 0; - } - } - } - - &__text-content { - display: flex; - flex-basis: 140%; - flex-direction: column; - } - - &__text { - white-space: pre-line; - - @include typography.body-12; - - @include responsive.apply-desktop { - @include typography.body-14; - } - } - - &__title { - @include typography.heading-3; - } -} diff --git a/projects/skills/src/app/task/shared/video-task/info-task.component.ts b/projects/skills/src/app/task/shared/video-task/info-task.component.ts deleted file mode 100644 index a3f361683..000000000 --- a/projects/skills/src/app/task/shared/video-task/info-task.component.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** @format */ - -import { Component, inject, Input } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import type { InfoSlide } from "../../../../models/step.model"; -import { DomSanitizer, type SafeResourceUrl } from "@angular/platform-browser"; -import { ParseBreaksPipe, ParseLinksPipe, YtExtractService } from "@corelib"; - -/** - * Компонент информационного слайда с видео/изображением - * Отображает информационный контент с поддержкой различных медиа-форматов - * - * Входные параметры: - * @Input data - данные информационного слайда типа InfoSlide - * - * Функциональность: - * - Отображает текст и описание слайда - * - Поддерживает видео (mp4), GIF и изображения (webp, jpg, png) - * - Извлекает YouTube ссылки из текста - * - Автоматически определяет тип контента по расширению файла - * - Адаптивная компоновка для разных типов медиа - */ -@Component({ - selector: "app-info-task", - standalone: true, - imports: [CommonModule, ParseLinksPipe, ParseBreaksPipe], - templateUrl: "./info-task.component.html", - styleUrl: "./info-task.component.scss", -}) -export class InfoTaskComponent { - @Input({ required: true }) data!: InfoSlide; // Данные информационного слайда - - sanitizer = inject(DomSanitizer); // Сервис для безопасной работы с HTML - ytExtractService = inject(YtExtractService); // Сервис для извлечения YouTube ссылок - - videoUrl?: SafeResourceUrl; // Безопасная ссылка на видео - description: any; // Обработанное описание - sanitizedFileUrl?: SafeResourceUrl; // Безопасная ссылка на файл - contentType: "gif" | "webp" | "mp4" | string = ""; // Тип медиа-контента - - ngOnInit(): void { - // Извлекаем YouTube ссылку из текста - const res = this.ytExtractService.transform(this.data.text); - - // Определяем тип контента по расширению файла - if (this.data.files.length) { - this.contentType = this.data.files[0].slice(-3).toLocaleLowerCase(); - } - - if (res.extractedLink) - this.videoUrl = this.sanitizer.bypassSecurityTrustResourceUrl(res.extractedLink); - - // Обрабатываем файлы, если они есть - if (this.data.files.length) { - this.sanitizedFileUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.data.files[0]); - } - - // Безопасно обрабатываем HTML в описании - this.description = this.sanitizer.bypassSecurityTrustHtml(this.data.description); - } -} diff --git a/projects/skills/src/app/task/shared/write-task/write-task.component.html b/projects/skills/src/app/task/shared/write-task/write-task.component.html deleted file mode 100644 index 34fe224fd..000000000 --- a/projects/skills/src/app/task/shared/write-task/write-task.component.html +++ /dev/null @@ -1,37 +0,0 @@ - - -
- @if (!sanitizedFileUrl) { -

{{ data.text }}

- } -
- @if (sanitizedFileUrl) { - - } -
-
- -
- @if (sanitizedFileUrl) { -

{{ data.text }}

- } -
- -
-
-
diff --git a/projects/skills/src/app/task/shared/write-task/write-task.component.scss b/projects/skills/src/app/task/shared/write-task/write-task.component.scss deleted file mode 100644 index 621fe6bf4..000000000 --- a/projects/skills/src/app/task/shared/write-task/write-task.component.scss +++ /dev/null @@ -1,52 +0,0 @@ -@use "styles/responsive"; - -.write-task { - display: flex; - flex-direction: column; - padding: 26px; - background: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - - @include responsive.apply-desktop { - &--hasVideo { - flex-direction: row; - } - - justify-content: space-evenly; - } - - &__text { - margin-bottom: 26px; - } - - &__description { - width: 100%; - height: 100%; - min-height: 100%; - margin-bottom: 26px; - color: var(--grey-text); - white-space: pre-line; - - &--hasVideo { - max-width: 435px; - } - - @include responsive.apply-desktop { - min-height: 100%; - max-height: 285px; - } - } - - &__textarea { - display: block; - width: 100%; - padding: 20px 16px; - color: var(--color-text-primary); - resize: none; - border: 1px solid var(--grey-button); - border-radius: 15px; - outline: none; - scrollbar-width: none; - } -} diff --git a/projects/skills/src/app/task/shared/write-task/write-task.component.ts b/projects/skills/src/app/task/shared/write-task/write-task.component.ts deleted file mode 100644 index d923a498e..000000000 --- a/projects/skills/src/app/task/shared/write-task/write-task.component.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, Input, type OnInit, Output, inject } from "@angular/core"; -import { CommonModule, JsonPipe } from "@angular/common"; -import type { WriteQuestion } from "../../../../models/step.model"; -import { ParseBreaksPipe, YtExtractService } from "@corelib"; -import { DomSanitizer, type SafeResourceUrl } from "@angular/platform-browser"; - -/** - * Компонент задачи с текстовым вводом - * Позволяет пользователю вводить текстовый ответ в textarea - * - * Входные параметры: - * @Input data - данные вопроса типа WriteQuestion - * @Input success - флаг успешного выполнения задания - * - * Выходные события: - * @Output update - событие обновления ответа с текстом - * - * Функциональность: - * - Отображает текст задания и описание - * - Поддерживает встроенные видео и файлы - * - Автоматически изменяет высоту textarea при вводе - * - Извлекает YouTube ссылки из описания - */ -@Component({ - selector: "app-write-task", - standalone: true, - imports: [CommonModule, JsonPipe, ParseBreaksPipe], - templateUrl: "./write-task.component.html", - styleUrl: "./write-task.component.scss", -}) -export class WriteTaskComponent implements OnInit { - @Input({ required: true }) data!: WriteQuestion; // Данные вопроса - @Output() update = new EventEmitter<{ text: string }>(); // Событие обновления ответа - - @Input() success = false; // Флаг успешного выполнения - - sanitizer = inject(DomSanitizer); // Сервис для безопасной работы с HTML - ytExtractService = inject(YtExtractService); // Сервис для извлечения YouTube ссылок - - videoUrl?: SafeResourceUrl; // Безопасная ссылка на видео - description: any; // Обработанное описание - sanitizedFileUrl?: SafeResourceUrl; // Безопасная ссылка на файл - - ngOnInit(): void { - // Извлекаем YouTube ссылку из описания - const res = this.ytExtractService.transform(this.data.description); - - if (res.extractedLink) - this.videoUrl = this.sanitizer.bypassSecurityTrustResourceUrl(res.extractedLink); - - // Обрабатываем файлы, если они есть - if (this.data.files.length) { - this.sanitizedFileUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.data.files[0]); - } - - // Безопасно обрабатываем HTML в описании - this.description = this.sanitizer.bypassSecurityTrustHtml(this.data.description); - } - - /** - * Обработчик ввода текста в textarea - * Автоматически изменяет высоту поля и отправляет событие обновления - * @param event - событие клавиатуры - */ - onKeyUp(event: KeyboardEvent) { - const target = event.target as HTMLTextAreaElement; - - // Автоматическое изменение высоты textarea - target.style.height = "0px"; - target.style.height = target.scrollHeight + "px"; - - // Отправляем событие с введенным текстом - this.update.emit({ text: target.value }); - } -} diff --git a/projects/skills/src/app/task/subtask/subtask.component.html b/projects/skills/src/app/task/subtask/subtask.component.html deleted file mode 100644 index f30dd70e7..000000000 --- a/projects/skills/src/app/task/subtask/subtask.component.html +++ /dev/null @@ -1,240 +0,0 @@ - - -@if (loading()) { -
- -
-} @else { -
- @if (infoSlide(); as is) { @if(is.popups && is.popups.length > 0){ - -
- - -

{{ is.popups[0].title }}

- - @if (is.popups[0].fileLink; as fileLink) { -
- - @if(is.popups[0].text; as popupText){ -

- } -
- } @else { @if(is.popups[0].text; as popupText){ -

- } } - - продолжить - wave -
-
- } - - } @if (singleQuestion(); as sq) { @if(sq.popups && sq.popups.length > 0){ - -
- - -

{{ sq.popups[0].title }}

- - @if (sq.popups[0].fileLink; as fileLink) { -
-
- -
- @if(sq.popups[0].text; as popupText){ -

- } -
- } @else { @if(sq.popups[0].text; as popupText){ -

- } } - - продолжить - wave -
-
- } - - } @if (connectQuestion(); as cq) { @if(cq.popups && cq.popups.length > 0){ - -
- - -

{{ cq.popups[0].title }}

- - @if (cq.popups[0].fileLink; as fileLink) { -
-
- -
- @if (cq.popups[0].text; as popupText) { -

- } -
- } @else { @if (cq.popups[0].text; as popupText) { -

- } } - - продолжить - wave -
-
- } - - } @if (excludeQuestion(); as eq) { @if(eq.popups && eq.popups.length > 0){ - -
- - -

{{ eq.popups[0].title }}

- - @if (eq.popups[0].fileLink; as fileLink) { -
-
- -
- @if (eq.popups[0].text; as popupText) { -

- } -
- } @else { @if (eq.popups[0].text; as popupText) { -

- } } - - продолжить - wave -
-
- } - - } @if (writeQuestion(); as wq) { @if(wq.popups && wq.popups.length > 0){ - -
- - -

{{ wq.popups[0].title }}

- - @if (wq.popups[0].fileLink; as fileLink) { -
-
- -
- @if(wq.popups[0].text; as popupText){ -

- } -
- } @else { @if(wq.popups[0].text; as popupText){ -

- } } - - продолжить - wave -
-
- } - - } - -
- продолжить - -
-

вышла неточность...

-
- -
-

всё верно! так держать

-
-
-
-} diff --git a/projects/skills/src/app/task/subtask/subtask.component.scss b/projects/skills/src/app/task/subtask/subtask.component.scss deleted file mode 100644 index efbd9d8cb..000000000 --- a/projects/skills/src/app/task/subtask/subtask.component.scss +++ /dev/null @@ -1,143 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; - -.subtask { - display: flex; - flex-direction: column; - gap: 26px; -} - -.loading { - display: flex; - align-items: center; - justify-content: center; - height: 400px; -} - -.action { - position: relative; - margin-top: 20px; - - &__button { - position: relative; - z-index: 1; - } - - &__badge { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - display: flex; - display: none; - justify-content: center; - padding: 12px 12px 35px; - text-align: center; - border-radius: var(--rounded-xl); - transition: all 0.2s; - transform: translateY(0%); - - &--open { - display: block; - transform: translateY(-80%); - } - } - - &__error { - color: var(--red); - background-color: var(--light-red); - } - - &__success { - color: var(--green); - background-color: var(--light-green); - } -} - -.cancel { - display: flex; - flex-direction: column; - align-items: center; - width: 345px; - max-height: calc(100vh - 40px); - padding: 0 0 40px; - overflow-y: auto; - - @include responsive.apply-desktop { - width: 586px; - } - - &__cross { - position: absolute; - top: 0; - right: 0; - width: 32px; - height: 32px; - cursor: pointer; - - @include responsive.apply-desktop { - top: 8px; - right: 8px; - } - } - - &__title { - align-self: flex-start; - margin-bottom: 20px; - color: var(--black); - - @include typography.heading-3; - - @include responsive.apply-desktop { - margin-bottom: 23px; - } - } - - &__image { - display: flex; - flex-direction: column; - gap: 15px; - align-items: center; - justify-content: space-between; - margin: 30px 0; - - @include responsive.apply-desktop { - display: flex; - flex-direction: row; - gap: 15px; - align-items: center; - justify-content: space-between; - margin: 30px 0; - } - } - - &__description { - flex-basis: 55%; - align-self: self-start; - margin-bottom: 40px; - } - - &__wave { - position: absolute; - bottom: 0%; - left: 0; - width: 100%; - transform: rotate(180deg); - } - - &__link { - color: var(--accent); - cursor: pointer; - } - - &__pdf { - display: block; - width: 24px; - height: 24px; - } - - &__line { - color: var(--accent); - } -} diff --git a/projects/skills/src/app/task/subtask/subtask.component.spec.ts b/projects/skills/src/app/task/subtask/subtask.component.spec.ts deleted file mode 100644 index 46a19e28f..000000000 --- a/projects/skills/src/app/task/subtask/subtask.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { SubtaskComponent } from "./subtask.component"; - -describe("SubtaskComponent", () => { - let component: SubtaskComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SubtaskComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(SubtaskComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/task/subtask/subtask.component.ts b/projects/skills/src/app/task/subtask/subtask.component.ts deleted file mode 100644 index 317775ed5..000000000 --- a/projects/skills/src/app/task/subtask/subtask.component.ts +++ /dev/null @@ -1,329 +0,0 @@ -/** @format */ - -import { - ChangeDetectorRef, - Component, - inject, - type OnInit, - signal, - ViewChild, -} from "@angular/core"; -import { CommonModule, NgOptimizedImage } from "@angular/common"; -import { InfoTaskComponent } from "../shared/video-task/info-task.component"; -import { RadioSelectTaskComponent } from "../shared/radio-select-task/radio-select-task.component"; -import { RelationsTaskComponent } from "../shared/relations-task/relations-task.component"; -import { ButtonComponent } from "@ui/components"; -import { ExcludeTaskComponent } from "../shared/exclude-task/exclude-task.component"; -import { ActivatedRoute, NavigationStart, Router, RouterLink } from "@angular/router"; -import { concatMap, map, tap } from "rxjs"; -import { LoaderComponent } from "@ui/components/loader/loader.component"; -import { TaskService } from "../services/task.service"; -import type { TaskStep } from "../../../models/skill.model"; -import { toSignal } from "@angular/core/rxjs-interop"; -import type { - ConnectQuestion, - ConnectQuestionResponse, - ExcludeQuestion, - ExcludeQuestionResponse, - InfoSlide, - SingleQuestion, - SingleQuestionError, - StepType, - WriteQuestion, -} from "../../../models/step.model"; -import { WriteTaskComponent } from "../shared/write-task/write-task.component"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { IconComponent } from "@uilib"; -import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; -import { SkillService } from "../../skills/services/skill.service"; - -/** - * Компонент подзадания - * - * Обрабатывает выполнение отдельных шагов задания и взаимодействие пользователя. - * Этот компонент динамически отображает различные типы вопросов и заданий - * на основе типа шага, управляет ответами пользователя и обрабатывает навигацию - * между шагами. - * - * Поддерживаемые типы шагов: - * - info_slide: Отображение информации без взаимодействия - * - question_single_answer: Вопросы с единственным выбором - * - question_connect: Задания на соединение/сопоставление - * - exclude_question: Задания на исключение - * - question_write: Задания с письменным ответом - * - * Функции: - * - Динамическое отображение компонентов на основе типа шага - * - Проверка ответов в реальном времени и обратная связь - * - Обработка ошибок с подсказками и исправлениями - * - Автоматическое продвижение к следующим шагам - * - Модальные всплывающие окна для дополнительной информации - * - Состояния загрузки - */ -@Component({ - selector: "app-subtask", - standalone: true, - imports: [ - CommonModule, - InfoTaskComponent, - RadioSelectTaskComponent, - RelationsTaskComponent, - ButtonComponent, - ExcludeTaskComponent, - RouterLink, - LoaderComponent, - WriteTaskComponent, - ModalComponent, - IconComponent, - NgOptimizedImage, - ParseBreaksPipe, - ParseLinksPipe, - ], - templateUrl: "./subtask.component.html", - styleUrl: "./subtask.component.scss", -}) -export class SubtaskComponent implements OnInit { - // Внедренные сервисы - router = inject(Router); - route = inject(ActivatedRoute); - cdref = inject(ChangeDetectorRef); - taskService = inject(TaskService); - skillService = inject(SkillService); - - // Состояние UI - loading = signal(false); - hint = signal(""); - - // Получение ID подзадания из параметров маршрута - subTaskId = toSignal( - this.route.params.pipe( - map(r => r["subTaskId"]), - map(Number) - ) - ); - - // Ссылка на компонент отношений для управления линиями соединения - @ViewChild(RelationsTaskComponent) relationsTask: RelationsTaskComponent | null = null; - - // Сигналы состояния для различных типов шагов - infoSlide = signal(null); - singleQuestion = signal(null); - connectQuestion = signal(null); - excludeQuestion = signal(null); - writeQuestion = signal(null); - - // Сигналы ошибок для различных типов вопросов - connectQuestionError = signal(null); - singleQuestionError = signal(null); - excludeQuestionError = signal(null); - anyError = signal(false); - success = signal(false); - - // Текущий открытый тип вопроса для модального окна - openQuestion = signal< - | "exclude_question" - | "question_single_answer" - | "question_connect" - | "info_slide" - | "question_write" - | null - >(null); - - constructor() { - // Обработка навигации назад через браузер - this.router.events.subscribe(event => { - if (event instanceof NavigationStart) { - if (event.navigationTrigger === "popstate" && event.restoredState) { - this.router.navigateByUrl(`/skills/${this.skillService.getSkillId()}`); - } - } - }); - } - - ngOnInit() { - // Загрузка данных шага при изменении параметров маршрута - this.route.params - .pipe( - map(p => p["subTaskId"]), - tap(() => { - this.loading.set(true); - }), - concatMap(subTaskId => { - this.openQuestion.set(this.route.snapshot.queryParams["type"]); - return this.taskService.fetchStep(subTaskId, this.route.snapshot.queryParams["type"]); - }) - ) - .subscribe({ - next: step => { - this.setStepData(step); - setTimeout(() => this.loading.set(false), 500); - }, - complete: () => { - setTimeout(() => this.loading.set(false), 500); - }, - }); - } - - /** - * Устанавливает данные шага на основе типа - * Очищает предыдущие данные и устанавливает новые в соответствующий сигнал - */ - setStepData(step: StepType) { - const type = this.route.snapshot.queryParams["type"] as TaskStep["type"]; - - this.clearData(); - - if (type === "question_single_answer") { - this.singleQuestion.set(step as SingleQuestion); - } else if (type === "question_connect") { - this.connectQuestion.set(step as ConnectQuestion); - } else if (type === "info_slide") { - this.infoSlide.set(step as InfoSlide); - } else if (type === "exclude_question") { - this.excludeQuestion.set(step as ExcludeQuestion); - } else if (type === "question_write") { - this.writeQuestion.set(step as WriteQuestion); - } - } - - // Тело запроса для отправки ответов - body = signal({}); - - /** - * Очищает все данные шагов - * Сбрасывает все сигналы состояния к null - */ - clearData() { - [ - this.singleQuestion, - this.connectQuestion, - this.infoSlide, - this.excludeQuestion, - this.writeQuestion, - this.singleQuestionError, - this.connectQuestionError, - ].forEach(s => s.set(null)); - } - - /** - * Обработчик изменения состояния модального окна - */ - onOpenChange(event: any) { - if (!event) { - this.openQuestion.set(null); - } else { - this.openQuestion.set(event); - } - } - - /** - * Обработчик закрытия модального окна - * Переходит к следующему шагу или к результатам - */ - onCloseModal() { - const id = this.subTaskId(); - if (!id) return; - - setTimeout(() => { - this.success.set(false); - - const nextStep = this.taskService.getNextStep(id); - const taskId = this.route.parent?.snapshot.params["taskId"]; - if (!taskId) return; - - if (!nextStep) { - this.router.navigate(["/task", taskId, "results"]).then(() => { - console.debug("Маршрут изменен из SubtaskComponent"); - location.reload(); - }); - this.taskService.currentTaskDone.set(true); - return; - } - - this.router - .navigate(["/task", taskId, nextStep.id], { - queryParams: { type: nextStep.type }, - }) - .then(() => { - console.debug("Маршрут изменен из SubtaskComponent"); - location.reload(); - }); - }, 1000); - } - - /** - * Обработчик перехода к следующему шагу - * Отправляет ответ пользователя и обрабатывает результат - */ - onNext() { - const id = this.subTaskId(); - if (!id) return; - - const type = this.route.snapshot.queryParams["type"] as TaskStep["type"]; - - // Отправка ответа на сервер для проверки - this.taskService.checkStep(id, type, this.body()).subscribe({ - next: _res => { - this.success.set(true); - - // Проверка наличия всплывающих окон для отображения - if ( - (type === "info_slide" && !this.infoSlide()?.popups.length) || - (type === "exclude_question" && !this.excludeQuestion()?.popups.length) || - (type === "question_connect" && !this.connectQuestion()?.popups.length) || - (type === "question_single_answer" && !this.singleQuestion()?.popups.length) || - (type === "question_write" && !this.writeQuestion()?.popups.length) - ) { - // Автоматический переход к следующему шагу, если нет всплывающих окон - setTimeout(() => { - this.success.set(false); - - const nextStep = this.taskService.getNextStep(id); - const taskId = this.route.parent?.snapshot.params["taskId"]; - if (!taskId) return; - - if (!nextStep) { - this.router.navigate(["/task", taskId, "results"]).then(() => { - console.debug("Маршрут изменен из SubtaskComponent"); - location.reload(); - }); - this.taskService.currentTaskDone.set(true); - return; - } - - this.router - .navigate(["/task", taskId, nextStep.id], { - queryParams: { type: nextStep.type }, - }) - .then(() => { - console.debug("Маршрут изменен из SubtaskComponent"); - location.reload(); - }); - }, 1000); - } - }, - error: err => { - // Обработка ошибок и отображение подсказок - this.anyError.set(true); - if (err.error.hint) { - this.hint.set(err.error.hint); - this.cdref.detectChanges(); - } - console.error(err.error.hint); - setTimeout(() => { - this.anyError.set(false); - }, 2000); - - // Установка специфичных ошибок для разных типов вопросов - if (type === "question_connect") { - this.connectQuestionError.set(err.error); - this.relationsTask?.removeLines(); - } else if (type === "question_single_answer") { - this.singleQuestionError.set(err.error); - } else if (type === "exclude_question") { - this.excludeQuestionError.set(err.error); - } - }, - }); - } -} diff --git a/projects/skills/src/app/task/task.routes.ts b/projects/skills/src/app/task/task.routes.ts deleted file mode 100644 index 7d6c477d0..000000000 --- a/projects/skills/src/app/task/task.routes.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** @format */ - -import type { Routes } from "@angular/router"; -import { TaskComponent } from "./task/task.component"; -import { SubtaskComponent } from "./subtask/subtask.component"; -import { TaskCompleteComponent } from "./complete/complete.component"; -import { taskDetailResolver } from "./task.resolver"; -import { taskCompleteResolver } from "./complete/complete.resolver"; - -/** - * Конфигурация маршрутов для модуля задач - * Определяет структуру навигации и связывает компоненты с URL-путями - * - * Структура маршрутов: - * - /:taskId - основной компонент задачи - * - /results - компонент результатов выполнения задачи - * - /:subTaskId - компонент подзадачи - */ -export const TASK_ROUTES: Routes = [ - { - path: ":taskId", // Маршрут с параметром ID задачи - component: TaskComponent, // Основной компонент задачи - resolve: { - data: taskDetailResolver, // Предварительная загрузка данных задачи - }, - children: [ - { - path: "results", // Маршрут для отображения результатов - component: TaskCompleteComponent, - resolve: { - data: taskCompleteResolver, // Предварительная загрузка результатов - }, - }, - { - path: ":subTaskId", // Маршрут для подзадач - component: SubtaskComponent, - }, - ], - }, -]; diff --git a/projects/skills/src/app/task/task/task.component.html b/projects/skills/src/app/task/task/task.component.html deleted file mode 100644 index 50841db64..000000000 --- a/projects/skills/src/app/task/task/task.component.html +++ /dev/null @@ -1,56 +0,0 @@ - - -@if (skillStepsResponse()) { -
-
-
-

{{ skillStepsResponse()?.skillName }}

-
- развитие навыка стало доступным благодаря
- начните обучение прямо сейчас -
-
- -
-
-
-
-
- Задание {{ skillStepsResponse()?.currentLevel }} -
- @for (id of taskIds(); track $index) { -
- - } - -
- {{ - skillStepsResponse()?.nextLevel - ? skillStepsResponse()?.nextLevel - : skillStepsResponse()?.currentLevel - }} - Задание -
-
- -
-} diff --git a/projects/skills/src/app/task/task/task.component.scss b/projects/skills/src/app/task/task/task.component.scss deleted file mode 100644 index d35623303..000000000 --- a/projects/skills/src/app/task/task/task.component.scss +++ /dev/null @@ -1,142 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; - -.task { - display: flex; - flex-direction: column; - gap: 15px; - - @include responsive.apply-desktop { - gap: 18px; - } -} - -.badge { - display: flex; - align-items: center; - justify-content: space-between; - padding: 24px 15px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - - &__title { - color: var(--black); - - @include typography.body-bold-18; - - @include responsive.apply-desktop { - @include typography.heading-3; - } - } - - &__text { - color: var(--accent); - - @include typography.body-12; - - @include responsive.apply-desktop { - @include typography.body-16; - } - } - - &__img { - width: 20%; - height: 20px; - object-fit: contain; - margin-right: 15px; - - @include responsive.apply-desktop { - height: 80px; - } - } -} - -.progress { - position: relative; - display: flex; - align-items: center; - justify-content: space-between; - - &__line, - &__line-done { - position: absolute; - top: 50%; - left: 0; - height: 4px; - transform: translateY(-50%); - } - - &__line { - right: 0; - background-color: var(--grey-button); - } - - &__line-done { - background-color: var(--accent); - } - - &__border { - position: relative; - z-index: 2; - display: flex; - align-items: center; - justify-content: center; - width: 70px; - height: 24px; - color: var(--grey-button); - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 10px; - - @include typography.body-12; - - @include responsive.apply-desktop { - width: 115px; - height: 43px; - - @include typography.bold-body-16; - } - - &--done { - color: var(--black); - border-color: var(--accent); - border-width: 2px; - } - } - - &__point { - position: relative; - z-index: 2; - display: block; - width: 10px; - height: 10px; - cursor: pointer; - background-color: var(--grey-button); - border-radius: 50%; - - @include responsive.apply-desktop { - width: 18px; - height: 18px; - } - - &--done { - background-color: var(--accent); - } - } - - &__brand-point { - position: absolute; - top: 50%; - z-index: 10; - width: 10px; - height: 10px; - border-radius: 50%; - transform: translateY(-50%); - - @include responsive.apply-desktop { - width: 18px; - height: 18px; - } - } -} diff --git a/projects/skills/src/app/task/task/task.component.ts b/projects/skills/src/app/task/task/task.component.ts deleted file mode 100644 index b2271a6c4..000000000 --- a/projects/skills/src/app/task/task/task.component.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** @format */ - -import { - Component, - computed, - effect, - type ElementRef, - inject, - type OnInit, - signal, - ViewChild, - ViewChildren, -} from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ActivatedRoute, Router, RouterLink, RouterOutlet } from "@angular/router"; -import { map } from "rxjs"; -import type { TaskStepsResponse } from "../../../models/skill.model"; -import { ButtonComponent } from "@ui/components"; -import { TaskService } from "../services/task.service"; - -/** - * Компонент задания - * - * Основной контейнерный компонент для выполнения заданий и навигации. - * Управляет общим потоком задания, визуализацией прогресса и навигацией по шагам. - * - * Функции: - * - Визуальная полоса прогресса, показывающая статус завершения - * - Пошаговая навигация через компоненты задания - * - Автоматическая маршрутизация к следующим шагам при завершении - * - Отслеживание прогресса и управление состоянием - * - * Компонент использует Angular signals для реактивного управления состоянием - * и эффекты для побочных эффектов, таких как манипуляции с DOM и маршрутизация. - */ -@Component({ - selector: "app-task", - standalone: true, - imports: [CommonModule, RouterOutlet, ButtonComponent, RouterLink], - templateUrl: "./task.component.html", - styleUrl: "./task.component.scss", -}) -export class TaskComponent implements OnInit { - // ViewChild ссылки для манипуляций с DOM - @ViewChildren("pointEls") pointEls?: ElementRef[]; - @ViewChild("progressBarEl") progressBarEl?: ElementRef; - @ViewChild("progressDone") progressDone?: ElementRef; - - // Внедренные сервисы - route = inject(ActivatedRoute); - router = inject(Router); - taskService = inject(TaskService); - - // Реактивное состояние - skillStepsResponse = signal(null); - - constructor() { - /** - * Эффект для обновления визуальной позиции полосы прогресса - * Вычисляет позицию индикатора прогресса на основе текущего шага - */ - effect( - () => { - const targetEl = !this.taskService.currentTaskDone() - ? this.pointEls?.find(el => { - const subTaskPointId = el.nativeElement.dataset["id"]; - if (!subTaskPointId) return false; - - return Number(subTaskPointId) === this.currentSubTaskId(); - }) - : this.progressDone; - - if (targetEl && this.progressBarEl) { - const { left: leftParent } = this.progressBarEl.nativeElement.getBoundingClientRect(); - const { left: leftChild } = targetEl.nativeElement.getBoundingClientRect(); - - const left = leftChild - leftParent; - this.progressDoneWidth.set(left); - } - }, - { allowSignalWrites: true } - ); - - /** - * Эффект для определения текущего активного шага - * Устанавливает текущий шаг на основе статуса завершения и порядка шагов - */ - effect( - () => { - const skillsResponse = this.skillStepsResponse(); - if (!skillsResponse) return; - - // Сортировка шагов по ID (TODO: изменить на ordinalNumber, когда бэкенд добавит это) - const sortedSteps = skillsResponse.stepData.sort((prev, next) => prev.id - next.id); - - const doneSteps = sortedSteps.filter(step => step.isDone); - if (doneSteps.length === sortedSteps.length) return; - - // Найти следующий незавершенный шаг - const lastDoneStep = sortedSteps[doneSteps.length]; - if (lastDoneStep) { - this.currentSubTaskId.set(lastDoneStep.id); - return; - } - - // Если никакие шаги не выполнены, начать с первого шага - const firstStep = sortedSteps[0]; - this.currentSubTaskId.set(firstStep.id); - }, - { allowSignalWrites: true } - ); - - /** - * Эффект для автоматической навигации к текущему шагу - * Обновляет маршрут при изменении текущего шага - */ - effect(() => { - const subTaskId = this.currentSubTaskId(); - if (!subTaskId) return; - - this.router - .navigate(["/task", this.route.snapshot.params["taskId"], subTaskId], { - queryParams: { type: this.currentSubTask()?.type ?? "" }, - }) - .then(() => console.debug("Маршрут изменен из TaskComponent")); - }); - } - - ngOnInit() { - // Загрузка данных шагов задания из резолвера маршрута - this.route.data.pipe(map(r => r["data"])).subscribe((res: TaskStepsResponse) => { - this.skillStepsResponse.set(res); - - // Проверка, завершены ли все шаги, и перенаправление к результатам - if (res.stepData.filter(s => s.isDone).length === res.stepData.length) { - this.taskService.currentTaskDone.set(true); - this.router.navigate(["/task", this.route.snapshot.params["taskId"], "results"]); - } - }); - - // Прослушивание изменений параметров маршрута для обновления текущего шага - this.route.firstChild?.params - .pipe( - map(r => r["subTaskId"]), - map(Number) - ) - .subscribe(s => { - this.currentSubTaskId.set(s); - }); - - // Отладочное логирование для изменений маршрута - this.route.firstChild?.url.subscribe(console.log); - } - - // Вычисляемые свойства и сигналы - progressDoneWidth = signal(0); - - /** - * Вычисляемый массив всех ID шагов задания - */ - taskIds = computed(() => { - const stepsResponse = this.skillStepsResponse(); - if (!stepsResponse) return []; - - return stepsResponse.stepData.map(s => s.id); - }); - - currentSubTaskId = signal(null); - - /** - * Вычисляемые данные текущего шага - */ - currentSubTask = computed(() => { - const stepsResponse = this.skillStepsResponse(); - const subTaskId = this.currentSubTaskId(); - - if (!stepsResponse || !subTaskId) return; - - return stepsResponse.stepData.find(step => step.id === subTaskId); - }); - - /** - * Вычисляемый массив завершенных ID заданий для визуализации прогресса - */ - doneTasks = computed(() => { - const subTaskId = this.currentSubTaskId(); - if (!subTaskId) return []; - - return this.taskIds()?.filter(t => t <= subTaskId); - }); -} diff --git a/projects/skills/src/app/trajectories/track-bussiness/track-bussiness.component.html b/projects/skills/src/app/trajectories/track-bussiness/track-bussiness.component.html deleted file mode 100644 index dd7864ed4..000000000 --- a/projects/skills/src/app/trajectories/track-bussiness/track-bussiness.component.html +++ /dev/null @@ -1,14 +0,0 @@ - - -
-
- -
- -
- soon -

Скоро...

-
- - -
diff --git a/projects/skills/src/app/trajectories/track-bussiness/track-bussiness.component.scss b/projects/skills/src/app/trajectories/track-bussiness/track-bussiness.component.scss deleted file mode 100644 index 22357e787..000000000 --- a/projects/skills/src/app/trajectories/track-bussiness/track-bussiness.component.scss +++ /dev/null @@ -1,34 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; - -.profile { - display: flex; - flex-direction: column; - - &__overlay { - position: absolute; - top: 0%; - right: 0; - bottom: 0; - left: 0; - z-index: 10; - align-content: center; - align-items: center; - justify-content: center; - text-align: center; - - @include responsive.apply-desktop { - top: 500%; - } - - &__text { - @include typography.heading-1; - - color: var(--grey-for-text); - } - - &__image { - margin: 0 auto; - } - } -} diff --git a/projects/skills/src/app/trajectories/track-bussiness/track-bussiness.component.spec.ts b/projects/skills/src/app/trajectories/track-bussiness/track-bussiness.component.spec.ts deleted file mode 100644 index 36b2cb0e3..000000000 --- a/projects/skills/src/app/trajectories/track-bussiness/track-bussiness.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { TrackBussinessComponent } from "./track-bussiness.component"; - -describe("TrackBussinessComponent", () => { - let component: TrackBussinessComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TrackBussinessComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(TrackBussinessComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/trajectories/track-bussiness/track-bussiness.component.ts b/projects/skills/src/app/trajectories/track-bussiness/track-bussiness.component.ts deleted file mode 100644 index ba2e78797..000000000 --- a/projects/skills/src/app/trajectories/track-bussiness/track-bussiness.component.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; -import { RouterLink, RouterModule } from "@angular/router"; -import { BarComponent } from "@ui/components"; - -@Component({ - selector: "app-track-bussiness", - standalone: true, - imports: [CommonModule, BarComponent, RouterModule], - templateUrl: "./track-bussiness.component.html", - styleUrl: "./track-bussiness.component.scss", -}) -export class TrackBussinessComponent {} diff --git a/projects/skills/src/app/trajectories/track-bussiness/track-bussiness.routes.ts b/projects/skills/src/app/trajectories/track-bussiness/track-bussiness.routes.ts deleted file mode 100644 index 867e8c58d..000000000 --- a/projects/skills/src/app/trajectories/track-bussiness/track-bussiness.routes.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { TrackBussinessComponent } from "./track-bussiness.component"; - -export const TRACK_BUSSINESS_ROUTES: Routes = [ - { - path: "", - component: TrackBussinessComponent, - }, -]; diff --git a/projects/skills/src/app/trajectories/track-career/detail/info/info.component.html b/projects/skills/src/app/trajectories/track-career/detail/info/info.component.html index 954f53e35..3d62690b5 100644 --- a/projects/skills/src/app/trajectories/track-career/detail/info/info.component.html +++ b/projects/skills/src/app/trajectories/track-career/detail/info/info.component.html @@ -1,213 +1,71 @@ -
-
-
-

{{ trajectory.name }}

- cover image -
+@if (trajectory) { +
+
+
-
- @if (trajectory.description) { @if (desktopMode$ | async; as desktopMode) { -
-

-
- } @else { -
-

- @if (descriptionExpandable) { -
- {{ readFullDescription ? "Скрыть" : "Читать полностью" }} +
+
+
+

о курсе

+ +
+ @if (trajectory.description) { +
+

+ @if (descriptionExpandable) { +
+ {{ readFullDescription ? "cкрыть" : "подробнее" }} +
+ }
}
- } } -
- -
-
-

- Стартовая встреча с наставником -

- -
- -
-

- Финальная встреча с наставником -

- -
-
-
-
- -
-
-

Персональные навыки

-
- @if (userTrajectory()?.individualSkills) { @for(skill of userTrajectory()?.individualSkills; - track $index) { - - } }
+ -
-

Навыки текущего месяца

-
- @if (userTrajectory()?.availableSkills) { @for(skill of availableSkills(); track $index) { - - } } @if (completeAllMainSkills()) { - complete all - } -
-
+ +
+

ты прошел модуль!

-
-

Грядущие навыки

-
- @if (userTrajectory()?.unavailableSkills) { @for(skill of - userTrajectory()?.unavailableSkills; track $index) { - - } } -
-
+ complete module image -
-

Пройденные навыки

-
- @if (userTrajectory()?.completedSkills) { @for (skill of completedSkills(); track $index) { - - } } -
+ отлично
-
-
+ +
+} diff --git a/projects/skills/src/app/trajectories/track-career/detail/info/info.component.scss b/projects/skills/src/app/trajectories/track-career/detail/info/info.component.scss index b175f6830..14cf4cbf8 100644 --- a/projects/skills/src/app/trajectories/track-career/detail/info/info.component.scss +++ b/projects/skills/src/app/trajectories/track-career/detail/info/info.component.scss @@ -1,55 +1,117 @@ @use "styles/responsive"; @use "styles/typography"; -@mixin expandable-list { - &__remaining { +.trajectory { + padding-top: 12px; + padding-bottom: 100px; + + @include responsive.apply-desktop { + padding-bottom: 0; + } + + &__main { display: grid; - grid-template-rows: 0fr; - overflow: hidden; - transition: all 0.5s ease-in-out; + grid-template-columns: 1fr; + } - &--show { - grid-template-rows: 1fr; - margin-top: 12px; - } + &__right { + display: flex; + flex-direction: column; + max-height: 226px; + } - ul { - min-height: 0; + &__left { + max-width: 157px; + } + + &__section { + padding: 24px; + margin-bottom: 14px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } + + &__info { + @include responsive.apply-desktop { + grid-column: span 3; } + } - li { - &:first-child { - margin-top: 12px; - } + &__details { + display: grid; + grid-template-columns: 2fr 5fr 3fr; + grid-gap: 20px; + } - &:not(:last-child) { - margin-bottom: 12px; + &__progress { + position: relative; + height: 48px; + padding: 10px; + text-align: center; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + &--cover { + position: absolute; + top: 0%; + left: 0%; + height: 48px; + background-color: var(--accent); + border-radius: var(--rounded-lg); + opacity: 0.15; + + &--complete { + background-color: var(--green-dark); } } - } -} -.read-more { - margin-top: 12px; - color: var(--accent); - cursor: pointer; - transition: color 0.2s; + &--percent { + color: var(--green-dark); + } - &:hover { - color: var(--accent-dark); + &--complete { + color: var(--black); + } } - @include typography.body-14; + &__skills { + display: flex; + flex-direction: column; + gap: 20px; + margin-top: 18px; + } } .about { + padding: 24px; + background-color: var(--light-white); + border-radius: var(--rounded-lg); + + &__head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + + &--icon { + color: var(--accent); + } + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } /* stylelint-disable value-no-vendor-prefix */ &__text { p { display: -webkit-box; overflow: hidden; - color: var(--dark-grey); + color: var(--black); text-overflow: ellipsis; -webkit-box-orient: vertical; -webkit-line-clamp: 5; @@ -72,273 +134,40 @@ } } } - /* stylelint-enable value-no-vendor-prefix */ &__read-full { - margin-top: 2px; + margin-top: 8px; color: var(--accent); cursor: pointer; } } -.trajectory__infoItem { - @include responsive.apply-desktop { - margin-left: 50px; - } - - .trajectory { - display: flex; - flex-direction: column; - align-items: center; - justify-content: space-between; - - @include responsive.apply-desktop { - flex-direction: row; - } - - &__cover { - display: flex; - flex-basis: 200%; - flex-direction: column; - gap: 30px; - align-items: center; - width: 100%; - - img { - width: 150px; - height: 130px; - } - - @include responsive.apply-desktop { - align-items: flex-start; - - img { - width: 275px; - height: 275px; - } - } - } - - &__image { - width: 40px; - height: 40px; - border-radius: 50%; - object-fit: cover; - } - - &__title { - @include typography.heading-4; - - width: 100%; - - @include responsive.apply-desktop { - @include typography.heading-2; - } - } - - &__info { - display: flex; - flex-basis: 270%; - flex-direction: column; - gap: 10px; - } - - &__info--additional { - display: none; - flex-direction: column; - gap: 12px; - align-items: center; - justify-content: space-between; - padding: 10px 25px; - border: 1px solid var(--grey-button); - border-radius: 15px; - - @include responsive.apply-desktop { - flex-direction: row; - } - } - - &__timeline { - display: flex; - flex-direction: column; - align-items: center; - justify-content: space-between; - padding: 10px 25px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - - ::ng-deep app-month-block { - display: block; - width: 100%; - - .month { - display: flex; - justify-content: center; - } - - .item { - max-width: 65px; - height: 20px; - - &__name { - top: -40%; - left: 40%; - font-size: 10px; - font-weight: 600; - - @include responsive.apply-desktop { - top: -50%; - left: 44%; - - @include typography.body-14; - } - } - - @include responsive.apply-desktop { - max-width: 120px; - } - } - } - - @include responsive.apply-desktop { - flex-direction: row; - } - } - - &__mentor { - display: none; - flex-direction: column; - gap: 20px; - align-items: flex-start; - justify-content: space-between; - padding: 10px 25px; - border: 1px solid var(--grey-button); - border-radius: 15px; - - @include responsive.apply-desktop { - flex-direction: row; - gap: 12px; - align-items: center; - } - } - - &__description { - height: auto; - padding: 18px 25px; - overflow-y: auto; - border: 1px solid var(--grey-button); - border-radius: 15px; +.read-more { + margin-top: 8px; + color: var(--accent); + cursor: pointer; + transition: background-color 0.2s; - @include responsive.apply-desktop { - height: 235px; - overflow-y: auto; - } - } + &:hover { + color: var(--accent-dark); } +} - .timeline { - &__images { - display: flex; - flex-direction: row; - gap: 20px; - align-items: center; - } - - &__date { - color: var(--dark-grey); - - @include responsive.apply-desktop { - align-self: center !important; - } - } - - &__start { - align-self: baseline; - margin-top: 10px; - color: var(--dark-grey); - - @include responsive.apply-desktop { - margin: 0; - } - } - - &__end { - align-self: end; - margin-bottom: 10px; - color: var(--dark-grey); - - @include responsive.apply-desktop { - margin: 0; - } - } - } +.cancel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + max-height: calc(100vh - 40px); + padding: 0 200px; - .info__additional { - display: flex; - gap: 10px; - align-items: center; - padding: 5px 24px; + &__text { + color: var(--dark-grey); text-align: center; - border-radius: 15px; - - p { - width: 100%; - } } - .mentor { - &__years { - color: var(--dark-grey); - } - - &__info { - display: flex; - gap: 12px; - align-items: center; - } - } - - .skills { - display: flex; - flex-direction: column; - gap: 20px; - margin-top: 10px; - - &__future-container, - &__personal-container, - &__past-container { - display: none; - } - - &__now-container { - display: flex; - flex-direction: column; - gap: 10px; - margin-top: 20px; - - h4 { - display: none; - } - } - - &__now, - &__past, - &__personal, - &__future { - display: grid; - grid-template-columns: 1fr; - gap: 18px; - - @include responsive.apply-desktop { - grid-template-columns: repeat(3, 1fr); - } - } - - &__complete { - width: 100%; - height: 100%; - } + &__img { + margin: 30px 0; } } diff --git a/projects/skills/src/app/trajectories/track-career/detail/info/info.component.ts b/projects/skills/src/app/trajectories/track-career/detail/info/info.component.ts index 8adbd1558..ff0ae407b 100644 --- a/projects/skills/src/app/trajectories/track-career/detail/info/info.component.ts +++ b/projects/skills/src/app/trajectories/track-career/detail/info/info.component.ts @@ -4,7 +4,6 @@ import { type AfterViewInit, ChangeDetectorRef, Component, - computed, type ElementRef, inject, type OnInit, @@ -13,19 +12,17 @@ import { } from "@angular/core"; import { ActivatedRoute, Router, RouterModule } from "@angular/router"; import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; -import { ButtonComponent } from "@ui/components"; -import { AvatarComponent, IconComponent } from "@uilib"; +import { IconComponent } from "@uilib"; import { expandElement } from "@utils/expand-element"; import { map, type Observable, type Subscription } from "rxjs"; -import { SkillCardComponent } from "../../../../skills/shared/skill-card/skill-card.component"; import { CommonModule } from "@angular/common"; -import { MonthBlockComponent } from "projects/skills/src/app/profile/shared/month-block/month-block.component"; import type { Trajectory, UserTrajectory } from "projects/skills/src/models/trajectory.model"; import { TrajectoriesService } from "../../../trajectories.service"; -import type { Month, UserData } from "projects/skills/src/models/profile.model"; -import { ProfileService } from "projects/skills/src/app/profile/services/profile.service"; -import { SkillService } from "projects/skills/src/app/skills/services/skill.service"; import { BreakpointObserver } from "@angular/cdk/layout"; +import { SoonCardComponent } from "@office/shared/soon-card/soon-card.component"; +import { SkillCardComponent } from "projects/skills/src/app/shared/skill-card/skill-card.component"; +import { ModalComponent } from "@ui/components/modal/modal.component"; +import { ButtonComponent } from "@ui/components"; /** * Компонент детальной информации о траектории @@ -42,13 +39,14 @@ import { BreakpointObserver } from "@angular/cdk/layout"; standalone: true, imports: [ IconComponent, - ButtonComponent, RouterModule, ParseBreaksPipe, ParseLinksPipe, - SkillCardComponent, - AvatarComponent, CommonModule, + SoonCardComponent, + SkillCardComponent, + ModalComponent, + ButtonComponent, ], templateUrl: "./info.component.html", styleUrl: "./info.component.scss", @@ -60,29 +58,14 @@ export class TrajectoryInfoComponent implements OnInit, AfterViewInit { cdRef = inject(ChangeDetectorRef); trajectoryService = inject(TrajectoriesService); - profileService = inject(ProfileService); - skillService = inject(SkillService); breakpointObserver = inject(BreakpointObserver); subscriptions$: Subscription[] = []; trajectory!: Trajectory; userTrajectory = signal(null); - profileId!: number; - - // Вычисляемые свойства для состояния навыков - completeAllMainSkills = computed( - () => this.userTrajectory()?.availableSkills.every(skill => skill.isDone) ?? false - ); - - availableSkills = computed( - () => this.userTrajectory()?.availableSkills.filter(s => !s.isDone) ?? [] - ); - completedSkills = computed(() => [ - ...(this.userTrajectory()?.completedSkills ?? []), - ...(this.userTrajectory()?.availableSkills.filter(s => s.isDone) ?? []), - ]); + isCompleteModule = signal(false); @ViewChild("descEl") descEl?: ElementRef; @@ -95,44 +78,17 @@ export class TrajectoryInfoComponent implements OnInit, AfterViewInit { * Загружает данные траектории, пользовательскую информацию и настраивает навыки */ ngOnInit(): void { - this.desktopMode$.subscribe(r => console.log(r)); + this.desktopMode$.subscribe(_ => {}); - this.route.data.pipe(map(r => r["data"])).subscribe(r => { + this.route.parent?.data.pipe(map(r => r["data"])).subscribe(r => { this.trajectory = r[0]; this.userTrajectory.set({ ...r[1], individualSkills: r[2] }); - - // Настройка доступности навыков - this.userTrajectory()?.availableSkills.forEach(i => (i.freeAccess = true)); - this.userTrajectory()?.completedSkills.forEach(i => { - i.freeAccess = true; - i.isDone = true; - }); - this.userTrajectory()?.unavailableSkills.forEach(i => (i.freeAccess = true)); - this.userTrajectory()?.individualSkills.forEach(i => (i.freeAccess = true)); - }); - - this.profileService.getUserData().subscribe((r: UserData) => { - this.profileId = r.id; - }); - - // Создание макета месяцев для временной шкалы - this.mockMonts = Array.from({ length: this.userTrajectory()!.durationMonths }, (_, index) => { - const monthNumber = index + 1; - - return { - month: `${monthNumber} месяц`, - successfullyDone: monthNumber <= this.userTrajectory()!.activeMonth, - }; + this.isCompleteModule.set(this.userTrajectory()!.completedSkills.some(skill => skill.isDone)); }); } - mockMonts: Month[] = []; - - placeholderUrl = - "https://uch-ibadan.org.ng/wp-content/uploads/2021/10/Profile_avatar_placeholder_large.png"; - - readFullDescription = false; descriptionExpandable?: boolean; + readFullDescription!: boolean; /** * Проверка возможности расширения описания после инициализации представления @@ -168,7 +124,6 @@ export class TrajectoryInfoComponent implements OnInit, AfterViewInit { * @param skillId - ID выбранного навыка */ onSkillClick(skillId: number) { - this.skillService.setSkillId(skillId); this.router.navigate(["skills", skillId]); } } diff --git a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.html b/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.html index ce09c1587..8c3bec611 100644 --- a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.html +++ b/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.html @@ -1,14 +1,46 @@
- -
+
+ @if(trajectory()) { +
+
+ +
+ + @if (!isTaskDetail()) { +

{{ trajectory()?.name }}

+ } +
+
+ +
+
+ {{ isTaskDetail() ? "назад к модулю?" : "назад" }} - + вернуться в программу +
+
+ + + } +
+
diff --git a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.scss b/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.scss index e69de29bb..ebab1169d 100644 --- a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.scss +++ b/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.scss @@ -0,0 +1,102 @@ +@use "styles/responsive"; +@use "styles/typography"; + +$detail-bar-height: 63px; +$detail-bar-mb: 12px; + +.detail { + display: flex; + flex-direction: column; + height: 100%; + max-height: 100%; + padding-top: 20px; + + &__body { + flex-grow: 1; + max-height: calc(100% - #{$detail-bar-height} - #{$detail-bar-mb}); + padding-bottom: 12px; + } +} + +.info { + $body-slide: 15px; + + position: relative; + padding: 0; + background-color: transparent; + border: none; + border-radius: $body-slide; + + &__cover { + position: relative; + height: 136px; + background: linear-gradient(var(--accent), var(--lime)); + border-radius: 15px 15px 0 0; + } + + &__body { + position: relative; + z-index: 2; + } + + &__avatar { + position: absolute; + bottom: -10px; + left: 50%; + z-index: 100; + display: block; + cursor: pointer; + background-color: var(--white); + border-radius: 50%; + + &--program { + bottom: 15px; + } + + @include responsive.apply-desktop { + transform: translate(-50%, 50%); + } + } + + &__row { + display: flex; + gap: 20px; + align-items: center; + justify-content: center; + margin-top: 2px; + + @include responsive.apply-desktop { + justify-content: unset; + margin-top: 0; + } + } + + &__title { + margin-top: 10px; + overflow: hidden; + color: var(--black); + text-align: center; + text-overflow: ellipsis; + + &--project { + transform: translateX(-31%); + } + } + + &__text { + color: var(--dark-grey); + } + + &__actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 180px; + align-items: center; + padding: 24px 0 30px; + + &--disabled { + cursor: not-allowed; + opacity: 0.5; + } + } +} diff --git a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.ts b/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.ts index 2b33ff185..264bd1cf8 100644 --- a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.ts +++ b/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.ts @@ -1,11 +1,14 @@ /** @format */ import { CommonModule } from "@angular/common"; -import { Component, inject, type OnDestroy, type OnInit } from "@angular/core"; -import { ActivatedRoute, RouterOutlet } from "@angular/router"; -import { BarComponent } from "@ui/components"; +import { Component, DestroyRef, inject, signal, type OnDestroy, type OnInit } from "@angular/core"; +import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from "@angular/router"; import type { Trajectory } from "projects/skills/src/models/trajectory.model"; -import { map, type Subscription } from "rxjs"; +import { filter, map, type Subscription } from "rxjs"; +import { AvatarComponent } from "@ui/components/avatar/avatar.component"; +import { ButtonComponent } from "@ui/components"; +import { ModalComponent } from "@ui/components/modal/modal.component"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; /** * Компонент детального просмотра траектории @@ -15,33 +18,45 @@ import { map, type Subscription } from "rxjs"; @Component({ selector: "app-trajectory-detail", standalone: true, - imports: [CommonModule, BarComponent, RouterOutlet], + imports: [CommonModule, RouterOutlet, AvatarComponent, ButtonComponent, ModalComponent], templateUrl: "./trajectory-detail.component.html", styleUrl: "./trajectory-detail.component.scss", }) export class TrajectoryDetailComponent implements OnInit, OnDestroy { route = inject(ActivatedRoute); + router = inject(Router); + destroyRef = inject(DestroyRef); subscriptions$: Subscription[] = []; - trajectory?: Trajectory; - trackId?: string; + trajectory = signal(undefined); + isDisabled = signal(false); + isTaskDetail = signal(false); /** * Инициализация компонента * Подписывается на параметры маршрута и данные траектории */ ngOnInit(): void { - const trackIdSub = this.route.params.subscribe(params => { - this.trackId = params["trackId"]; - }); + this.route.data + .pipe( + map(data => data["data"]), + filter(trajectory => !!trajectory) + ) + .subscribe({ + next: trajectory => { + this.trajectory.set(trajectory[0]); + }, + }); - const trajectorySub$ = this.route.data.pipe(map(r => r["data"])).subscribe(trajectory => { - this.trajectory = trajectory; - }); - - trajectorySub$ && this.subscriptions$.push(trajectorySub$); - trackIdSub && this.subscriptions$.push(trackIdSub); + this.router.events + .pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + this.isTaskDetail.set(this.router.url.includes("task")); + }); } /** @@ -51,4 +66,15 @@ export class TrajectoryDetailComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.subscriptions$.forEach($ => $.unsubscribe()); } + + /** + * Перенаправляет на страницу с информацией в завивисимости от listType + */ + redirectDetailInfo(trackId?: number): void { + if (this.trajectory()) { + this.router.navigateByUrl(`/trackCar/${trackId}`); + } else { + this.router.navigateByUrl("/trackCar/all"); + } + } } diff --git a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.routes.ts b/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.routes.ts index 10715ba00..fed430607 100644 --- a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.routes.ts +++ b/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.routes.ts @@ -10,14 +10,17 @@ export const TRAJECTORY_DETAIL_ROUTES = [ path: "", component: TrajectoryDetailComponent, canActivate: [TrajectoryInfoRequiredGuard], - + resolve: { + data: TrajectoryDetailResolver, + }, children: [ { path: "", component: TrajectoryInfoComponent, - resolve: { - data: TrajectoryDetailResolver, - }, + }, + { + path: "task", + loadChildren: () => import("../../../task/task.routes").then(m => m.TASK_ROUTES), }, ], }, diff --git a/projects/skills/src/app/trajectories/track-career/list/list.component.html b/projects/skills/src/app/trajectories/track-career/list/list.component.html index ffcd70f9b..7d4222623 100644 --- a/projects/skills/src/app/trajectories/track-career/list/list.component.html +++ b/projects/skills/src/app/trajectories/track-career/list/list.component.html @@ -1,10 +1,13 @@ -
- @if (hasItems()) { @if (type() === 'all') { @for (trajectory of trajectoriesList().slice(0, 2); - track $index) { - - } } @else if (type() === 'my') { - - } } + diff --git a/projects/skills/src/app/trajectories/track-career/list/list.component.scss b/projects/skills/src/app/trajectories/track-career/list/list.component.scss index 7987768d9..ef3eb00e7 100644 --- a/projects/skills/src/app/trajectories/track-career/list/list.component.scss +++ b/projects/skills/src/app/trajectories/track-career/list/list.component.scss @@ -1,6 +1,9 @@ -.list { - display: flex; - flex-direction: column; - gap: 12px; - margin-bottom: 20px; +.trajectories { + margin-top: 20px; + + &__list { + display: grid; + grid-template-columns: 4fr 4fr; + grid-gap: 20px; + } } diff --git a/projects/skills/src/app/trajectories/track-career/list/list.component.ts b/projects/skills/src/app/trajectories/track-career/list/list.component.ts index b628438d0..5c1de0690 100644 --- a/projects/skills/src/app/trajectories/track-career/list/list.component.ts +++ b/projects/skills/src/app/trajectories/track-career/list/list.component.ts @@ -4,7 +4,6 @@ import { type AfterViewInit, ChangeDetectorRef, Component, - computed, inject, type OnDestroy, type OnInit, @@ -38,12 +37,9 @@ export class TrajectoriesListComponent implements OnInit, AfterViewInit, OnDestr totalItemsCount = signal(0); trajectoriesList = signal([]); - userTrajectory = signal(undefined); trajectoriesPage = signal(1); perFetchTake = signal(20); - type = signal<"all" | "my" | null>(null); - subscriptions$ = signal([]); /** @@ -51,16 +47,9 @@ export class TrajectoriesListComponent implements OnInit, AfterViewInit, OnDestr * Определяет тип списка (all/my) и загружает начальные данные */ ngOnInit(): void { - const currentType = this.router.url.split("/").pop() as "all" | "my"; - this.type.set(currentType); - this.route.data.pipe(map(r => r["data"])).subscribe(data => { - if (currentType === "all") { - this.trajectoriesList.set(data as Trajectory[]); - this.totalItemsCount.set((data as Trajectory[]).length); - } else { - this.userTrajectory.set(data as Trajectory); - } + this.trajectoriesList.set(data as Trajectory[]); + this.totalItemsCount.set((data as Trajectory[]).length); }); } @@ -129,8 +118,4 @@ export class TrajectoriesListComponent implements OnInit, AfterViewInit, OnDestr map(res => res) ); } - - hasItems = computed(() => { - return this.type() === "all" ? this.trajectoriesList().length > 0 : !!this.userTrajectory(); - }); } diff --git a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.html b/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.html index 23d6f2e01..7ce78cd10 100644 --- a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.html +++ b/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.html @@ -1,315 +1,49 @@ -@if (trajectory) { -
- - -
-
-
-

{{ trajectory.name }}

-
- @if (trajectory.description) { @if (desktopMode$ | async; as desktopMode) { -
-

-
- } @else { -
-

- @if (descriptionExpandable) { -
- {{ readFullDescription ? "Скрыть" : "Читать полностью" }} -
- } -
- } } -
-
- -
-

- {{ trajectory.durationMonths }} - {{ trajectory.durationMonths | pluralize: ["месяц", "месяца", "месяцев"] }} - -

-

ряд 43, место А320

-
-
- -
-
-

- Навыки которые ты прокачаешь -

-

Посадочный талон

-
-
-
    - @if(trajectory.skills.length > 0){ @for(skill of trajectory.skills; track $index){ -
  • - skill image -

    {{ skill.name }}

    -
  • - } } -
- - -
- -
-

- -

-
- @if (type() === "my") { - Перейти - } @else { - - Выбрать - } -
-
-
-
-
-} @else { -

- У вас нет выбранной траектории! -

-} - - -
-
-
-

Доступно в подписке

-

- Эта программа — не просто обучение,
а полноценный старт в карьере с поддержкой на - каждом этапе. Вот что делает её уникальной: -

- -
    - @for(advantage of trajectoryMore; track $index){ -
  • - -

    {{ advantage.label }}

    -
  • - } -
-
- - more image -
- +@if(trajectory) { +
+
+ + Назад + {{ + isMember() + ? "для участников программы" + : isSubs() + ? "доступно по подписке" + : "доступно всем пользователям" + }} +
- - - -
-
- more image -

Подтверждение

-

- Теперь у вас есть доступ ко всем возможностям платформы. Начните использовать прямо сейчас! -

-
- Назад - - Поехали -
-
+
+

{{ trajectory.name | truncate: 50 }}

+

+ {{ + isDates() + ? "16.02.2026 - 16.04.2026" + : isDate() + ? "16.02.2026" + : isEnded() + ? "курс завершен" + : "доступен до 16.04.2026" + }} +

- - - -
-

У вас нет активной подписки

-
- more image -

- Чтобы получить доступ ко всем возможностям платформы, оформите подписку прямо сейчас! -

- -
- Назад - Купить -
-
+
+ @if (!isStarted()) { + + } @else { +

+ {{ isStarted() ? "начать" : "продолжить обучение" }} +

+ + }
- - - -
-

У вас уже есть активная траектория!

-
- more image - -
- Перейти -
-
-
-
- - -
-
-
- more image - - more image - - more image - - more image -
-

Узнай больше

-
-

- Навыки открываются раз в месяц, за месяц тебе необходимо пройти путь навыков, которые - отображаются в разделе "Навыки на месяц -

- -

- Здесь отображаются,которые буду ждать тебя дальше -

- -

- Здесь отображаются пройденные навыки -

- -

- Здесь отображаются пройденные навыки -

-
- -
- -
- @for(dot of dotsArray(4); track $index){ -
- } -
- -
-
-
-
+
+} diff --git a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.scss b/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.scss index ae0306472..513120114 100644 --- a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.scss +++ b/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.scss @@ -1,488 +1,76 @@ @use "styles/responsive"; @use "styles/typography"; -@mixin expandable-list { - &__remaining { - display: grid; - grid-template-rows: 0fr; - overflow: hidden; - transition: all 0.5s ease-in-out; - - &--show { - grid-template-rows: 1fr; - margin-top: 12px; - } - - ul { - min-height: 0; - } - - li { - &:first-child { - margin-top: 12px; - } - - &:not(:last-child) { - margin-bottom: 12px; - } - } - } -} - -.read-more { - margin-top: 12px; - color: var(--accent); +.trajectory { + position: relative; + width: 100%; + max-width: 333px; + overflow: hidden; cursor: pointer; - transition: color 0.2s; - - &:hover { - color: var(--accent-dark); - } - - @include typography.body-14; -} - -.about { - - /* stylelint-disable value-no-vendor-prefix */ - &__text { - p { - display: -webkit-box; - overflow: hidden; - color: var(--dark-grey); - text-overflow: ellipsis; - -webkit-box-orient: vertical; - -webkit-line-clamp: 5; - transition: all 0.7s ease-in-out; - - &.expanded { - -webkit-line-clamp: unset; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + &__cover { + padding: 35px 24px; + background: linear-gradient(var(--accent), var(--lime)); + border-radius: var(--rounded-lg); + + app-button { + ::ng-deep { + .button { + position: absolute; + top: 15px; + right: 24px; + width: 166px; + padding: 0; + } } } - ::ng-deep a { - color: var(--accent); - text-decoration: underline; - text-decoration-color: transparent; - text-underline-offset: 3px; - transition: text-decoration-color 0.2s; - - &:hover { - text-decoration-color: var(--accent); + app-avatar { + ::ng-deep { + .avatar { + img { + box-shadow: 0 0 8px rgba($color: #333, $alpha: 30%); + } + } } } } - /* stylelint-enable value-no-vendor-prefix */ - - &__read-full { - margin-top: 2px; - color: var(--accent); - cursor: pointer; - } -} - -.trajectory { - position: relative; - height: 100%; - padding: 18px 0 20px 18px; - margin-bottom: 30px; - border: 1px solid var(--grey-button); - border-radius: 15px; - - &__container { + &__info { display: flex; flex-direction: column; - width: 100%; + gap: 2px; + padding: 12px 24px; - @include responsive.apply-desktop { - flex-direction: row; + &--date { + color: var(--grey-for-text) !important; } } - &__line { + &__action { position: absolute; - bottom: 34%; - left: 44%; - width: 1px; - transform: rotate(90deg); + right: 14px; + bottom: 16px; - @include responsive.apply-desktop { - top: 0; - left: 37%; - height: 100%; - transform: rotate(0); + &--started { + color: var(--green) !important; } } &__rocket { position: absolute; - top: 0%; - right: 0%; + top: -46%; + right: -13%; z-index: 0; - display: none; - - @include responsive.apply-desktop { - display: block; - } - } - - &__images { - display: flex; - justify-content: center; - } - - &__image { - width: 85px; - height: 80px; - border-radius: 50%; - object-fit: cover; - - @include responsive.apply-desktop { - width: 150px; - height: 135px; - } - } - - &__seat { - p { - display: none; - - @include responsive.apply-desktop { - display: block; - } - } - } - - &__date { - display: block !important; - margin-top: 48px; - margin-bottom: 36px; - - @include responsive.apply-desktop { - margin: 0; - } - } - - &__description { - margin-top: 14px; - - p { - display: box; - overflow: hidden; - color: var(--dark-grey); - text-overflow: ellipsis; - -webkit-box-orient: vertical; - -webkit-line-clamp: 5; - transition: all 0.7s ease-in-out; - - &.expanded { - -webkit-line-clamp: unset; - } - } - - ::ng-deep a { - color: var(--accent); - text-decoration: underline; - text-decoration-color: transparent; - text-underline-offset: 3px; - transition: text-decoration-color 0.2s; - - &:hover { - text-decoration-color: var(--accent); - } - } - - &__read-full { - margin-top: 2px; - color: var(--accent); - cursor: pointer; - } - } - - &__info { - display: flex; - flex-basis: 36%; - flex-direction: column; - justify-content: space-between; - } - - &__content { - margin-right: 20px; - } - - &__skills { - width: 100%; - margin: 0; - - @include responsive.apply-desktop { - width: 60%; - margin-left: 30px; - } - - &-top { - display: flex; - align-items: center; - justify-content: space-between; - - p { - display: none; - - @include responsive.apply-desktop { - display: inline; - } - } - } - - &-bottom { - position: sticky; - z-index: 10; - display: flex; - flex-direction: column; - margin-top: 20px; - - @include responsive.apply-desktop { - flex-direction: row; - align-items: center; - justify-content: space-between; - } - } - - &-list { - display: flex; - flex-direction: column; - gap: 10px; - height: 225px; - } - } - - &__buttons-group { - display: flex; - gap: 5px; - align-items: center; - justify-content: center; - } - - &__inner { - display: flex; - align-items: flex-start; - justify-content: space-between; - margin-top: 20px; - } - - &__skill { - display: flex; - gap: 15px; - align-items: center; - - .skill__image { - width: 40px; - height: 40px; - border-radius: 100%; - } - } -} - -.button__pick { - width: 90%; -} - -.cancel { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 340px; - max-height: calc(100vh - 40px); - padding: 24px 20px 20px 24px; - overflow-y: auto; - - @include responsive.apply-desktop { - width: 800px; - padding: 52px 50px 24px 42px; - } - - &__info { - display: flex; - flex-direction: column-reverse; - - @include responsive.apply-desktop { - display: flex; - flex-direction: row; - gap: 80px; - align-items: flex-start; - justify-content: space-between; - } - } - - &__image { - display: flex; - align-items: center; - align-self: center; - justify-content: center; - width: 130px; - height: 110px; - margin-bottom: 18px; - - @include responsive.apply-desktop { - width: 100%; - height: 218px; - } - } - - &__title { - margin-bottom: 10px; - color: var(--accent); - } - - &__text { - margin-bottom: 5px; - - @include responsive.apply-desktop { - width: 108%; - margin-bottom: 10px; - } - } - - &__subtext { - width: 100%; - margin-bottom: 24px; - - @include responsive.apply-desktop { - width: 60%; - } - } - - &__text-block { - display: flex; - justify-content: center; - } - - &__button { - width: 100%; - text-align: center; - - @include responsive.apply-desktop { - width: 20%; - } - } - - &__advantages { - display: flex; - flex-direction: column; - } - - &__advantage { - display: flex; - gap: 10px; - align-items: center; - - p { - width: 105%; - } - } - - &__buttons-group { - display: flex; - gap: 15px; - } - - &__confirm { - display: flex; - flex-direction: column; - align-items: center; - - @include responsive.apply-desktop { - align-items: center; - text-align: center; - } - } -} - -.confirm { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 340px; - padding: 0; - text-align: center; - - @include responsive.apply-desktop { - width: 750px; - padding: 20px 50px 24px; - } - - &__title { - margin-bottom: 20px; - } - - &__image { - width: 130px; - height: 125px; - margin-top: 24px; - margin-bottom: 18px; - - @include responsive.apply-desktop { - width: 100%; - height: 218px; - } - } - - &__explaining { - width: 278px; - height: 125px; - margin-top: 24px; - margin-bottom: 18px; - - @include responsive.apply-desktop { - width: 100%; - height: 150px; - } - } - - &__dots-group { - display: flex; - gap: 40px; - align-items: center; - } - - .dots-group { - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 25px; - - @include responsive.apply-desktop { - gap: 10px; - font-size: 20px; - font-weight: 600; - } - - &__icon { - width: 32px; - height: 32px; - cursor: pointer; - } - - &-prev { - color: var(--dark-grey); - } - - &-next { - transform: rotate(180deg); - } - - &__dots { - display: flex; - gap: 18px; - align-items: center; - } + width: 175px; + transform: rotate(-53deg); } - .dot { - width: 8px; - height: 8px; - border-radius: 100%; + p, + i { + color: var(--black); } } diff --git a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.ts b/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.ts index e31625742..6a73d4d11 100644 --- a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.ts +++ b/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.ts @@ -1,30 +1,12 @@ /** @format */ import { CommonModule } from "@angular/common"; -import { - type AfterViewInit, - ChangeDetectorRef, - Component, - type ElementRef, - inject, - Input, - type OnInit, - signal, - ViewChild, -} from "@angular/core"; +import { Component, inject, Input, signal } from "@angular/core"; import { Router, RouterModule } from "@angular/router"; -import { ButtonComponent } from "@ui/components"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { TrajectoriesService } from "../../../trajectories.service"; -import { DomSanitizer } from "@angular/platform-browser"; -import { expandElement } from "@utils/expand-element"; -import { IconComponent } from "@uilib"; -import { ParseBreaksPipe, ParseLinksPipe, PluralizePipe } from "@corelib"; +import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; import { Trajectory } from "projects/skills/src/models/trajectory.model"; -import { HttpErrorResponse } from "@angular/common/http"; -import { BreakpointObserver } from "@angular/cdk/layout"; -import { map, Observable } from "rxjs"; -import { trajectoryMoreList } from "projects/core/src/consts/lists/trajectory-more-list.const"; +import { IconComponent, ButtonComponent } from "@ui/components"; +import { AvatarComponent } from "@ui/components/avatar/avatar.component"; /** * Компонент отображения карточки траектории @@ -39,153 +21,26 @@ import { trajectoryMoreList } from "projects/core/src/consts/lists/trajectory-mo standalone: true, imports: [ CommonModule, + RouterModule, + TruncatePipe, + IconComponent, + AvatarComponent, ButtonComponent, - ModalComponent, IconComponent, - RouterModule, - ParseLinksPipe, - PluralizePipe, - ParseBreaksPipe, ], templateUrl: "./trajectory.component.html", styleUrl: "./trajectory.component.scss", }) -export class TrajectoryComponent implements AfterViewInit, OnInit { +export class TrajectoryComponent { @Input() trajectory!: Trajectory; - protected readonly dotsArray = Array; - protected readonly trajectoryMore = trajectoryMoreList; router = inject(Router); - trajectoryService = inject(TrajectoriesService); - breakpointObserver = inject(BreakpointObserver); - - desktopMode$: Observable = this.breakpointObserver - .observe("(min-width: 920px)") - .pipe(map(result => result.matches)); - - cdRef = inject(ChangeDetectorRef); - sanitizer = inject(DomSanitizer); - - @ViewChild("descEl") descEl?: ElementRef; - - descriptionExpandable!: boolean; - readFullDescription = false; - - currentPage = 1; - - // Состояния модальных окон - moreModalOpen = signal(false); - confirmModalOpen = signal(false); - nonConfirmerModalOpen = signal(false); - instructionModalOpen = signal(false); - activatedModalOpen = signal(false); - - type = signal<"all" | "my" | null>(null); - - placeholderUrl = - "https://uch-ibadan.org.ng/wp-content/uploads/2021/10/Profile_avatar_placeholder_large.png"; - - /** - * Инициализация компонента - * Определяет тип отображения (all/my) на основе текущего URL - */ - ngOnInit() { - this.type.set(this.router.url.split("/").slice(-1)[0] as "all" | "my"); - } - - /** - * Проверка возможности расширения описания после инициализации представления - */ - ngAfterViewInit(): void { - const descElement = this.descEl?.nativeElement; - this.descriptionExpandable = descElement?.clientHeight < descElement?.scrollHeight; - - this.cdRef.detectChanges(); - } - - /** - * Обработчик клика по кнопке "Выбрать" - * Активирует траекторию и обрабатывает различные сценарии ответа сервера - */ - onOpenConfirmClick() { - this.trajectoryService.activateTrajectory(this.trajectory.id).subscribe({ - next: () => { - if (!this.trajectory.isActiveForUser) { - this.confirmModalOpen.set(true); - } - }, - error: err => { - if (err instanceof HttpErrorResponse) { - if (err.status === 403) { - this.nonConfirmerModalOpen.set(true); - } else if (err.status === 400) { - this.activatedModalOpen.set(true); - this.nonConfirmerModalOpen.set(false); - this.instructionModalOpen.set(false); - this.confirmModalOpen.set(false); - } - } - }, - }); - } - - /** - * Подтверждение выбора траектории - * Закрывает модальное окно подтверждения и открывает инструкции - */ - onConfirmClick() { - this.confirmModalOpen.set(false); - this.instructionModalOpen.set(true); - } - - /** - * Переход к предыдущей странице инструкций - */ - prevPage(): void { - if (this.currentPage > 1) { - this.currentPage -= 1; - } - } - - /** - * Переход к следующей странице инструкций или к траектории - */ - nextPage(): void { - if (this.currentPage === 4) { - this.navigateOnTrajectory(); - } else if (this.currentPage < 4) { - this.currentPage += 1; - } - } - - /** - * Навигация к детальной странице траектории - */ - navigateOnTrajectory() { - this.router.navigate(["/trackCar/" + this.trajectory.id]).catch(err => { - if (err.status === 403) { - this.nonConfirmerModalOpen.set(true); - } - }); - } - /** - * Закрытие модального окна активной траектории и переход к ней - * @param trajectoryId - ID траектории для перехода - */ - onCloseModalActiveTrajectory(trajectoryId: number | string) { - this.activatedModalOpen.set(false); - this.router.navigate([`/trackCar/${trajectoryId}`]); - } + protected readonly isStarted = signal(false); + protected readonly isDates = signal(false); + protected readonly isDate = signal(false); + protected readonly isEnded = signal(false); - /** - * Переключение развернутого/свернутого состояния описания - * @param elem - HTML элемент описания - * @param expandedClass - CSS класс для развернутого состояния - * @param isExpanded - текущее состояние (развернуто/свернуто) - */ - onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readFullDescription = !isExpanded; - } + protected readonly isMember = signal(false); + protected readonly isSubs = signal(false); } diff --git a/projects/skills/src/app/trajectories/track-career/track-career-my.resolver.ts b/projects/skills/src/app/trajectories/track-career/track-career-my.resolver.ts deleted file mode 100644 index 5a7557c1d..000000000 --- a/projects/skills/src/app/trajectories/track-career/track-career-my.resolver.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { TrajectoriesService } from "../trajectories.service"; - -/** - * Резолвер для загрузки персональной траектории пользователя - * Выполняется перед активацией маршрута "my" - * @returns Observable с данными пользовательской траектории - */ - -/** - * Функция-резолвер для получения персональной траектории пользователя - * @returns Promise/Observable с данными пользовательской траектории - */ -export const TrajectoriesMyResolver = () => { - const trajectoriesService = inject(TrajectoriesService); - - return trajectoriesService.getMyTrajectory(); -}; diff --git a/projects/skills/src/app/trajectories/track-career/track-career.component.html b/projects/skills/src/app/trajectories/track-career/track-career.component.html index 4cf213030..a89e50624 100644 --- a/projects/skills/src/app/trajectories/track-career/track-career.component.html +++ b/projects/skills/src/app/trajectories/track-career/track-career.component.html @@ -1,22 +1,18 @@
-
- -
+ + +
+ - + - + +
diff --git a/projects/skills/src/app/trajectories/track-career/track-career.component.scss b/projects/skills/src/app/trajectories/track-career/track-career.component.scss index 22357e787..53c48f8bc 100644 --- a/projects/skills/src/app/trajectories/track-career/track-career.component.scss +++ b/projects/skills/src/app/trajectories/track-career/track-career.component.scss @@ -2,33 +2,9 @@ @use "styles/responsive"; .profile { - display: flex; - flex-direction: column; - - &__overlay { - position: absolute; - top: 0%; - right: 0; - bottom: 0; - left: 0; - z-index: 10; - align-content: center; - align-items: center; - justify-content: center; - text-align: center; - - @include responsive.apply-desktop { - top: 500%; - } - - &__text { - @include typography.heading-1; - - color: var(--grey-for-text); - } - - &__image { - margin: 0 auto; - } + &__info { + display: grid; + grid-template-columns: 8fr 2fr; + column-gap: 20px; } } diff --git a/projects/skills/src/app/trajectories/track-career/track-career.component.ts b/projects/skills/src/app/trajectories/track-career/track-career.component.ts index 545a5d095..56f31b633 100644 --- a/projects/skills/src/app/trajectories/track-career/track-career.component.ts +++ b/projects/skills/src/app/trajectories/track-career/track-career.component.ts @@ -1,9 +1,12 @@ /** @format */ import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, inject } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { BarComponent } from "@ui/components"; +import { BackComponent } from "@uilib"; +import { SearchComponent } from "@ui/components/search/search.component"; +import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { SoonCardComponent } from "@office/shared/soon-card/soon-card.component"; /** * Главный компонент модуля отслеживания карьерных траекторий @@ -13,8 +16,25 @@ import { BarComponent } from "@ui/components"; @Component({ selector: "app-track-career", standalone: true, - imports: [CommonModule, BarComponent, RouterModule], + imports: [ + CommonModule, + RouterModule, + BackComponent, + SearchComponent, + ReactiveFormsModule, + SoonCardComponent, + ], templateUrl: "./track-career.component.html", styleUrl: "./track-career.component.scss", }) -export class TrackCareerComponent {} +export class TrackCareerComponent { + private readonly fb = inject(FormBuilder); + + constructor() { + this.searchForm = this.fb.group({ + search: [""], + }); + } + + searchForm: FormGroup; +} diff --git a/projects/skills/src/app/trajectories/track-career/track-career.routes.ts b/projects/skills/src/app/trajectories/track-career/track-career.routes.ts index 674f0454b..84c84ea52 100644 --- a/projects/skills/src/app/trajectories/track-career/track-career.routes.ts +++ b/projects/skills/src/app/trajectories/track-career/track-career.routes.ts @@ -4,7 +4,6 @@ import { Routes } from "@angular/router"; import { TrackCareerComponent } from "./track-career.component"; import { TrajectoriesListComponent } from "./list/list.component"; import { TrajectoriesResolver } from "./track-career.resolver"; -import { TrajectoriesMyResolver } from "./track-career-my.resolver"; /** * Конфигурация маршрутов для модуля карьерных траекторий @@ -32,13 +31,6 @@ export const TRACK_CAREER_ROUTES: Routes = [ data: TrajectoriesResolver, }, }, - { - path: "my", - component: TrajectoriesListComponent, - resolve: { - data: TrajectoriesMyResolver, - }, - }, ], }, { diff --git a/projects/skills/src/app/trajectories/trajectories.service.ts b/projects/skills/src/app/trajectories/trajectories.service.ts index 5ed332264..b563e7f17 100644 --- a/projects/skills/src/app/trajectories/trajectories.service.ts +++ b/projects/skills/src/app/trajectories/trajectories.service.ts @@ -42,20 +42,6 @@ export class TrajectoriesService { return this.apiService.get(this.TRAJECTORY_URL); } - /** - * Получает траекторию, в которой текущий пользователь активно зарегистрирован - * - * @returns Observable - Массив, содержащий активную траекторию пользователя (если есть) - */ - getMyTrajectory() { - return this.apiService.get(this.TRAJECTORY_URL).pipe( - map(track => { - const choosedTrajctory = track.find(trajectory => trajectory.isActiveForUser === true); - return choosedTrajctory; - }) - ); - } - /** * Получает подробную информацию о конкретной траектории * diff --git a/projects/skills/src/app/webinars/list/list.component.html b/projects/skills/src/app/webinars/list/list.component.html deleted file mode 100644 index bd85e9488..000000000 --- a/projects/skills/src/app/webinars/list/list.component.html +++ /dev/null @@ -1,11 +0,0 @@ - - -
- @if (type() === 'actual') { @if (webinarList().length) { @for(webinar of webinarList(); track - $index){ - - } } } @else if (type() === 'records'){ @if (recordsList().length) { @for (record of recordsList(); - track $index) { - - } } } -
diff --git a/projects/skills/src/app/webinars/list/list.component.scss b/projects/skills/src/app/webinars/list/list.component.scss deleted file mode 100644 index 7987768d9..000000000 --- a/projects/skills/src/app/webinars/list/list.component.scss +++ /dev/null @@ -1,6 +0,0 @@ -.list { - display: flex; - flex-direction: column; - gap: 12px; - margin-bottom: 20px; -} diff --git a/projects/skills/src/app/webinars/list/list.component.ts b/projects/skills/src/app/webinars/list/list.component.ts deleted file mode 100644 index d31406fb4..000000000 --- a/projects/skills/src/app/webinars/list/list.component.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** @format */ - -import { - type AfterViewInit, - ChangeDetectorRef, - Component, - inject, - type OnInit, - signal, -} from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ActivatedRoute, Router, RouterModule } from "@angular/router"; -import { concatMap, fromEvent, map, noop, of, type Subscription, tap, throttleTime } from "rxjs"; -import { WebinarComponent } from "../shared/webinar/webinar.component"; -import type { Webinar } from "projects/skills/src/models/webinars.model"; -import { WebinarService } from "../services/webinar.service"; -import type { ApiPagination } from "projects/skills/src/models/api-pagination.model"; - -/** - * Компонент списка вебинаров - * - * Универсальный компонент для отображения как актуальных вебинаров, - * так и записей завершенных вебинаров. Поддерживает бесконечную прокрутку - * для постепенной загрузки контента. - * - * Функциональность: - * - Отображение списка вебинаров в зависимости от типа (актуальные/записи) - * - Бесконечная прокрутка для загрузки дополнительного контента - * - Автоматическое определение типа контента по URL - * - Оптимизированная загрузка данных порциями - */ -@Component({ - selector: "app-list", - standalone: true, - imports: [CommonModule, RouterModule, WebinarComponent], - templateUrl: "./list.component.html", - styleUrl: "./list.component.scss", -}) -export class WebinarsListComponent implements OnInit, AfterViewInit { - // Внедрение зависимостей - router = inject(Router); - route = inject(ActivatedRoute); - webinarService = inject(WebinarService); - cdRef = inject(ChangeDetectorRef); - - // Сигналы для управления состоянием - totalItemsCount = signal(0); - webinarList = signal([]); - recordsList = signal([]); - webinarPage = signal(1); - perFetchTake = signal(20); - - // Тип отображаемого контента - type = signal<"actual" | "records" | null>(null); - - // Управление подписками - subscriptions$ = signal([]); - - /** - * Инициализация компонента - * - * Определяет тип контента по URL и загружает соответствующие данные - * из резолвера маршрута - */ - ngOnInit(): void { - // Определение типа контента по последнему сегменту URL - this.type.set(this.router.url.split("/").slice(-1)[0] as "actual" | "records"); - - // Получение данных из резолвера в зависимости от типа - const routeData$ = - this.type() === "actual" - ? this.route.data.pipe(map(r => r["data"])) - : this.route.data.pipe(map(r => r["data"])); - - const subscription = routeData$.subscribe((vacancy: ApiPagination) => { - if (this.type() === "actual") { - this.webinarList.set(vacancy.results as Webinar[]); - } else if (this.type() === "records") { - this.recordsList.set(vacancy.results as Webinar[]); - } - this.totalItemsCount.set(vacancy.count); - }); - - this.subscriptions$().push(subscription); - } - - /** - * Настройка бесконечной прокрутки после инициализации представления - * - * Подписывается на события прокрутки контейнера и запускает - * загрузку дополнительных данных при достижении конца списка - */ - ngAfterViewInit(): void { - const target = document.querySelector(".office__body"); - if (target) { - const scrollEvents$ = fromEvent(target, "scroll") - .pipe( - concatMap(() => this.onScroll()), - throttleTime(500) // Ограничение частоты запросов - ) - .subscribe(noop); - this.subscriptions$().push(scrollEvents$); - } - } - - /** - * Обработчик события прокрутки - * - * Проверяет, достиг ли пользователь конца списка, и если да, - * загружает следующую порцию данных - * - * @returns Observable с новыми данными или пустой Observable - */ - onScroll() { - // Проверка, загружены ли все доступные элементы - if (this.totalItemsCount() && this.recordsList().length >= this.totalItemsCount()) - return of({}); - - if (this.totalItemsCount() && this.webinarList().length >= this.totalItemsCount()) - return of({}); - - const target = document.querySelector(".office__body"); - if (!target) return of({}); - - // Вычисление позиции прокрутки - const diff = target.scrollTop - target.scrollHeight + target.clientHeight; - - // Если достигнут конец списка, загружаем следующую порцию - if (diff > 0) { - return this.onFetch(this.webinarPage() * this.perFetchTake(), this.perFetchTake()).pipe( - tap((webinarChunk: Webinar[]) => { - if (this.type() === "actual") { - this.webinarPage.update(page => page + 1); - this.webinarList.update(items => [...items, ...webinarChunk]); - } else if (this.type() === "records") { - this.webinarPage.update(page => page + 1); - this.recordsList.update(items => [...items, ...webinarChunk]); - } - }) - ); - } - - return of({}); - } - - /** - * Загрузка дополнительных данных - * - * Выполняет запрос к API для получения следующей порции вебинаров - * в зависимости от текущего типа контента - * - * @param offset - смещение для пагинации - * @param limit - количество элементов для загрузки - * @returns Observable с новыми данными - */ - onFetch(offset: number, limit: number) { - if (this.type() === "actual") { - return this.webinarService.getActualWebinars(limit, offset).pipe( - tap((res: any) => { - this.totalItemsCount.set(res.count); - this.webinarList.update(items => [...items, ...res.results]); - }), - map(res => res) - ); - } else if (this.type() === "records") { - return this.webinarService.getRecords(limit, offset).pipe( - tap((res: any) => { - this.totalItemsCount.set(res.count); - this.recordsList.update(items => [...items, ...res.results]); - }), - map(res => res) - ); - } - - return of([]); - } -} diff --git a/projects/skills/src/app/webinars/list/list.routes.ts b/projects/skills/src/app/webinars/list/list.routes.ts deleted file mode 100644 index acfb2ec55..000000000 --- a/projects/skills/src/app/webinars/list/list.routes.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { RecordsResolver } from "./records.resolver"; -import { WebinarsListComponent } from "./list.component"; - -export const RECORDS_ROUTES: Routes = [ - { - path: "", - component: WebinarsListComponent, - resolve: { - data: RecordsResolver, - }, - }, -]; diff --git a/projects/skills/src/app/webinars/list/records.resolver.ts b/projects/skills/src/app/webinars/list/records.resolver.ts deleted file mode 100644 index 88d1fecc8..000000000 --- a/projects/skills/src/app/webinars/list/records.resolver.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { WebinarService } from "../services/webinar.service"; - -/** - * Резолвер для загрузки записей вебинаров - * - * Предоставляет начальную порцию записей завершенных вебинаров - * для отображения в списке. Загружает первые 20 записей. - * - * @returns Observable с пагинированным списком записей вебинаров - */ -export const RecordsResolver = () => { - const webinarService = inject(WebinarService); - - return webinarService.getRecords(20, 0); -}; diff --git a/projects/skills/src/app/webinars/services/webinar.service.spec.ts b/projects/skills/src/app/webinars/services/webinar.service.spec.ts deleted file mode 100644 index ccb8f998e..000000000 --- a/projects/skills/src/app/webinars/services/webinar.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { WebinarService } from "../../../../webinar.service"; - -describe("WebinarService", () => { - let service: WebinarService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(WebinarService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/webinars/services/webinar.service.ts b/projects/skills/src/app/webinars/services/webinar.service.ts deleted file mode 100644 index 9c4ce45b6..000000000 --- a/projects/skills/src/app/webinars/services/webinar.service.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** @format */ - -import { HttpParams } from "@angular/common/http"; -import { Injectable } from "@angular/core"; -import { SkillsApiService } from "@corelib"; -import { plainToInstance } from "class-transformer"; -import { Webinar } from "projects/skills/src/models/webinars.model"; -import { map, Observable } from "rxjs"; - -/** - * Сервис для работы с вебинарами - * - * Предоставляет методы для получения информации о вебинарах, - * управления регистрацией и доступом к записям. - * - * Взаимодействует с API вебинаров и обрабатывает трансформацию - * данных в типизированные модели. - */ -@Injectable({ - providedIn: "root", -}) -export class WebinarService { - private readonly WEBINAR_URL = "/webinars"; - - constructor(private readonly apiService: SkillsApiService) {} - - /** - * Получение списка актуальных (предстоящих) вебинаров - * - * Загружает вебинары, которые еще не прошли и доступны - * для регистрации и участия - * - * @param limit - количество вебинаров для загрузки - * @param offset - смещение для пагинации - * @returns Observable - массив актуальных вебинаров - */ - getActualWebinars(limit: number, offset: number): Observable { - const params = new HttpParams(); - - params.set("limit", limit); - params.set("offset", offset); - - return this.apiService - .get(`${this.WEBINAR_URL}/actual/`, params) - .pipe(map(webinar => plainToInstance(Webinar, webinar))); - } - - /** - * Получение списка записей завершенных вебинаров - * - * Загружает вебинары, которые уже прошли и доступны - * для просмотра в записи - * - * @param limit - количество записей для загрузки - * @param offset - смещение для пагинации - * @returns Observable - пагинированный список записей вебинаров - */ - getRecords(limit: number, offset: number) { - const params = new HttpParams(); - - params.set("limit", limit); - params.set("offset", offset); - - return this.apiService - .get(`${this.WEBINAR_URL}/records/`, params) - .pipe(map(webinar => plainToInstance(Webinar, webinar))); - } - - /** - * Получение ссылки на запись вебинара - * - * Предоставляет прямую ссылку для просмотра записи - * конкретного завершенного вебинара - * - * @param webinarId - идентификатор вебинара - * @returns Observable<{recordingLink: string}> - объект со ссылкой на запись - */ - getWebinarLink(webinarId: number) { - return this.apiService.get<{ recordingLink: string }>( - `${this.WEBINAR_URL}/records/${webinarId}/link/` - ); - } - - /** - * Регистрация пользователя на вебинар - * - * Выполняет регистрацию текущего пользователя на предстоящий вебинар. - * После успешной регистрации пользователь получит уведомления - * и доступ к участию в вебинаре. - * - * @param webinarId - идентификатор вебинара для регистрации - * @returns Observable - результат операции регистрации - */ - registrationOnWebinar(webinarId: number) { - return this.apiService.post(`${this.WEBINAR_URL}/actual/${webinarId}/`, {}); - } -} diff --git a/projects/skills/src/app/webinars/shared/webinar/webinar.component.html b/projects/skills/src/app/webinars/shared/webinar/webinar.component.html deleted file mode 100644 index 307c63c4a..000000000 --- a/projects/skills/src/app/webinars/shared/webinar/webinar.component.html +++ /dev/null @@ -1,136 +0,0 @@ - - -
-
-
-

{{ webinar.title }}

-
-

- @if (descriptionExpandable) { -
- {{ readFullDescription ? "скрыть" : "подробнее" }} -
- } -
-
- -
- @if (webinar.speaker; as speaker) { -

Спикер

- -
-

{{ speaker.fullName }}

-

{{ speaker.position }}

-
- } -
-
- -
-

- {{ formattedDate() }}, {{ webinar.duration / 60 | number: "1.0-1" }} часа -

- @if(type() === "actual"){ @if(webinar.isRegistrated || isRegistrated()){ -
- -

Вы зарегистрировались

-
- } @else { - - Регистрация - - } } @else { - Посмотреть запись - } -
-
- - -
-
- - smart-people -

- Вы зарегистрировались на вебинар “{{ webinar.title }}”. На вашу почту придет письмо с - ссылкой на подключение -

-
- LETS GO -
-
- - - - -
-
- - smart-people -

- {{ isSubscribedModalText() }} -

-
- Купить -
-
diff --git a/projects/skills/src/app/webinars/shared/webinar/webinar.component.scss b/projects/skills/src/app/webinars/shared/webinar/webinar.component.scss deleted file mode 100644 index b7c3e55b0..000000000 --- a/projects/skills/src/app/webinars/shared/webinar/webinar.component.scss +++ /dev/null @@ -1,166 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.webinar { - padding: 24px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - - &__inner { - display: flex; - flex-direction: column; - align-items: flex-end; - justify-content: space-between; - - @include responsive.apply-desktop { - flex-direction: row; - align-items: center; - } - } - - &__info { - @include responsive.apply-desktop { - flex-basis: 46%; - } - } - - &__speaker { - display: flex; - flex-direction: column; - gap: 10px; - align-items: flex-end; - } - - .speaker__info { - display: flex; - flex-direction: column; - gap: 5px; - align-items: flex-end; - } - - &__description { - margin-top: 14px; - - p { - display: box; - overflow: hidden; - color: var(--dark-grey); - text-overflow: ellipsis; - -webkit-box-orient: vertical; - -webkit-line-clamp: 5; - transition: all 0.7s ease-in-out; - - &.expanded { - -webkit-line-clamp: unset; - } - } - - ::ng-deep a { - color: var(--accent); - text-decoration: underline; - text-decoration-color: transparent; - text-underline-offset: 3px; - transition: text-decoration-color 0.2s; - - &:hover { - text-decoration-color: var(--accent); - } - } - - &__read-full { - margin-top: 2px; - color: var(--accent); - cursor: pointer; - } - } - - &__registration { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 12px; - } - - &__registrated { - display: flex; - gap: 5px; - align-items: center; - } - - &__watch-button { - width: 35%; - - @include responsive.apply-desktop { - width: 18%; - } - } -} - -.read-more { - margin-top: 12px; - color: var(--accent); - cursor: pointer; - transition: color 0.2s; - - &:hover { - color: var(--accent-dark); - } - - @include typography.body-14; -} - -.cancel { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - max-height: calc(100vh - 40px); - padding: 24px; - overflow-y: auto; - - &__top { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 68%; - padding-top: 14px; - } - - &__image { - display: flex; - flex-direction: column; - gap: 15px; - align-items: center; - justify-content: space-between; - - @include responsive.apply-desktop { - display: flex; - flex-direction: row; - gap: 15px; - align-items: center; - justify-content: space-between; - margin: 14px 0 5px; - } - } - - &__cross { - position: absolute; - top: 0; - right: 0; - width: 32px; - height: 32px; - cursor: pointer; - - @include responsive.apply-desktop { - top: 8px; - right: 8px; - } - } - - &__title { - margin-bottom: 15px; - text-align: center; - } -} diff --git a/projects/skills/src/app/webinars/shared/webinar/webinar.component.spec.ts b/projects/skills/src/app/webinars/shared/webinar/webinar.component.spec.ts deleted file mode 100644 index 8397b4b34..000000000 --- a/projects/skills/src/app/webinars/shared/webinar/webinar.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { WebinarComponent } from "./webinar.component"; - -describe("WebinarComponent", () => { - let component: WebinarComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [WebinarComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(WebinarComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/webinars/shared/webinar/webinar.component.ts b/projects/skills/src/app/webinars/shared/webinar/webinar.component.ts deleted file mode 100644 index 78e9daa04..000000000 --- a/projects/skills/src/app/webinars/shared/webinar/webinar.component.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** @format */ - -import { CommonModule, DatePipe } from "@angular/common"; -import { - ChangeDetectorRef, - Component, - ElementRef, - inject, - Input, - OnInit, - signal, - ViewChild, -} from "@angular/core"; -import { Router, RouterModule } from "@angular/router"; -import { ParseBreaksPipe, ParseLinksPipe, YtExtractService } from "@corelib"; -import { ButtonComponent } from "@ui/components"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { AvatarComponent, IconComponent } from "@uilib"; -import { expandElement } from "@utils/expand-element"; -import { Webinar } from "projects/skills/src/models/webinars.model"; -import { ProfileService } from "../../../profile/services/profile.service"; -import { DomSanitizer } from "@angular/platform-browser"; -import { WebinarService } from "../../services/webinar.service"; -import { tap, throwError } from "rxjs"; -import { HttpErrorResponse } from "@angular/common/http"; - -@Component({ - selector: "app-webinar", - standalone: true, - imports: [ - CommonModule, - RouterModule, - AvatarComponent, - ButtonComponent, - ModalComponent, - IconComponent, - ], - templateUrl: "./webinar.component.html", - styleUrl: "./webinar.component.scss", -}) -export class WebinarComponent implements OnInit { - @Input() webinar!: Webinar; - - router = inject(Router); - webinarService = inject(WebinarService); - - cdRef = inject(ChangeDetectorRef); - sanitizer = inject(DomSanitizer); - - @ViewChild("descEl") descEl?: ElementRef; - - descriptionExpandable!: boolean; - readFullDescription = false; - - type = signal<"actual" | "records" | null>(null); - isRegistrated = signal(false); - - registrationModalOpen = signal(false); - isSubscribedModalOpen = signal(false); - isSubscribedModalText = signal(""); - - formattedDate = signal(""); - - ngOnInit() { - this.type.set(this.router.url.split("/").slice(-1)[0] as "actual" | "records"); - - this.formattedDate.set( - new Date(this.webinar.datetimeStart).toLocaleDateString("ru-RU", { - day: "numeric", - month: "long", - }) - ); - } - - ngAfterViewInit(): void { - const descElement = this.descEl?.nativeElement; - this.descriptionExpandable = descElement?.clientHeight < descElement?.scrollHeight; - - this.cdRef.detectChanges(); - } - - onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readFullDescription = !isExpanded; - } - - onRegistration(webinarId: number) { - if (!this.webinar.isRegistrated) { - this.webinarService.registrationOnWebinar(webinarId).subscribe(); - this.registrationModalOpen.set(true); - this.isRegistrated.set(true); - } - } - - onWatchRecord(webinarId: number) { - this.webinarService.getWebinarLink(webinarId).subscribe({ - next: ({ recordingLink }) => { - window.open(recordingLink, "_blank"); - }, - error: error => { - if (error instanceof HttpErrorResponse) { - if (error.status === 403) { - this.isSubscribedModalText.set(error.error.detail); - this.isSubscribedModalOpen.set(true); - console.log(error.error.detail); - } - } - }, - }); - } -} diff --git a/projects/skills/src/app/webinars/webinars.component.html b/projects/skills/src/app/webinars/webinars.component.html deleted file mode 100644 index 6c6787200..000000000 --- a/projects/skills/src/app/webinars/webinars.component.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/projects/skills/src/app/webinars/webinars.component.scss b/projects/skills/src/app/webinars/webinars.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/projects/skills/src/app/webinars/webinars.component.spec.ts b/projects/skills/src/app/webinars/webinars.component.spec.ts deleted file mode 100644 index 801dd27f4..000000000 --- a/projects/skills/src/app/webinars/webinars.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { WebinarsComponent } from "./webinars.component"; - -describe("WebinarsComponent", () => { - let component: WebinarsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [WebinarsComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(WebinarsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/webinars/webinars.component.ts b/projects/skills/src/app/webinars/webinars.component.ts deleted file mode 100644 index 55ed9da59..000000000 --- a/projects/skills/src/app/webinars/webinars.component.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** @format */ - -import { Component } from "@angular/core"; -import { BarComponent } from "../../../../social_platform/src/app/ui/components/bar/bar.component"; -import { CommonModule } from "@angular/common"; -import { RouterLink, RouterModule } from "@angular/router"; - -@Component({ - selector: "app-webinars", - standalone: true, - imports: [BarComponent, CommonModule, RouterModule], - templateUrl: "./webinars.component.html", - styleUrl: "./webinars.component.scss", -}) -export class WebinarsComponent {} diff --git a/projects/skills/src/app/webinars/webinars.resolver.ts b/projects/skills/src/app/webinars/webinars.resolver.ts deleted file mode 100644 index b0d1744d6..000000000 --- a/projects/skills/src/app/webinars/webinars.resolver.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { WebinarService } from "./services/webinar.service"; - -export const WebinarsResolver = () => { - const webinarService = inject(WebinarService); - - return webinarService.getActualWebinars(20, 0); -}; diff --git a/projects/skills/src/app/webinars/webinars.routes.ts b/projects/skills/src/app/webinars/webinars.routes.ts deleted file mode 100644 index 0a7dbba59..000000000 --- a/projects/skills/src/app/webinars/webinars.routes.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { WebinarsComponent } from "./webinars.component"; -import { WebinarsResolver } from "./webinars.resolver"; -import { WebinarsListComponent } from "./list/list.component"; - -export const WEBINARS_ROUTES: Routes = [ - { - path: "", - component: WebinarsComponent, - children: [ - { - path: "", - redirectTo: "actual", - pathMatch: "full", - }, - { - path: "actual", - component: WebinarsListComponent, - resolve: { - data: WebinarsResolver, - }, - }, - { - path: "records", - loadChildren: () => import("./list/list.routes").then(c => c.RECORDS_ROUTES), - }, - ], - }, -]; diff --git a/projects/skills/src/assets/icons/svg/folder.svg b/projects/skills/src/assets/icons/svg/folder.svg new file mode 100644 index 000000000..bc5130c5c --- /dev/null +++ b/projects/skills/src/assets/icons/svg/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/projects/skills/src/assets/icons/svg/lock.svg b/projects/skills/src/assets/icons/svg/lock.svg new file mode 100644 index 000000000..3e17fbe86 --- /dev/null +++ b/projects/skills/src/assets/icons/svg/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/projects/skills/src/assets/icons/svg/logout3.svg b/projects/skills/src/assets/icons/svg/logout3.svg new file mode 100644 index 000000000..b7c27c199 --- /dev/null +++ b/projects/skills/src/assets/icons/svg/logout3.svg @@ -0,0 +1,3 @@ + + + diff --git a/projects/skills/src/assets/icons/svg/person.svg b/projects/skills/src/assets/icons/svg/person.svg new file mode 100644 index 000000000..8746fa9c3 --- /dev/null +++ b/projects/skills/src/assets/icons/svg/person.svg @@ -0,0 +1,3 @@ + + + diff --git a/projects/skills/src/assets/icons/svg/triangle.svg b/projects/skills/src/assets/icons/svg/triangle.svg new file mode 100644 index 000000000..c98333c02 --- /dev/null +++ b/projects/skills/src/assets/icons/svg/triangle.svg @@ -0,0 +1,3 @@ + + + diff --git a/projects/skills/src/assets/icons/symbol/svg/sprite.css.svg b/projects/skills/src/assets/icons/symbol/svg/sprite.css.svg index 7162e7b05..bf5d7f495 100644 --- a/projects/skills/src/assets/icons/symbol/svg/sprite.css.svg +++ b/projects/skills/src/assets/icons/symbol/svg/sprite.css.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/projects/skills/src/assets/images/task/character.svg b/projects/skills/src/assets/images/task/character.svg new file mode 100644 index 000000000..64f0ea9dd --- /dev/null +++ b/projects/skills/src/assets/images/task/character.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/skills/src/assets/images/task/complete.svg b/projects/skills/src/assets/images/task/complete.svg new file mode 100644 index 000000000..e97c7693a --- /dev/null +++ b/projects/skills/src/assets/images/task/complete.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/skills/src/models/skill.model.ts b/projects/skills/src/models/skill.model.ts deleted file mode 100644 index 91d736e02..000000000 --- a/projects/skills/src/models/skill.model.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** @format */ - -/** - * Основная модель навыка в системе обучения - * - * Представляет навык, который пользователь может изучать. - * Содержит информацию о доступности, прогрессе и метаданных навыка. - */ -export interface Skill { - /** Уникальный идентификатор навыка */ - id: number; - - /** Название навыка */ - name: string; - - /** Общее количество уровней в навыке */ - quantityOfLevels: number; - - /** Ссылка на изображение/иконку навыка */ - fileLink: string; - - /** Автор/создатель навыка */ - whoCreated: string; - - /** Описание навыка */ - description: string; - - /** Флаг принадлежности навыка к траектории обучения */ - isFromTrajectory: boolean; - - /** Требует ли навык активную подписку для доступа */ - requiresSubscription: boolean; - - /** Завершен ли навык пользователем */ - isDone: boolean; - - /** Доступен ли навык бесплатно */ - freeAccess: boolean; - - /** Просрочен ли навык (опциональное поле) */ - overdue?: boolean; -} - -/** - * Детальная информация о задаче навыка - * - * Содержит метаданные конкретной задачи в рамках навыка. - */ -export interface TaskDetail { - /** Название навыка, к которому относится задача */ - skillName: string; - - /** Описание задачи */ - description: string; - - /** Уровень сложности задачи */ - level: number; - - /** Путь к файлу с дополнительными материалами */ - file: string; -} - -/** - * Модель задачи в рамках навыка - * - * Представляет отдельную задачу с информацией о её состоянии. - */ -export interface Task { - /** Уникальный идентификатор задачи */ - id: number; - - /** Уровень задачи в навыке */ - level: number; - - /** Название задачи */ - name: string; - - /** Статус выполнения задачи (true - выполнена, false - не выполнена) */ - status: boolean; -} - -/** - * Ответ сервера со списком задач и статистикой - * - * Содержит задачи навыка и статистику выполнения по неделям. - */ -export interface TasksResponse { - /** Массив задач навыка */ - tasks: Task[]; - - /** Статистика выполнения по неделям */ - statsOfWeeks: { - /** Выполнено ли вовремя (null если неприменимо) */ - doneOnTime: boolean | null; - - /** Выполнено ли вообще */ - isDone: boolean; - - /** Номер недели */ - week: number; - }[]; - - /** Общий прогресс выполнения навыка в процентах (0-100) */ - progress: number; -} - -/** - * Шаг выполнения задачи - * - * Представляет отдельный шаг в рамках задачи с определенным типом взаимодействия. - */ -export interface TaskStep { - /** Уникальный идентификатор шага */ - id: number; - - /** Выполнен ли шаг */ - isDone: boolean; - - /** Тип шага задачи */ - type: - | "exclude_question" // Вопрос на исключение - | "question_single_answer" // Вопрос с одним ответом - | "question_connect" // Вопрос на соединение - | "info_slide" // Информационный слайд - | "question_write"; // Вопрос с письменным ответом - - /** Порядковый номер шага в задаче */ - ordinalNumber: number; -} - -/** - * Ответ сервера с шагами задачи и дополнительной информацией - * - * Расширяет TaskDetail дополнительными данными о прогрессе и шагах. - */ -export interface TaskStepsResponse extends TaskDetail { - /** Общее количество шагов в задаче */ - count: number; - - /** Текущий уровень пользователя в навыке */ - currentLevel: number; - - /** Следующий уровень навыка */ - nextLevel: number; - - /** Название навыка */ - skillName: string; - - /** Ссылка на логотип навыка для отображения баллов */ - skillPointLogo: string; - - /** Ссылка на превью навыка */ - skillPreview: string; - - /** Массив шагов задачи */ - stepData: TaskStep[]; -} - -/** - * Результаты выполнения задачи - * - * Содержит информацию о результатах прохождения задачи пользователем. - */ -export interface TaskResults { - /** Количество полученных баллов за выполнение */ - pointsGained: number; - - /** Количество правильно выполненных шагов */ - quantityDoneCorrect: number; - - /** Общее количество шагов в задаче */ - quantityAll: number; - - /** Прогресс выполнения в процентах (0-100) */ - progress: number; - - /** ID следующей задачи (null если это последняя задача) */ - nextTaskId: null | number; - - /** Уровень задачи */ - level: number; - - /** Название навыка */ - skillName: string; -} diff --git a/projects/skills/src/models/step.model.ts b/projects/skills/src/models/step.model.ts index bd61e7535..7df3b4b13 100644 --- a/projects/skills/src/models/step.model.ts +++ b/projects/skills/src/models/step.model.ts @@ -78,6 +78,7 @@ export interface SingleQuestion extends BaseStep { isAnswered: boolean; // Был ли вопрос уже отвечен text: string; // Основной текст вопроса popups: Popup[]; // Всплывающие окна для отображения после ответа + videoUrl: string; } /** diff --git a/projects/skills/src/models/trajectory.model.ts b/projects/skills/src/models/trajectory.model.ts index ddf1a075c..9d2f9eae8 100644 --- a/projects/skills/src/models/trajectory.model.ts +++ b/projects/skills/src/models/trajectory.model.ts @@ -1,7 +1,7 @@ /** @format */ import type { UserData } from "./profile.model"; -import type { Skill } from "./skill.model"; +import type { Skill } from "../../../social_platform/src/app/office/models/skill.model"; /** * Информация о навыке в контексте траектории diff --git a/projects/skills/src/styles.scss b/projects/skills/src/styles.scss index d19efbe44..5f3eec7e4 100644 --- a/projects/skills/src/styles.scss +++ b/projects/skills/src/styles.scss @@ -20,7 +20,7 @@ @import "styles/pages/project-detail.scss"; :root { - --app-height: 100%; + --app-height: 100vh; } html, diff --git a/projects/skills/src/styles/_colors.scss b/projects/skills/src/styles/_colors.scss index c30eec8b2..5f321991a 100644 --- a/projects/skills/src/styles/_colors.scss +++ b/projects/skills/src/styles/_colors.scss @@ -12,25 +12,24 @@ --accent-light: #9a80e6; // GOLD - --gold: #f6ff8b; - --gold-dark: #f7cf4d; + --gold: #e5b25d; + --gold-dark: #c69849; // GRAY - --white: #fafafa; --light-white: #fff; + --white: #fafafa; --black: #333; - --dark-grey: #e7e7e7; + --dark-grey: #a59fb9; --gray: #d3d3d3; --light-gray: #f9f9f9; --grey-button: #e5e5e5e5; - --gray-for-shadow: rgb(159 159 159 / 15%); --medium-grey-for-outline: #eee; - --grey-for-text: #a59fb9; + --grey-for-text: #827e80; // FUNCTIONAL - --green: #92e3a9; - --light-green: #e3f8e9; - --red: rgb(242 66 72 / 50%); - --light-red: #ffd2d2; + --green: #88c9a1; + --green-dark: #297373; + --lime: #d6ff54; + --red: #d48a9e; --red-dark: #{color.adjust(#d48a9e, $blackness: 10%)}; } diff --git a/projects/skills/src/styles/_global.scss b/projects/skills/src/styles/_global.scss index 124c807ba..e29a38145 100644 --- a/projects/skills/src/styles/_global.scss +++ b/projects/skills/src/styles/_global.scss @@ -10,10 +10,10 @@ margin: 0; } -html, -body { - overflow-y: hidden; -} +// html, +// body { +// overflow-y: hidden; +// } ul, li, @@ -31,10 +31,6 @@ img { max-width: 100%; } -iframe { - border-radius: 15px; -} - a, .link { color: var(--accent); @@ -74,16 +70,21 @@ button { label.field-label { display: block; margin-bottom: 6px; + margin-left: 12px; color: var(--black); - @include body-14; + @include body-12; } .error { display: block; gap: 10px; margin-top: 6px; - color: var(--red); + color: var(--red) !important; + + i { + color: var(--red) !important; + } p { &:not(:last-child) { diff --git a/projects/skills/src/styles/_typography.scss b/projects/skills/src/styles/_typography.scss index 2142c754e..e39bad451 100644 --- a/projects/skills/src/styles/_typography.scss +++ b/projects/skills/src/styles/_typography.scss @@ -66,9 +66,9 @@ @mixin heading-1 { font-family: Mont, sans-serif; - font-size: 36px; + font-size: 18px; font-style: normal; - font-weight: 700; + font-weight: 400; line-height: 150%; } @@ -76,11 +76,23 @@ @include heading-1; } +@mixin heading-1-bold { + font-family: Mont, sans-serif; + font-size: 18px; + font-style: normal; + font-weight: 600; + line-height: 150%; +} + +.text-heading-1-bold { + @include heading-1-bold; +} + @mixin heading-2 { font-family: Mont, sans-serif; - font-size: 28px; + font-size: 16px; font-style: normal; - font-weight: 700; + font-weight: 400; line-height: 150%; } @@ -88,6 +100,18 @@ @include heading-2; } +@mixin heading-2-bold { + font-family: Mont, sans-serif; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: 150%; +} + +.text-heading-2-bold { + @include heading-2-bold; +} + @mixin heading-3 { font-family: Mont, sans-serif; font-size: 24px; @@ -168,7 +192,7 @@ line-height: 150%; } -.text-bold-body-14 { +.text-body-bold-14 { @include bold-body-14; } @@ -176,7 +200,7 @@ font-family: Mont, sans-serif; font-size: 14px; font-style: normal; - font-weight: 600; + font-weight: 400; line-height: 150%; } @@ -188,7 +212,7 @@ font-family: Mont, sans-serif; font-size: 12px; font-style: normal; - font-weight: 600; + font-weight: 400; line-height: 130%; } @@ -196,6 +220,18 @@ @include body-12; } +@mixin body-bold-12 { + font-family: Mont, sans-serif; + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 130%; +} + +.text-body-bold-12 { + @include body-bold-12; +} + @mixin body-10 { font-family: Mont, sans-serif; font-size: 10px; @@ -208,3 +244,15 @@ .text-body-10 { @include body-10; } + +@mixin body-6 { + font-family: Mont, sans-serif; + font-size: 6px; + font-style: normal; + font-weight: 400; + line-height: 130%; +} + +.text-body-6 { + @include body-6; +} diff --git a/projects/skills/src/styles/pages/auth.scss b/projects/skills/src/styles/pages/auth.scss index d901d3d94..b82a86567 100644 --- a/projects/skills/src/styles/pages/auth.scss +++ b/projects/skills/src/styles/pages/auth.scss @@ -4,21 +4,25 @@ @use "styles/typography"; .auth { + padding: 100px 0; + &__logo { position: fixed; - top: 85px; - left: 15px; - display: none; + top: 45px; + left: 18px; + display: block; + width: 95px; @include responsive.apply-desktop { - top: 50px; - left: 100px; - display: block; + top: 120px; + left: 200px; + width: 157px; + height: 30px; } } &__greeting { - max-width: 915px; + max-width: 510px; margin-bottom: 20px; @include responsive.apply-desktop { @@ -28,7 +32,7 @@ &__wrapper { @include responsive.apply-desktop { - max-width: 415px; + max-width: 333px; } } @@ -40,7 +44,7 @@ @include typography.heading-4; } - @include typography.heading-2; + @include typography.heading-1; @include responsive.apply-desktop { &--register { @@ -50,11 +54,7 @@ } &__info { - @include typography.body-12; - - @include responsive.apply-desktop { - @include typography.body-14; - } + @include typography.heading-2; } &__field { @@ -125,17 +125,9 @@ } &__toggle { - @include typography.body-14; - - margin-top: 8vh; - text-align: center; - - @include responsive.apply-desktop { - @include typography.body-16; + @include typography.body-12; - margin-top: 20px; - text-align: left; - } + margin-top: 4vh; } a { diff --git a/projects/social_platform/src/app/auth/models/user.model.ts b/projects/social_platform/src/app/auth/models/user.model.ts index 402ca5dea..dce1893ac 100644 --- a/projects/social_platform/src/app/auth/models/user.model.ts +++ b/projects/social_platform/src/app/auth/models/user.model.ts @@ -2,7 +2,7 @@ import { Project } from "@models/project.model"; import { FileModel } from "@office/models/file.model"; -import { Skill } from "@office/models/skill"; +import { Skill } from "@office/models/skill.model"; import { Program } from "@office/program/models/program.model"; /** diff --git a/projects/social_platform/src/app/auth/services/profile.service.ts b/projects/social_platform/src/app/auth/services/profile.service.ts index 984a0e909..ae1363520 100644 --- a/projects/social_platform/src/app/auth/services/profile.service.ts +++ b/projects/social_platform/src/app/auth/services/profile.service.ts @@ -5,7 +5,7 @@ import { ApiService } from "projects/core"; import { Achievement } from "../models/user.model"; import { map, Observable } from "rxjs"; import { plainToInstance } from "class-transformer"; -import { Approve } from "@office/models/skill"; +import { Approve } from "@office/models/skill.model"; /** * Сервис управления профилем пользователя diff --git a/projects/social_platform/src/app/core/services/file.service.ts b/projects/social_platform/src/app/core/services/file.service.ts index 8f3bb4819..d21f68697 100644 --- a/projects/social_platform/src/app/core/services/file.service.ts +++ b/projects/social_platform/src/app/core/services/file.service.ts @@ -33,17 +33,44 @@ export class FileService { formData.append("file", file); return new Observable<{ url: string }>(observer => { - fetch(`${environment.apiUrl}${this.FILES_URL}/`, { - method: "POST", - headers: { - Authorization: `Bearer ${this.tokenService.getTokens()?.access}`, - }, - body: formData, - }) - .then(res => res.json()) + const doFetch = (token: string) => + fetch(`${environment.apiUrl}${this.FILES_URL}/`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + + const token = this.tokenService.getTokens()?.access; + if (!token) { + observer.error(new Error("No access token")); + return; + } + + doFetch(token) + .then(res => { + if (res.status === 401) { + this.tokenService.refreshTokens().subscribe({ + next: newTokens => { + this.tokenService.memTokens(newTokens); + doFetch(newTokens.access) + .then(r => r.json()) + .then(data => { + observer.next(data); + observer.complete(); + }) + .catch(err => observer.error(err)); + }, + error: err => observer.error(err), + }); + return; + } + return res.json(); + }) .then(res => { - observer.next(res); - observer.complete(); + if (res) { + observer.next(res); + observer.complete(); + } }) .catch(err => observer.error(err)); }); diff --git a/projects/social_platform/src/app/office/courses/courses.component.html b/projects/social_platform/src/app/office/courses/courses.component.html new file mode 100644 index 000000000..d8faa75eb --- /dev/null +++ b/projects/social_platform/src/app/office/courses/courses.component.html @@ -0,0 +1,18 @@ + + +
+ + +
+ + + + + +
+
diff --git a/projects/social_platform/src/app/office/courses/courses.component.scss b/projects/social_platform/src/app/office/courses/courses.component.scss new file mode 100644 index 000000000..53c48f8bc --- /dev/null +++ b/projects/social_platform/src/app/office/courses/courses.component.scss @@ -0,0 +1,10 @@ +@use "styles/typography"; +@use "styles/responsive"; + +.profile { + &__info { + display: grid; + grid-template-columns: 8fr 2fr; + column-gap: 20px; + } +} diff --git a/projects/skills/src/app/profile/shared/info-block/info-block.component.spec.ts b/projects/social_platform/src/app/office/courses/courses.component.spec.ts similarity index 53% rename from projects/skills/src/app/profile/shared/info-block/info-block.component.spec.ts rename to projects/social_platform/src/app/office/courses/courses.component.spec.ts index 107ae0586..a58d1165f 100644 --- a/projects/skills/src/app/profile/shared/info-block/info-block.component.spec.ts +++ b/projects/social_platform/src/app/office/courses/courses.component.spec.ts @@ -2,18 +2,18 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { InfoBlockComponent } from "./info-block.component"; +import { TrackCareerComponent } from "./courses.component"; -describe("InfoBlockComponent", () => { - let component: InfoBlockComponent; - let fixture: ComponentFixture; +describe("TrackCareerComponent", () => { + let component: TrackCareerComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [InfoBlockComponent], + imports: [TrackCareerComponent], }).compileComponents(); - fixture = TestBed.createComponent(InfoBlockComponent); + fixture = TestBed.createComponent(TrackCareerComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/projects/social_platform/src/app/office/courses/courses.component.ts b/projects/social_platform/src/app/office/courses/courses.component.ts new file mode 100644 index 000000000..14673e447 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/courses.component.ts @@ -0,0 +1,40 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { Component, inject } from "@angular/core"; +import { RouterModule } from "@angular/router"; +import { BackComponent } from "@uilib"; +import { SearchComponent } from "@ui/components/search/search.component"; +import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { SoonCardComponent } from "@office/shared/soon-card/soon-card.component"; + +/** + * Главный компонент модуля отслеживания карьерных траекторий + * Служит контейнером для дочерних компонентов и маршрутизации + * Отображает навигационную панель с вкладками "Траектории" и "Моя траектория" + */ +@Component({ + selector: "app-track-career", + standalone: true, + imports: [ + CommonModule, + RouterModule, + BackComponent, + SearchComponent, + ReactiveFormsModule, + SoonCardComponent, + ], + templateUrl: "./courses.component.html", + styleUrl: "./courses.component.scss", +}) +export class CoursesComponent { + private readonly fb = inject(FormBuilder); + + constructor() { + this.searchForm = this.fb.group({ + search: [""], + }); + } + + searchForm: FormGroup; +} diff --git a/projects/social_platform/src/app/office/courses/courses.resolver.ts b/projects/social_platform/src/app/office/courses/courses.resolver.ts new file mode 100644 index 000000000..8d269ba6f --- /dev/null +++ b/projects/social_platform/src/app/office/courses/courses.resolver.ts @@ -0,0 +1,20 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { CoursesService } from "./courses.service"; + +/** + * Резолвер для загрузки списка всех доступных траекторий + * Выполняется перед активацией маршрута для предзагрузки данных + * @returns Observable с массивом траекторий (20 элементов с offset 0) + */ + +/** + * Функция-резолвер для получения списка траекторий + * @returns Promise/Observable с данными траекторий + */ +export const CoursesResolver = () => { + const coursesService = inject(CoursesService); + + return coursesService.getCourses(); +}; diff --git a/projects/social_platform/src/app/office/courses/courses.routes.ts b/projects/social_platform/src/app/office/courses/courses.routes.ts new file mode 100644 index 000000000..ac341aa9f --- /dev/null +++ b/projects/social_platform/src/app/office/courses/courses.routes.ts @@ -0,0 +1,39 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { CoursesListComponent } from "./list/list.component"; +import { CoursesComponent } from "./courses.component"; +import { CoursesResolver } from "./courses.resolver"; + +/** + * Конфигурация маршрутов для модуля карьерных траекторий + * Определяет структуру навигации: + * - "" - редирект на "all" + * - "all" - список всех доступных траекторий + * - ":courseId" - детальная информация о конкретном курсе + */ + +export const COURSES_ROUTES: Routes = [ + { + path: "", + component: CoursesComponent, + children: [ + { + path: "", + redirectTo: "all", + pathMatch: "full", + }, + { + path: "all", + component: CoursesListComponent, + resolve: { + data: CoursesResolver, + }, + }, + ], + }, + { + path: ":courseId", + loadChildren: () => import("./detail/course-detail.routes").then(c => c.COURSE_DETAIL_ROUTES), + }, +]; diff --git a/projects/social_platform/src/app/office/courses/courses.service.ts b/projects/social_platform/src/app/office/courses/courses.service.ts new file mode 100644 index 000000000..629fa14ea --- /dev/null +++ b/projects/social_platform/src/app/office/courses/courses.service.ts @@ -0,0 +1,87 @@ +/** @format */ + +import { Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { + CourseCard, + CourseDetail, + CourseLesson, + CourseStructure, + TaskAnswerResponse, +} from "@office/models/courses.model"; +import { Observable } from "rxjs"; + +/** + * Сервис Курсов + * + * Управляет всеми операциями, связанными с курсами, включая: + * - Отслеживание прогресса пользователя по курсу + */ +@Injectable({ + providedIn: "root", +}) +export class CoursesService { + private readonly COURSE_URL = "/courses"; + + constructor(private readonly apiService: ApiService) {} + + /** + * Получает доступные курсов + * + * @returns Observable - Список доступных курсов + */ + getCourses(): Observable { + return this.apiService.get(`${this.COURSE_URL}/`); + } + + /** + * Получает подробную информацию о конкретном курсе + * + * @param id - Уникальный идентификатор курса + * @returns Observable - Полная информация о курсе + */ + getCourseDetail(id: number): Observable { + return this.apiService.get(`${this.COURSE_URL}/${id}/`); + } + + /** + * Получает подробную информацию о структуре курса + * + * @param id - Уникальный идентификатор курса + * @returns Observable - Полную структуру курса + */ + getCourseStructure(id: number): Observable { + return this.apiService.get(`${this.COURSE_URL}/${id}/structure/`); + } + + /** + * Получает полную информацию по отдельному уроку внутри курса + * + * @param id - Уникальный идентификатор урока + * @returns Observable - Полная информация для урока конкретного + */ + getCourseLesson(id: number): Observable { + return this.apiService.get(`${this.COURSE_URL}/lessons/${id}/`); + } + + /** + * + * @param id - Уникальный идентификатор задачи + * @param answerText - Текст ответа + * @param optionIds - id ответов выбранных + * @param fileIds - id файлов загруженных + * @returns Observable - Информация от прохождении урока + */ + postAnswerQuestion( + id: number, + answerText?: any, + optionIds?: number[], + fileIds?: number[] + ): Observable { + return this.apiService.post(`${this.COURSE_URL}/tasks/${id}/answer/`, { + answerText, + optionIds, + fileIds, + }); + } +} diff --git a/projects/social_platform/src/app/office/courses/detail/course-detail.component.html b/projects/social_platform/src/app/office/courses/detail/course-detail.component.html new file mode 100644 index 000000000..e874a8146 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/detail/course-detail.component.html @@ -0,0 +1,47 @@ + + +
+
+ @if(course()) { +
+ cover + +
+ + @if (!isTaskDetail()) { +

{{ course()!.title }}

+ } +
+
+ +
+
+ {{ isTaskDetail() ? "назад к модулю" : "назад" }} + + вернуться в программу +
+
+ + + } +
+
diff --git a/projects/social_platform/src/app/office/courses/detail/course-detail.component.scss b/projects/social_platform/src/app/office/courses/detail/course-detail.component.scss new file mode 100644 index 000000000..5383399fb --- /dev/null +++ b/projects/social_platform/src/app/office/courses/detail/course-detail.component.scss @@ -0,0 +1,114 @@ +@use "styles/responsive"; +@use "styles/typography"; + +$detail-bar-height: 63px; +$detail-bar-mb: 12px; + +.detail { + display: flex; + flex-direction: column; + height: 100%; + max-height: 100%; + padding-top: 20px; + + &__body { + flex-grow: 1; + max-height: calc(100% - #{$detail-bar-height} - #{$detail-bar-mb}); + padding-bottom: 12px; + } +} + +.info { + $body-slide: 15px; + + position: relative; + padding: 0; + border: none; + border-radius: $body-slide; + + &__cover { + position: relative; + width: 100%; + height: 136px; + border-radius: 15px 15px 0 0; + + img { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + } + + &__body { + position: relative; + z-index: 2; + } + + &__avatar { + position: absolute; + bottom: -10px; + left: 50%; + z-index: 100; + display: block; + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + border-radius: 50%; + + &--program { + bottom: 15px; + } + + @include responsive.apply-desktop { + transform: translate(-50%, 50%); + } + } + + &__row { + display: flex; + gap: 20px; + align-items: center; + justify-content: center; + margin-top: 2px; + + @include responsive.apply-desktop { + justify-content: unset; + margin-top: 0; + } + } + + &__title { + margin-top: 10px; + overflow: hidden; + color: var(--black); + text-align: center; + text-overflow: ellipsis; + + &--project { + transform: translateX(-31%); + } + } + + &__text { + color: var(--dark-grey); + } + + &__actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 180px; + align-items: center; + padding: 24px 0 30px; + + &--disabled { + cursor: not-allowed; + opacity: 0.5; + } + } +} diff --git a/projects/skills/src/app/rating/shared/basic-rating-card/basic-rating-card.component.spec.ts b/projects/social_platform/src/app/office/courses/detail/course-detail.component.spec.ts similarity index 52% rename from projects/skills/src/app/rating/shared/basic-rating-card/basic-rating-card.component.spec.ts rename to projects/social_platform/src/app/office/courses/detail/course-detail.component.spec.ts index 9a29e05c2..f79e3c7d4 100644 --- a/projects/skills/src/app/rating/shared/basic-rating-card/basic-rating-card.component.spec.ts +++ b/projects/social_platform/src/app/office/courses/detail/course-detail.component.spec.ts @@ -2,18 +2,18 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { BasicRatingCardComponent } from "./basic-rating-card.component"; +import { VacanciesDetailComponent } from "./trajectory-detail.component"; -describe("BasicRatingCardComponent", () => { - let component: BasicRatingCardComponent; - let fixture: ComponentFixture; +describe("VacanciesDetailComponent", () => { + let component: VacanciesDetailComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [BasicRatingCardComponent], + imports: [VacanciesDetailComponent], }).compileComponents(); - fixture = TestBed.createComponent(BasicRatingCardComponent); + fixture = TestBed.createComponent(VacanciesDetailComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/projects/social_platform/src/app/office/courses/detail/course-detail.component.ts b/projects/social_platform/src/app/office/courses/detail/course-detail.component.ts new file mode 100644 index 000000000..b66e3a03b --- /dev/null +++ b/projects/social_platform/src/app/office/courses/detail/course-detail.component.ts @@ -0,0 +1,82 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { Component, DestroyRef, inject, signal, OnInit } from "@angular/core"; +import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from "@angular/router"; +import { filter, map, tap } from "rxjs"; +import { AvatarComponent } from "@ui/components/avatar/avatar.component"; +import { ButtonComponent } from "@ui/components"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { CourseDetail, CourseStructure } from "@office/models/courses.model"; + +/** + * Компонент детального просмотра траектории + * Отображает навигационную панель и служит контейнером для дочерних компонентов + * Управляет состоянием выбранной траектории и ID траектории из URL + */ +@Component({ + selector: "app-course-detail", + standalone: true, + imports: [CommonModule, RouterOutlet, AvatarComponent, ButtonComponent], + templateUrl: "./course-detail.component.html", + styleUrl: "./course-detail.component.scss", +}) +export class CourseDetailComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + protected readonly isTaskDetail = signal(false); + protected readonly isDisabled = signal(false); + + protected readonly courseModules = signal([]); + protected readonly course = signal(undefined); + + /** + * Инициализация компонента + * Подписывается на параметры маршрута и данные траектории + */ + ngOnInit(): void { + this.route.data + .pipe( + map(data => data["data"]), + filter(course => !!course), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: ([course, _]: [CourseDetail, CourseStructure]) => { + this.course.set(course); + + if (!course.partnerProgramId) { + this.isDisabled.set(true); + } + }, + }); + + this.isTaskDetail.set(this.router.url.includes("lesson")); + + this.router.events + .pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + this.isTaskDetail.set(this.router.url.includes("lesson")); + }); + } + + /** + * Перенаправляет на страницу с информацией в завивисимости от listType + */ + redirectDetailInfo(courseId?: number): void { + if (courseId != null) { + this.router.navigateByUrl(`/office/courses/${courseId}`); + } else { + this.router.navigateByUrl("/office/courses/all"); + } + } + + redirectToProgram(): void { + this.router.navigateByUrl(`/office/program/${this.course()?.partnerProgramId}`); + } +} diff --git a/projects/social_platform/src/app/office/courses/detail/course-detail.resolver.ts b/projects/social_platform/src/app/office/courses/detail/course-detail.resolver.ts new file mode 100644 index 000000000..dc4553380 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/detail/course-detail.resolver.ts @@ -0,0 +1,30 @@ +/** @format */ + +import { inject } from "@angular/core"; +import type { ActivatedRouteSnapshot } from "@angular/router"; +import { Router } from "@angular/router"; +import { CoursesService } from "../courses.service"; +import { forkJoin, tap } from "rxjs"; + +/** + * Резолвер для получения детальной информации о курсе + * Также проверяет isAvailable — если false, редиректит на список курсов + * @param route - снимок маршрута содержащий параметр courseId + * @returns Observable с данными о курсе + */ +export const CoursesDetailResolver = (route: ActivatedRouteSnapshot) => { + const coursesService = inject(CoursesService); + const router = inject(Router); + const courseId = route.parent?.params["courseId"]; + + return forkJoin([ + coursesService.getCourseDetail(courseId).pipe( + tap(course => { + if (!course.isAvailable) { + router.navigate(["/office/courses/all"]); + } + }) + ), + coursesService.getCourseStructure(courseId), + ]); +}; diff --git a/projects/social_platform/src/app/office/courses/detail/course-detail.routes.ts b/projects/social_platform/src/app/office/courses/detail/course-detail.routes.ts new file mode 100644 index 000000000..8b168483c --- /dev/null +++ b/projects/social_platform/src/app/office/courses/detail/course-detail.routes.ts @@ -0,0 +1,27 @@ +/** @format */ + +import type { Routes } from "@angular/router"; +import { TrajectoryInfoComponent } from "./info/info.component"; +import { CourseDetailComponent } from "./course-detail.component"; +import { CoursesDetailResolver } from "./course-detail.resolver"; + +export const COURSE_DETAIL_ROUTES: Routes = [ + { + path: "", + component: CourseDetailComponent, + runGuardsAndResolvers: "always", + resolve: { + data: CoursesDetailResolver, + }, + children: [ + { + path: "", + component: TrajectoryInfoComponent, + }, + { + path: "lesson", + loadChildren: () => import("../lesson/lesson.routes").then(m => m.LESSON_ROUTES), + }, + ], + }, +]; diff --git a/projects/social_platform/src/app/office/courses/detail/info/info.component.html b/projects/social_platform/src/app/office/courses/detail/info/info.component.html new file mode 100644 index 000000000..1902dec97 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/detail/info/info.component.html @@ -0,0 +1,80 @@ + + +@if (courseStructure()) { +
+
+
+
+ +
+ +
+
+

прогресс по курсу

+

{{ courseStructure()!.percent }}%

+
+
+ +
+ @for (courseModule of courseStructure()!.modules; track courseModule) { + + } @empty { +

на данные момент модулей нет!

+ } +
+
+ +
+
+
+

о курсе

+ +
+ @if (courseDetail()!.description) { +
+

+ @if (descriptionExpandable) { +
+ {{ readFullDescription ? "cкрыть" : "подробнее" }} +
+ } +
+ } +
+
+
+
+ + +
+

+ {{ isCourseCompleted() ? "ты прошел курс!" : "ты прошел модуль!" }} +

+ + complete module image + + отлично +
+
+
+} diff --git a/projects/social_platform/src/app/office/courses/detail/info/info.component.scss b/projects/social_platform/src/app/office/courses/detail/info/info.component.scss new file mode 100644 index 000000000..fa96d9440 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/detail/info/info.component.scss @@ -0,0 +1,181 @@ +@use "styles/responsive"; +@use "styles/typography"; + +.course { + padding-top: 12px; + padding-bottom: 100px; + + @include responsive.apply-desktop { + padding-bottom: 0; + } + + &__main { + display: grid; + grid-template-columns: 1fr; + } + + &__right { + display: flex; + flex-direction: column; + max-height: 226px; + } + + &__left { + max-width: 157px; + } + + &__section { + padding: 24px; + margin-bottom: 14px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } + + &__info { + @include responsive.apply-desktop { + grid-column: span 3; + } + } + + &__details { + display: grid; + grid-template-columns: 2fr 5fr 3fr; + grid-gap: 20px; + } + + &__progress { + position: relative; + height: 48px; + padding: 10px; + margin-bottom: 18px; + overflow: hidden; + text-align: center; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + &--cover { + position: absolute; + top: 0%; + left: 0%; + height: 48px; + background-color: var(--accent); + border-radius: var(--rounded-lg); + opacity: 0.15; + } + + &--percent { + position: relative; + z-index: 1; + color: var(--green-dark); + } + + &--complete { + background-color: var(--green-light); + + .course__progress--percent { + color: var(--black); + } + + .course__progress--cover { + background-color: var(--green-dark); + } + } + } + + &__modules { + display: flex; + flex-direction: column; + gap: 20px; + margin-top: 18px; + } +} + +.about { + padding: 24px; + background-color: var(--light-white); + border-radius: var(--rounded-lg); + + &__head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + + &--icon { + color: var(--accent); + } + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } + + /* stylelint-disable value-no-vendor-prefix */ + &__text { + p { + display: -webkit-box; + overflow: hidden; + color: var(--black); + text-overflow: ellipsis; + -webkit-box-orient: vertical; + -webkit-line-clamp: 5; + transition: all 0.7s ease-in-out; + + &.expanded { + -webkit-line-clamp: unset; + } + } + + ::ng-deep a { + color: var(--accent); + text-decoration: underline; + text-decoration-color: transparent; + text-underline-offset: 3px; + transition: text-decoration-color 0.2s; + + &:hover { + text-decoration-color: var(--accent); + } + } + } + /* stylelint-enable value-no-vendor-prefix */ + + &__read-full { + margin-top: 8px; + color: var(--accent); + cursor: pointer; + } +} + +.read-more { + margin-top: 8px; + color: var(--accent); + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + color: var(--accent-dark); + } +} + +.cancel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + max-height: calc(100vh - 40px); + padding: 0 200px; + + &__text { + color: var(--dark-grey); + text-align: center; + } + + &__img { + margin: 30px 0; + } +} diff --git a/projects/skills/src/app/skills/detail/detail.component.spec.ts b/projects/social_platform/src/app/office/courses/detail/info/info.component.spec.ts similarity index 56% rename from projects/skills/src/app/skills/detail/detail.component.spec.ts rename to projects/social_platform/src/app/office/courses/detail/info/info.component.spec.ts index b92741065..6ced6bd14 100644 --- a/projects/skills/src/app/skills/detail/detail.component.spec.ts +++ b/projects/social_platform/src/app/office/courses/detail/info/info.component.spec.ts @@ -2,18 +2,18 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { DetailComponent } from "./detail.component"; +import { InfoComponent } from "./info.component"; -describe("DetailComponent", () => { - let component: DetailComponent; - let fixture: ComponentFixture; +describe("InfoComponent", () => { + let component: InfoComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [DetailComponent], + imports: [InfoComponent], }).compileComponents(); - fixture = TestBed.createComponent(DetailComponent); + fixture = TestBed.createComponent(InfoComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/projects/social_platform/src/app/office/courses/detail/info/info.component.ts b/projects/social_platform/src/app/office/courses/detail/info/info.component.ts new file mode 100644 index 000000000..5e15b937d --- /dev/null +++ b/projects/social_platform/src/app/office/courses/detail/info/info.component.ts @@ -0,0 +1,127 @@ +/** @format */ + +import { + AfterViewInit, + ChangeDetectorRef, + Component, + DestroyRef, + ElementRef, + inject, + OnInit, + signal, + ViewChild, +} from "@angular/core"; +import { ActivatedRoute, RouterModule } from "@angular/router"; +import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; +import { IconComponent } from "@uilib"; +import { expandElement } from "@utils/expand-element"; +import { map } from "rxjs"; +import { CommonModule } from "@angular/common"; +import { SoonCardComponent } from "@office/shared/soon-card/soon-card.component"; +import { ModalComponent } from "@ui/components/modal/modal.component"; +import { ButtonComponent } from "@ui/components"; +import { CourseModuleCardComponent } from "@office/courses/shared/course-module-card/course-module-card.component"; +import { CourseDetail, CourseStructure } from "@office/models/courses.model"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +/** + * Компонент детальной информации о траектории + * Отображает полную информацию о выбранной траектории пользователя: + * - Основную информацию (название, изображение, описание) + * - Временную шкалу траектории + * - Информацию о наставнике + * - Навыки (персональные, текущие, будущие, пройденные) + * + * Поддерживает навигацию к отдельным навыкам и взаимодействие с наставником + */ +@Component({ + selector: "app-detail", + standalone: true, + imports: [ + IconComponent, + RouterModule, + ParseBreaksPipe, + ParseLinksPipe, + CommonModule, + SoonCardComponent, + ModalComponent, + ButtonComponent, + CourseModuleCardComponent, + ], + templateUrl: "./info.component.html", + styleUrl: "./info.component.scss", +}) +export class TrajectoryInfoComponent implements OnInit, AfterViewInit { + @ViewChild("descEl") descEl?: ElementRef; + + private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); + + private readonly cdRef = inject(ChangeDetectorRef); + + protected readonly courseStructure = signal(undefined); + protected readonly courseDetail = signal(undefined); + protected readonly isCompleteModule = signal(false); + protected readonly isCourseCompleted = signal(false); + + /** + * Инициализация компонента + * Загружает данные траектории, пользовательскую информацию и настраивает навыки + */ + ngOnInit(): void { + this.route.parent?.data + .pipe( + map(r => r["data"]), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(([courseDetail, courseStructure]: [CourseDetail, CourseStructure]) => { + this.courseStructure.set(courseStructure); + this.courseDetail.set(courseDetail); + + const completedModuleIds = courseStructure.modules + .filter(m => m.progressStatus === "completed") + .map(m => m.id); + + const unseenModule = completedModuleIds.find( + id => + !localStorage.getItem(`course_${courseStructure.courseId}_module_${id}_complete_seen`) + ); + + if (unseenModule) { + const allModulesCompleted = courseStructure.modules.every( + m => m.progressStatus === "completed" + ); + this.isCourseCompleted.set(allModulesCompleted); + this.isCompleteModule.set(true); + localStorage.setItem( + `course_${courseStructure.courseId}_module_${unseenModule}_complete_seen`, + "true" + ); + } + }); + } + + protected descriptionExpandable?: boolean; + protected readFullDescription!: boolean; + + /** + * Проверка возможности расширения описания после инициализации представления + */ + ngAfterViewInit(): void { + const descElement = this.descEl?.nativeElement; + this.descriptionExpandable = descElement?.clientHeight < descElement?.scrollHeight; + + this.cdRef.detectChanges(); + } + + /** + * Переключение развернутого/свернутого состояния описания + * @param elem - HTML элемент описания + * @param expandedClass - CSS класс для развернутого состояния + * @param isExpanded - текущее состояние (развернуто/свернуто) + */ + onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { + expandElement(elem, expandedClass, isExpanded); + this.readFullDescription = !isExpanded; + } +} diff --git a/projects/social_platform/src/app/office/courses/lesson/complete/complete.component.html b/projects/social_platform/src/app/office/courses/lesson/complete/complete.component.html new file mode 100644 index 000000000..b17df2859 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/complete/complete.component.html @@ -0,0 +1,16 @@ + + +
+
+ complete img + +
+

поздравялем!

+

урок пройден

+
+ + отлично +
+
diff --git a/projects/social_platform/src/app/office/courses/lesson/complete/complete.component.scss b/projects/social_platform/src/app/office/courses/lesson/complete/complete.component.scss new file mode 100644 index 000000000..0e2a04514 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/complete/complete.component.scss @@ -0,0 +1,24 @@ +@use "styles/responsive"; +@use "styles/typography"; + +.complete { + min-height: 358px; + padding: 24px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + &__block { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin: 50px 0; + text-align: center; + } + + &__text { + margin-top: 20px; + margin-bottom: 20px; + } +} diff --git a/projects/skills/src/app/task/complete/complete.component.spec.ts b/projects/social_platform/src/app/office/courses/lesson/complete/complete.component.spec.ts similarity index 100% rename from projects/skills/src/app/task/complete/complete.component.spec.ts rename to projects/social_platform/src/app/office/courses/lesson/complete/complete.component.spec.ts diff --git a/projects/skills/src/app/task/complete/complete.component.ts b/projects/social_platform/src/app/office/courses/lesson/complete/complete.component.ts similarity index 64% rename from projects/skills/src/app/task/complete/complete.component.ts rename to projects/social_platform/src/app/office/courses/lesson/complete/complete.component.ts index ff35c1d18..16059464f 100644 --- a/projects/skills/src/app/task/complete/complete.component.ts +++ b/projects/social_platform/src/app/office/courses/lesson/complete/complete.component.ts @@ -1,13 +1,9 @@ /** @format */ -import { Component, inject } from "@angular/core"; +import { Component, inject, OnInit, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { CircleProgressBarComponent } from "../../shared/circle-progress-bar/circle-progress-bar.component"; -import { IconComponent } from "@uilib"; import { ButtonComponent } from "@ui/components"; import { ActivatedRoute, Router } from "@angular/router"; -import { map, type Observable } from "rxjs"; -import type { TaskResults } from "../../../models/skill.model"; /** * Компонент завершения задачи @@ -22,14 +18,22 @@ import type { TaskResults } from "../../../models/skill.model"; @Component({ selector: "app-complete", standalone: true, - imports: [CommonModule, CircleProgressBarComponent, IconComponent, ButtonComponent], + imports: [CommonModule, ButtonComponent], templateUrl: "./complete.component.html", styleUrl: "./complete.component.scss", }) -export class TaskCompleteComponent { +export class TaskCompleteComponent implements OnInit { route = inject(ActivatedRoute); // Сервис для работы с активным маршрутом router = inject(Router); // Сервис для навигации + courseId = signal(null); - // Получаем результаты задачи из данных маршрута - results = this.route.data.pipe(map(r => r["data"])) as Observable; + ngOnInit(): void { + const courseId = Number(this.route.parent?.parent?.parent?.snapshot.paramMap.get("courseId")); + this.courseId.set(isNaN(courseId) ? null : courseId); + } + + routeToCourses(): void { + const id = this.courseId(); + this.router.navigate(id ? ["/office/courses", id] : ["/office/courses/all"]); + } } diff --git a/projects/social_platform/src/app/office/courses/lesson/lesson.component.html b/projects/social_platform/src/app/office/courses/lesson/lesson.component.html new file mode 100644 index 000000000..7afe4c7cf --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/lesson.component.html @@ -0,0 +1,100 @@ + + +@if (lessonInfo()) { +
+
+
+

модуль {{ lessonInfo()?.moduleOrder }}

+
+ +
+ @for (task of tasks(); track task.id) { +
+

{{ task.order }}

+
+ } +
+
+ + @if (loading()) { +
+ +
+ } @else { +
+ + + @if (!isComplete() && currentTask(); as task) { +
+ + @if (task.answerType === null && (task.informationalType === 'text' || task.informationalType + === 'video_text' || task.informationalType === 'text_image')) { + + } + + + @if (task.answerType === 'text' || task.answerType === 'text_and_files') { + + } + + + @if (task.answerType === 'multiple_choice') { + + } + + + @if (task.answerType === 'single_choice') { + + } + + + @if (task.answerType === 'files') { + + } + +
+
+ + + {{ isLastTask() ? "завершить тест" : "следующее задание" }} + +
+
+ } +
+ } +
+} diff --git a/projects/social_platform/src/app/office/courses/lesson/lesson.component.scss b/projects/social_platform/src/app/office/courses/lesson/lesson.component.scss new file mode 100644 index 000000000..26c394963 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/lesson.component.scss @@ -0,0 +1,80 @@ +@use "styles/typography"; +@use "styles/responsive"; + +.loading { + display: flex; + align-items: center; + justify-content: center; + height: auto; +} + +.task { + display: grid; + grid-template-columns: 68px 1fr; + gap: 20px; + + &__wrapper { + display: flex; + flex-direction: column; + } + + &__tasks { + margin-bottom: 12px; + text-align: center; + } + + &__skill-name { + color: var(--black); + } + + &__name { + color: var(--dark-grey); + } + + &__progress { + display: flex; + flex-direction: column; + gap: 12px; + } + + &__main { + display: flex; + align-items: center; + justify-content: center; + } + + &__complete { + display: block; + } + + &__actions { + display: grid; + grid-template-columns: 6.2fr 4fr; + grid-gap: 20px; + margin-top: 20px; + + :last-child { + width: 333px; + } + } +} + +.progress { + &__task { + height: 23px; + padding: 4px 0; + text-align: center; + background-color: var(--light-white); + border-radius: var(--rounded-xl); + + &--done { + color: var(--light-white); + background-color: var(--green); + border: 0 !important; + } + + &--current { + border: 0.5px solid var(--accent); + } + } +} diff --git a/projects/skills/src/app/task/task/task.component.spec.ts b/projects/social_platform/src/app/office/courses/lesson/lesson.component.spec.ts similarity index 100% rename from projects/skills/src/app/task/task/task.component.spec.ts rename to projects/social_platform/src/app/office/courses/lesson/lesson.component.spec.ts diff --git a/projects/social_platform/src/app/office/courses/lesson/lesson.component.ts b/projects/social_platform/src/app/office/courses/lesson/lesson.component.ts new file mode 100644 index 000000000..ba51d4543 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/lesson.component.ts @@ -0,0 +1,228 @@ +/** @format */ + +import { Component, computed, DestroyRef, inject, OnInit, signal } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from "@angular/router"; +import { filter, map, tap } from "rxjs"; +import { CourseLesson, Task } from "@office/models/courses.model"; +import { CoursesService } from "../courses.service"; +import { ButtonComponent } from "@ui/components"; +import { SnackbarService } from "@ui/services/snackbar.service"; +import { InfoTaskComponent } from "./shared/video-task/info-task.component"; +import { WriteTaskComponent } from "./shared/write-task/write-task.component"; +import { ExcludeTaskComponent } from "./shared/exclude-task/exclude-task.component"; +import { RadioSelectTaskComponent } from "./shared/radio-select-task/radio-select-task.component"; +import { FileTaskComponent } from "./shared/file-task/file-task.component"; +import { LoaderComponent } from "@ui/components/loader/loader.component"; + +@Component({ + selector: "app-lesson", + standalone: true, + imports: [ + CommonModule, + RouterOutlet, + ButtonComponent, + InfoTaskComponent, + WriteTaskComponent, + ExcludeTaskComponent, + RadioSelectTaskComponent, + FileTaskComponent, + LoaderComponent, + ], + templateUrl: "./lesson.component.html", + styleUrl: "./lesson.component.scss", +}) +export class LessonComponent implements OnInit { + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); + private readonly coursesService = inject(CoursesService); + private readonly snackbarService = inject(SnackbarService); + + protected readonly lessonInfo = signal(undefined); + protected readonly isComplete = signal(false); + protected readonly currentTaskId = signal(null); + + protected readonly loader = signal(false); + protected readonly loading = signal(false); + protected readonly success = signal(false); + + protected readonly answerBody = signal(null); + protected readonly hasError = signal(false); + protected readonly completedTaskIds = signal>(new Set()); + + protected readonly tasks = computed(() => this.lessonInfo()?.tasks ?? []); + + protected readonly currentTask = computed(() => { + const id = this.currentTaskId(); + return this.tasks().find(t => t.id === id) ?? null; + }); + + protected readonly isLastTask = computed(() => { + const allTasksLength = this.tasks().length; + return allTasksLength === this.currentTask()?.order; + }); + + protected readonly isSubmitDisabled = computed(() => { + const task = this.currentTask(); + const body = this.answerBody(); + if (!task) return true; + + switch (task.answerType) { + case "text": + return !body || (typeof body === "string" && !body.trim()); + case "text_and_files": + return !body?.text?.trim() || !body?.fileUrls?.length; + case "single_choice": + case "multiple_choice": + return !body || (Array.isArray(body) && body.length === 0); + case "files": + return !body || (Array.isArray(body) && body.length === 0); + default: + return false; + } + }); + + ngOnInit() { + this.route.data + .pipe( + map(data => data["data"] as CourseLesson), + tap(() => { + this.loading.set(true); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: lessonInfo => { + this.lessonInfo.set(lessonInfo); + + // Если курс уже завершен, редирект на results + if (lessonInfo.progressStatus === "completed") { + setTimeout(() => { + this.loading.set(false); + this.router.navigate(["results"], { relativeTo: this.route }); + }, 500); + return; + } + + const nextTaskId = + lessonInfo.currentTaskId ?? + lessonInfo.tasks.find(t => t.isAvailable && !t.isCompleted)?.id ?? + null; + + const allCompleted = lessonInfo.tasks.every(t => t.isCompleted); + const onResultsPage = this.router.url.includes("results"); + + if (onResultsPage && !allCompleted) { + // Находимся на results, но не все задания выполнены — редирект обратно + this.currentTaskId.set(nextTaskId); + setTimeout(() => { + this.loading.set(false); + this.router.navigate(["./"], { relativeTo: this.route }); + }, 500); + } else if (nextTaskId === null && allCompleted) { + // Все задания выполнены, редирект на results + setTimeout(() => { + this.loading.set(false); + this.router.navigate(["results"], { relativeTo: this.route }); + }, 500); + } else { + this.currentTaskId.set(nextTaskId); + setTimeout(() => this.loading.set(false), 500); + } + }, + complete: () => { + setTimeout(() => this.loading.set(false), 500); + }, + }); + + this.router.events + .pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + this.isComplete.set(this.router.url.includes("results")); + }); + + this.isComplete.set(this.router.url.includes("results")); + } + + isCurrent(taskId: number): boolean { + return this.currentTaskId() === taskId; + } + + isDone(task: Task): boolean { + return task.isCompleted || this.completedTaskIds().has(task.id); + } + + onSubmitAnswer() { + const task = this.currentTask(); + if (!task) return; + + this.loader.set(true); + + const body = this.answerBody(); + const isTextFile = task.answerType === "text_and_files"; + const answerText = task.answerType === "text" || isTextFile ? body?.text : undefined; + const optionIds = + task.answerType === "single_choice" || task.answerType === "multiple_choice" + ? body + : undefined; + const fileIds = task.answerType === "files" ? body : isTextFile ? body?.fileUrls : undefined; + + this.coursesService + .postAnswerQuestion(task.id, answerText, optionIds, fileIds) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: res => { + this.loader.set(false); + + if (res.isCorrect) { + this.success.set(true); + this.hasError.set(false); + this.completedTaskIds.update(ids => new Set([...ids, task.id])); + this.snackbarService.success("правильный ответ, продолжайте дальше"); + } else { + this.hasError.set(true); + this.success.set(false); + this.snackbarService.error("неверный ответ, попробуйте еще раз!"); + setTimeout(() => this.hasError.set(false), 1000); + return; + } + + if (!res.canContinue) return; + + setTimeout(() => { + const nextId = res.nextTaskId ?? this.getNextTask()?.id ?? null; + + if (nextId) { + this.currentTaskId.set(nextId); + this.success.set(false); + this.answerBody.set(null); + } else { + this.router.navigate(["results"], { relativeTo: this.route }); + } + }, 1000); + }, + error: () => { + this.loader.set(false); + this.hasError.set(true); + this.snackbarService.error("неверный ответ, попробуйте еще раз!"); + }, + }); + } + + private getNextTask(): Task | null { + const currentId = this.currentTaskId(); + const allTasks = this.tasks(); + const currentIndex = allTasks.findIndex(t => t.id === currentId); + const next = allTasks.slice(currentIndex + 1).find(t => t.isAvailable && !t.isCompleted); + return next ?? null; + } + + onAnswerChange(value: any) { + this.answerBody.set(value); + } +} diff --git a/projects/skills/src/app/task/task.resolver.ts b/projects/social_platform/src/app/office/courses/lesson/lesson.resolver.ts similarity index 62% rename from projects/skills/src/app/task/task.resolver.ts rename to projects/social_platform/src/app/office/courses/lesson/lesson.resolver.ts index 89155558f..3c05ac9e7 100644 --- a/projects/skills/src/app/task/task.resolver.ts +++ b/projects/social_platform/src/app/office/courses/lesson/lesson.resolver.ts @@ -2,8 +2,8 @@ import type { ResolveFn } from "@angular/router"; import { inject } from "@angular/core"; -import { TaskService } from "./services/task.service"; -import type { TaskStepsResponse } from "../../models/skill.model"; +import { CourseLesson } from "@office/models/courses.model"; +import { CoursesService } from "../courses.service"; /** * Резолвер для получения данных задачи @@ -11,11 +11,12 @@ import type { TaskStepsResponse } from "../../models/skill.model"; * * @param route - объект маршрута, содержащий параметры URL (включая taskId) * @param _state - состояние маршрутизатора (не используется) - * @returns Promise - промис с данными о шагах задачи + * @returns Promise - промис с данными о шагах задачи */ -export const taskDetailResolver: ResolveFn = (route, _state) => { - const taskService = inject(TaskService); +export const lessonDetailResolver: ResolveFn = (route, _state) => { + const coursesService = inject(CoursesService); + const lessonId = route.params["lessonId"]; // Получаем ID задачи из параметров маршрута и загружаем шаги задачи - return taskService.fetchSteps(route.params["taskId"]); + return coursesService.getCourseLesson(lessonId); }; diff --git a/projects/social_platform/src/app/office/courses/lesson/lesson.routes.ts b/projects/social_platform/src/app/office/courses/lesson/lesson.routes.ts new file mode 100644 index 000000000..7983a6cd4 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/lesson.routes.ts @@ -0,0 +1,30 @@ +/** @format */ + +import type { Routes } from "@angular/router"; +import { LessonComponent } from "./lesson.component"; +import { TaskCompleteComponent } from "./complete/complete.component"; +import { lessonDetailResolver } from "./lesson.resolver"; + +/** + * Конфигурация маршрутов для модуля уроков + * Определяет структуру навигации и связывает компоненты с URL-путями + * + * Структура маршрутов: + * - /:lessonId - основной компонент урока + * - /results - компонент результатов выполнения урока + */ +export const LESSON_ROUTES: Routes = [ + { + path: ":lessonId", + component: LessonComponent, + resolve: { + data: lessonDetailResolver, + }, + children: [ + { + path: "results", + component: TaskCompleteComponent, + }, + ], + }, +]; diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/exclude-task/exclude-task.component.html b/projects/social_platform/src/app/office/courses/lesson/shared/exclude-task/exclude-task.component.html new file mode 100644 index 000000000..4c44360ac --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/shared/exclude-task/exclude-task.component.html @@ -0,0 +1,40 @@ + + +
+
+

задание {{ data.order }}

+
+ @if (getSafeVideoUrl(); as videoUrl) { + + } @else if (data.imageUrl) { + exclude-image +

{{ data.title | truncate: 50 }}

+ } +

{{ data.bodyText | truncate: 700 }}

+
+
+ +
+

ответ

+
    +

    {{ data.answerTitle | truncate: 110 }}

    + @for (op of data.options; track op.id) { +
  • + + +
  • + } +
+
+
diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/exclude-task/exclude-task.component.scss b/projects/social_platform/src/app/office/courses/lesson/shared/exclude-task/exclude-task.component.scss new file mode 100644 index 000000000..4ebd7b812 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/shared/exclude-task/exclude-task.component.scss @@ -0,0 +1,123 @@ +@use "styles/responsive"; +@use "styles/typography"; + +.exclude { + display: flex; + flex-direction: row; + gap: 20px; + + // &--hasContent { + // display: flex; + // flex-direction: row-reverse; + // } + + &__answer { + color: var(--grey-for-text); + } + + &__text { + margin: 23px 0; + + &--hasContent { + display: flex; + flex-direction: column; + align-items: center; + align-self: center; + justify-content: center; + + iframe { + display: block; + width: 100%; + max-width: 400px; + height: auto; + aspect-ratio: 16 / 9; + border: 0; + border-radius: var(--rounded-lg); + } + } + } + + &__list { + display: flex; + flex-direction: column; + gap: 10px; + } + + &__item { + display: flex; + gap: 14px; + align-items: center; + + &--success { + ::ng-deep { + app-checkbox { + .field--checked { + background-color: var(--green) !important; + border-color: var(--green) !important; + } + } + } + } + + &--error { + ::ng-deep { + app-checkbox { + .field--checked { + background-color: var(--red) !important; + border-color: var(--red) !important; + } + } + } + } + + label { + cursor: pointer; + } + } + + &__description { + text-align: justify; + } + + &__image { + width: 100%; + max-width: 300px; + height: 100%; + max-height: 150px; + margin: 22px 0; + object-fit: cover; + border-radius: var(--rounded-lg); + } + + &__body, + &__content { + flex-grow: 1; + min-height: 358px; + padding: 24px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } + + // &__body { + // &--hasContent { + // max-width: 333px; + // } + // } + + &__content { + &--hasContent { + min-width: 333px; + } + + &:not(&--hasContent) { + max-width: 333px; + } + } + + :host ::ng-deep &__hint p { + margin-top: 20px; + + @include typography.body-14; + } +} diff --git a/projects/skills/src/app/task/shared/exclude-task/exclude-task.component.spec.ts b/projects/social_platform/src/app/office/courses/lesson/shared/exclude-task/exclude-task.component.spec.ts similarity index 100% rename from projects/skills/src/app/task/shared/exclude-task/exclude-task.component.spec.ts rename to projects/social_platform/src/app/office/courses/lesson/shared/exclude-task/exclude-task.component.spec.ts diff --git a/projects/skills/src/app/task/shared/exclude-task/exclude-task.component.ts b/projects/social_platform/src/app/office/courses/lesson/shared/exclude-task/exclude-task.component.ts similarity index 57% rename from projects/skills/src/app/task/shared/exclude-task/exclude-task.component.ts rename to projects/social_platform/src/app/office/courses/lesson/shared/exclude-task/exclude-task.component.ts index 499bc451e..3d6cfff03 100644 --- a/projects/skills/src/app/task/shared/exclude-task/exclude-task.component.ts +++ b/projects/social_platform/src/app/office/courses/lesson/shared/exclude-task/exclude-task.component.ts @@ -2,9 +2,12 @@ import { Component, EventEmitter, inject, Input, type OnInit, Output, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; -import type { ExcludeQuestion, ExcludeQuestionResponse } from "../../../../models/step.model"; -import { DomSanitizer, type SafeResourceUrl } from "@angular/platform-browser"; -import { ParseBreaksPipe, YtExtractService } from "@corelib"; +import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; +import { CheckboxComponent } from "@ui/components"; +import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; +import { Task } from "@office/models/courses.model"; +import { resolveVideoUrlForIframe } from "@utils/video-url-embed"; +import { ImagePreviewDirective } from "../image-preview/image-preview.directive"; /** * Компо��ент задачи на исключение лишнего @@ -29,39 +32,50 @@ import { ParseBreaksPipe, YtExtractService } from "@corelib"; @Component({ selector: "app-exclude-task", standalone: true, - imports: [CommonModule, ParseBreaksPipe], + imports: [CommonModule, TruncatePipe, CheckboxComponent, ImagePreviewDirective], templateUrl: "./exclude-task.component.html", styleUrl: "./exclude-task.component.scss", }) export class ExcludeTaskComponent implements OnInit { - @Input({ required: true }) data!: ExcludeQuestion; // Данные вопроса + private readonly sanitizer = inject(DomSanitizer); + + @Input({ required: true }) data!: Task; // Данные вопроса @Input() hint!: string; // Текст подсказки @Output() update = new EventEmitter(); // Событие обновления выбранных ответов @Input() success = false; // Флаг успешного выполнения - // Сеттер для обработки ошибок и сброса состояния @Input() - set error(value: ExcludeQuestionResponse | null) { + set error(value: boolean) { this._error.set(value); - value !== null && this.result.set([]); // Сбрасываем выбранные ответы при ошибке + if (value) { + setTimeout(() => { + this.result.set([]); + this._error.set(false); + }, 1000); + } } get error() { return this._error(); } - // Состояние компонента - result = signal([]); // Массив ID выбранных ответов - _error = signal(null); // Состояние ошибки + result = signal([]); + _error = signal(false); + + getSafeVideoUrl(): SafeResourceUrl | null { + const iframeUrl = resolveVideoUrlForIframe(this.data?.videoUrl); + if (!iframeUrl) { + return null; + } - sanitizer = inject(DomSanitizer); // Сервис для безопасной работы с HTML - ytExtractService = inject(YtExtractService); // Сервис для извлечения YouTube ссылок + return this.sanitizer.bypassSecurityTrustResourceUrl(iframeUrl); + } - videoUrl?: SafeResourceUrl; // Безопасная ссылка на видео - description: any; // Обработанное описание - sanitizedFileUrl?: SafeResourceUrl; // Безопасная ссылка на файл + hasVideoUrl(): boolean { + return !!resolveVideoUrlForIframe(this.data?.videoUrl); + } /** * Обработчик выбора/отмены выбора варианта ответа @@ -80,19 +94,5 @@ export class ExcludeTaskComponent implements OnInit { this.update.emit(this.result()); } - ngOnInit(): void { - // Извлекаем YouTube ссылку из описания - const res = this.ytExtractService.transform(this.data.description); - - if (res.extractedLink) - this.videoUrl = this.sanitizer.bypassSecurityTrustResourceUrl(res.extractedLink); - - // Обрабатываем файлы, если они есть - if (this.data.files.length) { - this.sanitizedFileUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.data.files[0]); - } - - // Безопасно обрабатываем HTML в описании - this.description = this.sanitizer.bypassSecurityTrustHtml(this.data.description); - } + ngOnInit(): void {} } diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/file-task/file-task.component.html b/projects/social_platform/src/app/office/courses/lesson/shared/file-task/file-task.component.html new file mode 100644 index 000000000..89c07e9a5 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/shared/file-task/file-task.component.html @@ -0,0 +1,88 @@ + + +
+
+

задание {{ data.order }}

+ @if ((!data.videoUrl && !data.imageUrl) && data.attachmentUrl) { +

{{ data.title | truncate: 80 }}

+ } +
+ @if (getSafeVideoUrl(); as videoUrl) { + + } @if (data.imageUrl) { + + } +
+ @if (data.imageUrl) { +

{{ data.title | truncate: 80 }}

+ } +
+ + @if (descriptionExpandable) { +
+ {{ readFullDescription ? "скрыть" : "подробнее" }} +
+ } +
+ + @if (data.attachmentUrl) { + + } +
+ + @if (hint.length) { +
+ } +
+ +
+

ответ

+

{{ data.answerTitle | truncate: 80 }}

+ +
+ +
+ +

загрузите файл до 100 MB

+
+
+ + @for (file of uploadedFiles(); track $index) { + + } +
+
+
diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/file-task/file-task.component.scss b/projects/social_platform/src/app/office/courses/lesson/shared/file-task/file-task.component.scss new file mode 100644 index 000000000..af53fa8b2 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/shared/file-task/file-task.component.scss @@ -0,0 +1,181 @@ +@use "styles/responsive"; +@use "styles/typography"; + +.file-task { + display: flex; + flex-direction: row; + gap: 20px; + + @include responsive.apply-desktop { + &--hasVideo { + flex-direction: row; + } + } + + &__header, + &__content { + flex-grow: 1; + min-height: 358px; + max-height: 393px; + padding: 24px; + background: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } + + &__content { + min-width: 333px; + padding: 12px 24px; + + ::ng-deep { + app-upload-file { + .control { + max-height: 83px; + border-radius: var(--rounded-lg); + } + } + } + + &--success { + border-color: var(--green); + } + + &--error { + border-color: var(--red); + } + } + + &__files { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 12px; + } + + &__file-row { + display: flex; + gap: 10px; + align-items: center; + } + + &__delete { + color: var(--red); + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: var(--red-dark); + } + } + + &__upload { + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; + text-align: center; + + p { + color: var(--grey-for-text); + } + } + + &__description, + &__text { + display: flex; + flex-direction: column; + margin-top: 23px; + white-space: pre-line; + + img { + align-self: center; + } + + &--hasVideo { + display: flex; + align-items: center; + align-self: center; + justify-content: center; + } + + iframe { + display: block; + width: 100%; + max-width: 400px; + height: auto; + aspect-ratio: 16 / 9; + border: 0; + border-radius: var(--rounded-lg); + } + } + + &__label { + margin-bottom: 12px; + color: var(--grey-for-text); + } + + &__task { + color: var(--grey-for-text); + } + + &__image { + width: 100%; + max-width: 300px; + height: 100%; + max-height: 150px; + border-radius: var(--rounded-lg); + + &-text { + display: flex; + flex-direction: column; + gap: 5px; + + p { + text-align: center; + } + } + } + + &__desc { + max-height: 12em; + overflow: hidden; + text-align: justify; + transition: max-height 0.5s ease-in-out; + + &:not(&--expanded) { + display: box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 8; + } + + &--expanded { + max-height: 210px; + overflow-y: auto; + } + } + + &__file { + &-text { + display: flex; + flex-direction: column; + margin-bottom: 10px; + } + } + + .read-more { + margin-top: 12px; + color: var(--accent); + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: var(--accent-dark); + } + } + + :host ::ng-deep &__hint p { + margin-top: 20px; + + @include typography.body-14; + } +} diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/file-task/file-task.component.ts b/projects/social_platform/src/app/office/courses/lesson/shared/file-task/file-task.component.ts new file mode 100644 index 000000000..b4b885498 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/shared/file-task/file-task.component.ts @@ -0,0 +1,138 @@ +/** @format */ + +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + inject, + Input, + Output, + signal, + ViewChild, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; +import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; +import { TruncateHtmlPipe } from "projects/core/src/lib/pipes/truncate-html.pipe"; +import { UploadFileComponent } from "@ui/components/upload-file/upload-file.component"; +import { IconComponent } from "@ui/components"; +import { FileItemComponent } from "@ui/components/file-item/file-item.component"; +import { FileService } from "@core/services/file.service"; +import { Task } from "@office/models/courses.model"; +import { FileModel } from "@office/models/file.model"; +import { resolveVideoUrlForIframe } from "@utils/video-url-embed"; +import { ImagePreviewDirective } from "../image-preview/image-preview.directive"; + +@Component({ + selector: "app-file-task", + standalone: true, + imports: [ + CommonModule, + TruncatePipe, + TruncateHtmlPipe, + UploadFileComponent, + IconComponent, + FileItemComponent, + ImagePreviewDirective, + ], + templateUrl: "./file-task.component.html", + styleUrl: "./file-task.component.scss", +}) +export class FileTaskComponent implements AfterViewInit { + private readonly fileService = inject(FileService); + private readonly sanitizer = inject(DomSanitizer); + private readonly cdRef = inject(ChangeDetectorRef); + + @ViewChild("descEl") descEl?: ElementRef; + + @Input({ required: true }) data!: Task; + @Input() success = false; + @Input() hint = ""; + + @Input() + set error(value: boolean) { + this._error.set(value); + + if (value) { + setTimeout(() => { + this.uploadedFiles.set([]); + this._error.set(false); + this.update.emit([]); + }, 1000); + } + } + + get error() { + return this._error(); + } + + @Output() update = new EventEmitter(); + + _error = signal(false); + uploadedFiles = signal([]); + descriptionExpandable = false; + readFullDescription = false; + + ngAfterViewInit(): void { + const el = this.descEl?.nativeElement; + if (el) { + this.descriptionExpandable = el.scrollHeight > el.clientHeight; + this.cdRef.detectChanges(); + } + } + + onToggleDescription(): void { + this.readFullDescription = !this.readFullDescription; + } + + getSafeVideoUrl(): SafeResourceUrl | null { + const iframeUrl = resolveVideoUrlForIframe(this.data?.videoUrl); + if (!iframeUrl) { + return null; + } + + return this.sanitizer.bypassSecurityTrustResourceUrl(iframeUrl); + } + + hasVideoUrl(): boolean { + return !!resolveVideoUrlForIframe(this.data?.videoUrl); + } + + onFileUploaded(event: { url: string; name: string; size: number; mimeType: string }) { + const ext = event.name.split(".").pop()?.toLowerCase() || ""; + const file: FileModel = { + name: event.name, + size: event.size, + mimeType: event.mimeType, + link: event.url, + extension: ext, + datetimeUploaded: new Date().toISOString(), + user: 0, + }; + + this.uploadedFiles.update(files => [...files, file]); + this.emitLinks(); + } + + onFileRemoved(index: number) { + const file = this.uploadedFiles()[index]; + if (!file) return; + + this.fileService.deleteFile(file.link).subscribe({ + next: () => { + this.uploadedFiles.update(files => files.filter((_, i) => i !== index)); + this.emitLinks(); + }, + error: () => { + this.uploadedFiles.update(files => files.filter((_, i) => i !== index)); + this.emitLinks(); + }, + }); + } + + private emitLinks() { + this.update.emit(this.uploadedFiles().map(f => f.link)); + } +} diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/image-preview/image-preview.directive.ts b/projects/social_platform/src/app/office/courses/lesson/shared/image-preview/image-preview.directive.ts new file mode 100644 index 000000000..0d1fd81f5 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/shared/image-preview/image-preview.directive.ts @@ -0,0 +1,44 @@ +/** @format */ + +import { Directive, ElementRef, HostListener, inject, Renderer2 } from "@angular/core"; + +@Directive({ + selector: "img[appImagePreview]", + standalone: true, +}) +export class ImagePreviewDirective { + private readonly el = inject(ElementRef); + private readonly renderer = inject(Renderer2); + + constructor() { + this.el.nativeElement.style.cursor = "pointer"; + } + + @HostListener("click") + onClick(): void { + const src = this.el.nativeElement.src; + if (!src) return; + + const dialog = this.renderer.createElement("dialog") as HTMLDialogElement; + dialog.classList.add("image-preview-dialog"); + + const img = this.renderer.createElement("img") as HTMLImageElement; + img.src = src; + img.classList.add("image-preview-dialog__img"); + + this.renderer.appendChild(dialog, img); + this.renderer.appendChild(document.body, dialog); + + dialog.showModal(); + + dialog.addEventListener("click", (e: MouseEvent) => { + if (e.target === dialog) { + dialog.close(); + } + }); + + dialog.addEventListener("close", () => { + dialog.remove(); + }); + } +} diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/image-preview/image-preview.scss b/projects/social_platform/src/app/office/courses/lesson/shared/image-preview/image-preview.scss new file mode 100644 index 000000000..7a9a86c09 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/shared/image-preview/image-preview.scss @@ -0,0 +1,51 @@ +.image-preview-dialog { + position: fixed; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + width: 100vw; + max-width: 100vw; + height: 100vh; + max-height: 100vh; + padding: 40px; + margin: 0; + background: transparent; + border: none; + + &::backdrop { + background: rgb(0 0 0 / 60%); + animation: fade-in 0.25s ease-out; + } + + &__img { + max-width: 90vw; + max-height: 85vh; + object-fit: contain; + border-radius: var(--rounded-lg); + animation: zoom-in 0.25s ease-out; + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes zoom-in { + from { + opacity: 0; + transform: scale(0.85); + } + + to { + opacity: 1; + transform: scale(1); + } +} diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.html b/projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.html new file mode 100644 index 000000000..0a6e0961b --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.html @@ -0,0 +1,48 @@ + + +
+
+

задание {{ data.order }}

+ @if (getSafeVideoUrl(); as videoUrl) { + + } @if (data.imageUrl) { +
+ +

{{ data.title | truncate: 50 }}

+
+ } + +

{{ data.bodyText | truncate: 700 }}

+ @if (data.attachmentUrl) { + + } @if (hint.length) { +
+ } +
+ +
+

ответ

+
    +

    {{ data.answerTitle | truncate: 110 }}

    + @for (op of data.options; track op.id) { +
  • + + +
  • + } +
+
+
diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.scss b/projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.scss new file mode 100644 index 000000000..6b9ccd070 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.scss @@ -0,0 +1,145 @@ +@use "styles/typography"; +@use "styles/responsive"; + +.radio { + @include responsive.apply-desktop { + display: flex; + flex-direction: row; + gap: 20px; + + // &--hasContent { + // flex-direction: row-reverse; + // } + } + + &__answer { + align-self: baseline; + color: var(--grey-for-text); + } + + &__title { + margin: 23px 0; + } + + &__list { + display: flex; + flex-direction: column; + gap: 17px; + } + + &__description { + text-align: justify; + } + + &__img { + width: 100%; + max-width: 300px; + height: 100%; + max-height: 150px; + margin: 22px 0; + object-fit: cover; + border-radius: var(--rounded-lg); + + &-text { + text-align: center; + } + } + + &__item { + display: flex; + gap: 14px; + align-items: center; + cursor: pointer; + + label { + cursor: pointer; + } + + input[type="radio"] { + position: relative; + min-width: 18px; + min-height: 18px; + cursor: pointer; + background: transparent; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: 50%; + outline: none; + appearance: none; + } + + input[type="radio"]:checked { + background-color: var(--accent); + } + + input[type="radio"]:checked::after { + position: absolute; + top: 50%; + left: 50%; + width: 8px; + height: 8px; + content: ""; + background-color: var(--light-white); + border-radius: 50%; + transform: translate(-50%, -50%); + } + + &--success { + input[type="radio"]:checked { + background-color: var(--green); + } + } + + &--error { + input[type="radio"]:checked { + background-color: var(--red); + } + } + } + + &__body, + &__content { + flex-grow: 1; + min-height: 358px; + padding: 24px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } + + &__body { + &--hasContent { + display: flex; + flex-direction: column; + gap: 5px; + align-items: center; + justify-content: center; + text-align: center; + + iframe { + display: block; + width: 100%; + max-width: 288px; + height: auto; + aspect-ratio: 16 / 9; + border: 0; + border-radius: var(--rounded-lg); + } + } + } + + &__content { + &--hasContent { + min-width: 333px; + } + + &:not(&--hasContent) { + max-width: 333px; + } + } + + :host ::ng-deep &__hint p { + margin-top: 20px; + + @include typography.body-14; + } +} diff --git a/projects/skills/src/app/task/shared/radio-select-task/radio-select-task.component.spec.ts b/projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.spec.ts similarity index 100% rename from projects/skills/src/app/task/shared/radio-select-task/radio-select-task.component.spec.ts rename to projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.spec.ts diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.ts b/projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.ts new file mode 100644 index 000000000..f2bb6128f --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.ts @@ -0,0 +1,64 @@ +/** @format */ + +import { Component, EventEmitter, Input, Output, signal } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; +import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; +import { Task } from "@office/models/courses.model"; +import { resolveVideoUrlForIframe } from "@utils/video-url-embed"; +import { FileItemComponent } from "@ui/components/file-item/file-item.component"; +import { ImagePreviewDirective } from "../image-preview/image-preview.directive"; + +@Component({ + selector: "app-radio-select-task", + standalone: true, + imports: [CommonModule, TruncatePipe, FileItemComponent, ImagePreviewDirective], + templateUrl: "./radio-select-task.component.html", + styleUrl: "./radio-select-task.component.scss", +}) +export class RadioSelectTaskComponent { + @Input({ required: true }) data!: Task; + @Input() success = false; + @Input() hint = ""; + + @Input() + set error(value: boolean) { + this._error.set(value); + + if (value) { + setTimeout(() => { + this.result.set({ answerId: null }); + this._error.set(false); + }, 1000); + } + } + + get error() { + return this._error(); + } + + @Output() update = new EventEmitter<{ answerId: number }>(); + + result = signal<{ answerId: number | null }>({ answerId: null }); + _error = signal(false); + + constructor(private sanitizer: DomSanitizer) {} + + getSafeVideoUrl(): SafeResourceUrl | null { + const iframeUrl = resolveVideoUrlForIframe(this.data?.videoUrl); + if (!iframeUrl) { + return null; + } + + return this.sanitizer.bypassSecurityTrustResourceUrl(iframeUrl); + } + + hasVideoUrl(): boolean { + return !!resolveVideoUrlForIframe(this.data?.videoUrl); + } + + onSelect(id: number) { + this.result.set({ answerId: id }); + this.update.emit({ answerId: id }); + } +} diff --git a/projects/skills/src/app/task/shared/relations-task/relations-task.component.html b/projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.html similarity index 100% rename from projects/skills/src/app/task/shared/relations-task/relations-task.component.html rename to projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.html diff --git a/projects/skills/src/app/task/shared/relations-task/relations-task.component.scss b/projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.scss similarity index 100% rename from projects/skills/src/app/task/shared/relations-task/relations-task.component.scss rename to projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.scss diff --git a/projects/skills/src/app/task/shared/relations-task/relations-task.component.spec.ts b/projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.spec.ts similarity index 100% rename from projects/skills/src/app/task/shared/relations-task/relations-task.component.spec.ts rename to projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.spec.ts diff --git a/projects/skills/src/app/task/shared/relations-task/relations-task.component.ts b/projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.ts similarity index 99% rename from projects/skills/src/app/task/shared/relations-task/relations-task.component.ts rename to projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.ts index 6d4cd1251..de13975f9 100644 --- a/projects/skills/src/app/task/shared/relations-task/relations-task.component.ts +++ b/projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.ts @@ -20,12 +20,12 @@ import { CommonModule } from "@angular/common"; import { DomSanitizer } from "@angular/platform-browser"; import { fromEvent, type Subscription } from "rxjs"; import { debounceTime } from "rxjs/operators"; -import type { +import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; +import { ConnectQuestion, ConnectQuestionRequest, ConnectQuestionResponse, -} from "../../../../models/step.model"; -import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; +} from "projects/skills/src/models/step.model"; /** * Компонент задачи на установление связей diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.html b/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.html new file mode 100644 index 000000000..7ce4b2c17 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.html @@ -0,0 +1,59 @@ + + +
+
+ @if ((data.videoUrl || data.attachmentUrl) && !data.imageUrl) { +

задание {{ data.order }}

+ } + +
+ @if (data.videoUrl) { @if (getSafeVideoUrl(); as videoUrl) { + + } } @if (data.imageUrl) { + + } + +
+ @if (!data.videoUrl) { +

задание {{ data.order }}

+ } + +

+ {{ data.title | truncate: (hasVideoUrl() ? 80 : data.imageUrl ? 100 : 200) }} +

+
+ + @if (data.attachmentUrl) { + + } +
+
+
+
diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.scss b/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.scss new file mode 100644 index 000000000..0174367ec --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.scss @@ -0,0 +1,75 @@ +@use "styles/responsive"; + +.video { + width: 100%; + + &__body { + display: flex; + flex-direction: row; + gap: 24px; + min-height: 358px; + padding: 24px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + iframe { + display: block; + width: 100%; + max-width: 400px; + height: auto; + aspect-ratio: 16 / 9; + border: 0; + border-radius: var(--rounded-lg); + } + + &--hasImage { + flex-direction: row-reverse; + } + + &--hasVideo { + flex-direction: column; + } + } + + &__wrapper { + display: flex; + gap: 24px; + width: 100%; + + &--hasVideo { + flex-direction: row; + flex-grow: 1; + } + + &--hasImage { + flex-direction: row-reverse; + flex-grow: 1; + } + } + + &__task { + color: var(--grey-for-text); + } + + &__img { + align-self: center; + width: 100%; + max-width: 300px; + height: 100%; + max-height: 200px; + object-fit: cover; + border-radius: var(--rounded-lg); + } + + &__text-content { + display: flex; + flex-direction: column; + gap: 24px; + width: 100%; + } + + &__text { + white-space: pre-line; + } +} diff --git a/projects/skills/src/app/task/shared/video-task/info-task.component.spec.ts b/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.spec.ts similarity index 100% rename from projects/skills/src/app/task/shared/video-task/info-task.component.spec.ts rename to projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.spec.ts diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.ts b/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.ts new file mode 100644 index 000000000..2eed6cf9d --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.ts @@ -0,0 +1,58 @@ +/** @format */ + +import { Component, inject, Input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { DomSanitizer, type SafeResourceUrl } from "@angular/platform-browser"; +import { TruncateHtmlPipe } from "projects/core/src/lib/pipes/truncate-html.pipe"; +import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; +import { resolveVideoUrlForIframe } from "@utils/video-url-embed"; +import { ImagePreviewDirective } from "../image-preview/image-preview.directive"; +import { Task } from "@office/models/courses.model"; +import { FileItemComponent } from "@ui/components/file-item/file-item.component"; + +/** + * Компонент информационного слайда с видео/изображением + * Отображает информационный контент с поддержкой различных медиа-форматов + * + * Входные параметры: + * @Input data - данные информационной задачи типа Task + * + * Функциональность: + * - Отображает текст и описание слайда + * - Поддерживает видео в iframe (YouTube, RuTube, Google Drive, прямые видео-файлы) + * - Автоматически определяет тип контента по URL/расширению файла + * - Адаптивная компоновка для разных типов медиа + */ +@Component({ + selector: "app-info-task", + standalone: true, + imports: [CommonModule, TruncateHtmlPipe, TruncatePipe, ImagePreviewDirective, FileItemComponent], + templateUrl: "./info-task.component.html", + styleUrl: "./info-task.component.scss", +}) +export class InfoTaskComponent { + @Input({ required: true }) data!: Task; // Данные информационной задачи + + private readonly sanitizer = inject(DomSanitizer); // Сервис для безопасной работы с HTML + + private getVideoUrl(): string | null { + return resolveVideoUrlForIframe(this.data?.videoUrl); + } + + getSafeVideoUrl(): SafeResourceUrl | null { + const iframeUrl = this.getVideoUrl(); + if (!iframeUrl) { + return null; + } + + return this.sanitizer.bypassSecurityTrustResourceUrl(iframeUrl); + } + + hasVideoUrl(): boolean { + return !!this.getVideoUrl(); + } + + hasContent(): boolean { + return this.hasVideoUrl() || !!this.data?.imageUrl; + } +} diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.html b/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.html new file mode 100644 index 000000000..3cc8710c1 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.html @@ -0,0 +1,83 @@ + + +
+
+

задание {{ data.order }}

+ @if (!hasVideoUrl() && !data.imageUrl) { +

{{ data.title | truncate: 80 }}

+ } +
+ @if (getSafeVideoUrl(); as videoUrl) { + + } @else if (data.imageUrl) { +
+ +

{{ data.title | truncate: 50 }}

+
+ } +
+ + @if (type === 'text-file' && data.attachmentUrl) { + + } +
+
+ +
+

ответ

+
+ +
+

+ {{ currentLength() }} / {{ maxLength }} +

+
+
+ + @if (type === 'text-file') { +
+ +
+ +

загрузите файл до 100 MB

+
+
+ + @for (file of uploadedFiles(); track $index) { + + } +
+ } +
+
diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.scss b/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.scss new file mode 100644 index 000000000..f56118d29 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.scss @@ -0,0 +1,130 @@ +@use "styles/responsive"; + +.write-task { + display: flex; + flex-direction: row; + gap: 20px; + + @include responsive.apply-desktop { + &--hasVideo { + flex-direction: row; + } + } + + &__header, + &__content { + flex-grow: 1; + min-height: 358px; + padding: 24px; + background: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } + + &__content { + min-width: 333px; + padding: 12px 24px; + } + + &__description, + &__text { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 23px; + white-space: pre-line; + + &--hasVideo { + display: flex; + align-items: center; + align-self: center; + justify-content: center; + } + + iframe { + display: block; + width: 100%; + max-width: 400px; + height: auto; + aspect-ratio: 16 / 9; + border: 0; + border-radius: var(--rounded-lg); + } + } + + &__input { + position: relative; + margin-top: 12px; + color: var(--grey-for-text); + } + + &__textarea { + display: block; + width: 100%; + padding: 12px 18px; + resize: none; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + outline: none; + scrollbar-width: none; + } + + &__task { + color: var(--grey-for-text); + } + + &__block { + display: flex; + flex-direction: column; + gap: 5px; + align-items: center; + align-self: center; + text-align: center; + } + + &__image { + width: 100%; + max-width: 300px; + max-height: 150px; + object-fit: cover; + border-radius: var(--rounded-lg); + } + + &__counter { + position: absolute; + right: 2%; + bottom: 2%; + + p { + color: var(--grey) !important; + } + } + + &__files { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 12px; + + ::ng-deep { + app-upload-file { + .control { + max-height: 83px; + border-radius: var(--rounded-lg); + } + } + } + } + + &__upload { + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; + text-align: center; + + p { + color: var(--grey-for-text); + } + } +} diff --git a/projects/skills/src/app/task/shared/write-task/write-task.component.spec.ts b/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.spec.ts similarity index 100% rename from projects/skills/src/app/task/shared/write-task/write-task.component.spec.ts rename to projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.spec.ts diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.ts b/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.ts new file mode 100644 index 000000000..242e9da35 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.ts @@ -0,0 +1,114 @@ +/** @format */ + +import { Component, EventEmitter, inject, Input, Output, signal } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; +import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; +import { UploadFileComponent } from "@ui/components/upload-file/upload-file.component"; +import { IconComponent } from "@ui/components"; +import { FileItemComponent } from "@ui/components/file-item/file-item.component"; +import { FileService } from "@core/services/file.service"; +import { Task } from "@models/courses.model"; +import { FileModel } from "@office/models/file.model"; +import { resolveVideoUrlForIframe } from "@utils/video-url-embed"; +import { ImagePreviewDirective } from "../image-preview/image-preview.directive"; + +@Component({ + selector: "app-write-task", + standalone: true, + imports: [ + CommonModule, + TruncatePipe, + UploadFileComponent, + IconComponent, + FileItemComponent, + ImagePreviewDirective, + ], + templateUrl: "./write-task.component.html", + styleUrl: "./write-task.component.scss", +}) +export class WriteTaskComponent { + private readonly fileService = inject(FileService); + private readonly sanitizer = inject(DomSanitizer); + + @Input({ required: true }) data!: Task; + @Input() type: "text" | "text-file" = "text"; + @Input() success = false; + + @Output() update = new EventEmitter<{ text: string; fileUrls?: string[] }>(); + + readonly maxLength = 1000; + + uploadedFiles = signal([]); + currentLength = signal(0); + private currentText = ""; + + getSafeVideoUrl(): SafeResourceUrl | null { + const iframeUrl = resolveVideoUrlForIframe(this.data?.videoUrl); + if (!iframeUrl) { + return null; + } + + return this.sanitizer.bypassSecurityTrustResourceUrl(iframeUrl); + } + + hasVideoUrl(): boolean { + return !!resolveVideoUrlForIframe(this.data?.videoUrl); + } + + onKeyUp(event: Event) { + const target = event.target as HTMLTextAreaElement; + + target.style.height = "0px"; + target.style.height = target.scrollHeight + "px"; + + this.currentText = target.value; + this.currentLength.set(target.value.length); + this.emitUpdate(); + } + + onFileUploaded(event: { url: string; name: string; size: number; mimeType: string }) { + if (!event.url) return; + + const ext = event.name.split(".").pop()?.toLowerCase() || ""; + const file: FileModel = { + name: event.name, + size: event.size, + mimeType: event.mimeType, + link: event.url, + extension: ext, + datetimeUploaded: new Date().toISOString(), + user: 0, + }; + + this.uploadedFiles.update(files => [...files, file]); + this.emitUpdate(); + } + + onFileRemoved(index: number) { + const file = this.uploadedFiles()[index]; + if (!file) return; + + this.fileService.deleteFile(file.link).subscribe({ + next: () => { + this.uploadedFiles.update(files => files.filter((_, i) => i !== index)); + this.emitUpdate(); + }, + error: () => { + this.uploadedFiles.update(files => files.filter((_, i) => i !== index)); + this.emitUpdate(); + }, + }); + } + + private emitUpdate() { + if (this.type === "text-file") { + this.update.emit({ + text: this.currentText, + fileUrls: this.uploadedFiles().map(f => f.link), + }); + } else { + this.update.emit({ text: this.currentText }); + } + } +} diff --git a/projects/social_platform/src/app/office/courses/list/list.component.html b/projects/social_platform/src/app/office/courses/list/list.component.html new file mode 100644 index 000000000..ae371b7ce --- /dev/null +++ b/projects/social_platform/src/app/office/courses/list/list.component.html @@ -0,0 +1,13 @@ + + +
+ @if (coursesList().length) { +
+ @for (course of coursesList(); track course.id) { + + + + } +
+ } +
diff --git a/projects/social_platform/src/app/office/courses/list/list.component.scss b/projects/social_platform/src/app/office/courses/list/list.component.scss new file mode 100644 index 000000000..f7601a355 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/list/list.component.scss @@ -0,0 +1,9 @@ +.courses { + margin-top: 20px; + + &__list { + display: grid; + grid-template-columns: 4fr 4fr; + grid-gap: 20px; + } +} diff --git a/projects/skills/src/app/webinars/list/list.component.spec.ts b/projects/social_platform/src/app/office/courses/list/list.component.spec.ts similarity index 100% rename from projects/skills/src/app/webinars/list/list.component.spec.ts rename to projects/social_platform/src/app/office/courses/list/list.component.spec.ts diff --git a/projects/social_platform/src/app/office/courses/list/list.component.ts b/projects/social_platform/src/app/office/courses/list/list.component.ts new file mode 100644 index 000000000..32241bcfe --- /dev/null +++ b/projects/social_platform/src/app/office/courses/list/list.component.ts @@ -0,0 +1,46 @@ +/** @format */ + +import { Component, inject, type OnDestroy, type OnInit, signal } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { ActivatedRoute, RouterModule } from "@angular/router"; +import { map, Subscription } from "rxjs"; +import { CourseComponent } from "../shared/course/course.component"; +import { CourseCard } from "@office/models/courses.model"; + +/** + * Компонент списка траекторий + * Отображает список доступных траекторий с поддержкой пагинации + * Поддерживает два режима: "all" (все траектории) и "my" (пользовательские) + * Реализует бесконечную прокрутку для загрузки дополнительных элементов + */ +@Component({ + selector: "app-list", + standalone: true, + imports: [CommonModule, RouterModule, CourseComponent], + templateUrl: "./list.component.html", + styleUrl: "./list.component.scss", +}) +export class CoursesListComponent implements OnInit, OnDestroy { + private readonly route = inject(ActivatedRoute); + + protected readonly coursesList = signal([]); + + private readonly subscriptions$: Subscription[] = []; + + /** + * Инициализация компонента + * Определяет тип списка (all/my) и загружает начальные данные + */ + ngOnInit(): void { + this.route.data.pipe(map(r => r["data"])).subscribe(courses => { + this.coursesList.set(courses); + }); + } + + /** + * Очистка ресурсов при уничтожении компонента + */ + ngOnDestroy(): void { + this.subscriptions$.forEach(s => s.unsubscribe()); + } +} diff --git a/projects/skills/src/app/webinars/list/list.resolver.spec.ts b/projects/social_platform/src/app/office/courses/list/list.resolver.spec.ts similarity index 100% rename from projects/skills/src/app/webinars/list/list.resolver.spec.ts rename to projects/social_platform/src/app/office/courses/list/list.resolver.spec.ts diff --git a/projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.html b/projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.html new file mode 100644 index 000000000..3264b1a61 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.html @@ -0,0 +1,37 @@ + + +
+ @if (mode === 'progress') { +

{{ progress }}%

+ } @else { @if (appereance === 'open') { + + } @else { + + } } +
+ + + + +
+ + @if (haveDate) { +

c 16.03.26

+ } +
diff --git a/projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.scss b/projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.scss new file mode 100644 index 000000000..f532a9b47 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.scss @@ -0,0 +1,57 @@ +@use "styles/typography"; +@use "styles/responsive"; + +.progress-bar { + position: relative; + + &__text { + position: absolute; + top: 49%; + left: 49%; + color: var(--dark-grey); + transform: translate(-49%, -49%); + + &--icon { + top: 45%; + left: 55%; + color: var(--accent-light); + } + + &--closed { + top: 43%; + left: 50%; + color: var(--red); + } + } + + &__date { + position: absolute; + bottom: -15%; + left: 5%; + } +} + +.track, +.filled { + fill: none; + stroke-width: 7; +} + +.track { + opacity: 0.2; + stroke: var(--accent-light); + + &--closed { + opacity: 1; + stroke: var(--red); + } +} + +.filled { + stroke: var(--accent); + transition: all 0.2s; + + &--complete { + stroke: var(--green); + } +} diff --git a/projects/skills/src/app/shared/circle-progress-bar/circle-progress-bar.component.spec.ts b/projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.spec.ts similarity index 100% rename from projects/skills/src/app/shared/circle-progress-bar/circle-progress-bar.component.spec.ts rename to projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.spec.ts diff --git a/projects/skills/src/app/shared/circle-progress-bar/circle-progress-bar.component.ts b/projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.ts similarity index 91% rename from projects/skills/src/app/shared/circle-progress-bar/circle-progress-bar.component.ts rename to projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.ts index 593765cea..4b57daa79 100644 --- a/projects/skills/src/app/shared/circle-progress-bar/circle-progress-bar.component.ts +++ b/projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.ts @@ -2,6 +2,7 @@ import { Component, Input } from "@angular/core"; import { CommonModule } from "@angular/common"; +import { IconComponent } from "@ui/components"; /** * Компонент круглого прогресс-бара @@ -15,7 +16,7 @@ import { CommonModule } from "@angular/common"; @Component({ selector: "app-circle-progress-bar", standalone: true, - imports: [CommonModule], + imports: [CommonModule, IconComponent], templateUrl: "./circle-progress-bar.component.html", styleUrl: "./circle-progress-bar.component.scss", }) @@ -27,6 +28,12 @@ export class CircleProgressBarComponent { */ @Input() progress = 0; + @Input() mode: "button" | "progress" = "progress"; + + @Input() appereance?: "open" | "closed"; + + @Input() haveDate?: boolean = false; + /** * Радиус круга прогресс-бара в пикселях * Используется для расчета окружности и отступов diff --git a/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.html b/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.html new file mode 100644 index 000000000..77f899455 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.html @@ -0,0 +1,71 @@ + + +
+
+
+ + +
+

Модуль {{ courseModule.order }}

+ +

+ {{ courseModule.title }} +

+ +
+

+ {{ courseModule.lessons.length }} + {{ courseModule.lessons.length | pluralize: ["урок", "урока", "уроков"] }} +

+
+
+
+ + +
+ + @if (courseModule.lessons.length) { +
+ @if (isExpanded) { + + } @else { + + } +
+ } +
+ + diff --git a/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.scss b/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.scss new file mode 100644 index 000000000..fd009a50b --- /dev/null +++ b/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.scss @@ -0,0 +1,119 @@ +.skill-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + &__inner { + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; + } + + &--wrapper { + position: relative; + z-index: 10; + cursor: pointer; + } + + &__info { + display: flex; + flex-direction: column; + } + + &__title { + color: var(--accent); + } + + &__text { + color: var(--black); + } + + &__text-block { + display: flex; + gap: 3px; + } + + &__level { + color: var(--grey-for-text); + } + + &__expand { + position: absolute; + bottom: -9px; + left: 17%; + z-index: 10; + z-index: -10; + width: 300px; + height: 14px; + cursor: pointer; + background-color: var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + + i { + ::ng-deep { + .icon { + position: absolute; + bottom: 8%; + left: 50%; + color: var(--dark-grey); + transform: rotate(180deg); + } + } + } + } + + &__expanded { + i { + ::ng-deep { + .icon { + transform: rotate(0deg); + } + } + } + } +} + +.expandable { + display: none; + + &--expanded { + display: block; + } + + &__list { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 20px; + } + + &__item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + cursor: pointer; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } +} + +.topic { + &__number { + color: var(--accent); + } + + &__name { + color: var(--black); + } + + &__levels { + color: var(--grey-for-text); + } +} diff --git a/projects/skills/src/app/skills/shared/skill-card/skill-card.component.spec.ts b/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.spec.ts similarity index 89% rename from projects/skills/src/app/skills/shared/skill-card/skill-card.component.spec.ts rename to projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.spec.ts index 9eaabb6a6..d43aca54f 100644 --- a/projects/skills/src/app/skills/shared/skill-card/skill-card.component.spec.ts +++ b/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { SkillCardComponent } from "./skill-card.component"; +import { SkillCardComponent } from "./course-module-card.component"; describe("SkillCardComponent", () => { let component: SkillCardComponent; diff --git a/projects/skills/src/app/skills/shared/skill-card/skill-card.component.ts b/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.ts similarity index 53% rename from projects/skills/src/app/skills/shared/skill-card/skill-card.component.ts rename to projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.ts index 902d1ee4c..0f7b6f6f4 100644 --- a/projects/skills/src/app/skills/shared/skill-card/skill-card.component.ts +++ b/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.ts @@ -3,8 +3,11 @@ import { Component, Input } from "@angular/core"; import { CommonModule } from "@angular/common"; import { AvatarComponent } from "@uilib"; -import { Skill } from "../../../../models/skill.model"; import { PluralizePipe } from "@corelib"; +import { IconComponent } from "@ui/components"; +import { CircleProgressBarComponent } from "../circle-progress-bar/circle-progress-bar.component"; +import { CourseDetail, CourseModule } from "@office/models/courses.model"; +import { RouterLink } from "@angular/router"; /** * Компонент карточки навыка @@ -19,15 +22,32 @@ import { PluralizePipe } from "@corelib"; * - Поддержка двух визуальных стилей * - Индикация статуса навыка (подписка, просрочка, выполнение) * - Плюрализация для количества уровней + * - Раскрывающийся список тем и действий */ @Component({ - selector: "app-skill-card", + selector: "app-course-module-card", standalone: true, - imports: [CommonModule, AvatarComponent, PluralizePipe], - templateUrl: "./skill-card.component.html", - styleUrl: "./skill-card.component.scss", + imports: [ + CommonModule, + CircleProgressBarComponent, + IconComponent, + RouterLink, + PluralizePipe, + AvatarComponent, + ], + templateUrl: "./course-module-card.component.html", + styleUrl: "./course-module-card.component.scss", }) -export class SkillCardComponent { - @Input({ required: true }) skill!: Skill; +export class CourseModuleCardComponent { + @Input({ required: true }) courseModule!: CourseModule; @Input() type: "personal" | "base" = "base"; + + isExpanded = false; + + toggleExpand(event: Event): void { + if (this.courseModule.lessons.length) { + event.stopPropagation(); + this.isExpanded = !this.isExpanded; + } + } } diff --git a/projects/social_platform/src/app/office/courses/shared/course/course.component.html b/projects/social_platform/src/app/office/courses/shared/course/course.component.html new file mode 100644 index 000000000..cfee7e26a --- /dev/null +++ b/projects/social_platform/src/app/office/courses/shared/course/course.component.html @@ -0,0 +1,43 @@ + + +@if(course) { +
+
+ + + + {{ + isMember() + ? "для участников программы" + : isSubs() + ? "доступно по подписке" + : "доступно всем пользователям" + }} + + + course-cover +
+ +
+

{{ course.title | truncate: 50 }}

+

+ {{ course.dateLabel }} +

+
+ +
+ @if (isLock()) { + + } @else { +

+ {{ course.progressStatus === "not_started" ? "начать" : "продолжить обучение" }} +

+ + } +
+
+} diff --git a/projects/social_platform/src/app/office/courses/shared/course/course.component.scss b/projects/social_platform/src/app/office/courses/shared/course/course.component.scss new file mode 100644 index 000000000..5f73c704f --- /dev/null +++ b/projects/social_platform/src/app/office/courses/shared/course/course.component.scss @@ -0,0 +1,89 @@ +@use "styles/responsive"; +@use "styles/typography"; + +.course { + position: relative; + width: 100%; + max-width: 333px; + overflow: hidden; + cursor: pointer; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + &__cover { + border-radius: var(--rounded-lg); + + app-button { + ::ng-deep { + .button { + position: absolute; + top: 15px; + right: 24px; + width: 166px; + padding: 0; + } + } + } + + app-avatar { + ::ng-deep { + .avatar { + img { + position: absolute; + top: 35px; + left: 24px; + box-shadow: 0 0 8px rgba($color: #333, $alpha: 30%); + } + } + } + } + } + + &__image { + width: 100%; + max-width: 333px; + height: 100%; + max-height: 137px; + border-radius: var(--rounded-lg); + } + + &__info { + display: flex; + flex-direction: column; + gap: 2px; + padding: 12px 24px; + + &--date { + color: var(--grey-for-text) !important; + } + } + + &__action { + position: absolute; + right: 14px; + bottom: 16px; + display: flex; + gap: 5px; + align-items: center; + + &--started, + &-icon { + color: var(--green) !important; + } + } + + &__rocket { + position: absolute; + top: -46%; + right: -13%; + z-index: 0; + width: 175px; + transform: rotate(-53deg); + } + + p, + i { + color: var(--black); + } +} diff --git a/projects/skills/src/app/profile/profile.component.spec.ts b/projects/social_platform/src/app/office/courses/shared/course/course.component.spec.ts similarity index 55% rename from projects/skills/src/app/profile/profile.component.spec.ts rename to projects/social_platform/src/app/office/courses/shared/course/course.component.spec.ts index 0f8e275dd..ab7e087af 100644 --- a/projects/skills/src/app/profile/profile.component.spec.ts +++ b/projects/social_platform/src/app/office/courses/shared/course/course.component.spec.ts @@ -1,19 +1,18 @@ /** @format */ import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { CourseComponent } from "./course.component"; -import { ProfileComponent } from "./profile.component"; - -describe("ProfileComponent", () => { - let component: ProfileComponent; - let fixture: ComponentFixture; +describe("CourseComponent", () => { + let component: CourseComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ProfileComponent], + imports: [CourseComponent], }).compileComponents(); - fixture = TestBed.createComponent(ProfileComponent); + fixture = TestBed.createComponent(CourseComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/projects/social_platform/src/app/office/courses/shared/course/course.component.ts b/projects/social_platform/src/app/office/courses/shared/course/course.component.ts new file mode 100644 index 000000000..166337651 --- /dev/null +++ b/projects/social_platform/src/app/office/courses/shared/course/course.component.ts @@ -0,0 +1,68 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { Component, Input, OnInit, signal } from "@angular/core"; +import { RouterModule } from "@angular/router"; +import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; +import { IconComponent, ButtonComponent } from "@ui/components"; +import { AvatarComponent } from "@ui/components/avatar/avatar.component"; +import { CourseCard } from "@office/models/courses.model"; + +/** + * Компонент отображения карточки траектории + * Показывает информацию о траектории: название, описание, навыки, длительность + * Поддерживает различные модальные окна для взаимодействия с пользователем + * Обрабатывает выбор траектории и навигацию к детальной информации + * + * @Input trajectory - объект траектории для отображения + */ +@Component({ + selector: "app-course", + standalone: true, + imports: [ + CommonModule, + RouterModule, + TruncatePipe, + IconComponent, + AvatarComponent, + ButtonComponent, + IconComponent, + ], + templateUrl: "./course.component.html", + styleUrl: "./course.component.scss", +}) +export class CourseComponent implements OnInit { + @Input() course!: CourseCard; + + ngOnInit(): void { + this.accessType(); + this.actions(); + } + + protected readonly isLock = signal(false); + + protected readonly isMember = signal(false); + protected readonly isSubs = signal(false); + + private accessType() { + switch (this.course.accessType) { + case "program_members": { + this.isMember.set(true); + break; + } + + case "subscription_stub": + this.isSubs.set(true); + break; + } + } + + private actions() { + switch (this.course.actionState) { + case "lock": { + this.isLock.set(true); + break; + } + } + } +} diff --git a/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.ts b/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.ts index 3a1db27d9..aad91d08e 100644 --- a/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.ts +++ b/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.ts @@ -2,14 +2,11 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectorRef, Component, inject, Input, OnDestroy, OnInit } from "@angular/core"; -import { PluralizePipe } from "@corelib"; -import { Skill } from "@office/models/skill"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; +import { Skill } from "@office/models/skill.model"; import { ButtonComponent } from "@ui/components"; import { map, of, Subscription, switchMap } from "rxjs"; import { AuthService } from "@auth/services"; import { ActivatedRoute } from "@angular/router"; -import { ProfileService } from "projects/skills/src/app/profile/services/profile.service"; import { ProfileService as profileApproveSkillService } from "@auth/services/profile.service"; import { SnackbarService } from "@ui/services/snackbar.service"; import { HttpErrorResponse } from "@angular/common/http"; @@ -28,14 +25,7 @@ import { ApproveSkillPeopleComponent } from "@office/shared/approve-skill-people styleUrl: "./approve-skill.component.scss", templateUrl: "./approve-skill.component.html", standalone: true, - imports: [ - CommonModule, - AvatarComponent, - PluralizePipe, - ButtonComponent, - ModalComponent, - ApproveSkillPeopleComponent, - ], + imports: [CommonModule, ButtonComponent, ModalComponent, ApproveSkillPeopleComponent], }) export class ApproveSkillComponent implements OnInit, OnDestroy { private readonly authService = inject(AuthService); diff --git a/projects/social_platform/src/app/office/features/detail/detail.component.ts b/projects/social_platform/src/app/office/features/detail/detail.component.ts index 9b2173ab2..22f9daa5e 100644 --- a/projects/social_platform/src/app/office/features/detail/detail.component.ts +++ b/projects/social_platform/src/app/office/features/detail/detail.component.ts @@ -9,7 +9,7 @@ import { ActivatedRoute, Router, RouterModule } from "@angular/router"; import { AuthService } from "@auth/services"; import { AvatarComponent } from "@ui/components/avatar/avatar.component"; import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; -import { concatMap, EMPTY, filter, map, Observable, of, Subscription, tap } from "rxjs"; +import { concatMap, filter, map, Subscription, tap } from "rxjs"; import { User } from "@auth/models/user.model"; import { Collaborator } from "@office/models/collaborator.model"; import { ProjectService } from "@office/services/project.service"; @@ -20,7 +20,6 @@ import { ProgramDataService } from "@office/program/services/program-data.servic import { ChatService } from "@office/services/chat.service"; import { calculateProfileProgress } from "@utils/calculateProgress"; import { ProfileDataService } from "@office/profile/detail/services/profile-date.service"; -import { ProfileService } from "projects/skills/src/app/profile/services/profile.service"; import { SnackbarService } from "@ui/services/snackbar.service"; import { ApproveSkillComponent } from "../approve-skill/approve-skill.component"; import { ProgramService } from "@office/program/services/program.service"; @@ -30,7 +29,7 @@ import { projectNewAdditionalProgramVields, } from "@office/models/partner-program-fields.model"; import { saveFile } from "@utils/helpers/export-file"; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; import { ControlErrorPipe, ValidationService } from "@corelib"; import { ErrorMessage } from "@error/models/error-message"; @@ -69,7 +68,6 @@ export class DeatilComponent implements OnInit, OnDestroy { private readonly router = inject(Router); private readonly location = inject(Location); private readonly profileDataService = inject(ProfileDataService); - public readonly skillsProfileService = inject(ProfileService); public readonly chatService = inject(ChatService); private readonly cdRef = inject(ChangeDetectorRef); private readonly programService = inject(ProgramService); @@ -207,17 +205,6 @@ export class DeatilComponent implements OnInit, OnDestroy { this.projectAdditionalService.setAssignProjectToProgramError(error); } - /** - * Переключатель для модалки выбора проекта - */ - // toggleSubmitProjectModal(): void { - // this.showSubmitProjectModal.set(!this.showSubmitProjectModal()); - - // if (!this.showSubmitProjectModal()) { - // this.selectedProjectId = null; - // } - // } - /** Показать подсказку */ showTooltip(): void { this.isTooltipVisible = true; @@ -240,25 +227,6 @@ export class DeatilComponent implements OnInit, OnDestroy { } } - /** - * Добавление проекта на программу - */ - // selectProject(): void { - // if (this.selectedProjectId === null) { - // return; - // } - - // const selectedProject = this.memberProjects.find( - // project => project.id === this.selectedProjectId - // ); - - // console.log(selectedProject); - // } - - // get isProjectSelected(): boolean { - // return this.selectedProjectId !== null; - // } - addNewProject(): void { const newFieldsFormValues: projectNewAdditionalProgramVields[] = []; diff --git a/projects/social_platform/src/app/office/members/filters/members-filters.component.ts b/projects/social_platform/src/app/office/members/filters/members-filters.component.ts index 11a96157d..6bd0bd0cb 100644 --- a/projects/social_platform/src/app/office/members/filters/members-filters.component.ts +++ b/projects/social_platform/src/app/office/members/filters/members-filters.component.ts @@ -13,10 +13,10 @@ import { RangeInputComponent } from "@ui/components/range-input/range-input.comp import { MembersComponent } from "@office/members/members.component"; import { ReactiveFormsModule } from "@angular/forms"; import { AutoCompleteInputComponent } from "@ui/components/autocomplete-input/autocomplete-input.component"; -import { Specialization } from "@office/models/specialization"; +import { Specialization } from "@office/models/specialization.model"; import { SpecializationsService } from "@office/services/specializations.service"; import { SkillsService } from "@office/services/skills.service"; -import { Skill } from "@office/models/skill"; +import { Skill } from "@office/models/skill.model"; import { ActivatedRoute, Router } from "@angular/router"; import { CheckboxComponent } from "../../../ui/components/checkbox/checkbox.component"; diff --git a/projects/social_platform/src/app/office/models/courses.model.ts b/projects/social_platform/src/app/office/models/courses.model.ts new file mode 100644 index 000000000..83e435941 --- /dev/null +++ b/projects/social_platform/src/app/office/models/courses.model.ts @@ -0,0 +1,163 @@ +/** + * Как в базе + * + * id — ID курса (создается автоматически). + * title — название курса (до 45 символов). + * description — описание курса (до 600 символов, можно оставить пустым). + * access_type — тип доступа: для всех, для участников программы, по подписке. + * partner_program — связанная программа (может быть пустой, кроме сценария “для участников программы”). + * avatar_file — аватар курса (файл, необязательно). + * card_cover_file — обложка карточки курса в каталоге (файл, необязательно). + * header_cover_file — обложка шапки внутри страницы курса (файл, необязательно). + * start_date — дата старта курса (может быть пустой). + * end_date — дата окончания курса (может быть пустой). + * status — статус контента курса: черновик, опубликован, завершен. + * is_completed — логический флаг завершения курса. + * completed_at — дата/время завершения курса. + * datetime_created — дата/время создания. + * datetime_updated — дата/время обновления. + * + * @format + */ + +export interface CourseCard { + id: number; + title: string; + accessType: "all_users" | "program_members" | "subscription_stub"; + status: "draft" | "published" | "ended"; + avatarUrl: string; + cardCoverUrl: string; + startDate: Date; + endDate: Date; + dateLabel: string; + isAvailable: boolean; + progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; + percent: number; + actionState: "start" | "continue" | "lock"; +} + +export interface CourseDetail { + id: number; + title: string; + description: string; + accessType: "all_users" | "program_members" | "subscription_stub"; + status: "draft" | "published" | "ended"; + avatarUrl: string; + headerCoverUrl: string; + startDate: Date; + endDate: Date; + dateLabel: string; + isAvailable: boolean; + partnerProgramId: number; + progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; + percent: number; + analyticsStub: any; +} + +/** + * Как в базе + * + id — уникальный идентификатор модуля. + course — курс, к которому относится модуль (обязательная связь). + title — название модуля, максимум 40 символов. + avatar_file — аватар модуля (необязательный файл). + start_date — дата старта модуля (обязательная). + status — статус модуля: draft (черновик), published (опубликован) + order — порядковый номер модуля внутри курса (по нему сортируется вывод). + datetime_created — дата/время создания. + datetime_updated — дата/время последнего обновления. + * + */ + +export interface CourseLessons { + id: number; + moduleId: number; + title: string; + order: number; + status: string; + isAvailable: boolean; + progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; + percent: number; + currentTaskId: number; + taskCount: number; +} + +export interface CourseModule { + id: number; + courseId: number; + title: string; + order: number; + avatarUrl: string; + startDate: Date; + status: string; + isAvailable: boolean; + progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; + percent: number; + lessons: CourseLessons[]; +} + +export interface CourseStructure { + courseId: number; + progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; + percent: number; + modules: CourseModule[]; +} + +/** + * Как в базе + * + id — уникальный идентификатор урока. + module — модуль, к которому относится урок (обязательная связь). + title — название урока, максимум 45 символов. + status — статус урока: draft (черновик), published (опубликован) + order — порядковый номер урока внутри модуля. + datetime_created — дата/время создания. + datetime_updated — дата/время последнего обновления. + */ + +export interface Option { + id: number; + order: number; + text: string; +} + +export interface Task { + id: number; + order: number; + title: string; + answerTitle: string; + status: string; + taskKind: "question" | "informational"; + checkType: string | null; + informationalType: string | null; + questionType: string | null; + answerType: string | null; + bodyText: string; + videoUrl: string | null; + imageUrl: string | null; + attachmentUrl: string | null; + isAvailable: boolean; + isCompleted: boolean; + options: Option[]; +} + +export interface CourseLesson { + id: number; + moduleId: number; + courseId: number; + title: string; + progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; + percent: number; + currentTaskId: number; + moduleOrder: number; + tasks: Task[]; +} + +export interface TaskAnswerResponse { + answerId: number; + status: "submitted" | "pending_review"; + isCorrect: boolean; + canContinue: boolean; + nextTaskId: number | null; + submittedAt: Date; +} diff --git a/projects/social_platform/src/app/office/models/skill.ts b/projects/social_platform/src/app/office/models/skill.model.ts similarity index 100% rename from projects/social_platform/src/app/office/models/skill.ts rename to projects/social_platform/src/app/office/models/skill.model.ts diff --git a/projects/social_platform/src/app/office/models/skills-group.ts b/projects/social_platform/src/app/office/models/skills-group.model.ts similarity index 87% rename from projects/social_platform/src/app/office/models/skills-group.ts rename to projects/social_platform/src/app/office/models/skills-group.model.ts index 037c38a5c..0897e1398 100644 --- a/projects/social_platform/src/app/office/models/skills-group.ts +++ b/projects/social_platform/src/app/office/models/skills-group.model.ts @@ -1,6 +1,6 @@ /** @format */ -import { Skill } from "./skill"; // Assuming Skill is defined in a separate file +import { Skill } from "./skill.model"; // Assuming Skill is defined in a separate file /** * Интерфейс для группы навыков diff --git a/projects/social_platform/src/app/office/models/specialization.ts b/projects/social_platform/src/app/office/models/specialization.model.ts similarity index 100% rename from projects/social_platform/src/app/office/models/specialization.ts rename to projects/social_platform/src/app/office/models/specialization.model.ts diff --git a/projects/social_platform/src/app/office/models/specializations-group.ts b/projects/social_platform/src/app/office/models/specializations-group.model.ts similarity index 88% rename from projects/social_platform/src/app/office/models/specializations-group.ts rename to projects/social_platform/src/app/office/models/specializations-group.model.ts index 55149beb9..49717c780 100644 --- a/projects/social_platform/src/app/office/models/specializations-group.ts +++ b/projects/social_platform/src/app/office/models/specializations-group.model.ts @@ -1,6 +1,6 @@ /** @format */ -import type { Specialization } from "./specialization"; +import type { Specialization } from "./specialization.model"; /** * Модель группы специализаций diff --git a/projects/social_platform/src/app/office/models/step.model.ts b/projects/social_platform/src/app/office/models/step.model.ts new file mode 100644 index 000000000..7df3b4b13 --- /dev/null +++ b/projects/social_platform/src/app/office/models/step.model.ts @@ -0,0 +1,139 @@ +/** @format */ + +/** + * Base interface for all step types + * Contains common properties shared across different question types + */ +interface BaseStep { + id: number; // Unique identifier for the step +} + +/** + * Всплывающее окно с дополнительной информацией + * Отображается после завершения шага для предоставления дополнительного контекста + */ +export interface Popup { + title: string | null; // Заголовок всплывающего окна + text: string | null; // Текстовое содержимое + fileLink: string | null; // URL к связанному файлу или ресурсу + ordinalNumber: number; // Порядковый номер для сортировки +} + +/** + * Информационный слайд + * + * Отображает образовательный контент без требования взаимодействия пользователя. + * Используется для представления концепций, объяснений или инструкций. + */ +export interface InfoSlide extends BaseStep { + text: string; // Основной текстовый контент слайда + description: string; // Дополнительное описание или контекст + files: string[]; // Массив URL файлов для отображения (изображения, документы) + popups: Popup[]; // Всплывающие окна для отображения после просмотра + videoUrl?: string; // Ссылка для видео +} + +/** + * Вопрос на соединение/сопоставление + * + * Требует от пользователей сопоставления элементов из двух колонок или соединения связанных концепций. + * Проверяет понимание отношений между различными элементами. + */ +export interface ConnectQuestion extends BaseStep { + connectLeft: { id: number; text?: string; file?: string }[]; // Элементы левой колонки + connectRight: { id: number; text?: string; file?: string }[]; // Элементы правой колонки + description: string; // Инструкции по выполнению сопоставления + files: string[]; // Дополнительные файлы для контекста + isAnswered: boolean; // Был ли вопрос уже отвечен + text: string; // Основной текст вопроса + popups: Popup[]; // Всплывающие окна для отображения после ответа +} + +/** + * Структура запроса для вопросов на соединение + * Массив пар соединений, выбранных пользователем + */ +export type ConnectQuestionRequest = { leftId: number; rightId: number }[]; + +/** + * Структура ответа для вопросов на соединение + * Показывает правильность каждого соединения + */ +export type ConnectQuestionResponse = { + leftId: number; + rightId: number; + isCorrect: boolean; // Правильно ли это соединение +}[]; + +/** + * Вопрос с единственным правильным ответом + * + * Представляет вопрос с несколькими вариантами, где только один ответ правильный. + * Наиболее распространенный тип оценочного вопроса. + */ +export interface SingleQuestion extends BaseStep { + answers: { id: number; text: string }[]; // Доступные варианты ответов + description: string; // Дополнительное описание или контекст + files: string[]; // Связанные файлы (изображения, документы) + isAnswered: boolean; // Был ли вопрос уже отвечен + text: string; // Основной текст вопроса + popups: Popup[]; // Всплывающие окна для отображения после ответа + videoUrl: string; +} + +/** + * Ответ об ошибке для вопросов с единственным ответом + * Возвращается, когда пользователь выбирает неправильный ответ + */ +export interface SingleQuestionError { + correctAnswer: number; // ID правильного варианта + isCorrect: boolean; // Был ли ответ правильным (всегда false для ошибок) +} + +/** + * Вопрос на исключение + * + * Представляет несколько элементов, где пользователи должны определить, какой не принадлежит + * или какие элементы должны быть исключены из группы. + */ +export interface ExcludeQuestion extends BaseStep { + answers: { id: number; text: string }[]; // Элементы для рассмотрения + description: string; // Инструкции для задачи исключения + files: string[]; // Связанные файлы для контекста + isAnswered: boolean; // Был ли вопрос уже отвечен + text: string; // Основной текст вопроса + popups: Popup[]; // Всплывающие окна для отображения после ответа +} + +/** + * Структура ответа для вопросов на исключение + */ +export interface ExcludeQuestionResponse { + isCorrect: boolean; // Был ли ответ пользователя правильным + wrongAnswers: number[]; // ID неправильно выбранных элементов +} + +/** + * Вопрос с письменным ответом + * + * Требует от пользователей предоставления текстового ответа. + * Может использоваться для коротких ответов, эссе или отправки кода. + */ +export interface WriteQuestion extends BaseStep { + answer: string | null; // Текущий ответ пользователя (если есть) + description: string; // Инструкции или дополнительный контекст + files: string[]; // Связанные файлы для справки + text: string; // Основной текст вопроса или подсказка + popups: Popup[]; // Всплывающие окна для отображения после отправки +} + +/** + * Объединенный тип, представляющий все возможные типы шагов + * Используется для типобезопасной обработки различных вариаций шагов + */ +export type StepType = + | InfoSlide + | ConnectQuestion + | SingleQuestion + | ExcludeQuestion + | WriteQuestion; diff --git a/projects/social_platform/src/app/office/models/vacancy.model.ts b/projects/social_platform/src/app/office/models/vacancy.model.ts index bf4d49a93..0030391d9 100644 --- a/projects/social_platform/src/app/office/models/vacancy.model.ts +++ b/projects/social_platform/src/app/office/models/vacancy.model.ts @@ -1,7 +1,7 @@ /** @format */ import { Project } from "@models/project.model"; -import { Skill } from "./skill"; +import { Skill } from "./skill.model"; /** * Модель вакансии в проекте diff --git a/projects/social_platform/src/app/office/office.component.ts b/projects/social_platform/src/app/office/office.component.ts index a5a52a6ca..e3dbb6063 100644 --- a/projects/social_platform/src/app/office/office.component.ts +++ b/projects/social_platform/src/app/office/office.component.ts @@ -233,14 +233,8 @@ export class OfficeComponent implements OnInit, OnDestroy { { name: "проекты", icon: "projects", link: "projects" }, { name: "участники", icon: "people-bold", link: "members" }, { name: "программы", icon: "program", link: "program" }, + { name: "курсы", icon: "trajectories", link: "courses" }, { name: "вакансии", icon: "search-sidebar", link: "vacancies" }, - { - name: "траектории", - icon: "trajectories", - link: "skills", - isExternal: true, - isActive: false, - }, { name: "чаты", icon: "message", link: "chats" }, ]; } diff --git a/projects/social_platform/src/app/office/office.routes.ts b/projects/social_platform/src/app/office/office.routes.ts index 53548cbbf..109482981 100644 --- a/projects/social_platform/src/app/office/office.routes.ts +++ b/projects/social_platform/src/app/office/office.routes.ts @@ -36,24 +36,21 @@ export const OFFICE_ROUTES: Routes = [ redirectTo: "program", }, { - path: "feed", - loadChildren: () => import("./feed/feed.routes").then(c => c.FEED_ROUTES), - }, - { - path: "vacancies", - loadChildren: () => import("./vacancies/vacancies.routes").then(c => c.VACANCIES_ROUTES), + path: "profile/edit", + component: ProfileEditComponent, }, { - path: "projects", - loadChildren: () => import("./projects/projects.routes").then(c => c.PROJECTS_ROUTES), + path: "profile/:id", + loadChildren: () => + import("./profile/detail/profile-detail.routes").then(c => c.PROFILE_DETAIL_ROUTES), }, { - path: "program", - loadChildren: () => import("./program/program.routes").then(c => c.PROGRAM_ROUTES), + path: "feed", + loadChildren: () => import("./feed/feed.routes").then(c => c.FEED_ROUTES), }, { - path: "chats", - loadChildren: () => import("./chat/chat.routes").then(c => c.CHAT_ROUTES), + path: "projects", + loadChildren: () => import("./projects/projects.routes").then(c => c.PROJECTS_ROUTES), }, { path: "members", @@ -63,13 +60,20 @@ export const OFFICE_ROUTES: Routes = [ }, }, { - path: "profile/edit", - component: ProfileEditComponent, + path: "program", + loadChildren: () => import("./program/program.routes").then(c => c.PROGRAM_ROUTES), }, { - path: "profile/:id", - loadChildren: () => - import("./profile/detail/profile-detail.routes").then(c => c.PROFILE_DETAIL_ROUTES), + path: "courses", + loadChildren: () => import("./courses/courses.routes").then(c => c.COURSES_ROUTES), + }, + { + path: "vacancies", + loadChildren: () => import("./vacancies/vacancies.routes").then(c => c.VACANCIES_ROUTES), + }, + { + path: "chats", + loadChildren: () => import("./chat/chat.routes").then(c => c.CHAT_ROUTES), }, { path: "**", diff --git a/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.ts b/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.ts index 81728037a..303045aa5 100644 --- a/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.ts +++ b/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.ts @@ -10,9 +10,9 @@ import { OnboardingService } from "../services/onboarding.service"; import { ButtonComponent, IconComponent } from "@ui/components"; import { CommonModule } from "@angular/common"; import { AutoCompleteInputComponent } from "@ui/components/autocomplete-input/autocomplete-input.component"; -import { SpecializationsGroup } from "@office/models/specializations-group"; +import { SpecializationsGroup } from "@office/models/specializations-group.model"; import { SpecializationsGroupComponent } from "@office/shared/specializations-group/specializations-group.component"; -import { Specialization } from "@office/models/specialization"; +import { Specialization } from "@office/models/specialization.model"; import { SpecializationsService } from "@office/services/specializations.service"; import { ErrorMessage } from "@error/models/error-message"; import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; diff --git a/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.resolver.ts b/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.resolver.ts index 4e8f05f6a..220dedbad 100644 --- a/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.resolver.ts +++ b/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.resolver.ts @@ -3,7 +3,7 @@ import { inject } from "@angular/core"; import { ResolveFn } from "@angular/router"; import { SpecializationsService } from "@office/services/specializations.service"; -import { SpecializationsGroup } from "@office/models/specializations-group"; +import { SpecializationsGroup } from "@office/models/specializations-group.model"; /** * РЕЗОЛВЕР ПЕРВОГО ЭТАПА ОНБОРДИНГА diff --git a/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.ts b/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.ts index 193e4b4a2..363d5bf21 100644 --- a/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.ts +++ b/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.ts @@ -10,9 +10,9 @@ import { OnboardingService } from "../services/onboarding.service"; import { ButtonComponent, IconComponent } from "@ui/components"; import { CommonModule } from "@angular/common"; import { AutoCompleteInputComponent } from "@ui/components/autocomplete-input/autocomplete-input.component"; -import { Skill } from "@office/models/skill"; +import { Skill } from "@office/models/skill.model"; import { SkillsService } from "@office/services/skills.service"; -import { SkillsGroup } from "@office/models/skills-group"; +import { SkillsGroup } from "@office/models/skills-group.model"; import { SkillsGroupComponent } from "@office/shared/skills-group/skills-group.component"; import { SkillsBasketComponent } from "@office/shared/skills-basket/skills-basket.component"; import { ModalComponent } from "@ui/components/modal/modal.component"; diff --git a/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.resolver.ts b/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.resolver.ts index 38d91200e..462270cee 100644 --- a/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.resolver.ts +++ b/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.resolver.ts @@ -3,7 +3,7 @@ import { inject } from "@angular/core"; import { ResolveFn } from "@angular/router"; import { SkillsService } from "@office/services/skills.service"; -import { SkillsGroup } from "@office/models/skills-group"; +import { SkillsGroup } from "@office/models/skills-group.model"; /** * РЕЗОЛВЕР ВТОРОГО ЭТАПА ОНБОРДИНГА diff --git a/projects/social_platform/src/app/office/profile/detail/main/main.component.ts b/projects/social_platform/src/app/office/profile/detail/main/main.component.ts index 462626376..8687dddd5 100644 --- a/projects/social_platform/src/app/office/profile/detail/main/main.component.ts +++ b/projects/social_platform/src/app/office/profile/detail/main/main.component.ts @@ -32,7 +32,7 @@ import { AsyncPipe, CommonModule, NgTemplateOutlet } from "@angular/common"; import { ProfileService } from "@auth/services/profile.service"; import { ModalComponent } from "@ui/components/modal/modal.component"; import { AvatarComponent } from "../../../../ui/components/avatar/avatar.component"; -import { Skill } from "@office/models/skill"; +import { Skill } from "@office/models/skill.model"; import { HttpErrorResponse } from "@angular/common/http"; import { NewsFormComponent } from "@office/features/news-form/news-form.component"; import { NewsCardComponent } from "@office/features/news-card/news-card.component"; diff --git a/projects/social_platform/src/app/office/profile/edit/edit.component.ts b/projects/social_platform/src/app/office/profile/edit/edit.component.ts index ef14a51dc..b0c575b52 100644 --- a/projects/social_platform/src/app/office/profile/edit/edit.component.ts +++ b/projects/social_platform/src/app/office/profile/edit/edit.component.ts @@ -29,14 +29,14 @@ import { EditorSubmitButtonDirective } from "@ui/directives/editor-submit-button import { TextareaComponent } from "@ui/components/textarea/textarea.component"; import { AvatarControlComponent } from "@ui/components/avatar-control/avatar-control.component"; import { AsyncPipe, CommonModule } from "@angular/common"; -import { Specialization } from "@office/models/specialization"; +import { Specialization } from "@office/models/specialization.model"; import { SpecializationsService } from "@office/services/specializations.service"; import { AutoCompleteInputComponent } from "@ui/components/autocomplete-input/autocomplete-input.component"; import { SkillsGroupComponent } from "@office/shared/skills-group/skills-group.component"; import { SpecializationsGroupComponent } from "@office/shared/specializations-group/specializations-group.component"; import { SkillsBasketComponent } from "@office/shared/skills-basket/skills-basket.component"; import { ModalComponent } from "@ui/components/modal/modal.component"; -import { Skill } from "@office/models/skill"; +import { Skill } from "@office/models/skill.model"; import { SkillsService } from "@office/services/skills.service"; import { educationUserLevel, @@ -1110,6 +1110,8 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { : this.profileForm.value.phoneNumber, }; + console.log(newProfile); + this.authService .saveProfile(newProfile) .pipe(concatMap(() => this.authService.getProfile())) diff --git a/projects/social_platform/src/app/office/projects/edit/edit.component.ts b/projects/social_platform/src/app/office/projects/edit/edit.component.ts index 6cf76bc09..7806eeaf4 100644 --- a/projects/social_platform/src/app/office/projects/edit/edit.component.ts +++ b/projects/social_platform/src/app/office/projects/edit/edit.component.ts @@ -13,7 +13,7 @@ import { ActivatedRoute, Router, RouterModule } from "@angular/router"; import { ErrorMessage } from "@error/models/error-message"; import { Invite } from "@models/invite.model"; import { Project } from "@models/project.model"; -import { Skill } from "@office/models/skill"; +import { Skill } from "@office/models/skill.model"; import { ProgramService } from "@office/program/services/program.service"; import { SkillsService } from "@office/services/skills.service"; import { SkillsGroupComponent } from "@office/shared/skills-group/skills-group.component"; diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-vacancy.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-vacancy.service.ts index bcadeb069..0eb24ec3d 100644 --- a/projects/social_platform/src/app/office/projects/edit/services/project-vacancy.service.ts +++ b/projects/social_platform/src/app/office/projects/edit/services/project-vacancy.service.ts @@ -4,7 +4,7 @@ import { inject, Injectable, signal } from "@angular/core"; import { FormBuilder, FormGroup, Validators } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; import { ValidationService } from "@corelib"; -import { Skill } from "@office/models/skill"; +import { Skill } from "@office/models/skill.model"; import { Vacancy } from "@office/models/vacancy.model"; import { VacancyService } from "@office/services/vacancy.service"; import { stripNullish } from "@utils/stripNull"; diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.ts b/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.ts index e17b9a273..b5a14958e 100644 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.ts +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.ts @@ -9,7 +9,7 @@ import { ErrorMessage } from "@error/models/error-message"; import { AutoCompleteInputComponent } from "@ui/components/autocomplete-input/autocomplete-input.component"; import { SkillsBasketComponent } from "@office/shared/skills-basket/skills-basket.component"; import { VacancyCardComponent } from "@office/features/vacancy-card/vacancy-card.component"; -import { Skill } from "@office/models/skill"; +import { Skill } from "@office/models/skill.model"; import { ProjectVacancyService } from "../../services/project-vacancy.service"; import { ActivatedRoute } from "@angular/router"; import { IconComponent } from "@uilib"; diff --git a/projects/social_platform/src/app/office/services/skills.service.ts b/projects/social_platform/src/app/office/services/skills.service.ts index 528320d79..a9f0852fb 100644 --- a/projects/social_platform/src/app/office/services/skills.service.ts +++ b/projects/social_platform/src/app/office/services/skills.service.ts @@ -1,10 +1,10 @@ /** @format */ import { Injectable } from "@angular/core"; -import { SkillsGroup } from "../models/skills-group"; +import { SkillsGroup } from "../models/skills-group.model"; import { Observable } from "rxjs"; import { ApiService } from "@corelib"; -import { Skill } from "../models/skill"; +import { Skill } from "../models/skill.model"; import { ApiPagination } from "@office/models/api-pagination.model"; import { HttpParams } from "@angular/common/http"; diff --git a/projects/social_platform/src/app/office/services/specializations.service.ts b/projects/social_platform/src/app/office/services/specializations.service.ts index 5d4a3ac2e..786749a84 100644 --- a/projects/social_platform/src/app/office/services/specializations.service.ts +++ b/projects/social_platform/src/app/office/services/specializations.service.ts @@ -1,10 +1,10 @@ /** @format */ import { Injectable } from "@angular/core"; -import { SpecializationsGroup } from "../models/specializations-group"; +import { SpecializationsGroup } from "../models/specializations-group.model"; import { Observable } from "rxjs"; import { ApiService } from "@corelib"; -import { Specialization } from "../models/specialization"; +import { Specialization } from "../models/specialization.model"; import { ApiPagination } from "@office/models/api-pagination.model"; import { HttpParams } from "@angular/common/http"; diff --git a/projects/social_platform/src/app/office/shared/approve-skill-people/approve-skill-people.component.ts b/projects/social_platform/src/app/office/shared/approve-skill-people/approve-skill-people.component.ts index cf3d051dd..984311dce 100644 --- a/projects/social_platform/src/app/office/shared/approve-skill-people/approve-skill-people.component.ts +++ b/projects/social_platform/src/app/office/shared/approve-skill-people/approve-skill-people.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from "@angular/common"; import { Component, Input } from "@angular/core"; import { PluralizePipe } from "@corelib"; -import { Skill } from "@office/models/skill"; +import { Skill } from "@office/models/skill.model"; import { AvatarComponent } from "@ui/components/avatar/avatar.component"; @Component({ diff --git a/projects/social_platform/src/app/office/shared/skills-basket/skills-basket.component.ts b/projects/social_platform/src/app/office/shared/skills-basket/skills-basket.component.ts index 564c2b69b..91a1a9920 100644 --- a/projects/social_platform/src/app/office/shared/skills-basket/skills-basket.component.ts +++ b/projects/social_platform/src/app/office/shared/skills-basket/skills-basket.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { Component, forwardRef, Input, signal } from "@angular/core"; -import { Skill } from "@office/models/skill"; +import { Skill } from "@office/models/skill.model"; import { IconComponent } from "@ui/components"; import { NG_VALUE_ACCESSOR } from "@angular/forms"; import { noop } from "rxjs"; diff --git a/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.ts b/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.ts index cce0b02be..c53fc5e95 100644 --- a/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.ts +++ b/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.ts @@ -9,7 +9,7 @@ import { signal, } from "@angular/core"; import { IconComponent } from "@ui/components"; -import { Skill } from "@office/models/skill"; +import { Skill } from "@office/models/skill.model"; /** * Компонент группы навыков с возможностью множественного выбора diff --git a/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.ts b/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.ts index 70bb7cb3f..47faab9bf 100644 --- a/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.ts +++ b/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.ts @@ -10,7 +10,7 @@ import { signal, } from "@angular/core"; import { IconComponent } from "@ui/components"; -import { Specialization } from "@office/models/specialization"; +import { Specialization } from "@office/models/specialization.model"; /** * Компонент группы специализаций с возможностью сворачивания/разворачивания diff --git a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.html b/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.html index 541b87c8f..a3e68b892 100644 --- a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.html +++ b/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.html @@ -1,4 +1,10 @@
- +
diff --git a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.scss b/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.scss index 94ed06479..373ebf399 100644 --- a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.scss +++ b/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.scss @@ -17,6 +17,6 @@ } &__check { - color: var(--white); + color: var(--light-white); } } diff --git a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.ts b/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.ts index fcc8b59ab..d86affbf9 100644 --- a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.ts +++ b/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.ts @@ -27,6 +27,8 @@ export class CheckboxComponent implements OnInit { /** Состояние чекбокса */ @Input({ required: true }) checked = false; + @Input() size?: string; + /** Событие изменения состояния */ @Output() checkedChange = new EventEmitter(); diff --git a/projects/social_platform/src/app/ui/components/file-item/file-item.component.html b/projects/social_platform/src/app/ui/components/file-item/file-item.component.html index e2ba2ecad..aece7cfb1 100644 --- a/projects/social_platform/src/app/ui/components/file-item/file-item.component.html +++ b/projects/social_platform/src/app/ui/components/file-item/file-item.component.html @@ -2,32 +2,38 @@ @if (name && link) {
- +
+ @if (mode === 'preview') { + + } @else { + + } -
-
- {{ name }} -
+
+
+ {{ name }} +
- @if (type) { -
- {{ type.includes("/") ? (type | fileType) : (type | uppercase) }} - {{ size | formatedFileSize }} + @if (type) { +
+ {{ type.includes("/") ? (type | fileType) : (type | uppercase) }} + {{ size | formatedFileSize }} +
+ }
- }
@if (canDelete) { diff --git a/projects/social_platform/src/app/ui/components/file-item/file-item.component.scss b/projects/social_platform/src/app/ui/components/file-item/file-item.component.scss index 6efc7cf0b..b45dc26e9 100644 --- a/projects/social_platform/src/app/ui/components/file-item/file-item.component.scss +++ b/projects/social_platform/src/app/ui/components/file-item/file-item.component.scss @@ -4,6 +4,7 @@ display: flex; gap: 10px; align-items: center; + justify-content: space-between; &__name { overflow: hidden; @@ -16,6 +17,12 @@ } } + &__left { + display: flex; + gap: 5px; + align-items: center; + } + &__meta { color: var(--dark-grey); } @@ -25,10 +32,11 @@ align-items: center; align-self: center; justify-content: center; - width: 20px; - height: 20px; + width: 35px; + height: 35px; + cursor: pointer; border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); + border-radius: var(--rounded-xxl); &--delete { color: var(--red); @@ -39,5 +47,10 @@ color: var(--accent); cursor: pointer; } + + &--file { + color: var(--accent); + cursor: pointer; + } } } diff --git a/projects/social_platform/src/app/ui/components/file-item/file-item.component.ts b/projects/social_platform/src/app/ui/components/file-item/file-item.component.ts index 79d9d9d64..aab60452d 100644 --- a/projects/social_platform/src/app/ui/components/file-item/file-item.component.ts +++ b/projects/social_platform/src/app/ui/components/file-item/file-item.component.ts @@ -1,5 +1,5 @@ /** @format */ -import { Component, inject, Input, OnInit } from "@angular/core"; +import { Component, EventEmitter, inject, Input, OnInit, Output } from "@angular/core"; import { FileTypePipe } from "@ui/pipes/file-type.pipe"; import { IconComponent } from "@ui/components"; import { UpperCasePipe } from "@angular/common"; @@ -33,6 +33,12 @@ export class FileItemComponent implements OnInit { @Input() canDelete = false; + /** Режим отображения: 'default' — скачивание + удаление через сервис, 'preview' — только просмотр + удаление через Output */ + @Input() mode: "default" | "preview" = "default"; + + /** Событие удаления файла (используется в режиме preview) */ + @Output() deleted = new EventEmitter(); + /** MIME-тип файла */ @Input() type = "file"; @@ -61,11 +67,17 @@ export class FileItemComponent implements OnInit { /** * Удаление файла - * Удаляет файл с сервера и из списка прикрепленных файлов + * В режиме preview — эмитит событие наружу + * В режиме default — удаляет через FileService */ onDeleteFile(): void { if (!this.link) return; + if (this.mode === "preview") { + this.deleted.emit(); + return; + } + this.fileService.deleteFile(this.link).subscribe(() => { this.link = ""; this.name = ""; diff --git a/projects/social_platform/src/app/ui/components/modal/modal.component.scss b/projects/social_platform/src/app/ui/components/modal/modal.component.scss index e37d9310e..f9d47022c 100644 --- a/projects/social_platform/src/app/ui/components/modal/modal.component.scss +++ b/projects/social_platform/src/app/ui/components/modal/modal.component.scss @@ -8,7 +8,7 @@ right: 0; bottom: 0; left: 0; - z-index: 10; + z-index: 10000; &__overlay { position: absolute; diff --git a/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.html b/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.html index e361d9af1..04c97ce83 100644 --- a/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.html +++ b/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.html @@ -12,7 +12,11 @@ @slideInOut > {{ snack.text }} - + } diff --git a/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.scss b/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.scss index d274c275d..26cb93c9c 100644 --- a/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.scss +++ b/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.scss @@ -33,4 +33,9 @@ color: var(--white); background-color: var(--red); } + + &--info { + color: var(--black); + background-color: var(--lime); + } } diff --git a/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.ts b/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.ts index d358c2a07..2c8223666 100644 --- a/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.ts +++ b/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.ts @@ -1,6 +1,6 @@ /** @format */ -import { Component, forwardRef, Input, OnInit } from "@angular/core"; +import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from "@angular/core"; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; import { FileService } from "@core/services/file.service"; import { nanoid } from "nanoid"; @@ -38,7 +38,7 @@ import { LoaderComponent } from "../loader/loader.component"; }, ], standalone: true, - imports: [IconComponent, SlicePipe, LoaderComponent], + imports: [IconComponent, LoaderComponent], }) export class UploadFileComponent implements OnInit, ControlValueAccessor { constructor(private fileService: FileService) {} @@ -49,6 +49,17 @@ export class UploadFileComponent implements OnInit, ControlValueAccessor { /** Состояние ошибки */ @Input() error = false; + /** Режим: после загрузки сбросить в пустое состояние и не показывать "файл успешно загружен" */ + @Input() resetAfterUpload = false; + + /** Событие с данными загруженного файла (url + метаданные оригинального файла) */ + @Output() uploaded = new EventEmitter<{ + url: string; + name: string; + size: number; + mimeType: string; + }>(); + ngOnInit(): void {} /** Уникальный ID для элемента input */ @@ -79,18 +90,30 @@ export class UploadFileComponent implements OnInit, ControlValueAccessor { /** Обработчик загрузки файла */ onUpdate(event: Event): void { - const files = (event.currentTarget as HTMLInputElement).files; + const input = event.currentTarget as HTMLInputElement; + const files = input.files; if (!files?.length) { return; } + const originalFile = files[0]; this.loading = true; - this.fileService.uploadFile(files[0]).subscribe(res => { + this.fileService.uploadFile(originalFile).subscribe(res => { this.loading = false; - this.value = res.url; - this.onChange(res.url); + if (this.resetAfterUpload) { + this.uploaded.emit({ + url: res.url, + name: originalFile.name, + size: originalFile.size, + mimeType: originalFile.type, + }); + input.value = ""; + } else { + this.value = res.url; + this.onChange(res.url); + } }); } diff --git a/projects/social_platform/src/app/utils/video-url-embed.ts b/projects/social_platform/src/app/utils/video-url-embed.ts new file mode 100644 index 000000000..2c8c23c05 --- /dev/null +++ b/projects/social_platform/src/app/utils/video-url-embed.ts @@ -0,0 +1,119 @@ +/** @format */ + +const VIDEO_FILE_EXTENSION_REGEX = /\.(mp4|webm|ogg|mov|m4v)(?:$|[?#])/i; +const HTTP_PROTOCOL_REGEX = /^https?:$/; +const PROVIDER_ID_REGEX = /^[A-Za-z0-9_-]{6,}$/; +const BLOCKED_FOR_IFRAME_HOSTS = new Set(["disk.yandex.ru", "yadi.sk"]); + +const trimProviderPrefix = (hostname: string): string => + hostname.toLowerCase().replace(/^www\./, ""); + +const normalizeToUrl = (value: string): URL | null => { + try { + return new URL(value); + } catch { + return null; + } +}; + +const extractYoutubeId = (url: URL, host: string): string | null => { + if (host === "youtu.be") { + const id = url.pathname.split("/").filter(Boolean)[0] ?? ""; + return PROVIDER_ID_REGEX.test(id) ? id : null; + } + + if (host !== "youtube.com" && host !== "m.youtube.com") { + return null; + } + + const byQuery = url.searchParams.get("v"); + if (byQuery && PROVIDER_ID_REGEX.test(byQuery)) { + return byQuery; + } + + const pathSegments = url.pathname.split("/").filter(Boolean); + if (!pathSegments.length) { + return null; + } + + const [prefix, maybeId] = pathSegments; + + if (prefix === "embed" || prefix === "shorts" || prefix === "live") { + return maybeId && PROVIDER_ID_REGEX.test(maybeId) ? maybeId : null; + } + + return null; +}; + +const resolveRutubeEmbedUrl = (url: URL): string | null => { + const host = trimProviderPrefix(url.hostname); + if (host !== "rutube.ru") { + return null; + } + + const path = url.pathname; + const embedMatch = path.match(/^\/play\/embed\/([A-Za-z0-9_-]+)\/?$/i); + if (embedMatch?.[1]) { + return `https://rutube.ru/play/embed/${embedMatch[1]}`; + } + + const videoMatch = path.match(/^\/video\/([A-Za-z0-9_-]+)\/?$/i); + if (videoMatch?.[1]) { + return `https://rutube.ru/play/embed/${videoMatch[1]}`; + } + + return null; +}; + +const resolveGoogleDriveEmbedUrl = (url: URL): string | null => { + const host = trimProviderPrefix(url.hostname); + if (host !== "drive.google.com") { + return null; + } + + const fileIdFromPath = url.pathname.match(/\/file\/d\/([^/]+)/)?.[1]; + if (fileIdFromPath) { + return `https://drive.google.com/file/d/${fileIdFromPath}/preview`; + } + + const fileIdFromQuery = url.searchParams.get("id"); + if (fileIdFromQuery && (url.pathname === "/uc" || url.pathname === "/open")) { + return `https://drive.google.com/file/d/${fileIdFromQuery}/preview`; + } + + return null; +}; + +const resolveYoutubeEmbedUrl = (url: URL): string | null => { + const host = trimProviderPrefix(url.hostname); + const id = extractYoutubeId(url, host); + if (!id) { + return null; + } + + return `https://www.youtube.com/embed/${id}`; +}; + +export const resolveVideoUrlForIframe = (value: string | null | undefined): string | null => { + const raw = value?.trim(); + if (!raw) { + return null; + } + + const parsed = normalizeToUrl(raw); + if (!parsed || !HTTP_PROTOCOL_REGEX.test(parsed.protocol)) { + return null; + } + + const host = trimProviderPrefix(parsed.hostname); + if (BLOCKED_FOR_IFRAME_HOSTS.has(host)) { + return null; + } + + return ( + resolveRutubeEmbedUrl(parsed) || + resolveGoogleDriveEmbedUrl(parsed) || + resolveYoutubeEmbedUrl(parsed) || + (VIDEO_FILE_EXTENSION_REGEX.test(parsed.pathname) ? raw : null) + ); +}; diff --git a/projects/social_platform/src/assets/icons/svg/rocket.svg b/projects/social_platform/src/assets/icons/svg/rocket.svg new file mode 100644 index 000000000..75422e0ec --- /dev/null +++ b/projects/social_platform/src/assets/icons/svg/rocket.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/social_platform/src/assets/icons/svg/triangle.svg b/projects/social_platform/src/assets/icons/svg/triangle.svg new file mode 100644 index 000000000..c98333c02 --- /dev/null +++ b/projects/social_platform/src/assets/icons/svg/triangle.svg @@ -0,0 +1,3 @@ + + + diff --git a/projects/social_platform/src/assets/icons/symbol/svg/sprite.css.svg b/projects/social_platform/src/assets/icons/symbol/svg/sprite.css.svg index 57e81601c..b4a4ac247 100644 --- a/projects/social_platform/src/assets/icons/symbol/svg/sprite.css.svg +++ b/projects/social_platform/src/assets/icons/symbol/svg/sprite.css.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/projects/social_platform/src/assets/images/courses/character.svg b/projects/social_platform/src/assets/images/courses/character.svg new file mode 100644 index 000000000..64f0ea9dd --- /dev/null +++ b/projects/social_platform/src/assets/images/courses/character.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/social_platform/src/assets/images/courses/complete.svg b/projects/social_platform/src/assets/images/courses/complete.svg new file mode 100644 index 000000000..e97c7693a --- /dev/null +++ b/projects/social_platform/src/assets/images/courses/complete.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/social_platform/src/styles.scss b/projects/social_platform/src/styles.scss index d61acb4fa..bef2f6d11 100644 --- a/projects/social_platform/src/styles.scss +++ b/projects/social_platform/src/styles.scss @@ -17,6 +17,7 @@ @import "styles/components/key-skills.scss"; @import "styles/components/nav.scss"; @import "styles/components/contact-link.scss"; +@import "app/office/courses/lesson/shared/image-preview/image-preview.scss"; // PAGES diff --git a/projects/social_platform/src/styles/_colors.scss b/projects/social_platform/src/styles/_colors.scss index 9deab347f..0d7f3cc02 100644 --- a/projects/social_platform/src/styles/_colors.scss +++ b/projects/social_platform/src/styles/_colors.scss @@ -29,6 +29,8 @@ // FUNCTIONAL --green: #88c9a1; --green-dark: #297373; + --green-light: #e3f0e8; + --lime: #d6ff54; --red: #d48a9e; --red-dark: #{color.adjust(#d48a9e, $blackness: 10%)}; } diff --git a/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.ts b/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.ts index 59f09f357..9aaf0f41b 100644 --- a/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.ts +++ b/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.ts @@ -8,6 +8,7 @@ import type { Invite } from "@office/models/invite.model"; import { RouterLink } from "@angular/router"; import type { User } from "../../../models/user.model"; import { EmptyManageCardComponent } from "../empty-manage-card/empty-manage-card.component"; +import { UserData } from "projects/skills/src/models/profile.model"; /** * Компонент панели управления профилем @@ -47,7 +48,7 @@ import { EmptyManageCardComponent } from "../empty-manage-card/empty-manage-card }) export class ProfileControlPanelComponent { /** Данные текущего пользователя */ - @Input({ required: true }) user!: User | null; + @Input({ required: true }) user!: User | UserData | null; /** Массив приглашений пользователя */ @Input({ required: true }) invites!: Invite[]; diff --git a/projects/ui/src/lib/components/layout/profile-info/profile-info.component.ts b/projects/ui/src/lib/components/layout/profile-info/profile-info.component.ts index 50ad1fc23..016f59943 100644 --- a/projects/ui/src/lib/components/layout/profile-info/profile-info.component.ts +++ b/projects/ui/src/lib/components/layout/profile-info/profile-info.component.ts @@ -5,6 +5,7 @@ import { Router, RouterLink } from "@angular/router"; import { DayjsPipe } from "projects/core"; import { AvatarComponent, IconComponent } from "@uilib"; import type { User } from "../../../models/user.model"; +import { UserData } from "projects/skills/src/models/profile.model"; /** * Компонент отображения информации о профиле пользователя @@ -25,7 +26,7 @@ import type { User } from "../../../models/user.model"; templateUrl: "./profile-info.component.html", styleUrl: "./profile-info.component.scss", standalone: true, - imports: [RouterLink, AvatarComponent, IconComponent, DayjsPipe], + imports: [RouterLink, AvatarComponent, IconComponent], }) export class ProfileInfoComponent implements OnInit { constructor(readonly router: Router) {} @@ -33,7 +34,7 @@ export class ProfileInfoComponent implements OnInit { ngOnInit(): void {} /** Данные пользователя для отображения */ - @Input({ required: true }) user!: User; + @Input({ required: true }) user!: User | UserData; /** Событие выхода из системы */ @Output() logout = new EventEmitter(); diff --git a/projects/ui/src/lib/components/layout/sidebar/sidebar.component.ts b/projects/ui/src/lib/components/layout/sidebar/sidebar.component.ts index 6a5392e0d..726238b71 100644 --- a/projects/ui/src/lib/components/layout/sidebar/sidebar.component.ts +++ b/projects/ui/src/lib/components/layout/sidebar/sidebar.component.ts @@ -10,13 +10,8 @@ import { type OnInit, } from "@angular/core"; import { RouterLink, RouterLinkActive } from "@angular/router"; -import { - IconComponent, - InviteManageCardComponent, - ProfileControlPanelComponent, - ProfileInfoComponent, -} from "@uilib"; -import { AsyncPipe, CommonModule } from "@angular/common"; +import { IconComponent } from "@uilib"; +import { CommonModule } from "@angular/common"; import { ClickOutsideModule } from "ng-click-outside"; /**