Initial commit

This commit is contained in:
2026-05-05 10:53:52 +02:00
commit 419e59ef2f
2269 changed files with 313143 additions and 0 deletions
+26
View File
@@ -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({})
]
};
+5
View File
@@ -0,0 +1,5 @@
:host {
display: block;
height: 100%;
background-color: var(--bg);
}
+1
View File
@@ -0,0 +1 @@
<router-outlet />
+35
View File
@@ -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]
}
];
+10
View File
@@ -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());
}
}
+14
View File
@@ -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']);
};
+61
View File
@@ -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
];
+70
View File
@@ -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;
}
+163
View File
@@ -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);
}
+139
View File
@@ -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 &rarr;
}
</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 &rarr;
}
</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>
+6
View File
@@ -0,0 +1,6 @@
import { Routes } from '@angular/router';
import { AuthPage } from './auth';
export const AUTH_ROUTES: Routes = [
{ path: '', component: AuthPage }
];
+91
View File
@@ -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'));
}
});
}
}
+169
View File
@@ -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;
}
+62
View File
@@ -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>
+6
View File
@@ -0,0 +1,6 @@
import { Routes } from '@angular/router';
import { CatalogPage } from './catalog';
export const CATALOG_ROUTES: Routes = [
{ path: '', component: CatalogPage }
];
+129
View File
@@ -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 &rarr;
}
</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';
}
}
}
+218
View File
@@ -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;
}
+86
View File
@@ -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 }
];
+113
View File
@@ -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);
}
}
+64
View File
@@ -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)));
}
}
+50
View File
@@ -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);
}
}
}
+55
View File
@@ -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`);
}
}
+31
View File
@@ -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 });
}
}
+55
View File
@@ -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}`);
}
}
+31
View File
@@ -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}`);
}
}
+15
View File
@@ -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;
}
+4
View File
@@ -0,0 +1,4 @@
export const environment = {
production: true,
apiUrl: 'http://romaric-thibault.fr:8080'
};
+4
View File
@@ -0,0 +1,4 @@
export const environment = {
production: false,
apiUrl: 'https://fluffy-space-goldfish-7gqj7v4jwg9frw7-5201.app.github.dev'
};
+14
View File
@@ -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>
+6
View File
@@ -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
View File
@@ -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;
}
+10
View File
@@ -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;
+10
View File
@@ -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";