Initial commit
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { icons } from './icons-provider';
|
||||
import { provideNzIcons } from 'ng-zorro-antd/icon';
|
||||
import { fr_FR, provideNzI18n } from 'ng-zorro-antd/i18n';
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
import fr from '@angular/common/locales/fr';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideIonicAngular } from '@ionic/angular/standalone';
|
||||
|
||||
registerLocaleData(fr);
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes),
|
||||
provideNzIcons(icons),
|
||||
provideNzI18n(fr_FR),
|
||||
provideAnimationsAsync(),
|
||||
provideHttpClient(), provideIonicAngular({})
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<router-outlet />
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard } from './guards/auth.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', pathMatch: 'full', redirectTo: '/catalog' },
|
||||
{
|
||||
path: 'auth',
|
||||
loadChildren: () => import('./pages/auth/auth.routes').then(m => m.AUTH_ROUTES)
|
||||
},
|
||||
{
|
||||
path: 'catalog',
|
||||
loadChildren: () => import('./pages/catalog/catalog.routes').then(m => m.CATALOG_ROUTES),
|
||||
canActivate: [authGuard]
|
||||
},
|
||||
{
|
||||
path: 'my-courses',
|
||||
loadChildren: () => import('./pages/my-courses/my-courses.routes').then(m => m.MY_COURSES_ROUTES),
|
||||
canActivate: [authGuard]
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
loadChildren: () => import('./pages/course-editor/course-editor.routes').then(m => m.COURSE_EDITOR_ROUTES),
|
||||
canActivate: [authGuard]
|
||||
},
|
||||
{
|
||||
path: 'courses/:id',
|
||||
loadChildren: () => import('./pages/course-viewer/course-viewer.routes').then(m => m.COURSE_VIEWER_ROUTES),
|
||||
canActivate: [authGuard]
|
||||
},
|
||||
{
|
||||
path: 'courses/:id/edit',
|
||||
loadChildren: () => import('./pages/course-editor/course-editor.routes').then(m => m.COURSE_EDITOR_ROUTES),
|
||||
canActivate: [authGuard]
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css'
|
||||
})
|
||||
export class App {}
|
||||
@@ -0,0 +1,45 @@
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background-color: var(--card);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
.bottom-nav-inner {
|
||||
display: flex;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 10px 0 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<nav class="bottom-nav">
|
||||
<div class="bottom-nav-inner">
|
||||
@for (tab of tabs; track tab.id) {
|
||||
<button
|
||||
class="nav-tab"
|
||||
[class.active]="currentTab() === tab.id"
|
||||
(click)="navigate(tab.id)"
|
||||
>
|
||||
<span nz-icon [nzType]="tab.icon" nzTheme="outline" class="nav-icon"></span>
|
||||
<span class="nav-label">{{ tab.label }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Component, inject, computed } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bottom-nav',
|
||||
imports: [NzIconModule, CommonModule],
|
||||
templateUrl: './bottom-nav.html',
|
||||
styleUrl: './bottom-nav.css'
|
||||
})
|
||||
export class BottomNav {
|
||||
private readonly router = inject(Router);
|
||||
|
||||
currentTab = computed(() => {
|
||||
const url = this.router.url;
|
||||
if (url.startsWith('/catalog')) return 'catalog';
|
||||
if (url.startsWith('/my-courses')) return 'my-courses';
|
||||
if (url.startsWith('/create')) return 'create';
|
||||
return '';
|
||||
});
|
||||
|
||||
navigate(tab: string): void {
|
||||
this.router.navigate([`/${tab}`]);
|
||||
}
|
||||
|
||||
tabs = [
|
||||
{ id: 'catalog', label: 'CATALOGUE', icon: 'appstore' },
|
||||
{ id: 'my-courses', label: 'APPRENDRE', icon: 'read' },
|
||||
{ id: 'create', label: 'CRÉER', icon: 'plus' }
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
.course-card {
|
||||
background-color: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.course-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.book-icon-wrapper {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background-color: var(--bg);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.book-icon {
|
||||
font-size: 20px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.community-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
background-color: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.completed-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #1abc9c;
|
||||
background: rgba(26, 188, 156, 0.12);
|
||||
border: 1px solid rgba(26, 188, 156, 0.3);
|
||||
border-radius: 6px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.enrolled-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.card-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card-progress .mc-progress-bar {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-progress .progress-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #1abc9c;
|
||||
width: 32px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.course-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0 0 6px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.course-description {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.author-icon {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.author-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.topic-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<div class="course-card" (click)="onClick()">
|
||||
<div class="card-header">
|
||||
<span class="community-badge">COMMUNAUTÉ</span>
|
||||
@if (isCompleted) {
|
||||
<span class="completed-badge">
|
||||
<span nz-icon nzType="check-circle" nzTheme="fill"></span>
|
||||
Terminé
|
||||
</span>
|
||||
} @else if (isEnrolled) {
|
||||
<span class="enrolled-badge">En cours</span>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="course-title">{{ course().title }}</h3>
|
||||
<p class="course-description">{{ course().description }}</p>
|
||||
</div>
|
||||
@if (isEnrolled) {
|
||||
<div class="card-progress">
|
||||
<div class="mc-progress-bar">
|
||||
<div class="mc-progress-fill" [style.width.%]="progressPercent"></div>
|
||||
</div>
|
||||
<span class="progress-label">{{ progressPercent }}%</span>
|
||||
</div>
|
||||
}
|
||||
<div class="card-footer">
|
||||
<span nz-icon nzType="user" nzTheme="outline" class="author-icon"></span>
|
||||
<span class="author-name">{{ course().creatorName }}</span>
|
||||
@if (course().topicCount > 0) {
|
||||
<span class="topic-count">· {{ course().topicCount }} sujet{{ course().topicCount > 1 ? 's' : '' }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { Course, CourseProgress } from '../../models/types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-course-card',
|
||||
imports: [NzIconModule],
|
||||
templateUrl: './course-card.html',
|
||||
styleUrl: './course-card.css'
|
||||
})
|
||||
export class CourseCard {
|
||||
course = input.required<Course>();
|
||||
progress = input<CourseProgress | null>(null);
|
||||
clicked = output<Course>();
|
||||
|
||||
get progressPercent(): number {
|
||||
return Math.round(this.progress()?.progressPercentage ?? 0);
|
||||
}
|
||||
|
||||
get isCompleted(): boolean {
|
||||
return this.progressPercent === 100;
|
||||
}
|
||||
|
||||
get isEnrolled(): boolean {
|
||||
return this.progress() !== null;
|
||||
}
|
||||
|
||||
onClick(): void {
|
||||
this.clicked.emit(this.course());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (authService.isLoggedIn()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return router.createUrlTree(['/auth']);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
BookOutline,
|
||||
UserOutline,
|
||||
SearchOutline,
|
||||
AppstoreOutline,
|
||||
PlusOutline,
|
||||
ReadOutline,
|
||||
LogoutOutline,
|
||||
BulbOutline,
|
||||
BulbFill,
|
||||
ArrowLeftOutline,
|
||||
ArrowRightOutline,
|
||||
EditOutline,
|
||||
DeleteOutline,
|
||||
CheckCircleOutline,
|
||||
CheckCircleFill,
|
||||
LockOutline,
|
||||
MailOutline,
|
||||
StarOutline,
|
||||
LinkOutline,
|
||||
VideoCameraOutline,
|
||||
FileOutline,
|
||||
FileTextOutline,
|
||||
MenuFoldOutline,
|
||||
MenuUnfoldOutline,
|
||||
CheckOutline,
|
||||
EyeOutline,
|
||||
CloseOutline,
|
||||
LoadingOutline
|
||||
} from '@ant-design/icons-angular/icons';
|
||||
|
||||
export const icons = [
|
||||
BookOutline,
|
||||
UserOutline,
|
||||
SearchOutline,
|
||||
AppstoreOutline,
|
||||
PlusOutline,
|
||||
ReadOutline,
|
||||
LogoutOutline,
|
||||
BulbOutline,
|
||||
BulbFill,
|
||||
ArrowLeftOutline,
|
||||
ArrowRightOutline,
|
||||
EditOutline,
|
||||
DeleteOutline,
|
||||
CheckCircleOutline,
|
||||
CheckCircleFill,
|
||||
LockOutline,
|
||||
MailOutline,
|
||||
StarOutline,
|
||||
LinkOutline,
|
||||
VideoCameraOutline,
|
||||
FileOutline,
|
||||
FileTextOutline,
|
||||
MenuFoldOutline,
|
||||
MenuUnfoldOutline,
|
||||
CheckOutline,
|
||||
EyeOutline,
|
||||
CloseOutline,
|
||||
LoadingOutline
|
||||
];
|
||||
@@ -0,0 +1,70 @@
|
||||
export type CourseStatus = 'Draft' | 'Published';
|
||||
export type ResourceType = 'Url' | 'Video' | 'Text' | 'File';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: CourseStatus;
|
||||
creatorId: string;
|
||||
creatorName: string;
|
||||
topicCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CourseDetails extends Course {
|
||||
topics: Topic[];
|
||||
}
|
||||
|
||||
export interface Topic {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
position: number;
|
||||
courseId: string;
|
||||
resources: Resource[];
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
id: string;
|
||||
type: ResourceType;
|
||||
title: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Enrollment {
|
||||
userId: string;
|
||||
courseId: string;
|
||||
courseTitle: string;
|
||||
enrolledAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface CourseProgress {
|
||||
courseId: string;
|
||||
userId: string;
|
||||
totalTopics: number;
|
||||
completedTopics: number;
|
||||
totalResources: number;
|
||||
completedResources: number;
|
||||
progressPercentage: number;
|
||||
}
|
||||
|
||||
export interface EnrollmentWithProgress extends Enrollment {
|
||||
progress?: CourseProgress;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
.auth-page {
|
||||
|
||||
background-color: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-color: var(--primary);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 16px;
|
||||
font-size: 28px;
|
||||
color: var(--primary-fg);
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.logo-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
width: 100%;
|
||||
background-color: var(--card);
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 14px 14px 42px;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.auth-input:focus {
|
||||
border-color: #1abc9c;
|
||||
}
|
||||
|
||||
.auth-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
font-size: 12px;
|
||||
color: #ff4d4f;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.auth-btn {
|
||||
width: 100%;
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-fg);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.auth-btn:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.auth-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-switch {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.switch-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #1abc9c;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 4px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background-color: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<div class="auth-page">
|
||||
<div class="auth-container">
|
||||
<div class="auth-logo">
|
||||
<h1 class="logo-title">MetaCourse</h1>
|
||||
<p class="logo-subtitle">
|
||||
@if (mode() === 'login') {
|
||||
Bienvenue, connectez-vous
|
||||
} @else {
|
||||
Créez votre compte
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
@if (mode() === 'login') {
|
||||
<form [formGroup]="loginForm" (ngSubmit)="login()" class="auth-form">
|
||||
<div class="input-group">
|
||||
<div class="input-wrapper">
|
||||
<span nz-icon nzType="mail" nzTheme="outline" class="input-icon"></span>
|
||||
<input
|
||||
type="email"
|
||||
formControlName="email"
|
||||
placeholder="Adresse email"
|
||||
class="auth-input"
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
@if (loginForm.controls.email.invalid && loginForm.controls.email.touched) {
|
||||
<span class="field-error">Email invalide</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<div class="input-wrapper">
|
||||
<span nz-icon nzType="lock" nzTheme="outline" class="input-icon"></span>
|
||||
<input
|
||||
type="password"
|
||||
formControlName="password"
|
||||
placeholder="Mot de passe"
|
||||
class="auth-input"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
@if (loginForm.controls.password.invalid && loginForm.controls.password.touched) {
|
||||
<span class="field-error">Mot de passe requis (min. 6 caractères)</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-btn" [disabled]="loading()">
|
||||
@if (loading()) {
|
||||
Connexion...
|
||||
} @else {
|
||||
Se Connecter →
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-switch">
|
||||
Pas encore de compte ?
|
||||
<button type="button" class="switch-btn" (click)="toggleMode()">S'inscrire</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Register Form -->
|
||||
@if (mode() === 'register') {
|
||||
<form [formGroup]="registerForm" (ngSubmit)="register()" class="auth-form">
|
||||
<div class="input-group">
|
||||
<div class="input-wrapper">
|
||||
<span nz-icon nzType="user" nzTheme="outline" class="input-icon"></span>
|
||||
<input
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Nom complet"
|
||||
class="auth-input"
|
||||
autocomplete="name"
|
||||
/>
|
||||
</div>
|
||||
@if (registerForm.controls.name.invalid && registerForm.controls.name.touched) {
|
||||
<span class="field-error">Nom requis (min. 2 caractères)</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<div class="input-wrapper">
|
||||
<span nz-icon nzType="mail" nzTheme="outline" class="input-icon"></span>
|
||||
<input
|
||||
type="email"
|
||||
formControlName="email"
|
||||
placeholder="Adresse email"
|
||||
class="auth-input"
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
@if (registerForm.controls.email.invalid && registerForm.controls.email.touched) {
|
||||
<span class="field-error">Email invalide</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<div class="input-wrapper">
|
||||
<span nz-icon nzType="lock" nzTheme="outline" class="input-icon"></span>
|
||||
<input
|
||||
type="password"
|
||||
formControlName="password"
|
||||
placeholder="Mot de passe"
|
||||
class="auth-input"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
@if (registerForm.controls.password.invalid && registerForm.controls.password.touched) {
|
||||
<span class="field-error">Mot de passe requis (min. 6 caractères)</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-btn" [disabled]="loading()">
|
||||
@if (loading()) {
|
||||
Création...
|
||||
} @else {
|
||||
S'inscrire →
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-switch">
|
||||
Déjà un compte ?
|
||||
<button type="button" class="switch-btn" (click)="toggleMode()">Se connecter</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<button class="theme-toggle" (click)="toggleTheme()" aria-label="Toggle theme">
|
||||
@if (isDark()) {
|
||||
<span nz-icon nzType="bulb" nzTheme="fill"></span>
|
||||
} @else {
|
||||
<span nz-icon nzType="bulb" nzTheme="outline"></span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { AuthPage } from './auth';
|
||||
|
||||
export const AUTH_ROUTES: Routes = [
|
||||
{ path: '', component: AuthPage }
|
||||
];
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { NzMessageService } from 'ng-zorro-antd/message';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { getErrorMessage } from '../../utils/error.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth',
|
||||
imports: [ReactiveFormsModule, NzIconModule],
|
||||
providers: [NzMessageService],
|
||||
templateUrl: './auth.html',
|
||||
styleUrl: './auth.css'
|
||||
})
|
||||
export class AuthPage {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly router = inject(Router);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly message = inject(NzMessageService);
|
||||
|
||||
mode = signal<'login' | 'register'>('login');
|
||||
loading = signal(false);
|
||||
isDark = signal(true);
|
||||
|
||||
loginForm = this.fb.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
password: ['', [Validators.required, Validators.minLength(6)]]
|
||||
});
|
||||
|
||||
registerForm = this.fb.group({
|
||||
name: ['', [Validators.required, Validators.minLength(2)]],
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
password: ['', [Validators.required, Validators.minLength(6)]]
|
||||
});
|
||||
|
||||
toggleMode(): void {
|
||||
this.mode.set(this.mode() === 'login' ? 'register' : 'login');
|
||||
}
|
||||
|
||||
toggleTheme(): void {
|
||||
const html = document.documentElement;
|
||||
if (html.classList.contains('dark')) {
|
||||
html.classList.remove('dark');
|
||||
this.isDark.set(false);
|
||||
} else {
|
||||
html.classList.add('dark');
|
||||
this.isDark.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
login(): void {
|
||||
if (this.loginForm.invalid) {
|
||||
Object.values(this.loginForm.controls).forEach(c => c.markAsTouched());
|
||||
return;
|
||||
}
|
||||
const { email, password } = this.loginForm.value;
|
||||
this.loading.set(true);
|
||||
this.authService.login(email!, password!).subscribe({
|
||||
next: () => {
|
||||
this.loading.set(false);
|
||||
this.router.navigate(['/catalog']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading.set(false);
|
||||
this.message.error(getErrorMessage(err, 'Email ou mot de passe incorrect'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
register(): void {
|
||||
if (this.registerForm.invalid) {
|
||||
Object.values(this.registerForm.controls).forEach(c => c.markAsTouched());
|
||||
return;
|
||||
}
|
||||
const { name, email, password } = this.registerForm.value;
|
||||
this.loading.set(true);
|
||||
this.authService.register(name!, email!, password!).subscribe({
|
||||
next: () => {
|
||||
this.loading.set(false);
|
||||
this.message.success('Compte créé avec succès !');
|
||||
this.mode.set('login');
|
||||
this.loginForm.patchValue({ email: email!, password: '' });
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading.set(false);
|
||||
this.message.error(getErrorMessage(err, 'Erreur lors de la création du compte'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
.catalog-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.catalog-header {
|
||||
padding: 16px 20px 0;
|
||||
background-color: var(--bg);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 22px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.catalog-title-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.catalog-title {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
margin: 0 0 4px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.catalog-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
background-color: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px 12px 42px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #1abc9c;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.catalog-content {
|
||||
padding: 4px 20px 0;
|
||||
}
|
||||
|
||||
.courses-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Skeleton loading */
|
||||
.loading-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 160px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<div class="page-container catalog-page">
|
||||
<!-- Header -->
|
||||
<div class="catalog-header">
|
||||
<div class="header-top">
|
||||
<div class="header-logo">
|
||||
<span class="logo-text">MetaCourse</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn" (click)="toggleTheme()" aria-label="Toggle theme">
|
||||
<span nz-icon [nzType]="isDark() ? 'bulb' : 'bulb'" [nzTheme]="isDark() ? 'fill' : 'outline'"></span>
|
||||
</button>
|
||||
<button class="icon-btn" (click)="logout()" aria-label="Déconnexion">
|
||||
<span nz-icon nzType="logout" nzTheme="outline"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="catalog-title-section">
|
||||
<h1 class="catalog-title">Explorer les Cours</h1>
|
||||
<p class="catalog-subtitle">Découvrez de nouvelles compétences.</p>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-wrapper">
|
||||
<span nz-icon nzType="search" nzTheme="outline" class="search-icon"></span>
|
||||
<input
|
||||
type="search"
|
||||
class="search-input"
|
||||
placeholder="Rechercher des sujets, auteurs..."
|
||||
[ngModel]="searchQuery()"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Course list -->
|
||||
<div class="catalog-content">
|
||||
@if (loading()) {
|
||||
<div class="loading-list">
|
||||
@for (_ of [1, 2, 3]; track $index) {
|
||||
<div class="skeleton-card"></div>
|
||||
}
|
||||
</div>
|
||||
} @else if (courses().length === 0) {
|
||||
<div class="empty-state">
|
||||
<span nz-icon nzType="read" nzTheme="outline" class="empty-icon"></span>
|
||||
<p class="empty-text">Aucun cours trouvé</p>
|
||||
@if (searchQuery()) {
|
||||
<p class="empty-hint">Essayez un autre terme de recherche</p>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="courses-list">
|
||||
@for (course of courses(); track course.id) {
|
||||
<app-course-card [course]="course" [progress]="getProgress(course.id)" (clicked)="onCourseClicked($event)" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<app-bottom-nav />
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { CatalogPage } from './catalog';
|
||||
|
||||
export const CATALOG_ROUTES: Routes = [
|
||||
{ path: '', component: CatalogPage }
|
||||
];
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Component, inject, signal, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { NzMessageService } from 'ng-zorro-antd/message';
|
||||
import { Subject, debounceTime, distinctUntilChanged, takeUntil, forkJoin, of } from 'rxjs';
|
||||
import { catchError, switchMap } from 'rxjs/operators';
|
||||
import { CourseService } from '../../services/course.service';
|
||||
import { EnrollmentService } from '../../services/enrollment.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { Course, CourseProgress } from '../../models/types';
|
||||
import { CourseCard } from '../../components/course-card/course-card';
|
||||
import { BottomNav } from '../../components/bottom-nav/bottom-nav';
|
||||
|
||||
@Component({
|
||||
selector: 'app-catalog',
|
||||
imports: [FormsModule, NzIconModule, CourseCard, BottomNav],
|
||||
providers: [NzMessageService],
|
||||
templateUrl: './catalog.html',
|
||||
styleUrl: './catalog.css'
|
||||
})
|
||||
export class CatalogPage implements OnInit, OnDestroy {
|
||||
private readonly courseService = inject(CourseService);
|
||||
private readonly enrollmentService = inject(EnrollmentService);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly message = inject(NzMessageService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
private readonly search$ = new Subject<string>();
|
||||
|
||||
courses = signal<Course[]>([]);
|
||||
progressMap = signal<Record<string, CourseProgress>>({});
|
||||
loading = signal(false);
|
||||
searchQuery = signal('');
|
||||
isDark = signal(true);
|
||||
|
||||
get currentUser() {
|
||||
return this.authService.currentUser();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.search$.pipe(
|
||||
debounceTime(400),
|
||||
distinctUntilChanged(),
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(query => {
|
||||
this.loadCourses(query);
|
||||
});
|
||||
|
||||
this.loadCourses('');
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
onSearchChange(value: string): void {
|
||||
this.searchQuery.set(value);
|
||||
this.search$.next(value);
|
||||
}
|
||||
|
||||
loadCourses(search: string): void {
|
||||
const user = this.currentUser;
|
||||
this.loading.set(true);
|
||||
|
||||
const courses$ = this.courseService.getCourses(search);
|
||||
const enrollments$ = user
|
||||
? this.enrollmentService.getEnrollments(user.userId).pipe(catchError(() => of([])))
|
||||
: of([]);
|
||||
|
||||
forkJoin([courses$, enrollments$]).subscribe({
|
||||
next: ([courses, enrollments]) => {
|
||||
this.courses.set(courses);
|
||||
|
||||
if (!user || enrollments.length === 0) {
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const progressRequests = enrollments.map((e: any) =>
|
||||
this.enrollmentService.getCourseProgress(e.courseId, user.userId).pipe(
|
||||
catchError(() => of(null))
|
||||
)
|
||||
);
|
||||
|
||||
forkJoin(progressRequests).subscribe({
|
||||
next: (progressList) => {
|
||||
const map: Record<string, CourseProgress> = {};
|
||||
enrollments.forEach((e: any, i: number) => {
|
||||
if (progressList[i]) map[e.courseId] = progressList[i]!;
|
||||
});
|
||||
this.progressMap.set(map);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => this.loading.set(false)
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.message.error('Impossible de charger les cours');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getProgress(courseId: string): CourseProgress | null {
|
||||
return this.progressMap()[courseId] ?? null;
|
||||
}
|
||||
|
||||
onCourseClicked(course: Course): void {
|
||||
this.router.navigate(['/courses', course.id]);
|
||||
}
|
||||
|
||||
toggleTheme(): void {
|
||||
const html = document.documentElement;
|
||||
if (html.classList.contains('dark')) {
|
||||
html.classList.remove('dark');
|
||||
this.isDark.set(false);
|
||||
} else {
|
||||
html.classList.add('dark');
|
||||
this.isDark.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.authService.logout();
|
||||
this.router.navigate(['/auth']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
.editor-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
font-size: 32px;
|
||||
color: #1abc9c;
|
||||
}
|
||||
|
||||
.loading-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.editor-header {
|
||||
padding: 16px 20px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mode-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.editor-content {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Section card */
|
||||
.section-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Fields */
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mc-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Topics list */
|
||||
.topic-item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topic-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.topic-position {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topic-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topic-item-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0 0 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.topic-item-resources {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.topic-item-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.icon-action {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.icon-action.danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
/* Resource chips */
|
||||
.topic-resources {
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.resource-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chip-title {
|
||||
max-width: 120px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chip-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* Danger zone */
|
||||
.danger-zone {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.publish-btn {
|
||||
width: 100%;
|
||||
background-color: #1abc9c;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.publish-btn:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.publish-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.status-badge.published {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #1abc9c;
|
||||
background: rgba(26, 188, 156, 0.1);
|
||||
border: 1px solid rgba(26, 188, 156, 0.3);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: 1px solid #ff4d4f;
|
||||
color: #ff4d4f;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: rgba(255, 77, 79, 0.08);
|
||||
}
|
||||
|
||||
/* Resource type pills */
|
||||
.type-pills {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.type-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: var(--bg);
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.type-pill.active {
|
||||
background: var(--primary);
|
||||
color: var(--primary-fg);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Resource picker */
|
||||
.resources-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.picker-item.linked {
|
||||
border-color: #1abc9c;
|
||||
background: rgba(26, 188, 156, 0.05);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.resource-icon-sm {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.picker-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picker-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0 0 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.picker-type {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.linked-icon {
|
||||
font-size: 18px;
|
||||
color: #1abc9c;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
<div class="page-container editor-page">
|
||||
@if (loading()) {
|
||||
<div class="loading-screen">
|
||||
<span nz-icon nzType="loading" nzTheme="outline" class="loading-spin"></span>
|
||||
</div>
|
||||
} @else {
|
||||
|
||||
<!-- COURSE FORM MODE -->
|
||||
@if (mode() === 'course') {
|
||||
<div class="editor-header">
|
||||
<div class="header-row">
|
||||
<button class="back-btn" (click)="goBack()">
|
||||
<span nz-icon nzType="arrow-left" nzTheme="outline"></span>
|
||||
</button>
|
||||
<h1 class="page-title">
|
||||
{{ isEditMode() ? 'Modifier le cours' : 'Nouveau cours' }}
|
||||
</h1>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-content">
|
||||
<!-- Course info form -->
|
||||
<div class="section-card">
|
||||
<h2 class="section-label">Informations</h2>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label">Titre du cours</label>
|
||||
<input
|
||||
type="text"
|
||||
class="mc-input"
|
||||
placeholder="Ex: Introduction à Angular"
|
||||
[ngModel]="courseTitle()"
|
||||
(ngModelChange)="courseTitle.set($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label">Description</label>
|
||||
<textarea
|
||||
class="mc-input mc-textarea"
|
||||
placeholder="Décrivez votre cours..."
|
||||
rows="4"
|
||||
[ngModel]="courseDescription()"
|
||||
(ngModelChange)="courseDescription.set($event)"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button class="mc-btn-primary" (click)="saveCourse()" [disabled]="saving()">
|
||||
@if (saving()) { Enregistrement... } @else {
|
||||
{{ isEditMode() ? 'Enregistrer les modifications' : 'Créer le cours' }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Topics (edit mode only) -->
|
||||
@if (isEditMode() && course()) {
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<h2 class="section-label">Sujets ({{ course()!.topics.length }})</h2>
|
||||
<button class="add-btn" (click)="openAddTopic()">
|
||||
<span nz-icon nzType="plus" nzTheme="outline"></span>
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (course()!.topics.length === 0) {
|
||||
<p class="empty-hint">Aucun sujet. Commencez par en ajouter un.</p>
|
||||
} @else {
|
||||
@for (topic of course()!.topics; track topic.id) {
|
||||
<div class="topic-item">
|
||||
<div class="topic-item-header">
|
||||
<div class="topic-position">{{ topic.position }}</div>
|
||||
<div class="topic-item-info">
|
||||
<p class="topic-item-title">{{ topic.title }}</p>
|
||||
<span class="topic-item-resources">{{ topic.resources.length }} ressource(s)</span>
|
||||
</div>
|
||||
<div class="topic-item-actions">
|
||||
<button class="icon-action" (click)="openLinkResource(topic, $event)" title="Lier une ressource">
|
||||
<span nz-icon nzType="link" nzTheme="outline"></span>
|
||||
</button>
|
||||
<button class="icon-action" (click)="openEditTopic(topic, $event)" title="Modifier">
|
||||
<span nz-icon nzType="edit" nzTheme="outline"></span>
|
||||
</button>
|
||||
<button class="icon-action danger" (click)="deleteTopic(topic, $event)" title="Supprimer">
|
||||
<span nz-icon nzType="delete" nzTheme="outline"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resources of topic -->
|
||||
@if (topic.resources.length > 0) {
|
||||
<div class="topic-resources">
|
||||
@for (resource of topic.resources; track resource.id) {
|
||||
<div class="resource-chip">
|
||||
<span nz-icon [nzType]="getResourceIcon(resource.type)" nzTheme="outline"></span>
|
||||
<span class="chip-title">{{ resource.title }}</span>
|
||||
<button class="chip-remove" (click)="unlinkResource(topic, resource, $event)">
|
||||
<span nz-icon nzType="close" nzTheme="outline"></span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Add resource to catalog -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<h2 class="section-label">Ressources ({{ allResources().length }})</h2>
|
||||
<button class="add-btn" (click)="openAddResource()">
|
||||
<span nz-icon nzType="plus" nzTheme="outline"></span>
|
||||
Nouvelle
|
||||
</button>
|
||||
</div>
|
||||
@if (allResources().length === 0) {
|
||||
<p class="empty-hint">Créez des ressources pour les lier aux sujets.</p>
|
||||
} @else {
|
||||
@for (resource of allResources(); track resource.id) {
|
||||
<div class="topic-item">
|
||||
<div class="topic-item-header">
|
||||
<div class="resource-icon-sm">
|
||||
<span nz-icon [nzType]="getResourceIcon(resource.type)" nzTheme="outline"></span>
|
||||
</div>
|
||||
<div class="topic-item-info">
|
||||
<p class="topic-item-title">{{ resource.title }}</p>
|
||||
<span class="topic-item-resources">{{ resource.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Publish / Delete -->
|
||||
<div class="section-card danger-zone">
|
||||
@if (course()!.status === 'Draft') {
|
||||
<button class="publish-btn" (click)="publishCourse()" [disabled]="publishing()">
|
||||
@if (publishing()) { Publication... } @else { Publier le cours }
|
||||
</button>
|
||||
} @else {
|
||||
<div class="status-badge published">Cours publié</div>
|
||||
}
|
||||
<button class="delete-btn" (click)="deleteCourse()">
|
||||
<span nz-icon nzType="delete" nzTheme="outline"></span>
|
||||
Supprimer le cours
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TOPIC FORM MODE -->
|
||||
@if (mode() === 'topic') {
|
||||
<div class="editor-header">
|
||||
<div class="header-row">
|
||||
<button class="back-btn" (click)="cancelMode()">
|
||||
<span nz-icon nzType="arrow-left" nzTheme="outline"></span>
|
||||
</button>
|
||||
<h1 class="page-title">
|
||||
{{ editingTopicId() ? 'Modifier le sujet' : 'Nouveau sujet' }}
|
||||
</h1>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-content">
|
||||
<div class="section-card">
|
||||
<div class="field-group">
|
||||
<label class="field-label">Titre du sujet</label>
|
||||
<input
|
||||
type="text"
|
||||
class="mc-input"
|
||||
placeholder="Ex: Introduction aux composants"
|
||||
[ngModel]="topicTitle()"
|
||||
(ngModelChange)="topicTitle.set($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label">Description (optionnel)</label>
|
||||
<textarea
|
||||
class="mc-input mc-textarea"
|
||||
placeholder="Description du sujet..."
|
||||
rows="3"
|
||||
[ngModel]="topicDescription()"
|
||||
(ngModelChange)="topicDescription.set($event)"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button class="mc-btn-primary" (click)="saveTopic()" [disabled]="saving()">
|
||||
@if (saving()) { Enregistrement... } @else {
|
||||
{{ editingTopicId() ? 'Mettre à jour' : 'Ajouter le sujet' }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- RESOURCE FORM MODE -->
|
||||
@if (mode() === 'resource') {
|
||||
<div class="editor-header">
|
||||
<div class="header-row">
|
||||
<button class="back-btn" (click)="cancelMode()">
|
||||
<span nz-icon nzType="arrow-left" nzTheme="outline"></span>
|
||||
</button>
|
||||
<h1 class="page-title">Nouvelle ressource</h1>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-content">
|
||||
<div class="section-card">
|
||||
<div class="field-group">
|
||||
<label class="field-label">Type</label>
|
||||
<div class="type-pills">
|
||||
@for (type of resourceTypes; track type) {
|
||||
<button
|
||||
class="type-pill"
|
||||
[class.active]="resourceType() === type"
|
||||
(click)="resourceType.set(type)"
|
||||
>
|
||||
<span nz-icon [nzType]="getResourceIcon(type)" nzTheme="outline"></span>
|
||||
{{ type }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label">Titre</label>
|
||||
<input
|
||||
type="text"
|
||||
class="mc-input"
|
||||
placeholder="Ex: Documentation Angular"
|
||||
[ngModel]="resourceTitle()"
|
||||
(ngModelChange)="resourceTitle.set($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label">
|
||||
@if (resourceType() === 'Text') { Contenu (texte/markdown) }
|
||||
@else if (resourceType() === 'File') { Chemin ou URL du fichier }
|
||||
@else { URL }
|
||||
</label>
|
||||
@if (resourceType() === 'Text') {
|
||||
<textarea
|
||||
class="mc-input mc-textarea"
|
||||
placeholder="Rédigez le contenu..."
|
||||
rows="6"
|
||||
[ngModel]="resourceContent()"
|
||||
(ngModelChange)="resourceContent.set($event)"
|
||||
></textarea>
|
||||
} @else {
|
||||
<input
|
||||
type="url"
|
||||
class="mc-input"
|
||||
placeholder="https://..."
|
||||
[ngModel]="resourceContent()"
|
||||
(ngModelChange)="resourceContent.set($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="mc-btn-primary" (click)="saveResource()" [disabled]="saving()">
|
||||
@if (saving()) { Enregistrement... } @else { Créer la ressource }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- LINK RESOURCE MODE -->
|
||||
@if (mode() === 'link-resource') {
|
||||
<div class="editor-header">
|
||||
<div class="header-row">
|
||||
<button class="back-btn" (click)="cancelMode()">
|
||||
<span nz-icon nzType="arrow-left" nzTheme="outline"></span>
|
||||
</button>
|
||||
<h1 class="page-title">Lier une ressource</h1>
|
||||
<div></div>
|
||||
</div>
|
||||
<p class="mode-subtitle">Sujet : <strong>{{ selectedTopic()?.title }}</strong></p>
|
||||
</div>
|
||||
|
||||
<div class="editor-content">
|
||||
@if (allResources().length === 0) {
|
||||
<div class="section-card">
|
||||
<p class="empty-hint">Aucune ressource disponible. Créez-en une d'abord.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="resources-picker">
|
||||
@for (resource of allResources(); track resource.id) {
|
||||
<div
|
||||
class="picker-item"
|
||||
[class.linked]="isResourceLinked(resource)"
|
||||
(click)="!isResourceLinked(resource) && linkResource(resource)"
|
||||
>
|
||||
<div class="resource-icon-sm">
|
||||
<span nz-icon [nzType]="getResourceIcon(resource.type)" nzTheme="outline"></span>
|
||||
</div>
|
||||
<div class="picker-info">
|
||||
<p class="picker-title">{{ resource.title }}</p>
|
||||
<span class="picker-type">{{ resource.type }}</span>
|
||||
</div>
|
||||
@if (isResourceLinked(resource)) {
|
||||
<span nz-icon nzType="check-circle" nzTheme="outline" class="linked-icon"></span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { CourseEditorPage } from './course-editor';
|
||||
|
||||
export const COURSE_EDITOR_ROUTES: Routes = [
|
||||
{ path: '', component: CourseEditorPage }
|
||||
];
|
||||
@@ -0,0 +1,346 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { NzMessageService } from 'ng-zorro-antd/message';
|
||||
import { CourseService } from '../../services/course.service';
|
||||
import { TopicService } from '../../services/topic.service';
|
||||
import { ResourceService } from '../../services/resource.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { CourseDetails, Topic, Resource, ResourceType } from '../../models/types';
|
||||
import { getErrorMessage } from '../../utils/error.utils';
|
||||
import { forkJoin } from 'rxjs';
|
||||
|
||||
type EditorMode = 'course' | 'topic' | 'resource' | 'link-resource';
|
||||
|
||||
@Component({
|
||||
selector: 'app-course-editor',
|
||||
imports: [FormsModule, NzIconModule],
|
||||
providers: [NzMessageService],
|
||||
templateUrl: './course-editor.html',
|
||||
styleUrl: './course-editor.css'
|
||||
})
|
||||
export class CourseEditorPage implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly courseService = inject(CourseService);
|
||||
private readonly topicService = inject(TopicService);
|
||||
private readonly resourceService = inject(ResourceService);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly message = inject(NzMessageService);
|
||||
|
||||
// State
|
||||
isEditMode = signal(false);
|
||||
loading = signal(true);
|
||||
saving = signal(false);
|
||||
publishing = signal(false);
|
||||
mode = signal<EditorMode>('course');
|
||||
|
||||
course = signal<CourseDetails | null>(null);
|
||||
allResources = signal<Resource[]>([]);
|
||||
selectedTopic = signal<Topic | null>(null);
|
||||
|
||||
// Course form
|
||||
courseTitle = signal('');
|
||||
courseDescription = signal('');
|
||||
|
||||
// Topic form
|
||||
topicTitle = signal('');
|
||||
topicDescription = signal('');
|
||||
editingTopicId = signal<string | null>(null);
|
||||
|
||||
// Resource form
|
||||
resourceType = signal<ResourceType>('Url');
|
||||
resourceTitle = signal('');
|
||||
resourceContent = signal('');
|
||||
editingResourceId = signal<string | null>(null);
|
||||
|
||||
resourceTypes: ResourceType[] = ['Url', 'Video', 'Text', 'File'];
|
||||
|
||||
get currentUser() {
|
||||
return this.authService.currentUser();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
if (id) {
|
||||
this.isEditMode.set(true);
|
||||
this.loadCourse(id);
|
||||
} else {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadCourse(id: string): void {
|
||||
this.loading.set(true);
|
||||
forkJoin([
|
||||
this.courseService.getCourseById(id),
|
||||
this.resourceService.getResources()
|
||||
]).subscribe({
|
||||
next: ([course, resources]) => {
|
||||
this.course.set(course);
|
||||
this.courseTitle.set(course.title);
|
||||
this.courseDescription.set(course.description);
|
||||
this.allResources.set(resources);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.message.error('Cours introuvable');
|
||||
this.router.navigate(['/catalog']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -- Course actions --
|
||||
|
||||
saveCourse(): void {
|
||||
if (!this.courseTitle().trim()) {
|
||||
this.message.warning('Le titre est requis');
|
||||
return;
|
||||
}
|
||||
this.saving.set(true);
|
||||
const user = this.currentUser;
|
||||
|
||||
if (this.isEditMode()) {
|
||||
const id = this.course()!.id;
|
||||
this.courseService.updateCourse(id, this.courseTitle(), this.courseDescription()).subscribe({
|
||||
next: (updated) => {
|
||||
this.course.update(c => c ? { ...c, title: updated.title, description: updated.description } : c);
|
||||
this.saving.set(false);
|
||||
this.message.success('Cours mis à jour');
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
this.message.error('Erreur lors de la mise à jour');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (!user) return;
|
||||
this.courseService.createCourse(this.courseTitle(), this.courseDescription(), user.userId).subscribe({
|
||||
next: (created) => {
|
||||
this.saving.set(false);
|
||||
this.message.success('Cours créé !');
|
||||
this.router.navigate(['/courses', created.id, 'edit']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving.set(false);
|
||||
this.message.error(getErrorMessage(err, 'Erreur lors de la création'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
publishCourse(): void {
|
||||
const course = this.course();
|
||||
if (!course) return;
|
||||
this.publishing.set(true);
|
||||
this.courseService.publishCourse(course.id).subscribe({
|
||||
next: (updated) => {
|
||||
this.course.update(c => c ? { ...c, status: updated.status } : c);
|
||||
this.publishing.set(false);
|
||||
this.message.success('Cours publié !');
|
||||
},
|
||||
error: (err) => {
|
||||
this.publishing.set(false);
|
||||
this.message.error(getErrorMessage(err, 'Impossible de publier le cours'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteCourse(): void {
|
||||
const course = this.course();
|
||||
if (!course) return;
|
||||
this.courseService.deleteCourse(course.id).subscribe({
|
||||
next: () => {
|
||||
this.message.success('Cours supprimé');
|
||||
this.router.navigate(['/catalog']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.message.error(getErrorMessage(err, 'Impossible de supprimer le cours'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -- Topic actions --
|
||||
|
||||
openAddTopic(): void {
|
||||
this.topicTitle.set('');
|
||||
this.topicDescription.set('');
|
||||
this.editingTopicId.set(null);
|
||||
this.mode.set('topic');
|
||||
}
|
||||
|
||||
openEditTopic(topic: Topic, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.topicTitle.set(topic.title);
|
||||
this.topicDescription.set(topic.description ?? '');
|
||||
this.editingTopicId.set(topic.id);
|
||||
this.mode.set('topic');
|
||||
}
|
||||
|
||||
saveTopic(): void {
|
||||
if (!this.topicTitle().trim()) {
|
||||
this.message.warning('Le titre du sujet est requis');
|
||||
return;
|
||||
}
|
||||
const course = this.course();
|
||||
if (!course) return;
|
||||
this.saving.set(true);
|
||||
|
||||
const editId = this.editingTopicId();
|
||||
if (editId) {
|
||||
const existing = course.topics.find(t => t.id === editId)!;
|
||||
this.topicService.updateTopic(editId, this.topicTitle(), this.topicDescription(), existing.position).subscribe({
|
||||
next: (updated) => {
|
||||
this.course.update(c => c ? {
|
||||
...c,
|
||||
topics: c.topics.map(t => t.id === editId ? { ...t, ...updated } : t)
|
||||
} : c);
|
||||
this.saving.set(false);
|
||||
this.mode.set('course');
|
||||
this.message.success('Sujet mis à jour');
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
this.message.error('Erreur lors de la mise à jour');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const position = course.topics.length + 1;
|
||||
const desc = this.topicDescription().trim() || null;
|
||||
this.topicService.createTopic(course.id, this.topicTitle(), desc, position).subscribe({
|
||||
next: (created) => {
|
||||
this.course.update(c => c ? {
|
||||
...c,
|
||||
topics: [...c.topics, { ...created, resources: [] }],
|
||||
topicCount: c.topicCount + 1
|
||||
} : c);
|
||||
this.saving.set(false);
|
||||
this.mode.set('course');
|
||||
this.message.success('Sujet ajouté');
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving.set(false);
|
||||
this.message.error(getErrorMessage(err, 'Erreur lors de la création du sujet'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
deleteTopic(topic: Topic, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.topicService.deleteTopic(topic.id).subscribe({
|
||||
next: () => {
|
||||
this.course.update(c => c ? {
|
||||
...c,
|
||||
topics: c.topics.filter(t => t.id !== topic.id),
|
||||
topicCount: c.topicCount - 1
|
||||
} : c);
|
||||
this.message.success('Sujet supprimé');
|
||||
},
|
||||
error: () => this.message.error('Impossible de supprimer ce sujet')
|
||||
});
|
||||
}
|
||||
|
||||
// -- Resource actions --
|
||||
|
||||
openAddResource(): void {
|
||||
this.resourceType.set('Url');
|
||||
this.resourceTitle.set('');
|
||||
this.resourceContent.set('');
|
||||
this.editingResourceId.set(null);
|
||||
this.mode.set('resource');
|
||||
}
|
||||
|
||||
openLinkResource(topic: Topic, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.selectedTopic.set(topic);
|
||||
this.resourceService.getResources().subscribe({
|
||||
next: (resources) => {
|
||||
this.allResources.set(resources);
|
||||
this.mode.set('link-resource');
|
||||
},
|
||||
error: () => this.message.error('Impossible de charger les ressources')
|
||||
});
|
||||
}
|
||||
|
||||
saveResource(): void {
|
||||
if (!this.resourceTitle().trim() || !this.resourceContent().trim()) {
|
||||
this.message.warning('Titre et contenu sont requis');
|
||||
return;
|
||||
}
|
||||
this.saving.set(true);
|
||||
this.resourceService.createResource(this.resourceType(), this.resourceTitle(), this.resourceContent()).subscribe({
|
||||
next: (created) => {
|
||||
this.allResources.update(list => [...list, created]);
|
||||
this.saving.set(false);
|
||||
this.mode.set('course');
|
||||
this.message.success('Ressource créée');
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving.set(false);
|
||||
this.message.error(getErrorMessage(err, 'Erreur lors de la création de la ressource'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
linkResource(resource: Resource): void {
|
||||
const topic = this.selectedTopic();
|
||||
if (!topic) return;
|
||||
|
||||
const position = topic.resources.length + 1;
|
||||
this.topicService.linkResource(topic.id, resource.id, position).subscribe({
|
||||
next: () => {
|
||||
this.course.update(c => c ? {
|
||||
...c,
|
||||
topics: c.topics.map(t => t.id === topic.id
|
||||
? { ...t, resources: [...t.resources, resource] }
|
||||
: t
|
||||
)
|
||||
} : c);
|
||||
this.selectedTopic.update(t => t ? { ...t, resources: [...t.resources, resource] } : t);
|
||||
this.message.success('Ressource liée');
|
||||
},
|
||||
error: () => this.message.error('Erreur lors de la liaison')
|
||||
});
|
||||
}
|
||||
|
||||
unlinkResource(topic: Topic, resource: Resource, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.topicService.unlinkResource(topic.id, resource.id).subscribe({
|
||||
next: () => {
|
||||
this.course.update(c => c ? {
|
||||
...c,
|
||||
topics: c.topics.map(t => t.id === topic.id
|
||||
? { ...t, resources: t.resources.filter(r => r.id !== resource.id) }
|
||||
: t
|
||||
)
|
||||
} : c);
|
||||
this.message.success('Ressource retirée');
|
||||
},
|
||||
error: () => this.message.error('Erreur lors du retrait')
|
||||
});
|
||||
}
|
||||
|
||||
isResourceLinked(resource: Resource): boolean {
|
||||
return this.selectedTopic()?.resources.some(r => r.id === resource.id) ?? false;
|
||||
}
|
||||
|
||||
getResourceIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'Video': return 'video-camera';
|
||||
case 'Text': return 'file-text';
|
||||
case 'File': return 'file';
|
||||
default: return 'link';
|
||||
}
|
||||
}
|
||||
|
||||
cancelMode(): void {
|
||||
this.mode.set('course');
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.router.navigate(['/catalog']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
.course-viewer-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
font-size: 32px;
|
||||
color: #1abc9c;
|
||||
}
|
||||
|
||||
.loading-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.viewer-header {
|
||||
padding: 16px 20px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 8px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.course-hero {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.course-icon-wrap {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: var(--text);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.community-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.course-title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: -0.3px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.course-description {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.course-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meta-dot {
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
/* Progress card */
|
||||
.progress-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #1abc9c;
|
||||
}
|
||||
|
||||
.progress-detail {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
/* Enroll button */
|
||||
.enroll-btn {
|
||||
width: 100%;
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-fg);
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.enroll-btn:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.enroll-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Restart button */
|
||||
.restart-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.restart-btn:hover {
|
||||
border-color: #ff4d4f;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
/* Topics section */
|
||||
.topics-section {
|
||||
padding: 20px 20px 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.empty-topics {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.topic-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topic-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.topic-position {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topic-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topic-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.topic-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.resource-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.topic-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topic-position.done {
|
||||
background: rgba(26, 188, 156, 0.15);
|
||||
border-color: #1abc9c;
|
||||
color: #1abc9c;
|
||||
}
|
||||
|
||||
.topic-done .topic-title {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.mark-done-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.mark-done-btn:hover {
|
||||
border-color: #1abc9c;
|
||||
color: #1abc9c;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Resources */
|
||||
.resources-list {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.resource-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.resource-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.resource-item:active {
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.resource-done {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.resource-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.resource-icon.done {
|
||||
background: rgba(26, 188, 156, 0.15);
|
||||
border-color: #1abc9c;
|
||||
color: #1abc9c;
|
||||
}
|
||||
|
||||
.resource-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0 0 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.resource-type {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.resource-open {
|
||||
color: #1abc9c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.resource-text-content {
|
||||
padding: 12px 14px 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg);
|
||||
border-top: 1px solid var(--border);
|
||||
white-space: pre-wrap;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mark-read-btn {
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #1abc9c;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<div class="page-container course-viewer-page">
|
||||
@if (loading()) {
|
||||
<div class="loading-screen">
|
||||
<span nz-icon nzType="loading" nzTheme="outline" class="loading-spin"></span>
|
||||
</div>
|
||||
} @else if (course()) {
|
||||
<!-- Header -->
|
||||
<div class="viewer-header">
|
||||
<div class="header-row">
|
||||
<button class="back-btn" (click)="goBack()">
|
||||
<span nz-icon nzType="arrow-left" nzTheme="outline"></span>
|
||||
</button>
|
||||
@if (isOwnCourse()) {
|
||||
<button class="edit-btn" (click)="editCourse()">
|
||||
<span nz-icon nzType="edit" nzTheme="outline"></span>
|
||||
Modifier
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="course-hero">
|
||||
<span class="community-badge">COMMUNAUTÉ</span>
|
||||
<h1 class="course-title">{{ course()!.title }}</h1>
|
||||
<p class="course-description">{{ course()!.description }}</p>
|
||||
<div class="course-meta">
|
||||
<span nz-icon nzType="user" nzTheme="outline"></span>
|
||||
<span>{{ course()!.creatorName }}</span>
|
||||
<span class="meta-dot">·</span>
|
||||
<span>{{ course()!.topicCount }} sujet{{ course()!.topicCount !== 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress (if enrolled) -->
|
||||
@if (isEnrolled()) {
|
||||
<div class="progress-card">
|
||||
<div class="progress-row">
|
||||
<span class="progress-label">Progression</span>
|
||||
<span class="progress-value">{{ progressPercent() }}%</span>
|
||||
</div>
|
||||
<div class="mc-progress-bar">
|
||||
<div class="mc-progress-fill" [style.width.%]="progressPercent()"></div>
|
||||
</div>
|
||||
@if (progress()) {
|
||||
<p class="progress-detail">
|
||||
{{ progress()!.completedTopics }}/{{ progress()!.totalTopics }} sujets ·
|
||||
{{ progress()!.completedResources }}/{{ progress()!.totalResources }} ressources
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Enroll button -->
|
||||
@if (!isEnrolled() && !isOwnCourse()) {
|
||||
<button class="enroll-btn" (click)="enroll()" [disabled]="enrolling()">
|
||||
@if (enrolling()) {
|
||||
<span nz-icon nzType="loading" nzTheme="outline"></span> Inscription...
|
||||
} @else {
|
||||
Commencer ce cours →
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Restart button -->
|
||||
@if (isEnrolled() && progressPercent() > 0) {
|
||||
<button class="restart-btn" (click)="restartCourse()">
|
||||
<span nz-icon nzType="reload" nzTheme="outline"></span>
|
||||
Recommencer depuis le début
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Topics -->
|
||||
<div class="topics-section">
|
||||
<h2 class="section-title">Contenu du cours</h2>
|
||||
|
||||
@if (course()!.topics.length === 0) {
|
||||
<div class="empty-topics">
|
||||
<p>Ce cours ne contient pas encore de sujets.</p>
|
||||
</div>
|
||||
} @else {
|
||||
@for (topic of course()!.topics; track topic.id) {
|
||||
<div class="topic-card" [class.topic-done]="isTopicDone(topic.id)">
|
||||
<!-- Topic header -->
|
||||
<div class="topic-header" (click)="toggleTopic(topic.id)">
|
||||
<div class="topic-position" [class.done]="isTopicDone(topic.id)">
|
||||
@if (isTopicDone(topic.id)) {
|
||||
<span nz-icon nzType="check" nzTheme="outline"></span>
|
||||
} @else {
|
||||
{{ topic.position }}
|
||||
}
|
||||
</div>
|
||||
<div class="topic-info">
|
||||
<h3 class="topic-title">{{ topic.title }}</h3>
|
||||
@if (topic.description) {
|
||||
<p class="topic-desc">{{ topic.description }}</p>
|
||||
}
|
||||
<span class="resource-count">
|
||||
{{ topic.resources.length }} ressource{{ topic.resources.length !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="topic-actions">
|
||||
@if (isEnrolled() && !isTopicDone(topic.id)) {
|
||||
<button class="mark-done-btn" (click)="markTopicDone(topic, $event)" title="Marquer comme terminé">
|
||||
Terminer
|
||||
</button>
|
||||
}
|
||||
<span
|
||||
nz-icon
|
||||
[nzType]="isTopicExpanded(topic.id) ? 'up' : 'down'"
|
||||
nzTheme="outline"
|
||||
class="expand-icon"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resources -->
|
||||
@if (isTopicExpanded(topic.id) && topic.resources.length > 0) {
|
||||
<div class="resources-list">
|
||||
@for (resource of topic.resources; track resource.id) {
|
||||
<div
|
||||
class="resource-item"
|
||||
[class.resource-done]="isResourceDone(resource.id)"
|
||||
[class.resource-clickable]="resource.type !== 'Text' || true"
|
||||
(click)="openResource(resource, $event)"
|
||||
>
|
||||
<div class="resource-icon" [class.done]="isResourceDone(resource.id)">
|
||||
@if (isResourceDone(resource.id)) {
|
||||
<span nz-icon nzType="check-circle" nzTheme="fill"></span>
|
||||
} @else {
|
||||
<span nz-icon [nzType]="getResourceIcon(resource.type)" nzTheme="outline"></span>
|
||||
}
|
||||
</div>
|
||||
<div class="resource-info">
|
||||
<p class="resource-title">{{ resource.title }}</p>
|
||||
<span class="resource-type">
|
||||
{{ resource.type === 'Url' ? 'Lien' : resource.type === 'Video' ? 'Vidéo' : resource.type === 'Text' ? 'Texte' : 'Fichier' }}
|
||||
@if (resource.type !== 'Text') {
|
||||
· <span class="resource-open">Ouvrir →</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (resource.type === 'Text') {
|
||||
<span nz-icon [nzType]="isResourceExpanded(resource.id) ? 'up' : 'down'" nzTheme="outline" class="expand-icon"></span>
|
||||
}
|
||||
</div>
|
||||
<!-- Text content inline -->
|
||||
@if (resource.type === 'Text' && isResourceExpanded(resource.id)) {
|
||||
<div class="resource-text-content">
|
||||
{{ resource.content }}
|
||||
@if (isEnrolled() && !isResourceDone(resource.id)) {
|
||||
<button class="mark-read-btn" (click)="markResourceDone(resource); $event.stopPropagation()">
|
||||
<span nz-icon nzType="check" nzTheme="outline"></span>
|
||||
Marquer comme lu
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { CourseViewerPage } from './course-viewer';
|
||||
|
||||
export const COURSE_VIEWER_ROUTES: Routes = [
|
||||
{ path: '', component: CourseViewerPage }
|
||||
];
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Component, inject, signal, OnInit, computed } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { NzMessageService } from 'ng-zorro-antd/message';
|
||||
import { CourseService } from '../../services/course.service';
|
||||
import { EnrollmentService } from '../../services/enrollment.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { CourseDetails, Topic, Resource, CourseProgress } from '../../models/types';
|
||||
import { getErrorMessage } from '../../utils/error.utils';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'app-course-viewer',
|
||||
imports: [NzIconModule],
|
||||
providers: [NzMessageService],
|
||||
templateUrl: './course-viewer.html',
|
||||
styleUrl: './course-viewer.css'
|
||||
})
|
||||
export class CourseViewerPage implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly courseService = inject(CourseService);
|
||||
private readonly enrollmentService = inject(EnrollmentService);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly message = inject(NzMessageService);
|
||||
|
||||
course = signal<CourseDetails | null>(null);
|
||||
progress = signal<CourseProgress | null>(null);
|
||||
loading = signal(true);
|
||||
enrolling = signal(false);
|
||||
isEnrolled = signal(false);
|
||||
expandedTopicId = signal<string | null>(null);
|
||||
completedTopicIds = signal<Set<string>>(new Set());
|
||||
completedResourceIds = signal<Set<string>>(new Set());
|
||||
expandedResourceId = signal<string | null>(null);
|
||||
|
||||
get currentUser() {
|
||||
return this.authService.currentUser();
|
||||
}
|
||||
|
||||
isOwnCourse = computed(() => {
|
||||
const user = this.currentUser;
|
||||
const course = this.course();
|
||||
return user && course ? course.creatorId === user.userId : false;
|
||||
});
|
||||
|
||||
progressPercent = computed(() => {
|
||||
const p = this.progress();
|
||||
return p ? Math.round(p.progressPercentage) : 0;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
const id = this.route.snapshot.paramMap.get('id')!;
|
||||
this.loadCourse(id);
|
||||
}
|
||||
|
||||
loadCourse(id: string): void {
|
||||
this.loading.set(true);
|
||||
const user = this.currentUser;
|
||||
|
||||
const course$ = this.courseService.getCourseById(id);
|
||||
const enrollments$ = user
|
||||
? this.enrollmentService.getEnrollments(user.userId).pipe(catchError(() => of([])))
|
||||
: of([]);
|
||||
const progress$ = user
|
||||
? this.enrollmentService.getCourseProgress(id, user.userId).pipe(catchError(() => of(null)))
|
||||
: of(null);
|
||||
|
||||
forkJoin([course$, enrollments$, progress$]).subscribe({
|
||||
next: ([course, enrollments, progress]) => {
|
||||
this.course.set(course);
|
||||
this.progress.set(progress);
|
||||
if (enrollments.length > 0) {
|
||||
this.isEnrolled.set(enrollments.some((e: any) => e.courseId === id));
|
||||
}
|
||||
if (course.topics.length > 0) {
|
||||
this.expandedTopicId.set(course.topics[0].id);
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.message.error('Impossible de charger le cours');
|
||||
this.router.navigate(['/catalog']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
enroll(): void {
|
||||
const user = this.currentUser;
|
||||
const course = this.course();
|
||||
if (!user || !course) return;
|
||||
|
||||
this.enrolling.set(true);
|
||||
this.enrollmentService.enroll(course.id, user.userId).subscribe({
|
||||
next: () => {
|
||||
this.isEnrolled.set(true);
|
||||
this.enrolling.set(false);
|
||||
this.message.success('Inscription réussie !');
|
||||
this.loadProgress(course.id, user.userId);
|
||||
},
|
||||
error: (err) => {
|
||||
this.enrolling.set(false);
|
||||
this.message.error(getErrorMessage(err, "Erreur lors de l'inscription"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadProgress(courseId: string, userId: string): void {
|
||||
this.enrollmentService.getCourseProgress(courseId, userId).subscribe({
|
||||
next: (p) => this.progress.set(p),
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
toggleTopic(topicId: string): void {
|
||||
this.expandedTopicId.set(
|
||||
this.expandedTopicId() === topicId ? null : topicId
|
||||
);
|
||||
}
|
||||
|
||||
isTopicExpanded(topicId: string): boolean {
|
||||
return this.expandedTopicId() === topicId;
|
||||
}
|
||||
|
||||
isTopicDone(topicId: string): boolean {
|
||||
return this.completedTopicIds().has(topicId);
|
||||
}
|
||||
|
||||
isResourceDone(resourceId: string): boolean {
|
||||
return this.completedResourceIds().has(resourceId);
|
||||
}
|
||||
|
||||
markTopicDone(topic: Topic, event: Event): void {
|
||||
event.stopPropagation();
|
||||
const user = this.currentUser;
|
||||
if (!user || !this.isEnrolled()) return;
|
||||
|
||||
this.enrollmentService.markTopicProgress(topic.id, user.userId, true).subscribe({
|
||||
next: () => {
|
||||
this.completedTopicIds.update(s => new Set([...s, topic.id]));
|
||||
const course = this.course();
|
||||
if (course) this.loadProgress(course.id, user.userId);
|
||||
this.message.success('Sujet marqué comme terminé');
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
openResource(resource: Resource, event: Event): void {
|
||||
event.stopPropagation();
|
||||
|
||||
if (resource.type === 'Text') {
|
||||
this.expandedResourceId.set(
|
||||
this.expandedResourceId() === resource.id ? null : resource.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resource.content) {
|
||||
window.open(resource.content, '_blank');
|
||||
}
|
||||
|
||||
if (this.isEnrolled()) {
|
||||
this.markResourceDone(resource);
|
||||
}
|
||||
}
|
||||
|
||||
isResourceExpanded(resourceId: string): boolean {
|
||||
return this.expandedResourceId() === resourceId;
|
||||
}
|
||||
|
||||
markResourceDone(resource: Resource): void {
|
||||
const user = this.currentUser;
|
||||
if (!user || !this.isEnrolled()) return;
|
||||
|
||||
this.enrollmentService.markResourceProgress(resource.id, user.userId, true).subscribe({
|
||||
next: () => {
|
||||
this.completedResourceIds.update(s => new Set([...s, resource.id]));
|
||||
const course = this.course();
|
||||
if (course) this.loadProgress(course.id, user.userId);
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
restartCourse(): void {
|
||||
const course = this.course();
|
||||
const user = this.currentUser;
|
||||
if (!course || !user) return;
|
||||
|
||||
const topicCalls = course.topics.map(t =>
|
||||
this.enrollmentService.markTopicProgress(t.id, user.userId, false)
|
||||
);
|
||||
const resourceCalls = course.topics.flatMap(t =>
|
||||
t.resources.map(r => this.enrollmentService.markResourceProgress(r.id, user.userId, false))
|
||||
);
|
||||
|
||||
forkJoin([...topicCalls, ...resourceCalls]).subscribe({
|
||||
next: () => {
|
||||
this.completedTopicIds.set(new Set());
|
||||
this.completedResourceIds.set(new Set());
|
||||
this.progress.set(null);
|
||||
this.loadProgress(course.id, user.userId);
|
||||
this.message.success('Progression remise à zéro');
|
||||
},
|
||||
error: () => this.message.error('Erreur lors de la remise à zéro')
|
||||
});
|
||||
}
|
||||
|
||||
editCourse(): void {
|
||||
const course = this.course();
|
||||
if (course) this.router.navigate(['/courses', course.id, 'edit']);
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.router.navigate(['/catalog']);
|
||||
}
|
||||
|
||||
getResourceIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'Video': return 'video-camera';
|
||||
case 'Text': return 'file-text';
|
||||
case 'File': return 'file';
|
||||
default: return 'link';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
.my-courses-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 20px 20px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--primary);
|
||||
color: var(--primary-fg);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.published {
|
||||
background: #1abc9c;
|
||||
}
|
||||
|
||||
.delete-course-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ff4d4f;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.delete-course-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.enrollments-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.enrollment-card {
|
||||
background-color: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.enrollment-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.enrollment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.course-icon-wrap {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background-color: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.enrollment-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.enrollment-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0 0 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.enrollment-date {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.progress-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.progress-percent {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #1abc9c;
|
||||
}
|
||||
|
||||
/* Skeleton loading */
|
||||
.loading-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<div class="page-container my-courses-page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Mes Cours</h1>
|
||||
<button class="create-btn" (click)="createCourse()">
|
||||
<span nz-icon nzType="plus" nzTheme="outline"></span>
|
||||
Créer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
@if (loading()) {
|
||||
<div class="loading-list">
|
||||
@for (_ of [1, 2, 3]; track $index) {
|
||||
<div class="skeleton-card"></div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
|
||||
<!-- Courses created by user -->
|
||||
@if (createdCourses().length > 0) {
|
||||
<p class="section-label">Mes créations</p>
|
||||
<div class="enrollments-list">
|
||||
@for (course of createdCourses(); track course.id) {
|
||||
<div class="enrollment-card" (click)="editCourse(course)">
|
||||
<div class="enrollment-header">
|
||||
<div class="enrollment-info">
|
||||
<h3 class="enrollment-title">{{ course.title }}</h3>
|
||||
<p class="enrollment-date">{{ course.status === 'Published' ? 'Publié' : 'Brouillon' }} · {{ course.topicCount }} sujet{{ course.topicCount !== 1 ? 's' : '' }}</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<span class="status-dot" [class.published]="course.status === 'Published'"></span>
|
||||
<button class="delete-course-btn" (click)="deleteCourse(course, $event)" aria-label="Supprimer">
|
||||
<span nz-icon nzType="delete" nzTheme="outline"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Enrolled courses -->
|
||||
@if (enrollments().length > 0) {
|
||||
<p class="section-label">Mon apprentissage</p>
|
||||
<div class="enrollments-list">
|
||||
@for (enrollment of enrollments(); track enrollment.courseId) {
|
||||
<div class="enrollment-card" (click)="openCourse(enrollment)">
|
||||
<div class="enrollment-header">
|
||||
<div class="enrollment-info">
|
||||
<h3 class="enrollment-title">{{ enrollment.courseTitle }}</h3>
|
||||
<p class="enrollment-date">Inscrit le {{ enrollment.enrolledAt | date:'dd/MM/yyyy' }}</p>
|
||||
</div>
|
||||
<span nz-icon nzType="arrow-right" nzTheme="outline" class="chevron"></span>
|
||||
</div>
|
||||
|
||||
@if (enrollment.progress) {
|
||||
<div class="progress-section">
|
||||
<div class="progress-stats">
|
||||
<span class="progress-label">
|
||||
{{ enrollment.progress.completedTopics }}/{{ enrollment.progress.totalTopics }} sujets
|
||||
</span>
|
||||
<span class="progress-percent">{{ getProgressPercent(enrollment) }}%</span>
|
||||
</div>
|
||||
<div class="mc-progress-bar">
|
||||
<div class="mc-progress-fill" [style.width.%]="getProgressPercent(enrollment)"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (createdCourses().length === 0 && enrollments().length === 0) {
|
||||
<div class="empty-state">
|
||||
<span nz-icon nzType="read" nzTheme="outline" class="empty-icon"></span>
|
||||
<p class="empty-text">Aucun cours pour l'instant</p>
|
||||
<p class="empty-hint">Créez votre premier cours ou explorez le catalogue</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
}
|
||||
</div>
|
||||
|
||||
<app-bottom-nav />
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { MyCoursesPage } from './my-courses';
|
||||
|
||||
export const MY_COURSES_ROUTES: Routes = [
|
||||
{ path: '', component: MyCoursesPage }
|
||||
];
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { NzMessageService } from 'ng-zorro-antd/message';
|
||||
import { EnrollmentService } from '../../services/enrollment.service';
|
||||
import { CourseService } from '../../services/course.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { Course, EnrollmentWithProgress } from '../../models/types';
|
||||
import { BottomNav } from '../../components/bottom-nav/bottom-nav';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'app-my-courses',
|
||||
imports: [NzIconModule, BottomNav, DatePipe],
|
||||
providers: [NzMessageService],
|
||||
templateUrl: './my-courses.html',
|
||||
styleUrl: './my-courses.css'
|
||||
})
|
||||
export class MyCoursesPage implements OnInit {
|
||||
private readonly enrollmentService = inject(EnrollmentService);
|
||||
private readonly courseService = inject(CourseService);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly message = inject(NzMessageService);
|
||||
|
||||
enrollments = signal<EnrollmentWithProgress[]>([]);
|
||||
createdCourses = signal<Course[]>([]);
|
||||
loading = signal(false);
|
||||
|
||||
get currentUser() {
|
||||
return this.authService.currentUser();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
loadAll(): void {
|
||||
const user = this.currentUser;
|
||||
if (!user) return;
|
||||
this.loading.set(true);
|
||||
|
||||
forkJoin({
|
||||
created: this.courseService.getMyCourses(user.userId).pipe(catchError(() => of([]))),
|
||||
enrollments: this.enrollmentService.getEnrollments(user.userId).pipe(catchError(() => of([])))
|
||||
}).subscribe({
|
||||
next: ({ created, enrollments }) => {
|
||||
this.createdCourses.set(created);
|
||||
|
||||
if (enrollments.length === 0) {
|
||||
this.enrollments.set([]);
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const progressRequests = enrollments.map((e: any) =>
|
||||
this.enrollmentService.getCourseProgress(e.courseId, user.userId).pipe(
|
||||
catchError(() => of(undefined))
|
||||
)
|
||||
);
|
||||
|
||||
forkJoin(progressRequests).subscribe({
|
||||
next: (progressList) => {
|
||||
const enriched: EnrollmentWithProgress[] = enrollments.map((e: any, i: number) => ({
|
||||
...e,
|
||||
progress: progressList[i] ?? undefined
|
||||
}));
|
||||
this.enrollments.set(enriched);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.enrollments.set(enrollments);
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.message.error('Impossible de charger vos cours');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openCourse(enrollment: EnrollmentWithProgress): void {
|
||||
this.router.navigate(['/courses', enrollment.courseId]);
|
||||
}
|
||||
|
||||
editCourse(course: Course): void {
|
||||
this.router.navigate(['/courses', course.id, 'edit']);
|
||||
}
|
||||
|
||||
createCourse(): void {
|
||||
this.router.navigate(['/create']);
|
||||
}
|
||||
|
||||
deleteCourse(course: Course, event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (!confirm(`Supprimer "${course.title}" ?`)) return;
|
||||
this.courseService.deleteCourse(course.id).subscribe({
|
||||
next: () => {
|
||||
this.createdCourses.set(this.createdCourses().filter(c => c.id !== course.id));
|
||||
this.message.success('Cours supprimé');
|
||||
},
|
||||
error: () => this.message.error('Impossible de supprimer ce cours')
|
||||
});
|
||||
}
|
||||
|
||||
getProgressPercent(enrollment: EnrollmentWithProgress): number {
|
||||
return Math.round(enrollment.progress?.progressPercentage ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
export interface ApiErrorItem {
|
||||
propertyName: string;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
statusCode: number;
|
||||
errors: ApiErrorItem[] | Record<string, string[]>;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ApiService {
|
||||
private readonly baseUrl = environment.apiUrl;
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
private handleError(error: HttpErrorResponse): Observable<never> {
|
||||
const apiError: ApiError = error.error ?? {
|
||||
statusCode: error.status,
|
||||
errors: { general: [error.message] }
|
||||
};
|
||||
return throwError(() => apiError);
|
||||
}
|
||||
|
||||
get<T>(path: string, params?: Record<string, string>): Observable<T> {
|
||||
let httpParams = new HttpParams();
|
||||
if (params) {
|
||||
Object.keys(params).forEach(key => {
|
||||
if (params[key] !== undefined && params[key] !== null) {
|
||||
httpParams = httpParams.set(key, params[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.http.get<T>(`${this.baseUrl}${path}`, { params: httpParams })
|
||||
.pipe(catchError(e => this.handleError(e)));
|
||||
}
|
||||
|
||||
post<T>(path: string, body: unknown): Observable<T> {
|
||||
return this.http.post<T>(`${this.baseUrl}${path}`, body)
|
||||
.pipe(catchError(e => this.handleError(e)));
|
||||
}
|
||||
|
||||
put<T>(path: string, body: unknown): Observable<T> {
|
||||
return this.http.put<T>(`${this.baseUrl}${path}`, body)
|
||||
.pipe(catchError(e => this.handleError(e)));
|
||||
}
|
||||
|
||||
patch<T>(path: string, body?: unknown): Observable<T> {
|
||||
return this.http.patch<T>(`${this.baseUrl}${path}`, body ?? {})
|
||||
.pipe(catchError(e => this.handleError(e)));
|
||||
}
|
||||
|
||||
delete<T>(path: string): Observable<T> {
|
||||
return this.http.delete<T>(`${this.baseUrl}${path}`)
|
||||
.pipe(catchError(e => this.handleError(e)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Injectable, inject, signal, computed } from '@angular/core';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { LoginResponse, User } from '../models/types';
|
||||
|
||||
const STORAGE_KEY = 'metacourse_user';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
currentUser = signal<LoginResponse | null>(null);
|
||||
isLoggedIn = computed(() => this.currentUser() !== null);
|
||||
|
||||
constructor() {
|
||||
this.loadFromStorage();
|
||||
}
|
||||
|
||||
login(email: string, password: string): Observable<LoginResponse> {
|
||||
return this.api.post<LoginResponse>('/api/users/login', { email, password }).pipe(
|
||||
tap(user => {
|
||||
this.currentUser.set(user);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
register(name: string, email: string, password: string): Observable<User> {
|
||||
return this.api.post<User>('/api/users/register', { name, email, password });
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.currentUser.set(null);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
loadFromStorage(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const user = JSON.parse(stored) as LoginResponse;
|
||||
this.currentUser.set(user);
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ApiService } from './api.service';
|
||||
import { Course, CourseDetails, ResourceType } from '../models/types';
|
||||
|
||||
const INT_TO_TYPE: Record<number, ResourceType> = { 0: 'Url', 1: 'Video', 2: 'Text', 3: 'File' };
|
||||
|
||||
function normalizeType(r: any) {
|
||||
return { ...r, type: typeof r.type === 'number' ? (INT_TO_TYPE[r.type] ?? 'Url') : r.type };
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class CourseService {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
getCourses(search?: string): Observable<Course[]> {
|
||||
const params = search ? { search } : undefined;
|
||||
return this.api.get<Course[]>('/api/courses', params);
|
||||
}
|
||||
|
||||
getCourseById(id: string): Observable<CourseDetails> {
|
||||
return this.api.get<any>(`/api/courses/${id}`).pipe(
|
||||
map(c => ({
|
||||
...c,
|
||||
topics: (c.topics ?? []).map((t: any) => ({
|
||||
...t,
|
||||
resources: (t.resources ?? []).map(normalizeType)
|
||||
}))
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
createCourse(title: string, description: string, creatorId: string): Observable<Course> {
|
||||
return this.api.post<Course>('/api/courses', { title, description, creatorId });
|
||||
}
|
||||
|
||||
updateCourse(id: string, title: string, description: string): Observable<Course> {
|
||||
return this.api.put<Course>(`/api/courses/${id}`, { id, title, description });
|
||||
}
|
||||
|
||||
publishCourse(id: string): Observable<Course> {
|
||||
return this.api.patch<Course>(`/api/courses/${id}/publish`, {});
|
||||
}
|
||||
|
||||
deleteCourse(id: string): Observable<void> {
|
||||
return this.api.delete<void>(`/api/courses/${id}`);
|
||||
}
|
||||
|
||||
getMyCourses(userId: string): Observable<Course[]> {
|
||||
return this.api.get<Course[]>(`/api/users/${userId}/courses`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { Enrollment, CourseProgress } from '../models/types';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class EnrollmentService {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
enroll(courseId: string, userId: string): Observable<Enrollment> {
|
||||
return this.api.post<Enrollment>(`/api/courses/${courseId}/enroll`, { userId, courseId });
|
||||
}
|
||||
|
||||
getEnrollments(userId: string): Observable<Enrollment[]> {
|
||||
return this.api.get<Enrollment[]>(`/api/users/${userId}/enrollments`);
|
||||
}
|
||||
|
||||
getCourseProgress(courseId: string, userId: string): Observable<CourseProgress> {
|
||||
return this.api.get<CourseProgress>(`/api/courses/${courseId}/progress`, { userId });
|
||||
}
|
||||
|
||||
markTopicProgress(topicId: string, userId: string, completed: boolean): Observable<void> {
|
||||
return this.api.post<void>(`/api/topics/${topicId}/progress`, { userId, topicId, completed });
|
||||
}
|
||||
|
||||
markResourceProgress(resourceId: string, userId: string, completed: boolean): Observable<void> {
|
||||
return this.api.post<void>(`/api/resources/${resourceId}/progress`, { userId, resourceId, completed });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ApiService } from './api.service';
|
||||
import { Resource, ResourceType } from '../models/types';
|
||||
|
||||
const TYPE_TO_INT: Record<ResourceType, number> = {
|
||||
'Url': 0,
|
||||
'Video': 1,
|
||||
'Text': 2,
|
||||
'File': 3,
|
||||
};
|
||||
|
||||
const INT_TO_TYPE: Record<number, ResourceType> = {
|
||||
0: 'Url',
|
||||
1: 'Video',
|
||||
2: 'Text',
|
||||
3: 'File',
|
||||
};
|
||||
|
||||
function normalizeResource(r: any): Resource {
|
||||
return {
|
||||
...r,
|
||||
type: typeof r.type === 'number' ? (INT_TO_TYPE[r.type] ?? 'Url') : r.type,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ResourceService {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
getResources(): Observable<Resource[]> {
|
||||
return this.api.get<any[]>('/api/resources').pipe(
|
||||
map(list => list.map(normalizeResource))
|
||||
);
|
||||
}
|
||||
|
||||
createResource(type: ResourceType, title: string, content: string): Observable<Resource> {
|
||||
return this.api.post<any>('/api/resources', { type: TYPE_TO_INT[type], title, content }).pipe(
|
||||
map(normalizeResource)
|
||||
);
|
||||
}
|
||||
|
||||
updateResource(id: string, type: ResourceType, title: string, content: string): Observable<Resource> {
|
||||
return this.api.put<any>(`/api/resources/${id}`, { id, type: TYPE_TO_INT[type], title, content }).pipe(
|
||||
map(normalizeResource)
|
||||
);
|
||||
}
|
||||
|
||||
deleteResource(id: string): Observable<void> {
|
||||
return this.api.delete<void>(`/api/resources/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { Topic } from '../models/types';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TopicService {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
createTopic(courseId: string, title: string, description: string | null, position: number): Observable<Topic> {
|
||||
return this.api.post<Topic>('/api/topics', { courseId, title, description, position });
|
||||
}
|
||||
|
||||
updateTopic(id: string, title: string, description: string, position: number): Observable<Topic> {
|
||||
return this.api.put<Topic>(`/api/topics/${id}`, { id, title, description, position });
|
||||
}
|
||||
|
||||
deleteTopic(id: string): Observable<void> {
|
||||
return this.api.delete<void>(`/api/topics/${id}`);
|
||||
}
|
||||
|
||||
linkResource(topicId: string, resourceId: string, position: number): Observable<void> {
|
||||
return this.api.post<void>(`/api/topics/${topicId}/resources/${resourceId}`, { position });
|
||||
}
|
||||
|
||||
unlinkResource(topicId: string, resourceId: string): Observable<void> {
|
||||
return this.api.delete<void>(`/api/topics/${topicId}/resources/${resourceId}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiError, ApiErrorItem } from '../services/api.service';
|
||||
|
||||
export function getErrorMessage(err: ApiError, fallback: string): string {
|
||||
const errors = err?.errors;
|
||||
if (!errors) return fallback;
|
||||
|
||||
// Array format: [{ propertyName, errorMessage }]
|
||||
if (Array.isArray(errors)) {
|
||||
return errors[0]?.errorMessage ?? fallback;
|
||||
}
|
||||
|
||||
// Dictionary format: { field: string[] }
|
||||
const first = Object.values(errors).flat()[0];
|
||||
return (typeof first === 'string' ? first : null) ?? fallback;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiUrl: 'http://romaric-thibault.fr:8080'
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'https://fluffy-space-goldfish-7gqj7v4jwg9frw7-5201.app.github.dev'
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="fr" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>MetaCourse</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/* Tailwind CSS v4 */
|
||||
@layer theme, base, components, utilities;
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/utilities.css" layer(utilities) important;
|
||||
|
||||
/* CSS Custom Properties for theming */
|
||||
:root {
|
||||
--bg: #f5f5f0;
|
||||
--card: #ffffff;
|
||||
--text: #111111;
|
||||
--text-muted: #666666;
|
||||
--border: #e5e5e5;
|
||||
--primary: #000000;
|
||||
--primary-fg: #ffffff;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--bg: #0a0a0a;
|
||||
--card: #1a1a1a;
|
||||
--text: #ffffff;
|
||||
--text-muted: #aaaaaa;
|
||||
--border: #333333;
|
||||
--primary: #ffffff;
|
||||
--primary-fg: #000000;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* App root and all routed page components fill height */
|
||||
app-root,
|
||||
app-catalog,
|
||||
app-my-courses,
|
||||
app-course-viewer,
|
||||
app-course-editor,
|
||||
app-auth {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Smooth transitions for theme changes */
|
||||
*, *::before, *::after {
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Override NZ-ZORRO for dark mode */
|
||||
html.dark .ant-input,
|
||||
html.dark .ant-select-selector,
|
||||
html.dark nz-input-group {
|
||||
background-color: var(--card) !important;
|
||||
border-color: var(--border) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
html.dark .ant-input::placeholder {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* Page container — the ONLY scrollable element */
|
||||
.page-container {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.mc-card {
|
||||
background-color: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.mc-input {
|
||||
width: 100%;
|
||||
background-color: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mc-input:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.mc-btn-primary {
|
||||
width: 100%;
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-fg);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mc-btn-primary:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.mc-progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background-color: var(--border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mc-progress-fill {
|
||||
height: 100%;
|
||||
background-color: #1abc9c;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
// Custom Theming for NG-ZORRO
|
||||
// For more information: https://ng.ant.design/docs/customize-theme/en
|
||||
@import "../node_modules/ng-zorro-antd/ng-zorro-antd.less";
|
||||
|
||||
// Override less variables to here
|
||||
// View all variables: https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/components/style/themes/default.less
|
||||
|
||||
// @primary-color: #1890ff;
|
||||
@primary-color: #1abc9c;
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Ionic Dark Theme
|
||||
* -----------------------------------------------------
|
||||
* For more info, please see:
|
||||
* https://ionicframework.com/docs/theming/dark-mode
|
||||
*/
|
||||
|
||||
/* @import "@ionic/angular/css/palettes/dark.always.css"; */
|
||||
/* @import "@ionic/angular/css/palettes/dark.class.css"; */
|
||||
@import "@ionic/angular/css/palettes/dark.system.css";
|
||||
Reference in New Issue
Block a user